Compare commits
384 Commits
Author | SHA1 | Date |
---|---|---|
Umut Solmaz | 2d705609df | |
josé m | 5de5adfda0 | |
Umut Solmaz | 93123f2df6 | |
Bruno-Van-den-Bosch | 52a45bc5df | |
Renovate Bot | bea9c5b75a | |
Renovate Bot | 3ebf11a6f6 | |
Hugh Daschbach | 5ee798abfb | |
Hugh Daschbach | 6f24535b79 | |
Hugh Daschbach | 467556d75c | |
Renovate Bot | 10035aa5fe | |
Georg Krause | 8b9a1201af | |
Hugh Daschbach | 2088e06a68 | |
Renovate Bot | 22a72d9e83 | |
Bruno-Van-den-Bosch | 2bdf904804 | |
mittwerk | 042d6b4d6e | |
mittwerk | 67aa47a4cb | |
Hugh Daschbach | 01c676acd8 | |
Hugh Daschbach | b27e4c85ee | |
Renovate Bot | c061c64c3d | |
Georg Krause | 554bc0ca5c | |
Georg Krause | ef54aad835 | |
Aitor | d23456d334 | |
Georg Krause | fef2d5b05f | |
josé m | 64f947aa23 | |
Thomas | 9666cccd5b | |
Hugh Daschbach | 36f1c7ba66 | |
Georg Krause | 1978fc4fb4 | |
Hugh Daschbach | c9056a2dbe | |
Hugh Daschbach | c1eb9d6b2a | |
Hugh Daschbach | b9ade47988 | |
Hugh Daschbach | 2133d4a4fb | |
Hugh Daschbach | feb86fe9c0 | |
Hugh Daschbach | f65e29af39 | |
Hugh Daschbach | 4dba9e29dd | |
Hugh Daschbach | 629ce2b309 | |
Renovate Bot | 080ba21c35 | |
Christophe Henry | 31908b6175 | |
Christophe Henry | 1a050c2d73 | |
Christophe Henry | 056e3a4d66 | |
Christophe Henry | b924a0c655 | |
Christophe Henry | 822adcac4a | |
Christophe Henry | fbbd90111d | |
Christophe Henry | 45773aac8d | |
josé m | 6472a3743e | |
Thomas | ada0b09a66 | |
Hugh Daschbach | 9c3d965a7e | |
Dylan Gageot | 5c5d86a728 | |
Dylan Gageot | 1288e050fd | |
Dylan Gageot | 8e09dccb9f | |
Dylan Gageot | 45ad4bdb8e | |
Dylan Gageot | 27e751df35 | |
Dylan Gageot | 33938e3705 | |
Renovate Bot | 1d5578febf | |
Georg krause | e1be5b1303 | |
Georg krause | 53bff969cd | |
Georg krause | cd82472c27 | |
Georg krause | 89c5718ac8 | |
Georg krause | de1cd69646 | |
Georg krause | 8da3cc78be | |
Georg krause | cc004dafdf | |
Georg krause | 22899ed2aa | |
Georg krause | de8343a973 | |
Georg krause | aab9e28e00 | |
Renovate Bot | f8838bae64 | |
Georg krause | 0075c10442 | |
Matyáš Caras | 103cac4145 | |
Georg krause | fb436ac43c | |
Georg krause | 006051dfa5 | |
Renovate Bot | 879d873156 | |
Renovate Bot | 8832b74cb4 | |
Renovate Bot | 8295d0e3e7 | |
Renovate Bot | 3d03067069 | |
Renovate Bot | 77eb1899fd | |
Georg krause | 87be45ba8a | |
Renovate Bot | 7822ce2cf0 | |
Renovate Bot | 201e8722b7 | |
Georg krause | f2c0e2cb3c | |
Christophe Henry | d25f29b4c1 | |
Georg Krause | df98e6fb99 | |
Renovate Bot | ea00a832a9 | |
Matyáš Caras | 04a659cc82 | |
Matyáš Caras | a55656d0f4 | |
Ryan Harg | 514cd46036 | |
Renovate Bot | e04f718335 | |
Ryan Harg | 79c869d51b | |
Renovate Bot | d0a47953dc | |
vicdorke | 95dcbf4616 | |
omarmaciasmolina | 7b16d46982 | |
Ryan Harg | 61ab3a918b | |
Renovate Bot | 0a32036558 | |
Ryan Harg | b892bd5c3c | |
Renovate Bot | 62ce3c7f60 | |
Ryan Harg | 019962c5a1 | |
Renovate Bot | fb09b95fb0 | |
aventijn | ef0701cd35 | |
aventijn | 0b1be2d572 | |
Georg Krause | a666490bd0 | |
Thomas | 585af743f2 | |
Ryan Harg | ca63e0d60c | |
Ryan Harg | f1947f3b88 | |
Ryan Harg | 3c21c0baec | |
Renovate Bot | fb80909fee | |
Ryan Harg | 4fc6bf978a | |
Renovate Bot | 56792e1940 | |
Ryan Harg | a83cd24185 | |
Ryan Harg | b4b988da48 | |
Ryan Harg | 14d583f0ae | |
Ryan Harg | d0579caac8 | |
Ryan Harg | bd61b50403 | |
Renovate Bot | dd1f7ddca8 | |
Ryan Harg | b80a54c87f | |
Renovate Bot | 28e85d60d0 | |
Georg Krause | c15d83a550 | |
Georg Krause | b1809d97e7 | |
Georg Krause | bda67a449b | |
Georg Krause | 6771b1a8a9 | |
Ryan Harg | eaf9275086 | |
Ryan Harg | c10b3d4a75 | |
Ryan Harg | bdbe14278e | |
Ryan Harg | a810e13cfb | |
Ryan Harg | 9202cc8dd0 | |
Christophe Henry | 2fb74b775e | |
Ryan Harg | 66b8888327 | |
Renovate Bot | 83cf417e5a | |
Ryan Harg | b24647663d | |
RenovateBot | 7abbd8dbaa | |
Ryan Harg | 7a01dc3a64 | |
Hugh Daschbach | 1566d1fbcf | |
Ryan Harg | c0b7e37cb4 | |
RenovateBot | 7f0671b055 | |
Ryan Harg | b9724fb7b9 | |
Renovate Bot | ecc9e6e096 | |
Thomas | ef7811dc6e | |
Philipp Wolfer | 435bbad122 | |
Ryan Harg | 0dbd9c2b9f | |
Renovate Bot | 1fb05f567d | |
Ryan Harg | b188005be3 | |
Hugh Daschbach | ec6187aeac | |
Ryan Harg | 2d272d13c9 | |
RenovateBot | bfab20a2b3 | |
Ryan Harg | d7afcbb1a1 | |
Ryan Harg | 826d10a702 | |
Ryan Harg | aa8e0ce1a6 | |
Ryan Harg | 87a0ef5a42 | |
Ryan Harg | cf5d6a21fe | |
Ryan Harg | 566dca1518 | |
Ryan Harg | 708daa8464 | |
Renovate Bot | 5c35c7e389 | |
Ryan Harg | 8c0f96ad42 | |
RenovateBot | 9f7f0294f6 | |
Ryan Harg | 6c652f2735 | |
Renovate Bot | 10242b0d01 | |
Ryan Harg | bd83872075 | |
Renovate Bot | 5bbf6b5ffa | |
Ryan Harg | 4ba25fce48 | |
Renovate Bot | 922aa16b8c | |
Ryan Harg | f235c06b86 | |
Ryan Harg | bbc82d8be5 | |
Ryan Harg | 82d0dd544d | |
RenovateBot | 159c7d8d47 | |
Ryan Harg | 10e67f1e80 | |
Ryan Harg | fa48937b56 | |
Ryan Harg | 2de6ca303e | |
Hugh Daschbach | d734953b54 | |
Hugh Daschbach | 24de54c7e0 | |
Hugh Daschbach | bea1d1f397 | |
Hugh Daschbach | 38a3183b9d | |
Hugh Daschbach | 8878e3e68f | |
Hugh Daschbach | 7d49819450 | |
Hugh Daschbach | 4827fbccc1 | |
Hugh Daschbach | 1a038b2355 | |
Hugh Daschbach | be8901390e | |
Hugh Daschbach | 6d1ad9cd78 | |
Hugh Daschbach | 72b4aea35a | |
Ryan Harg | ef01386f16 | |
Renovate Bot | a9aafef28b | |
omarmaciasmolina | b94363e035 | |
Ryan Harg | 234b4d79dd | |
Renovate Bot | c30d89cdea | |
Ryan Harg | a718f0a626 | |
Renovate Bot | b26e6eb78b | |
Ryan Harg | 73b112ad5d | |
Renovate Bot | 960adee40e | |
Ryan Harg | 8070aa0198 | |
Renovate Bot | adba9e01b1 | |
Ryan Harg | 84ab3826b1 | |
Renovate Bot | 298c682a60 | |
Ryan Harg | ef386be333 | |
Renovate Bot | 470a32434b | |
Ryan Harg | 0314a8dd7d | |
Renovate Bot | 5d7307206e | |
Ryan Harg | 4127a97327 | |
Renovate Bot | 05717f1067 | |
Ryan Harg | a6561266e4 | |
Renovate Bot | 072dbaf0af | |
Ryan Harg | 0e7994ec4d | |
Renovate Bot | 6fbe4e4e7a | |
Ryan Harg | bd8cec2d27 | |
Hugh Daschbach | 48570e24ea | |
Ryan Harg | 3da84cb6b5 | |
Renovate Bot | d139da56de | |
Ryan Harg | 8d9cdbb441 | |
Renovate Bot | c1c218eb6f | |
Ryan Harg | 6ce043893e | |
Ryan Harg | bfdac03d0c | |
Ryan Harg | 2d449549b0 | |
Renovate Bot | 19155a9c25 | |
Ryan Harg | f4abf4084a | |
Renovate Bot | 05ab1d7dc2 | |
Ryan Harg | 6db00911b4 | |
Renovate Bot | b2baea2b38 | |
Ryan Harg | fd6804dc20 | |
Renovate Bot | 8208f33d48 | |
Ryan Harg | 4e567dde41 | |
Renovate Bot | 83c73ee046 | |
Ryan Harg | fba19f1a46 | |
Renovate Bot | 5e789a2f28 | |
Ryan Harg | 117a17d0bf | |
Ryan Harg | 7c91e819c9 | |
Ryan Harg | b9335e545e | |
Renovate Bot | 61d3fdac31 | |
Ryan Harg | e93ad7a418 | |
Renovate Bot | a1de2611a0 | |
Ryan Harg | d4b8d0a684 | |
Renovate Bot | 5b95e03886 | |
Georg Krause | c56d6a6452 | |
Georg Krause | fdf9198a76 | |
Ryan Harg | 75c8453cca | |
Hugh Daschbach | 79e27578e5 | |
Ryan Harg | bc752b3057 | |
Renovate Bot | a6d3e0b597 | |
Ryan Harg | 4bc646a849 | |
Renovate Bot | faadfc1da2 | |
Ryan Harg | 7a05e50eb2 | |
Renovate Bot | 5224c9208a | |
Ryan Harg | 1f1fc26a1a | |
Renovate Bot | f60dae75e9 | |
Ryan Harg | 84816623e8 | |
Georg Krause | 2b240709fc | |
Ryan Harg | b36c121a84 | |
Ryan Harg | 96cfb42bde | |
Ryan Harg | 6fe879833e | |
Ryan Harg | 8570d79d56 | |
Georg Krause | d1c4bbfd29 | |
Ryan Harg | a10cb53b4f | |
Renovate Bot | 8ceaa85ac8 | |
Ryan Harg | c8c3ab89b3 | |
Renovate Bot | b93decac7a | |
Ryan Harg | 90c2af7347 | |
Ryan Harg | 8f1f565652 | |
Ryan Harg | 5ace27caef | |
Ryan Harg | c43baae8e8 | |
Ryan Harg | b9401d75a9 | |
Ryan Harg | 1b0381fde4 | |
Ryan Harg | eee0dacfdd | |
Ryan Harg | aac3995b87 | |
Ryan Harg | 9cac0e9aed | |
Ryan Harg | 857129efb5 | |
Hugh Daschbach | 37e270071a | |
Ryan Harg | c11be2c84f | |
Ryan Harg | 302c950b19 | |
Ryan Harg | 0d100a592b | |
Ryan Harg | 70d9ba241b | |
Ryan Harg | 3f6e010ace | |
Ryan Harg | 20ee27da21 | |
Ryan Harg | 2bdc7f09de | |
Georg Krause | fdadc5853a | |
Micha Gläß-Stöcker | 084b0c2faf | |
Ryan Harg | 1d29876943 | |
Renovate Bot | 1171f6bd1f | |
Ryan Harg | 2e7634fe6d | |
Renovate Bot | 87965863d4 | |
Ryan Harg | fd1d72bf21 | |
Renovate Bot | 58d9a57a53 | |
Ryan Harg | bcbee1fa6c | |
Renovate Bot | 4a9d65624b | |
Ryan Harg | 5e18294e10 | |
Renovate Bot | 926075d591 | |
Ryan Harg | 340073dc2a | |
Renovate Bot | c09b66b347 | |
Micha Gläß-Stöcker | 1ec04d0841 | |
Ryan Harg | cdfd2b72b1 | |
Renovate Bot | f8ad760fda | |
Ryan Harg | c92e40ec17 | |
Renovate Bot | 23f7c509ee | |
Ryan Harg | d6df4f3613 | |
Renovate Bot | 987f5a482a | |
Ryan Harg | 9f24b2e6c2 | |
Ryan Harg | a6b1730c4a | |
Ryan Harg | 2996c85fa2 | |
Renovate Bot | e17dc7531d | |
Ryan Harg | a218dd94ed | |
Renovate Bot | 7d92d7d06b | |
Éilias McTalún | f77b2e51e5 | |
Éilias McTalún | 02a6112eeb | |
Ryan Harg | d924d510ba | |
Renovate Bot | 1ad0513120 | |
Ryan Harg | caa0307fdf | |
Renovate Bot | 1fcf154993 | |
Ryan Harg | b881dd0f38 | |
Renovate Bot | cf634a1eac | |
Ryan Harg | fd90f2d1a0 | |
RenovateBot | 89d4515cd4 | |
Ryan Harg | 5e2e823422 | |
Ryan Harg | 2d1c2f34e5 | |
Ryan Harg | 54c3d1ef63 | |
Ryan Harg | 45ef5eb189 | |
Ryan Harg | b6f0afc5c3 | |
Renovate Bot | 56784b5871 | |
Ryan Harg | 7e7efab063 | |
Renovate Bot | 1b9cd0895f | |
Ryan Harg | 8efdd9ee8c | |
Renovate Bot | bbbefaaf60 | |
Ryan Harg | 4ee3f73fe3 | |
Renovate Bot | 6ad4a3f770 | |
Ryan Harg | c9ae07a330 | |
Renovate Bot | db6c484d56 | |
Ryan Harg | 431b28ecd4 | |
Mouath Ibrahim | c29e36c697 | |
ghose | 41519bda81 | |
Ryan Harg | 36751f9469 | |
Renovate Bot | 44d54ac730 | |
Michael Long | 8529fc441d | |
Ryan Harg | 83c0ad7f1b | |
Renovate Bot | d211e006e3 | |
Ryan Harg | a21ed67d93 | |
Renovate Bot | 3ae3ee7f5c | |
JuniorJPDJ | fb0e6985f7 | |
Ryan Harg | b67f4f872b | |
Ryan Harg | 82b9121433 | |
Kristoffer Grundström | 1522a5884b | |
Dignified Silence | a93afe4533 | |
Ryan Harg | 0ce41dec1e | |
Renovate Bot | 519bb79ea7 | |
Ryan Harg | 45de070cd5 | |
Renovate Bot | 29c2784f13 | |
Ryan Harg | 253ce9e0f2 | |
Ryan Harg | ad0c35b574 | |
Ryan Harg | 060db0afdf | |
Renovate Bot | 658dd78b7f | |
Ryan Harg | c3abbe4891 | |
Renovate Bot | ba24fdd820 | |
Ryan Harg | a0c5b83c0d | |
Ryan Harg | b54db01488 | |
Ryan Harg | aa874866a6 | |
Ryan Harg | be67a5e593 | |
Ryan Harg | 0c95675c91 | |
Ryan Harg | 5f6d38051d | |
Ryan Harg | de1c60bfb2 | |
Renovate Bot | 709bbd29bd | |
Ryan Harg | 3450103dea | |
Renovate Bot | 24eb65ec08 | |
Ryan Harg | 9e0843b758 | |
Renovate Bot | fa2830634b | |
Ryan Harg | b12779ed39 | |
Renovate Bot | d604629f3a | |
Ryan Harg | b842baa33b | |
Renovate Bot | 86f6dd2d54 | |
Ryan Harg | 23084cae4e | |
Renovate Bot | 59c8c265ff | |
Ryan Harg | d992b936d4 | |
Renovate Bot | 37ad6eaf5b | |
Ryan Harg | d9cd6afa0d | |
Renovate Bot | dc8a27535e | |
Ryan Harg | eec30e8582 | |
Renovate Bot | fe31e185fa | |
Thomas | 0fa69d837e | |
Ryan Harg | 1fc0f6e8ac | |
Georg Krause | 37409bdd7a | |
Erik Präntare | e000aa5e6a | |
Erik Präntare | 5d7583e7f5 | |
Burp | af9342428e | |
milotype | ed9f4e0e88 | |
Dignified Silence | 049e61ab7f | |
ghose | d3043fc8da | |
danigarau5dd7796dc359494b | 6a70540e0e | |
Ryan Harg | fd955bfc8d | |
Ryan Harg | c817bf8ec0 | |
Ryan Harg | 2431fec33e | |
Ryan Harg | 5e347008b9 | |
Ryan Harg | 25ec60b1e5 | |
Ryan Harg | 27bf59cb47 | |
Ryan Harg | fa28413653 | |
Ryan Harg | b85fdc884f |
|
@ -1,9 +1,9 @@
|
|||
*.iml
|
||||
.gradle
|
||||
**/.gradle
|
||||
/local.properties
|
||||
/.idea
|
||||
.DS_Store
|
||||
/build
|
||||
**/build
|
||||
/captures
|
||||
.externalNativeBuild
|
||||
*.keystore
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
image: jangrewe/gitlab-ci-android
|
||||
# This image lives in https://dev.funkwhale.audio/funkwhale/ci
|
||||
image: $CI_REGISTRY/funkwhale/ci/android:latest
|
||||
|
||||
variables:
|
||||
COBERTURA_REPORT: '$CI_PROJECT_DIR/app/build/reports/cobertura.xml'
|
||||
|
@ -6,11 +7,20 @@ variables:
|
|||
JACOCO_XML_LOCATION: '$CI_PROJECT_DIR/app/build/reports/jacoco/jacocoTestReport/jacocoTestReport.xml'
|
||||
|
||||
stages:
|
||||
- build_ci_env
|
||||
- test
|
||||
- visualize
|
||||
- build
|
||||
- test-after-build
|
||||
- deploy
|
||||
|
||||
cache: &global_cache
|
||||
key: ${CI_PIPELINE_ID}
|
||||
paths:
|
||||
- .gradle/wrapper
|
||||
- .gradle/caches
|
||||
policy: pull
|
||||
|
||||
.gradle-default:
|
||||
before_script:
|
||||
- export GRADLE_USER_HOME=$(pwd)/.gradle
|
||||
|
@ -19,11 +29,6 @@ stages:
|
|||
script:
|
||||
- echo "Overwrite me"
|
||||
|
||||
cache:
|
||||
key: ${CI_PROJECT_ID}
|
||||
paths:
|
||||
- .gradle/
|
||||
|
||||
.build:
|
||||
stage: build
|
||||
variables:
|
||||
|
@ -34,7 +39,7 @@ stages:
|
|||
before_script:
|
||||
- git fetch --unshallow --tags
|
||||
after_script:
|
||||
- export versionCode=`$ANDROID_HOME/build-tools/30.0.2/aapt dump badging $apk_file | grep versionCode | awk '{print $3}' | sed s/versionCode=//g | sed s/\'//g`
|
||||
- export versionCode=`$ANDROID_HOME/build-tools/30.0.3/aapt dump badging $apk_file | grep versionCode | awk '{print $3}' | sed s/versionCode=//g | sed s/\'//g`
|
||||
- apt update && apt install gettext-base
|
||||
- cat $metadata_template | envsubst > $metadata_file
|
||||
extends: .gradle-default
|
||||
|
@ -43,6 +48,9 @@ stages:
|
|||
- $apk_file
|
||||
- $metadata_file
|
||||
- $output_metadata
|
||||
cache:
|
||||
# inherit all global cache settings
|
||||
<<: *global_cache
|
||||
|
||||
test:
|
||||
extends: .gradle-default
|
||||
|
@ -50,17 +58,30 @@ test:
|
|||
except:
|
||||
- tags
|
||||
script:
|
||||
- ./gradlew test jacocoTestReport
|
||||
- ./gradlew --no-daemon --stacktrace test jacocoTestReport
|
||||
- awk -F"," '{ instructions += $4 + $5; covered += $5 } END { print covered, "/", instructions, " instructions covered"; print 100*covered/instructions, "% covered" }' $JACOCO_CSV_LOCATION
|
||||
artifacts:
|
||||
reports:
|
||||
junit: app/build/test-results/test**/TEST-*.xml
|
||||
paths:
|
||||
- $JACOCO_XML_LOCATION
|
||||
cache:
|
||||
# inherit all global cache settings
|
||||
<<: *global_cache
|
||||
# override the policy
|
||||
policy: pull-push
|
||||
|
||||
test_nonfree_code:
|
||||
stage: test-after-build
|
||||
image: registry.funkwhale.audio/funkwhale/ci/android-fdroidserver
|
||||
script:
|
||||
- fdroid scanner -v app/build/outputs/apk/*/app-*.apk |& tee output.txt
|
||||
- cat output.txt
|
||||
- (! grep "CRITICAL" output.txt)
|
||||
|
||||
coverage:
|
||||
stage: visualize
|
||||
image: gjrtimmer/jacoco2cobertura:1.0.8
|
||||
image: haynes/jacoco2cobertura:1.0.9
|
||||
script:
|
||||
# convert report from jacoco to cobertura, use relative project path
|
||||
- 'python /opt/cover2cover.py $JACOCO_XML_LOCATION $CI_PROJECT_DIR/app/src/main/java > app/build/reports/cobertura.xml'
|
||||
|
@ -71,13 +92,15 @@ coverage:
|
|||
- tags
|
||||
artifacts:
|
||||
reports:
|
||||
cobertura: $COBERTURA_REPORT
|
||||
coverage_report:
|
||||
coverage_format: cobertura
|
||||
path: $COBERTURA_REPORT
|
||||
|
||||
build-develop:
|
||||
extends: .build
|
||||
script:
|
||||
- echo -n $PREVIEW_SIGNING_KEY_STORE | base64 -d > app/android.keystore
|
||||
- ./gradlew assembleDebug -Psigning.store=android.keystore -Psigning.store_passphrase=$PREVIEW_SIGNING_KEY_PASS -Psigning.key_passphrase=$PREVIEW_SIGNING_KEY_PASS
|
||||
- ./gradlew --stacktrace --no-daemon assembleDebug -x check -Psigning.store=android.keystore -Psigning.store_passphrase=$PREVIEW_SIGNING_KEY_PASS -Psigning.key_passphrase=$PREVIEW_SIGNING_KEY_PASS
|
||||
only:
|
||||
- develop
|
||||
|
||||
|
@ -90,45 +113,51 @@ build-release:
|
|||
extends: .build
|
||||
script:
|
||||
- echo -n $SIGNING_KEY_STORE | base64 -d > app/android.keystore
|
||||
- ./gradlew assembleRelease -Psigning.store=android.keystore -Psigning.store_passphrase=$SIGNING_KEY_PASS -Psigning.key_passphrase=$SIGNING_KEY_PASS
|
||||
- ./gradlew --stacktrace --no-daemon assembleRelease -Psigning.store=android.keystore -Psigning.store_passphrase=$SIGNING_KEY_PASS -Psigning.key_passphrase=$SIGNING_KEY_PASS
|
||||
only:
|
||||
- tags
|
||||
|
||||
build-bleeding-edge:
|
||||
extends: .build
|
||||
script:
|
||||
- ./gradlew assembleDebug
|
||||
- ./gradlew --stacktrace --no-daemon -x check assembleDebug
|
||||
except:
|
||||
- develop
|
||||
- tags
|
||||
|
||||
.deploy:
|
||||
image: debian
|
||||
before_script:
|
||||
- apt update && apt -y install openssh-server
|
||||
image: curlimages/curl:latest
|
||||
script:
|
||||
- 'curl --header "JOB-TOKEN: $CI_JOB_TOKEN" --upload-file $FILE "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/$PACKAGE/$CI_COMMIT_SHORT_SHA/$PACKAGE-$CI_COMMIT_SHORT_SHA.apk"'
|
||||
|
||||
deploy-develop:
|
||||
extends: .deploy
|
||||
stage: deploy
|
||||
only:
|
||||
- develop
|
||||
script:
|
||||
- eval `ssh-agent -s`
|
||||
- ssh-add <(echo "$SSH_PRIVATE_KEY")
|
||||
- scp -o StrictHostKeyChecking=no app/build/outputs/apk/debug/app-debug.apk fdroid@apps.funkwhale.audio:/srv/fdroid/fdroid/develop/repo/audio.funkwhale.ffa.dev-$CI_COMMIT_SHORT_SHA.apk
|
||||
- scp -o StrictHostKeyChecking=no app/build/outputs/apk/debug/output-metadata.json fdroid@apps.funkwhale.audio:/srv/fdroid/fdroid/develop/output-metadata.json
|
||||
- scp -o StrictHostKeyChecking=no metadata/audio.funkwhale.android.dev.yml fdroid@apps.funkwhale.audio:/srv/fdroid/fdroid/develop/metadata/audio.funkwhale.ffa.dev.yml
|
||||
- ssh -o StrictHostKeyChecking=no fdroid@apps.funkwhale.audio 'docker run --rm -u $(id -u):$(id -g) -v /srv/fdroid/fdroid/develop:/repo registry.gitlab.com/fdroid/docker-executable-fdroidserver:master update'
|
||||
variables:
|
||||
FILE: app/build/outputs/apk/debug/app-debug.apk
|
||||
PACKAGE: audio.funkwhale.ffa.dev
|
||||
|
||||
deploy-release:
|
||||
extends: .deploy
|
||||
stage: deploy
|
||||
only:
|
||||
- tags
|
||||
script:
|
||||
- eval `ssh-agent -s`
|
||||
- ssh-add <(echo "$SSH_PRIVATE_KEY")
|
||||
- scp -o StrictHostKeyChecking=no app/build/outputs/apk/release/app-release.apk fdroid@apps.funkwhale.audio:/srv/fdroid/fdroid/develop/repo/audio.funkwhale.ffa-$CI_COMMIT_TAG.apk
|
||||
- scp -o StrictHostKeyChecking=no app/build/outputs/apk/release/output-metadata.json fdroid@apps.funkwhale.audio:/srv/fdroid/fdroid/develop/output-metadata.json
|
||||
- scp -o StrictHostKeyChecking=no metadata/audio.funkwhale.android.yml fdroid@apps.funkwhale.audio:/srv/fdroid/fdroid/develop/metadata/audio.funkwhale.ffa.yml
|
||||
- ssh -o StrictHostKeyChecking=no fdroid@apps.funkwhale.audio 'docker run --rm -u $(id -u):$(id -g) -v /srv/fdroid/fdroid/develop:/repo registry.gitlab.com/fdroid/docker-executable-fdroidserver:master update'
|
||||
variables:
|
||||
FILE: app/build/outputs/apk/release/app-release.apk
|
||||
PACKAGE: audio.funkwhale.ffa
|
||||
|
||||
trigger-fdroid-update-develop:
|
||||
stage: .post
|
||||
only:
|
||||
- develop
|
||||
image: curlimages/curl:7.88.1
|
||||
script: curl "https://fdroid.funkwhale.audio/hooks/update-index?name=audio.funkwhale.ffa.dev&version=$CI_COMMIT_SHORT_SHA"
|
||||
|
||||
trigger-fdroid-update-release:
|
||||
stage: .post
|
||||
only:
|
||||
- tags
|
||||
image: curlimages/curl:7.88.1
|
||||
script: curl "https://fdroid.funkwhale.audio/hooks/update-index?name=audio.funkwhale.ffa&version=$CI_COMMIT_SHORT_SHA"
|
||||
|
|
|
@ -1,3 +1,3 @@
|
|||
# Enable auto-env through the sdkman_auto_env config
|
||||
# Add key=value pairs of SDKs to use below
|
||||
java=11.0.11.hs-adpt
|
||||
java=11.0.13-tem
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
java temurin-11.0.16+101
|
58
CHANGELOG
58
CHANGELOG
|
@ -1,4 +1,60 @@
|
|||
0.1.3 (unreleased)
|
||||
0.3.0 (2023-12-12)
|
||||
Features:
|
||||
|
||||
- Add option to limit bandwidth usage by streaming transcoded music
|
||||
- Improve player bottom sheet, in particular fling support
|
||||
|
||||
|
||||
Enhancements:
|
||||
|
||||
- Refactor CoverArt.withContext().
|
||||
|
||||
|
||||
Bugfixes:
|
||||
|
||||
- Fix buffering progress bar display
|
||||
- Fix landscape view induced MainActivity leak.
|
||||
- Fix Too Many Receivers exception
|
||||
|
||||
|
||||
0.2.1 (2023-04-18)
|
||||
Bugfixes:
|
||||
|
||||
- Removed navigation-dynamic-features-fragment, which has proprietary dependencies and isn't needed
|
||||
|
||||
|
||||
0.2.0 (2023-04-05)
|
||||
Features:
|
||||
|
||||
- Add filtering functionality to favorites view (thanks @PhieF)
|
||||
- Allow backward skip after pause by configurable number of seconds (contributed by hdasch)
|
||||
- Use the track cover in an album track list if one is available
|
||||
|
||||
|
||||
Bugfixes:
|
||||
|
||||
- Make the mini player overlay stay on top (contributed by @christophehenry)
|
||||
- Use Picasso stableKey for better caching against pre-signed URLs (thanks @rickosborne)
|
||||
|
||||
|
||||
0.1.5 (2022-07-04)
|
||||
Bugfixes:
|
||||
|
||||
- Fix App crashes when interacting with playlist (@Mouath)
|
||||
- Fix leaked database cursor resource
|
||||
- Fix playback order to respect preference setting on albums fragment
|
||||
- Fix the removal of existing downloads
|
||||
- Fix unresponsive bluetooth buttons with Oreo and later (thanks @hdasch)
|
||||
- Fix warnings in log output due to leaked BufferedReader resource (thanks @hdasch)
|
||||
- Fixes problem where users are logged out sporadically (thanks to @hdasch)
|
||||
|
||||
|
||||
0.1.4 (2021-09-18)
|
||||
Bugfixes:
|
||||
|
||||
- Fix application crash when opening playlists view (#99)
|
||||
|
||||
0.1.3 (2021-09-17)
|
||||
Bugfixes:
|
||||
|
||||
- Disable landscape mode to avoid application crashes (#93)
|
||||
|
|
15
README.md
15
README.md
|
@ -7,9 +7,8 @@ You can get help and discuss Funkwhale on Matrix on [#funkwhale-android:matrix.o
|
|||
|
||||
## Installation
|
||||
|
||||
Currently you can install a preview version of Funkwhale for Android™ through a selfhosted [F-Droid repository](https://fdroid.funkwhale.audio/develop/).
|
||||
You'll have to add this repository to your F-Droid client, please visit the link above for further instructions. Once you added the repository, you can
|
||||
use F-Droid as usual and search for "Funkwhale".
|
||||
We have an official version available on F-Droid and the Google Play-Store, but you can also install a preview version of Funkwhale for Android™ through our selfhosted [F-Droid repository](https://fdroid.funkwhale.audio/develop/).
|
||||
You'll have to add this repository to your F-Droid client, please visit the link above for further instructions. Once you added the repository, you can use F-Droid as usual and search for "Funkwhale".
|
||||
|
||||
## State
|
||||
|
||||
|
@ -31,13 +30,9 @@ Funkwhale for Android™ will try to behave as you would expect a mobile music p
|
|||
|
||||
## Screenshots
|
||||
|
||||
<img src="https://dev.funkwhale.audio/funkwhale/funkwhale-android/-/raw/develop/app/src/main/play/listings/en-US/graphics/phone-screenshots/1.png" width="200" />
|
||||
<img src="https://dev.funkwhale.audio/funkwhale/funkwhale-android/-/raw/develop/app/src/main/play/listings/en-US/graphics/phone-screenshots/2.png" width="200" />
|
||||
<img src="https://dev.funkwhale.audio/funkwhale/funkwhale-android/-/raw/develop/app/src/main/play/listings/en-US/graphics/phone-screenshots/3.png" width="200" />
|
||||
<img src="https://dev.funkwhale.audio/funkwhale/funkwhale-android/-/raw/develop/app/src/main/play/listings/en-US/graphics/phone-screenshots/4.png" width="200" />
|
||||
<img src="https://dev.funkwhale.audio/funkwhale/funkwhale-android/-/raw/develop/app/src/main/play/listings/en-US/graphics/phone-screenshots/5.png" width="200" />
|
||||
<img src="https://dev.funkwhale.audio/funkwhale/funkwhale-android/-/raw/develop/app/src/main/play/listings/en-US/graphics/phone-screenshots/6.png" width="200" />
|
||||
<img src="https://dev.funkwhale.audio/funkwhale/funkwhale-android/-/raw/develop/app/src/main/play/listings/en-US/graphics/phone-screenshots/7.png" width="200" />
|
||||
<img src="https://dev.funkwhale.audio/funkwhale/funkwhale-android/-/raw/develop/app/src/main/play/listings/en-US/graphics/phone-screenshots/1.png" width="33%" />
|
||||
<img src="https://dev.funkwhale.audio/funkwhale/funkwhale-android/-/raw/develop/app/src/main/play/listings/en-US/graphics/phone-screenshots/2.png" width="33%" />
|
||||
<img src="https://dev.funkwhale.audio/funkwhale/funkwhale-android/-/raw/develop/app/src/main/play/listings/en-US/graphics/phone-screenshots/3.png" width="33%" />
|
||||
|
||||
## Translation
|
||||
|
||||
|
|
|
@ -5,12 +5,16 @@ import java.util.Properties
|
|||
plugins {
|
||||
id("com.android.application")
|
||||
id("kotlin-android")
|
||||
id("androidx.navigation.safeargs.kotlin")
|
||||
id("kotlin-parcelize")
|
||||
id("kotlin-kapt")
|
||||
|
||||
id("org.jlleitschuh.gradle.ktlint") version "10.1.0"
|
||||
id("org.jlleitschuh.gradle.ktlint") version "11.2.0"
|
||||
id("com.gladed.androidgitversion") version "0.4.14"
|
||||
id("com.github.triplet.play") version "3.6.0"
|
||||
id("com.github.triplet.play") version "3.8.1"
|
||||
id("de.mobilej.unmock")
|
||||
id("com.github.ben-manes.versions")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
jacoco
|
||||
}
|
||||
|
||||
|
@ -32,27 +36,35 @@ androidGitVersion {
|
|||
|
||||
android {
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||
targetCompatibility = JavaVersion.VERSION_1_8
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
namespace = "audio.funkwhale.ffa"
|
||||
|
||||
testCoverage {
|
||||
version = Versions.jacoco
|
||||
version = "0.8.7"
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = JavaVersion.VERSION_1_8.toString()
|
||||
jvmTarget = JavaVersion.VERSION_17.toString()
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
viewBinding = true
|
||||
dataBinding = true
|
||||
}
|
||||
|
||||
packagingOptions {
|
||||
resources.excludes.add("META-INF/LICENSE.md")
|
||||
resources.excludes.add("META-INF/LICENSE-notice.md")
|
||||
}
|
||||
|
||||
lint {
|
||||
disable += listOf("MissingTranslation", "ExtraTranslation")
|
||||
}
|
||||
|
||||
compileSdk = 30
|
||||
compileSdk = 33
|
||||
|
||||
defaultConfig {
|
||||
|
||||
|
@ -62,7 +74,7 @@ android {
|
|||
versionName = androidGitVersion.name()
|
||||
|
||||
minSdk = 24
|
||||
targetSdk = 30
|
||||
targetSdk = 33
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
|
@ -151,54 +163,65 @@ play {
|
|||
}
|
||||
|
||||
dependencies {
|
||||
val navVersion: String by rootProject.extra
|
||||
val lifecycleVersion: String by rootProject.extra
|
||||
|
||||
implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar", "*.aar"))))
|
||||
|
||||
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk7:${Versions.kotlin}")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.2")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.2")
|
||||
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.8.20")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4")
|
||||
|
||||
implementation("androidx.appcompat:appcompat:1.3.1")
|
||||
implementation("androidx.core:core-ktx:1.6.0")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.4.0-alpha03")
|
||||
implementation("androidx.coordinatorlayout:coordinatorlayout:1.1.0")
|
||||
implementation("androidx.preference:preference-ktx:1.1.1")
|
||||
implementation("androidx.appcompat:appcompat:1.6.1")
|
||||
implementation("androidx.core:core-ktx:1.9.0")
|
||||
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycleVersion")
|
||||
implementation("androidx.lifecycle:lifecycle-livedata-ktx:$lifecycleVersion")
|
||||
implementation("androidx.coordinatorlayout:coordinatorlayout:1.2.0")
|
||||
implementation("androidx.preference:preference-ktx:1.2.1")
|
||||
implementation("androidx.recyclerview:recyclerview:1.2.1")
|
||||
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")
|
||||
implementation("com.google.android.material:material:1.4.0")
|
||||
implementation("com.android.support.constraint:constraint-layout:2.0.4")
|
||||
implementation("com.google.android.material:material:1.9.0") {
|
||||
exclude("androidx.constraintlayout")
|
||||
}
|
||||
implementation("androidx.constraintlayout:constraintlayout:2.1.4")
|
||||
|
||||
implementation("com.google.android.exoplayer:exoplayer-core:${Versions.exoPlayer}")
|
||||
implementation("com.google.android.exoplayer:exoplayer-ui:${Versions.exoPlayer}")
|
||||
implementation("com.google.android.exoplayer:extension-mediasession:${Versions.exoPlayer}")
|
||||
implementation("com.google.android.exoplayer:exoplayer-core:2.18.1")
|
||||
implementation("com.google.android.exoplayer:exoplayer-ui:2.18.1")
|
||||
implementation("com.google.android.exoplayer:extension-mediasession:2.18.1")
|
||||
|
||||
implementation("io.insert-koin:koin-core:${Versions.koin}")
|
||||
implementation("io.insert-koin:koin-android:${Versions.koin}")
|
||||
testImplementation("io.insert-koin:koin-test:${Versions.koin}")
|
||||
implementation("io.insert-koin:koin-core:3.5.3")
|
||||
implementation("io.insert-koin:koin-android:3.5.3")
|
||||
testImplementation("io.insert-koin:koin-test:3.5.3")
|
||||
|
||||
implementation("com.github.PaulWoitaschek.ExoPlayer-Extensions:extension-opus:${Versions.exoPlayerExtensions}") {
|
||||
implementation("com.github.PaulWoitaschek.ExoPlayer-Extensions:extension-opus:789a4f83169cff5c7a91655bb828fde2cfde671a") {
|
||||
isTransitive = false
|
||||
}
|
||||
implementation("com.github.PaulWoitaschek.ExoPlayer-Extensions:extension-flac:${Versions.exoPlayerExtensions}") {
|
||||
implementation("com.github.PaulWoitaschek.ExoPlayer-Extensions:extension-flac:789a4f83169cff5c7a91655bb828fde2cfde671a") {
|
||||
isTransitive = false
|
||||
}
|
||||
|
||||
implementation("com.aliassadi:power-preference-lib:${Versions.powerPreference}")
|
||||
implementation("com.github.kittinunf.fuel:fuel:${Versions.fuel}")
|
||||
implementation("com.github.kittinunf.fuel:fuel-coroutines:${Versions.fuel}")
|
||||
implementation("com.github.kittinunf.fuel:fuel-android:${Versions.fuel}")
|
||||
implementation("com.github.kittinunf.fuel:fuel-gson:${Versions.fuel}")
|
||||
implementation("com.google.code.gson:gson:${Versions.gson}")
|
||||
implementation("com.github.AliAsadi:PowerPreference:2.1.1")
|
||||
implementation("com.github.kittinunf.fuel:fuel:2.3.1")
|
||||
implementation("com.github.kittinunf.fuel:fuel-coroutines:2.3.1")
|
||||
implementation("com.github.kittinunf.fuel:fuel-android:2.3.1")
|
||||
implementation("com.github.kittinunf.fuel:fuel-gson:2.3.1")
|
||||
implementation("com.google.code.gson:gson:2.10.1")
|
||||
implementation("com.squareup.picasso:picasso:2.71828")
|
||||
implementation("jp.wasabeef:picasso-transformations:2.4.0")
|
||||
implementation("net.openid:appauth:${Versions.openIdAppAuth}")
|
||||
implementation("net.openid:appauth:0.11.1")
|
||||
|
||||
implementation("androidx.navigation:navigation-fragment-ktx:$navVersion")
|
||||
implementation("androidx.navigation:navigation-ui-ktx:$navVersion")
|
||||
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
testImplementation("io.mockk:mockk:1.12.0")
|
||||
testImplementation("androidx.test:core:1.4.0")
|
||||
testImplementation("io.strikt:strikt-core:${Versions.strikt}")
|
||||
testImplementation("org.robolectric:robolectric:${Versions.robolectric}")
|
||||
testImplementation("io.mockk:mockk:1.13.4")
|
||||
testImplementation("androidx.test:core:1.5.0")
|
||||
testImplementation("io.strikt:strikt-core:0.34.1")
|
||||
testImplementation("org.robolectric:robolectric:4.9.2")
|
||||
debugImplementation("io.sentry:sentry-android:6.17.0")
|
||||
|
||||
androidTestImplementation("io.mockk:mockk-android:${Versions.mockk}")
|
||||
androidTestImplementation("io.mockk:mockk-android:1.13.4")
|
||||
androidTestImplementation("androidx.navigation:navigation-testing:$navVersion")
|
||||
}
|
||||
|
||||
project.afterEvaluate {
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="audio.funkwhale.ffa">
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<permission android:name="android.permission.MEDIA_CONTENT_CONTROL" />
|
||||
|
||||
|
@ -22,7 +23,7 @@
|
|||
android:name=".activities.SplashActivity"
|
||||
android:launchMode="singleInstance"
|
||||
android:noHistory="true"
|
||||
android:screenOrientation="portrait">
|
||||
android:exported="true">
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
@ -40,12 +41,7 @@
|
|||
android:screenOrientation="portrait" />
|
||||
|
||||
<activity
|
||||
android:name=".activities.MainActivity"
|
||||
android:screenOrientation="portrait" />
|
||||
|
||||
<activity
|
||||
android:name=".activities.SearchActivity"
|
||||
android:launchMode="singleTop" />
|
||||
android:name=".activities.MainActivity" />
|
||||
|
||||
<activity
|
||||
android:name=".activities.DownloadsActivity"
|
||||
|
@ -59,9 +55,15 @@
|
|||
android:name=".activities.LicencesActivity"
|
||||
android:screenOrientation="portrait" />
|
||||
|
||||
<activity
|
||||
android:name="net.openid.appauth.AuthorizationManagementActivity"
|
||||
android:launchMode="@integer/launch_mode_for_app_auth"
|
||||
tools:replace="android:launchMode" />
|
||||
|
||||
<service
|
||||
android:name=".playback.PlayerService"
|
||||
android:foregroundServiceType="mediaPlayback">
|
||||
android:foregroundServiceType="mediaPlayback"
|
||||
android:exported="false">
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MEDIA_BUTTON" />
|
||||
|
@ -80,12 +82,14 @@
|
|||
|
||||
</service>
|
||||
|
||||
<receiver android:name="androidx.media.session.MediaButtonReceiver">
|
||||
<receiver android:name="androidx.media.session.MediaButtonReceiver"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MEDIA_BUTTON" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<meta-data android:name="io.sentry.dsn" android:value="https://4e377f47d01242baae2d9d8bd689c3ef@am.funkwhale.audio/4" />
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
|
|
@ -6,13 +6,8 @@ import androidx.appcompat.app.AppCompatDelegate
|
|||
import audio.funkwhale.ffa.koin.authModule
|
||||
import audio.funkwhale.ffa.koin.exoplayerModule
|
||||
import audio.funkwhale.ffa.utils.AppContext
|
||||
import audio.funkwhale.ffa.utils.Command
|
||||
import audio.funkwhale.ffa.utils.Event
|
||||
import audio.funkwhale.ffa.utils.FFACache
|
||||
import audio.funkwhale.ffa.utils.Request
|
||||
import com.preference.PowerPreference
|
||||
import kotlinx.coroutines.channels.BroadcastChannel
|
||||
import kotlinx.coroutines.channels.ConflatedBroadcastChannel
|
||||
import org.koin.core.context.startKoin
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
|
@ -28,11 +23,6 @@ class FFA : Application() {
|
|||
|
||||
var defaultExceptionHandler: Thread.UncaughtExceptionHandler? = null
|
||||
|
||||
val eventBus: BroadcastChannel<Event> = BroadcastChannel(10)
|
||||
val commandBus: BroadcastChannel<Command> = BroadcastChannel(10)
|
||||
val requestBus: BroadcastChannel<Request> = BroadcastChannel(10)
|
||||
val progressBus: BroadcastChannel<Triple<Int, Int, Int>> = ConflatedBroadcastChannel()
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
|
@ -83,7 +73,7 @@ class FFA : Application() {
|
|||
|
||||
builder.appendLine(e.toString())
|
||||
|
||||
FFACache.set(this@FFA, "crashdump", builder.toString().toByteArray())
|
||||
FFACache.set(this@FFA, "crashdump", builder.toString())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -14,7 +14,6 @@ import com.google.android.exoplayer2.offline.DownloadManager
|
|||
import kotlinx.coroutines.Dispatchers.Default
|
||||
import kotlinx.coroutines.Dispatchers.Main
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koin.java.KoinJavaComponent.inject
|
||||
|
@ -65,10 +64,9 @@ class DownloadsActivity : AppCompatActivity() {
|
|||
|
||||
private fun refresh() {
|
||||
lifecycleScope.launch(Main) {
|
||||
val cursor = exoDownloadManager.downloadIndex.getDownloads()
|
||||
|
||||
adapter.downloads.clear()
|
||||
|
||||
exoDownloadManager.downloadIndex.getDownloads()
|
||||
.use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
val download = cursor.download
|
||||
|
||||
|
@ -78,7 +76,7 @@ class DownloadsActivity : AppCompatActivity() {
|
|||
)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
adapter.notifyDataSetChanged()
|
||||
}
|
||||
}
|
||||
|
@ -101,15 +99,17 @@ class DownloadsActivity : AppCompatActivity() {
|
|||
}
|
||||
|
||||
private suspend fun refreshProgress() {
|
||||
val cursor = exoDownloadManager.downloadIndex.getDownloads()
|
||||
|
||||
exoDownloadManager.downloadIndex.getDownloads()
|
||||
.use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
val download = cursor.download
|
||||
|
||||
download.getMetadata()?.let { info ->
|
||||
adapter.downloads.withIndex().associate { it.value to it.index }
|
||||
.filter { it.key.id == info.id }.toList().getOrNull(0)?.let { match ->
|
||||
if (download.state == Download.STATE_DOWNLOADING && download.percentDownloaded != info.download?.percentDownloaded ?: 0) {
|
||||
if (download.state == Download.STATE_DOWNLOADING &&
|
||||
download.percentDownloaded != (info.download?.percentDownloaded ?: 0)
|
||||
) {
|
||||
withContext(Main) {
|
||||
adapter.downloads[match.second] = info.apply {
|
||||
this.download = download
|
||||
|
@ -122,6 +122,7 @@ class DownloadsActivity : AppCompatActivity() {
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inner class DownloadChangedListener : DownloadsAdapter.OnDownloadChangedListener {
|
||||
override fun onItemRemoved(index: Int) {
|
||||
|
|
|
@ -1,10 +1,13 @@
|
|||
package audio.funkwhale.ffa.activities
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.res.Configuration
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.text.Editable
|
||||
import android.view.ViewGroup
|
||||
import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.view.doOnLayout
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
|
@ -40,13 +43,10 @@ class LoginActivity : AppCompatActivity() {
|
|||
limitContainerWidth()
|
||||
}
|
||||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
super.onActivityResult(requestCode, resultCode, data)
|
||||
|
||||
data?.let {
|
||||
when (requestCode) {
|
||||
0 -> {
|
||||
oAuth.exchange(this, data) {
|
||||
private var resultLauncher =
|
||||
registerForActivityResult(StartActivityForResult()) { result ->
|
||||
result.data?.let {
|
||||
oAuth.exchange(this, it) {
|
||||
PowerPreference
|
||||
.getFileByName(AppContext.PREFS_CREDENTIALS)
|
||||
.setBoolean("anonymous", false)
|
||||
|
@ -62,12 +62,17 @@ class LoginActivity : AppCompatActivity() {
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
with(binding) {
|
||||
val preferences = getPreferences(Context.MODE_PRIVATE)
|
||||
val hn = preferences?.getString("hostname", "")
|
||||
if (hn != null && !hn.isEmpty()) {
|
||||
hostname.text = Editable.Factory.getInstance().newEditable(hn)
|
||||
}
|
||||
cleartext.setChecked(preferences?.getBoolean("cleartext", false) ?: false)
|
||||
anonymous.setChecked(preferences?.getBoolean("anonymous", false) ?: false)
|
||||
login.setOnClickListener {
|
||||
var hostname = hostname.text.toString().trim().trim('/')
|
||||
|
||||
|
@ -100,6 +105,12 @@ class LoginActivity : AppCompatActivity() {
|
|||
|
||||
hostnameField.error = message
|
||||
}
|
||||
if (hostnameField.error == null) {
|
||||
val preferences = getPreferences(Context.MODE_PRIVATE)
|
||||
preferences?.edit()?.putString("hostname", hostname)?.commit()
|
||||
preferences?.edit()?.putBoolean("cleartext", cleartext.isChecked)?.commit()
|
||||
preferences?.edit()?.putBoolean("anonymous", anonymous.isChecked)?.commit()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -134,7 +145,7 @@ class LoginActivity : AppCompatActivity() {
|
|||
oAuth.init(hostname)
|
||||
return oAuth.register {
|
||||
PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).setString("hostname", hostname)
|
||||
oAuth.authorize(this)
|
||||
resultLauncher.launch(oAuth.authorizeIntent(this))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,236 +1,211 @@
|
|||
package audio.funkwhale.ffa.activities
|
||||
|
||||
import android.animation.Animator
|
||||
import android.animation.AnimatorListenerAdapter
|
||||
import android.animation.ObjectAnimator
|
||||
import android.animation.ValueAnimator
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Fragment
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.util.DisplayMetrics
|
||||
import android.view.Gravity
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.animation.AccelerateDecelerateInterpolator
|
||||
import android.widget.SeekBar
|
||||
import androidx.activity.addCallback
|
||||
import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.appcompat.widget.PopupMenu
|
||||
import androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.graphics.drawable.toDrawable
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.flowWithLifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.fragment.NavHostFragment
|
||||
import audio.funkwhale.ffa.FFA
|
||||
import audio.funkwhale.ffa.R
|
||||
import audio.funkwhale.ffa.databinding.ActivityMainBinding
|
||||
import audio.funkwhale.ffa.fragments.AddToPlaylistDialog
|
||||
import audio.funkwhale.ffa.fragments.AlbumsFragment
|
||||
import audio.funkwhale.ffa.fragments.ArtistsFragment
|
||||
import audio.funkwhale.ffa.fragments.BrowseFragment
|
||||
import audio.funkwhale.ffa.fragments.LandscapeQueueFragment
|
||||
import audio.funkwhale.ffa.fragments.BrowseFragmentDirections
|
||||
import audio.funkwhale.ffa.fragments.NowPlayingFragment
|
||||
import audio.funkwhale.ffa.fragments.QueueFragment
|
||||
import audio.funkwhale.ffa.fragments.TrackInfoDetailsFragment
|
||||
import audio.funkwhale.ffa.model.Track
|
||||
import audio.funkwhale.ffa.playback.MediaControlsManager
|
||||
import audio.funkwhale.ffa.playback.PinService
|
||||
import audio.funkwhale.ffa.playback.PlayerService
|
||||
import audio.funkwhale.ffa.repositories.FavoritedRepository
|
||||
import audio.funkwhale.ffa.repositories.FavoritesRepository
|
||||
import audio.funkwhale.ffa.repositories.Repository
|
||||
import audio.funkwhale.ffa.utils.AppContext
|
||||
import audio.funkwhale.ffa.utils.Command
|
||||
import audio.funkwhale.ffa.utils.CommandBus
|
||||
import audio.funkwhale.ffa.utils.Event
|
||||
import audio.funkwhale.ffa.utils.EventBus
|
||||
import audio.funkwhale.ffa.utils.FFACache
|
||||
import audio.funkwhale.ffa.utils.OAuth
|
||||
import audio.funkwhale.ffa.utils.ProgressBus
|
||||
import audio.funkwhale.ffa.utils.Request
|
||||
import audio.funkwhale.ffa.utils.RequestBus
|
||||
import audio.funkwhale.ffa.utils.Response
|
||||
import audio.funkwhale.ffa.utils.Settings
|
||||
import audio.funkwhale.ffa.utils.Userinfo
|
||||
import audio.funkwhale.ffa.utils.authorize
|
||||
import audio.funkwhale.ffa.utils.log
|
||||
import audio.funkwhale.ffa.utils.logError
|
||||
import audio.funkwhale.ffa.utils.maybeLoad
|
||||
import audio.funkwhale.ffa.utils.maybeNormalizeUrl
|
||||
import audio.funkwhale.ffa.utils.mustNormalizeUrl
|
||||
import audio.funkwhale.ffa.utils.onApi
|
||||
import audio.funkwhale.ffa.utils.toast
|
||||
import audio.funkwhale.ffa.utils.untilNetwork
|
||||
import audio.funkwhale.ffa.views.DisableableFrameLayout
|
||||
import audio.funkwhale.ffa.utils.wait
|
||||
import com.github.kittinunf.fuel.Fuel
|
||||
import com.github.kittinunf.fuel.coroutines.awaitStringResponse
|
||||
import com.google.android.exoplayer2.Player
|
||||
import com.google.android.exoplayer2.offline.DownloadService
|
||||
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||
import com.google.gson.Gson
|
||||
import com.preference.PowerPreference
|
||||
import com.squareup.picasso.Picasso
|
||||
import jp.wasabeef.picasso.transformations.RoundedCornersTransformation
|
||||
import kotlinx.coroutines.Dispatchers.Default
|
||||
import kotlinx.coroutines.Dispatchers.IO
|
||||
import kotlinx.coroutines.Dispatchers.Main
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koin.java.KoinJavaComponent.inject
|
||||
|
||||
|
||||
class MainActivity : AppCompatActivity() {
|
||||
enum class ResultCode(val code: Int) {
|
||||
LOGOUT(1001)
|
||||
}
|
||||
|
||||
private val favoriteRepository = FavoritesRepository(this)
|
||||
private val favoritedRepository = FavoritedRepository(this)
|
||||
private val favoritedRepository by lazy {
|
||||
FavoritedRepository(applicationContext)
|
||||
}
|
||||
private var menu: Menu? = null
|
||||
|
||||
private lateinit var binding: ActivityMainBinding
|
||||
private val oAuth: OAuth by inject(OAuth::class.java)
|
||||
|
||||
private val navigation: NavController by lazy {
|
||||
val navHost = supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment
|
||||
navHost.navController
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
AppContext.init(this)
|
||||
|
||||
AppContext.init(applicationContext)
|
||||
binding = ActivityMainBinding.inflate(layoutInflater)
|
||||
|
||||
(supportFragmentManager.findFragmentById(R.id.now_playing) as NowPlayingFragment).apply {
|
||||
onDetailsMenuItemClicked { binding.nowPlayingBottomSheet.close() }
|
||||
binding.nowPlayingBottomSheet.addBottomSheetCallback(
|
||||
object : BottomSheetBehavior.BottomSheetCallback() {
|
||||
override fun onStateChanged(bottomSheet: View, newState: Int) {
|
||||
// Add padding to the main fragment so that player control don't overlap
|
||||
// artists and albums
|
||||
addSiblingFragmentPadding()
|
||||
}
|
||||
|
||||
override fun onSlide(bottomSheet: View, slideOffset: Float) {
|
||||
// Animate the cover and other elements of the bottom sheet
|
||||
onBottomSheetDrag(slideOffset)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
addSiblingFragmentPadding()
|
||||
|
||||
setContentView(binding.root)
|
||||
|
||||
setSupportActionBar(binding.appbar)
|
||||
supportActionBar?.setDisplayShowTitleEnabled(false)
|
||||
|
||||
onBackPressedDispatcher.addCallback(this) {
|
||||
if (binding.nowPlayingBottomSheet.isOpen) {
|
||||
binding.nowPlayingBottomSheet.close()
|
||||
} else {
|
||||
navigation.navigateUp()
|
||||
}
|
||||
}
|
||||
|
||||
when (intent.action) {
|
||||
MediaControlsManager.NOTIFICATION_ACTION_OPEN_QUEUE.toString() -> launchDialog(QueueFragment())
|
||||
}
|
||||
|
||||
supportFragmentManager
|
||||
.beginTransaction()
|
||||
.replace(R.id.container, BrowseFragment())
|
||||
.commit()
|
||||
|
||||
lifecycleScope.launch {
|
||||
RequestBus.send(Request.GetQueue).wait<Response.Queue>()?.let {
|
||||
if (it.queue.isNotEmpty() && binding.nowPlayingBottomSheet.isHidden) {
|
||||
binding.nowPlayingBottomSheet.show()
|
||||
} else if (it.queue.isEmpty()) {
|
||||
binding.nowPlayingBottomSheet.hide()
|
||||
}
|
||||
}
|
||||
// Watch the event bus only after to prevent concurrency in displaying the bottom sheet
|
||||
watchEventBus()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
|
||||
(binding.container as? DisableableFrameLayout)?.setShouldRegisterTouch { _ ->
|
||||
if (binding.nowPlaying.isOpened()) {
|
||||
binding.nowPlaying.close()
|
||||
binding.nowPlaying.getFragment<NowPlayingFragment>().apply {
|
||||
favoritedRepository.update(applicationContext, lifecycleScope)
|
||||
|
||||
return@setShouldRegisterTouch false
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
favoritedRepository.update(this, lifecycleScope)
|
||||
|
||||
startService(Intent(this, PlayerService::class.java))
|
||||
DownloadService.start(this, PinService::class.java)
|
||||
startService(Intent(applicationContext, PlayerService::class.java))
|
||||
DownloadService.start(applicationContext, PinService::class.java)
|
||||
|
||||
CommandBus.send(Command.RefreshService)
|
||||
|
||||
lifecycleScope.launch(IO) {
|
||||
Userinfo.get(this@MainActivity, oAuth)
|
||||
}
|
||||
|
||||
with(binding) {
|
||||
|
||||
nowPlayingContainer?.nowPlayingToggle?.setOnClickListener {
|
||||
CommandBus.send(Command.ToggleState)
|
||||
}
|
||||
|
||||
nowPlayingContainer?.nowPlayingNext?.setOnClickListener {
|
||||
CommandBus.send(Command.NextTrack)
|
||||
}
|
||||
|
||||
nowPlayingContainer?.nowPlayingDetailsPrevious?.setOnClickListener {
|
||||
CommandBus.send(Command.PreviousTrack)
|
||||
}
|
||||
|
||||
nowPlayingContainer?.nowPlayingDetailsNext?.setOnClickListener {
|
||||
CommandBus.send(Command.NextTrack)
|
||||
}
|
||||
|
||||
nowPlayingContainer?.nowPlayingDetailsToggle?.setOnClickListener {
|
||||
CommandBus.send(Command.ToggleState)
|
||||
}
|
||||
|
||||
binding.nowPlayingContainer?.nowPlayingDetailsProgress?.setOnSeekBarChangeListener(object :
|
||||
SeekBar.OnSeekBarChangeListener {
|
||||
override fun onStopTrackingTouch(view: SeekBar?) {}
|
||||
|
||||
override fun onStartTrackingTouch(view: SeekBar?) {}
|
||||
|
||||
override fun onProgressChanged(view: SeekBar?, progress: Int, fromUser: Boolean) {
|
||||
if (fromUser) {
|
||||
CommandBus.send(Command.Seek(progress))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
landscapeQueue?.let {
|
||||
supportFragmentManager.beginTransaction()
|
||||
.replace(R.id.landscape_queue, LandscapeQueueFragment()).commit()
|
||||
Userinfo.get(applicationContext, oAuth)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBackPressed() {
|
||||
if (binding.nowPlaying.isOpened()) {
|
||||
binding.nowPlaying.close()
|
||||
return
|
||||
}
|
||||
|
||||
super.onBackPressed()
|
||||
}
|
||||
|
||||
override fun onPrepareOptionsMenu(menu: Menu?): Boolean {
|
||||
override fun onPrepareOptionsMenu(menu: Menu): Boolean {
|
||||
this.menu = menu
|
||||
|
||||
return super.onPrepareOptionsMenu(menu)
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||
menuInflater.inflate(R.menu.toolbar, menu)
|
||||
|
||||
menu?.findItem(R.id.nav_all_music)?.let {
|
||||
menu.findItem(R.id.nav_all_music)?.let {
|
||||
it.isChecked = Settings.getScopes().contains("all")
|
||||
it.isEnabled = !it.isChecked
|
||||
}
|
||||
|
||||
menu?.findItem(R.id.nav_my_music)?.isChecked = Settings.getScopes().contains("me")
|
||||
menu?.findItem(R.id.nav_followed)?.isChecked = Settings.getScopes().contains("subscribed")
|
||||
menu.findItem(R.id.nav_my_music)?.isChecked = Settings.getScopes().contains("me")
|
||||
menu.findItem(R.id.nav_followed)?.isChecked = Settings.getScopes().contains("subscribed")
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
var resultLauncher = registerForActivityResult(StartActivityForResult()) { result ->
|
||||
if (result.resultCode == ResultCode.LOGOUT.code) {
|
||||
Intent(this, LoginActivity::class.java).apply {
|
||||
FFA.get().deleteAllData(this@MainActivity)
|
||||
|
||||
flags =
|
||||
Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
|
||||
stopService(Intent(this@MainActivity, PlayerService::class.java))
|
||||
startActivity(this)
|
||||
finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
when (item.itemId) {
|
||||
android.R.id.home -> {
|
||||
binding.nowPlaying.close()
|
||||
|
||||
(supportFragmentManager.fragments.last() as? BrowseFragment)?.let {
|
||||
it.selectTabAt(0)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
launchFragment(BrowseFragment())
|
||||
binding.nowPlayingBottomSheet.close()
|
||||
navigation.popBackStack(R.id.browseFragment, false)
|
||||
}
|
||||
|
||||
R.id.nav_queue -> launchDialog(QueueFragment())
|
||||
R.id.nav_search -> startActivity(Intent(this, SearchActivity::class.java))
|
||||
R.id.nav_search -> navigation.navigate(BrowseFragmentDirections.browseToSearch())
|
||||
R.id.nav_all_music, R.id.nav_my_music, R.id.nav_followed -> {
|
||||
menu?.let { menu ->
|
||||
item.setShowAsAction(MenuItem.SHOW_AS_ACTION_COLLAPSE_ACTION_VIEW)
|
||||
item.actionView = View(this)
|
||||
item.setOnActionExpandListener(object : MenuItem.OnActionExpandListener {
|
||||
override fun onMenuItemActionExpand(item: MenuItem?) = false
|
||||
override fun onMenuItemActionCollapse(item: MenuItem?) = false
|
||||
override fun onMenuItemActionExpand(item: MenuItem) = false
|
||||
override fun onMenuItemActionCollapse(item: MenuItem) = false
|
||||
})
|
||||
|
||||
item.isChecked = !item.isChecked
|
||||
|
@ -279,118 +254,42 @@ class MainActivity : AppCompatActivity() {
|
|||
return false
|
||||
}
|
||||
}
|
||||
|
||||
R.id.nav_downloads -> startActivity(Intent(this, DownloadsActivity::class.java))
|
||||
R.id.settings -> startActivityForResult(Intent(this, SettingsActivity::class.java), 0)
|
||||
R.id.settings -> resultLauncher.launch(Intent(this, SettingsActivity::class.java))
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
super.onActivityResult(requestCode, resultCode, data)
|
||||
|
||||
if (resultCode == ResultCode.LOGOUT.code) {
|
||||
Intent(this, LoginActivity::class.java).apply {
|
||||
FFA.get().deleteAllData(this@MainActivity)
|
||||
|
||||
flags =
|
||||
Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
|
||||
stopService(Intent(this@MainActivity, PlayerService::class.java))
|
||||
startActivity(this)
|
||||
finish()
|
||||
}
|
||||
}
|
||||
private fun addSiblingFragmentPadding() {
|
||||
val anim = if (binding.nowPlayingBottomSheet.isHidden) {
|
||||
ValueAnimator.ofInt(binding.nowPlayingBottomSheet.peekHeight, 0)
|
||||
} else {
|
||||
ValueAnimator.ofInt(0, binding.nowPlayingBottomSheet.peekHeight)
|
||||
}
|
||||
|
||||
private fun launchFragment(fragment: Fragment) {
|
||||
supportFragmentManager.fragments.lastOrNull()?.also { oldFragment ->
|
||||
oldFragment.enterTransition = null
|
||||
oldFragment.exitTransition = null
|
||||
|
||||
supportFragmentManager.popBackStackImmediate(null, FragmentManager.POP_BACK_STACK_INCLUSIVE)
|
||||
anim.duration = 200
|
||||
anim.addUpdateListener {
|
||||
binding.navHostFragmentWrapper.setPadding(0, 0, 0, it.animatedValue as Int)
|
||||
}
|
||||
anim.start()
|
||||
}
|
||||
|
||||
supportFragmentManager
|
||||
.beginTransaction()
|
||||
.setCustomAnimations(0, 0, 0, 0)
|
||||
.replace(R.id.container, fragment)
|
||||
.commit()
|
||||
}
|
||||
|
||||
private fun launchDialog(fragment: DialogFragment) {
|
||||
supportFragmentManager.beginTransaction().let {
|
||||
fragment.show(it, "")
|
||||
}
|
||||
}
|
||||
private fun launchDialog(fragment: DialogFragment) =
|
||||
fragment.show(supportFragmentManager.beginTransaction(), "")
|
||||
|
||||
@SuppressLint("NewApi")
|
||||
private fun watchEventBus() {
|
||||
lifecycleScope.launch(Main) {
|
||||
EventBus.get().collect { message ->
|
||||
when (message) {
|
||||
is Event.LogOut -> {
|
||||
FFA.get().deleteAllData(this@MainActivity)
|
||||
startActivity(
|
||||
Intent(this@MainActivity, LoginActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_NO_HISTORY
|
||||
}
|
||||
)
|
||||
|
||||
finish()
|
||||
}
|
||||
|
||||
is Event.PlaybackError -> toast(message.message)
|
||||
|
||||
is Event.Buffering -> {
|
||||
when (message.value) {
|
||||
true -> binding.nowPlayingContainer?.nowPlayingBuffering?.visibility = View.VISIBLE
|
||||
false -> binding.nowPlayingContainer?.nowPlayingBuffering?.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
is Event.PlaybackStopped -> {
|
||||
if (binding.nowPlaying.visibility == View.VISIBLE) {
|
||||
(binding.container.layoutParams as? ViewGroup.MarginLayoutParams)?.let {
|
||||
it.bottomMargin = it.bottomMargin / 2
|
||||
}
|
||||
|
||||
binding.landscapeQueue?.let { landscape_queue ->
|
||||
(landscape_queue.layoutParams as? ViewGroup.MarginLayoutParams)?.let {
|
||||
it.bottomMargin = it.bottomMargin / 2
|
||||
}
|
||||
}
|
||||
|
||||
binding.nowPlaying.animate()
|
||||
.alpha(0.0f)
|
||||
.setDuration(400)
|
||||
.setListener(object : AnimatorListenerAdapter() {
|
||||
override fun onAnimationEnd(animator: Animator?) {
|
||||
binding.nowPlaying.visibility = View.GONE
|
||||
}
|
||||
})
|
||||
.start()
|
||||
}
|
||||
}
|
||||
|
||||
is Event.TrackFinished -> incrementListenCount(message.track)
|
||||
|
||||
is Event.StateChanged -> {
|
||||
when (message.playing) {
|
||||
true -> {
|
||||
binding.nowPlayingContainer?.nowPlayingToggle?.icon = getDrawable(R.drawable.pause)
|
||||
binding.nowPlayingContainer?.nowPlayingDetailsToggle?.icon = getDrawable(R.drawable.pause)
|
||||
}
|
||||
|
||||
false -> {
|
||||
binding.nowPlayingContainer?.nowPlayingToggle?.icon = getDrawable(R.drawable.play)
|
||||
binding.nowPlayingContainer?.nowPlayingDetailsToggle?.icon =
|
||||
getDrawable(R.drawable.play)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
EventBus.get().collect { event ->
|
||||
when (event) {
|
||||
is Event.LogOut -> logout()
|
||||
is Event.PlaybackError -> toast(event.message)
|
||||
is Event.PlaybackStopped -> binding.nowPlayingBottomSheet.hide()
|
||||
is Event.TrackFinished -> incrementListenCount(event.track)
|
||||
is Event.QueueChanged -> {
|
||||
if (binding.nowPlayingBottomSheet.isHidden) binding.nowPlayingBottomSheet.show()
|
||||
findViewById<View>(R.id.nav_queue)?.let { view ->
|
||||
ObjectAnimator.ofFloat(view, View.ROTATION, 0f, 360f).let {
|
||||
it.duration = 500
|
||||
|
@ -399,263 +298,42 @@ class MainActivity : AppCompatActivity() {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lifecycleScope.launch(Main) {
|
||||
CommandBus.get().collect { command ->
|
||||
CommandBus.get().flowWithLifecycle(
|
||||
this@MainActivity.lifecycle, Lifecycle.State.RESUMED
|
||||
).collect { command ->
|
||||
when (command) {
|
||||
is Command.StartService -> {
|
||||
Build.VERSION_CODES.O.onApi(
|
||||
{
|
||||
startForegroundService(
|
||||
Intent(
|
||||
this@MainActivity,
|
||||
PlayerService::class.java
|
||||
).apply {
|
||||
putExtra(PlayerService.INITIAL_COMMAND_KEY, command.command.toString())
|
||||
}
|
||||
)
|
||||
},
|
||||
{
|
||||
startService(
|
||||
Intent(this@MainActivity, PlayerService::class.java).apply {
|
||||
putExtra(PlayerService.INITIAL_COMMAND_KEY, command.command.toString())
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
is Command.RefreshTrack -> refreshCurrentTrack(command.track)
|
||||
|
||||
is Command.AddToPlaylist -> if (lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) {
|
||||
AddToPlaylistDialog.show(
|
||||
is Command.StartService -> startService(command.command)
|
||||
is Command.RefreshTrack -> refreshTrack(command.track)
|
||||
is Command.AddToPlaylist -> AddToPlaylistDialog.show(
|
||||
layoutInflater,
|
||||
this@MainActivity,
|
||||
lifecycleScope,
|
||||
command.tracks
|
||||
)
|
||||
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lifecycleScope.launch(Main) {
|
||||
ProgressBus.get().collect { (current, duration, percent) ->
|
||||
binding.nowPlayingContainer?.nowPlayingProgress?.progress = percent
|
||||
binding.nowPlayingContainer?.nowPlayingDetailsProgress?.progress = percent
|
||||
|
||||
val currentMins = (current / 1000) / 60
|
||||
val currentSecs = (current / 1000) % 60
|
||||
|
||||
val durationMins = duration / 60
|
||||
val durationSecs = duration % 60
|
||||
|
||||
binding.nowPlayingContainer?.nowPlayingDetailsProgressCurrent?.text =
|
||||
"%02d:%02d".format(currentMins, currentSecs)
|
||||
binding.nowPlayingContainer?.nowPlayingDetailsProgressDuration?.text =
|
||||
"%02d:%02d".format(durationMins, durationSecs)
|
||||
}
|
||||
private fun startService(command: Command) {
|
||||
val intent = Intent(this@MainActivity, PlayerService::class.java).apply {
|
||||
putExtra(PlayerService.INITIAL_COMMAND_KEY, command.toString())
|
||||
}
|
||||
ContextCompat.startForegroundService(this, intent)
|
||||
}
|
||||
|
||||
private fun refreshCurrentTrack(track: Track?) {
|
||||
track?.let {
|
||||
if (binding.nowPlaying.visibility == View.GONE) {
|
||||
binding.nowPlaying.visibility = View.VISIBLE
|
||||
binding.nowPlaying.alpha = 0f
|
||||
|
||||
binding.nowPlaying.animate()
|
||||
.alpha(1.0f)
|
||||
.setDuration(400)
|
||||
.setListener(null)
|
||||
.start()
|
||||
|
||||
(binding.container.layoutParams as? ViewGroup.MarginLayoutParams)?.let {
|
||||
it.bottomMargin = it.bottomMargin * 2
|
||||
}
|
||||
|
||||
binding.landscapeQueue?.let { landscape_queue ->
|
||||
(landscape_queue.layoutParams as? ViewGroup.MarginLayoutParams)?.let {
|
||||
it.bottomMargin = it.bottomMargin * 2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
binding.nowPlayingContainer?.nowPlayingTitle?.text = track.title
|
||||
binding.nowPlayingContainer?.nowPlayingAlbum?.text = track.artist.name
|
||||
binding.nowPlayingContainer?.nowPlayingToggle?.icon = getDrawable(R.drawable.pause)
|
||||
|
||||
binding.nowPlayingContainer?.nowPlayingDetailsTitle?.text = track.title
|
||||
binding.nowPlayingContainer?.nowPlayingDetailsArtist?.text = track.artist.name
|
||||
binding.nowPlayingContainer?.nowPlayingDetailsToggle?.icon = getDrawable(R.drawable.pause)
|
||||
|
||||
Picasso.get()
|
||||
.maybeLoad(maybeNormalizeUrl(track.album?.cover?.urls?.original))
|
||||
.fit()
|
||||
.centerCrop()
|
||||
.into(binding.nowPlayingContainer?.nowPlayingCover)
|
||||
|
||||
binding.nowPlayingContainer?.nowPlayingDetailsCover?.let { nowPlayingDetailsCover ->
|
||||
Picasso.get()
|
||||
.maybeLoad(maybeNormalizeUrl(track.album?.cover()))
|
||||
.fit()
|
||||
.centerCrop()
|
||||
.transform(RoundedCornersTransformation(16, 0))
|
||||
.into(nowPlayingDetailsCover)
|
||||
}
|
||||
|
||||
if (binding.nowPlayingContainer?.nowPlayingCover == null) {
|
||||
lifecycleScope.launch(Default) {
|
||||
val width = DisplayMetrics().apply {
|
||||
windowManager.defaultDisplay.getMetrics(this)
|
||||
}.widthPixels
|
||||
|
||||
val backgroundCover = Picasso.get()
|
||||
.maybeLoad(maybeNormalizeUrl(track.album?.cover()))
|
||||
.get()
|
||||
.run { Bitmap.createScaledBitmap(this, width, width, false).toDrawable(resources) }
|
||||
.apply {
|
||||
alpha = 20
|
||||
gravity = Gravity.CENTER
|
||||
}
|
||||
|
||||
withContext(Main) {
|
||||
binding.nowPlayingContainer?.nowPlayingDetails?.background = backgroundCover
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
binding.nowPlayingContainer?.nowPlayingDetailsRepeat?.let { now_playing_details_repeat ->
|
||||
changeRepeatMode(FFACache.get(this@MainActivity, "repeat")?.readLine()?.toInt() ?: 0)
|
||||
|
||||
now_playing_details_repeat.setOnClickListener {
|
||||
val current = FFACache.get(this@MainActivity, "repeat")?.readLine()?.toInt() ?: 0
|
||||
|
||||
changeRepeatMode((current + 1) % 3)
|
||||
}
|
||||
}
|
||||
|
||||
binding.nowPlayingContainer?.nowPlayingDetailsInfo?.let { nowPlayingDetailsInfo ->
|
||||
nowPlayingDetailsInfo.setOnClickListener {
|
||||
PopupMenu(
|
||||
this@MainActivity,
|
||||
nowPlayingDetailsInfo,
|
||||
Gravity.START,
|
||||
R.attr.actionOverflowMenuStyle,
|
||||
0
|
||||
).apply {
|
||||
inflate(R.menu.track_info)
|
||||
|
||||
setOnMenuItemClickListener {
|
||||
when (it.itemId) {
|
||||
R.id.track_info_artist -> ArtistsFragment.openAlbums(
|
||||
this@MainActivity,
|
||||
track.artist,
|
||||
art = track.album?.cover()
|
||||
)
|
||||
R.id.track_info_album -> AlbumsFragment.openTracks(this@MainActivity, track.album)
|
||||
R.id.track_info_details -> TrackInfoDetailsFragment.new(track)
|
||||
.show(supportFragmentManager, "dialog")
|
||||
}
|
||||
|
||||
binding.nowPlaying.close()
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
binding.nowPlayingContainer?.nowPlayingDetailsFavorite?.let { now_playing_details_favorite ->
|
||||
favoritedRepository.fetch().untilNetwork(lifecycleScope, IO) { favorites, _, _, _ ->
|
||||
lifecycleScope.launch(Main) {
|
||||
track.favorite = favorites.contains(track.id)
|
||||
|
||||
when (track.favorite) {
|
||||
true -> now_playing_details_favorite.setColorFilter(getColor(R.color.colorFavorite))
|
||||
false -> now_playing_details_favorite.setColorFilter(getColor(R.color.controlForeground))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
now_playing_details_favorite.setOnClickListener {
|
||||
when (track.favorite) {
|
||||
true -> {
|
||||
favoriteRepository.deleteFavorite(track.id)
|
||||
now_playing_details_favorite.setColorFilter(getColor(R.color.controlForeground))
|
||||
}
|
||||
|
||||
false -> {
|
||||
favoriteRepository.addFavorite(track.id)
|
||||
now_playing_details_favorite.setColorFilter(getColor(R.color.colorFavorite))
|
||||
}
|
||||
}
|
||||
|
||||
track.favorite = !track.favorite
|
||||
|
||||
favoriteRepository.fetch(Repository.Origin.Network.origin)
|
||||
}
|
||||
|
||||
binding.nowPlayingContainer?.nowPlayingDetailsAddToPlaylist?.setOnClickListener {
|
||||
CommandBus.send(Command.AddToPlaylist(listOf(track)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun changeRepeatMode(index: Int) {
|
||||
when (index) {
|
||||
// From no repeat to repeat all
|
||||
0 -> {
|
||||
FFACache.set(this@MainActivity, "repeat", "0".toByteArray())
|
||||
|
||||
binding.nowPlayingContainer?.nowPlayingDetailsRepeat?.setImageResource(R.drawable.repeat)
|
||||
binding.nowPlayingContainer?.nowPlayingDetailsRepeat?.setColorFilter(
|
||||
ContextCompat.getColor(
|
||||
this,
|
||||
R.color.controlForeground
|
||||
)
|
||||
)
|
||||
binding.nowPlayingContainer?.nowPlayingDetailsRepeat?.alpha = 0.2f
|
||||
|
||||
CommandBus.send(Command.SetRepeatMode(Player.REPEAT_MODE_OFF))
|
||||
}
|
||||
|
||||
// From repeat all to repeat one
|
||||
1 -> {
|
||||
FFACache.set(this@MainActivity, "repeat", "1".toByteArray())
|
||||
|
||||
binding.nowPlayingContainer?.nowPlayingDetailsRepeat?.setImageResource(R.drawable.repeat)
|
||||
binding.nowPlayingContainer?.nowPlayingDetailsRepeat?.setColorFilter(
|
||||
ContextCompat.getColor(
|
||||
this,
|
||||
R.color.controlForeground
|
||||
)
|
||||
)
|
||||
binding.nowPlayingContainer?.nowPlayingDetailsRepeat?.alpha = 1.0f
|
||||
|
||||
CommandBus.send(Command.SetRepeatMode(Player.REPEAT_MODE_ALL))
|
||||
}
|
||||
|
||||
// From repeat one to no repeat
|
||||
2 -> {
|
||||
FFACache.set(this@MainActivity, "repeat", "2".toByteArray())
|
||||
binding.nowPlayingContainer?.nowPlayingDetailsRepeat?.setImageResource(R.drawable.repeat_one)
|
||||
binding.nowPlayingContainer?.nowPlayingDetailsRepeat?.setColorFilter(
|
||||
ContextCompat.getColor(
|
||||
this,
|
||||
R.color.controlForeground
|
||||
)
|
||||
)
|
||||
binding.nowPlayingContainer?.nowPlayingDetailsRepeat?.alpha = 1.0f
|
||||
|
||||
CommandBus.send(Command.SetRepeatMode(Player.REPEAT_MODE_ONE))
|
||||
}
|
||||
private fun refreshTrack(track: Track?) {
|
||||
if (track != null) {
|
||||
binding.nowPlayingBottomSheet.show()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -666,7 +344,7 @@ class MainActivity : AppCompatActivity() {
|
|||
try {
|
||||
Fuel
|
||||
.post(mustNormalizeUrl("/api/v1/history/listenings/"))
|
||||
.authorize(this@MainActivity, oAuth)
|
||||
.authorize(applicationContext, oAuth)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(Gson().toJson(mapOf("track" to track.id)))
|
||||
.awaitStringResponse()
|
||||
|
@ -676,4 +354,15 @@ class MainActivity : AppCompatActivity() {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun logout() {
|
||||
FFA.get().deleteAllData(this@MainActivity)
|
||||
startActivity(
|
||||
Intent(this@MainActivity, LoginActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_NO_HISTORY
|
||||
}
|
||||
)
|
||||
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,197 +0,0 @@
|
|||
package audio.funkwhale.ffa.activities
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.appcompat.widget.SearchView
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import audio.funkwhale.ffa.adapters.FavoriteListener
|
||||
import audio.funkwhale.ffa.adapters.SearchAdapter
|
||||
import audio.funkwhale.ffa.databinding.ActivitySearchBinding
|
||||
import audio.funkwhale.ffa.fragments.AddToPlaylistDialog
|
||||
import audio.funkwhale.ffa.fragments.AlbumsFragment
|
||||
import audio.funkwhale.ffa.fragments.ArtistsFragment
|
||||
import audio.funkwhale.ffa.model.Album
|
||||
import audio.funkwhale.ffa.model.Artist
|
||||
import audio.funkwhale.ffa.repositories.AlbumsSearchRepository
|
||||
import audio.funkwhale.ffa.repositories.ArtistsSearchRepository
|
||||
import audio.funkwhale.ffa.repositories.FavoritesRepository
|
||||
import audio.funkwhale.ffa.repositories.Repository
|
||||
import audio.funkwhale.ffa.repositories.TracksSearchRepository
|
||||
import audio.funkwhale.ffa.utils.Command
|
||||
import audio.funkwhale.ffa.utils.CommandBus
|
||||
import audio.funkwhale.ffa.utils.Event
|
||||
import audio.funkwhale.ffa.utils.EventBus
|
||||
import audio.funkwhale.ffa.utils.getMetadata
|
||||
import audio.funkwhale.ffa.utils.untilNetwork
|
||||
import com.google.android.exoplayer2.offline.Download
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.net.URLEncoder
|
||||
import java.util.Locale
|
||||
|
||||
class SearchActivity : AppCompatActivity() {
|
||||
private lateinit var adapter: SearchAdapter
|
||||
|
||||
private lateinit var artistsRepository: ArtistsSearchRepository
|
||||
private lateinit var albumsRepository: AlbumsSearchRepository
|
||||
private lateinit var tracksRepository: TracksSearchRepository
|
||||
private lateinit var favoritesRepository: FavoritesRepository
|
||||
private lateinit var binding: ActivitySearchBinding
|
||||
|
||||
var done = 0
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
binding = ActivitySearchBinding.inflate(layoutInflater)
|
||||
|
||||
setContentView(binding.root)
|
||||
|
||||
binding.search.requestFocus()
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
CommandBus.get().collect { command ->
|
||||
when (command) {
|
||||
is Command.AddToPlaylist -> if (lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) {
|
||||
AddToPlaylistDialog.show(
|
||||
layoutInflater,
|
||||
this@SearchActivity,
|
||||
lifecycleScope,
|
||||
command.tracks
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
EventBus.get().collect { message ->
|
||||
when (message) {
|
||||
is Event.DownloadChanged -> refreshDownloadedTrack(message.download)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
artistsRepository = ArtistsSearchRepository(this@SearchActivity, "")
|
||||
albumsRepository = AlbumsSearchRepository(this@SearchActivity, "")
|
||||
tracksRepository = TracksSearchRepository(this@SearchActivity, "")
|
||||
favoritesRepository = FavoritesRepository(this@SearchActivity)
|
||||
|
||||
adapter =
|
||||
SearchAdapter(
|
||||
layoutInflater,
|
||||
this,
|
||||
SearchResultClickListener(),
|
||||
FavoriteListener(favoritesRepository)
|
||||
).also {
|
||||
binding.results.layoutManager = LinearLayoutManager(this)
|
||||
binding.results.adapter = it
|
||||
}
|
||||
|
||||
binding.search.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
|
||||
|
||||
override fun onQueryTextSubmit(rawQuery: String?): Boolean {
|
||||
binding.search.clearFocus()
|
||||
|
||||
rawQuery?.let {
|
||||
done = 0
|
||||
|
||||
val query = URLEncoder.encode(it, "UTF-8")
|
||||
|
||||
artistsRepository.query = query.lowercase(Locale.ROOT)
|
||||
albumsRepository.query = query.lowercase(Locale.ROOT)
|
||||
tracksRepository.query = query.lowercase(Locale.ROOT)
|
||||
|
||||
binding.searchSpinner.visibility = View.VISIBLE
|
||||
binding.searchEmpty.visibility = View.GONE
|
||||
binding.searchNoResults.visibility = View.GONE
|
||||
|
||||
adapter.artists.clear()
|
||||
adapter.albums.clear()
|
||||
adapter.tracks.clear()
|
||||
adapter.notifyDataSetChanged()
|
||||
|
||||
artistsRepository.fetch(Repository.Origin.Network.origin)
|
||||
.untilNetwork(lifecycleScope) { artists, _, _, _ ->
|
||||
done++
|
||||
|
||||
adapter.artists.addAll(artists)
|
||||
refresh()
|
||||
}
|
||||
|
||||
albumsRepository.fetch(Repository.Origin.Network.origin)
|
||||
.untilNetwork(lifecycleScope) { albums, _, _, _ ->
|
||||
done++
|
||||
|
||||
adapter.albums.addAll(albums)
|
||||
refresh()
|
||||
}
|
||||
|
||||
tracksRepository.fetch(Repository.Origin.Network.origin)
|
||||
.untilNetwork(lifecycleScope) { tracks, _, _, _ ->
|
||||
done++
|
||||
|
||||
adapter.tracks.addAll(tracks)
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onQueryTextChange(newText: String?) = true
|
||||
})
|
||||
}
|
||||
|
||||
private fun refresh() {
|
||||
adapter.notifyDataSetChanged()
|
||||
|
||||
if (adapter.artists.size + adapter.albums.size + adapter.tracks.size == 0) {
|
||||
binding.searchNoResults.visibility = View.VISIBLE
|
||||
} else {
|
||||
binding.searchNoResults.visibility = View.GONE
|
||||
}
|
||||
|
||||
if (done == 3) {
|
||||
binding.searchSpinner.visibility = View.INVISIBLE
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun refreshDownloadedTrack(download: Download) {
|
||||
if (download.state == Download.STATE_COMPLETED) {
|
||||
download.getMetadata()?.let { info ->
|
||||
adapter.tracks.withIndex().associate { it.value to it.index }
|
||||
.filter { it.key.id == info.id }.toList().getOrNull(0)?.let { match ->
|
||||
withContext(Dispatchers.Main) {
|
||||
adapter.tracks[match.second].downloaded = true
|
||||
adapter.notifyItemChanged(
|
||||
adapter.getPositionOf(
|
||||
SearchAdapter.ResultType.Track,
|
||||
match.second
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inner class SearchResultClickListener : SearchAdapter.OnSearchResultClickListener {
|
||||
override fun onArtistClick(holder: View?, artist: Artist) {
|
||||
ArtistsFragment.openAlbums(this@SearchActivity, artist)
|
||||
}
|
||||
|
||||
override fun onAlbumClick(holder: View?, album: Album) {
|
||||
AlbumsFragment.openTracks(this@SearchActivity, album)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -40,8 +40,6 @@ class SettingsActivity : AppCompatActivity() {
|
|||
)
|
||||
.commit()
|
||||
}
|
||||
|
||||
fun getThemeResId(): Int = R.style.AppTheme
|
||||
}
|
||||
|
||||
class SettingsFragment :
|
||||
|
@ -51,7 +49,7 @@ class SettingsFragment :
|
|||
override fun onResume() {
|
||||
super.onResume()
|
||||
|
||||
preferenceScreen.sharedPreferences.registerOnSharedPreferenceChangeListener(this)
|
||||
preferenceScreen.sharedPreferences?.registerOnSharedPreferenceChangeListener(this)
|
||||
}
|
||||
|
||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||
|
@ -60,14 +58,14 @@ class SettingsFragment :
|
|||
updateValues()
|
||||
}
|
||||
|
||||
override fun onPreferenceTreeClick(preference: Preference?): Boolean {
|
||||
when (preference?.key) {
|
||||
override fun onPreferenceTreeClick(preference: Preference): Boolean {
|
||||
when (preference.key) {
|
||||
"oss_licences" -> startActivity(Intent(activity, LicencesActivity::class.java))
|
||||
|
||||
"crash" -> {
|
||||
activity?.let { activity ->
|
||||
(activity.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager)?.also { clip ->
|
||||
FFACache.get(activity, "crashdump")?.readLines()?.joinToString("\n").also {
|
||||
FFACache.getLines(activity, "crashdump")?.joinToString("\n").also {
|
||||
clip.setPrimaryClip(ClipData.newPlainText("Funkwhale logs", it))
|
||||
|
||||
Toast.makeText(
|
||||
|
@ -116,6 +114,14 @@ class SettingsFragment :
|
|||
}
|
||||
}
|
||||
|
||||
preferenceManager.findPreference<ListPreference>("bandwidth_limitation")?.let {
|
||||
it.summary = when (it.value) {
|
||||
"unlimited" -> activity.getString(R.string.settings_bandwidth_limitation_summary_unlimited)
|
||||
"limited" -> activity.getString(R.string.settings_bandwidth_limitation_summary_limited)
|
||||
else -> activity.getString(R.string.settings_bandwidth_limitation_summary_unlimited)
|
||||
}
|
||||
}
|
||||
|
||||
preferenceManager.findPreference<ListPreference>("play_order")?.let {
|
||||
it.summary = when (it.value) {
|
||||
"shuffle" -> activity.getString(R.string.settings_play_order_shuffle_summary)
|
||||
|
@ -150,7 +156,7 @@ class SettingsFragment :
|
|||
}
|
||||
|
||||
preferenceManager.findPreference<SeekBarPreference>("media_cache_size")?.let {
|
||||
it.summary = getString(R.string.settings_media_cache_size_summary, it.value)
|
||||
it.summary = getString(R.string.settings_media_cache_size_summary, it.value as Int) // manual cast to address a bug in AGP
|
||||
}
|
||||
|
||||
preferenceManager.findPreference<Preference>("version")?.let {
|
||||
|
|
|
@ -8,9 +8,7 @@ import androidx.recyclerview.widget.RecyclerView
|
|||
import audio.funkwhale.ffa.databinding.RowAlbumBinding
|
||||
import audio.funkwhale.ffa.fragments.FFAAdapter
|
||||
import audio.funkwhale.ffa.model.Album
|
||||
import audio.funkwhale.ffa.utils.maybeLoad
|
||||
import audio.funkwhale.ffa.utils.maybeNormalizeUrl
|
||||
import com.squareup.picasso.Picasso
|
||||
import audio.funkwhale.ffa.utils.CoverArt
|
||||
import jp.wasabeef.picasso.transformations.RoundedCornersTransformation
|
||||
|
||||
class AlbumsAdapter(
|
||||
|
@ -45,8 +43,7 @@ class AlbumsAdapter(
|
|||
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||
val album = data[position]
|
||||
|
||||
Picasso.get()
|
||||
.maybeLoad(maybeNormalizeUrl(album.cover()))
|
||||
CoverArt.requestCreator(album.cover())
|
||||
.fit()
|
||||
.transform(RoundedCornersTransformation(8, 0))
|
||||
.into(holder.art)
|
||||
|
|
|
@ -8,9 +8,8 @@ import audio.funkwhale.ffa.R
|
|||
import audio.funkwhale.ffa.databinding.RowAlbumGridBinding
|
||||
import audio.funkwhale.ffa.fragments.FFAAdapter
|
||||
import audio.funkwhale.ffa.model.Album
|
||||
import audio.funkwhale.ffa.utils.maybeLoad
|
||||
import audio.funkwhale.ffa.utils.CoverArt
|
||||
import audio.funkwhale.ffa.utils.maybeNormalizeUrl
|
||||
import com.squareup.picasso.Picasso
|
||||
import jp.wasabeef.picasso.transformations.RoundedCornersTransformation
|
||||
|
||||
class AlbumsGridAdapter(
|
||||
|
@ -40,10 +39,8 @@ class AlbumsGridAdapter(
|
|||
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||
val album = data[position]
|
||||
|
||||
Picasso.get()
|
||||
.maybeLoad(maybeNormalizeUrl(album.cover()))
|
||||
CoverArt.requestCreator(maybeNormalizeUrl(album.cover()))
|
||||
.fit()
|
||||
.placeholder(R.drawable.cover)
|
||||
.transform(RoundedCornersTransformation(16, 0))
|
||||
.into(holder.cover)
|
||||
|
||||
|
|
|
@ -9,9 +9,8 @@ import audio.funkwhale.ffa.R
|
|||
import audio.funkwhale.ffa.databinding.RowArtistBinding
|
||||
import audio.funkwhale.ffa.fragments.FFAAdapter
|
||||
import audio.funkwhale.ffa.model.Artist
|
||||
import audio.funkwhale.ffa.utils.maybeLoad
|
||||
import audio.funkwhale.ffa.utils.CoverArt
|
||||
import audio.funkwhale.ffa.utils.maybeNormalizeUrl
|
||||
import com.squareup.picasso.Picasso
|
||||
import jp.wasabeef.picasso.transformations.RoundedCornersTransformation
|
||||
|
||||
class ArtistsAdapter(
|
||||
|
@ -62,15 +61,12 @@ class ArtistsAdapter(
|
|||
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||
val artist = active[position]
|
||||
|
||||
artist.albums?.let { albums ->
|
||||
if (albums.isNotEmpty()) {
|
||||
Picasso.get()
|
||||
.maybeLoad(maybeNormalizeUrl(albums[0].cover?.urls?.original))
|
||||
artist.cover()?.let { coverUrl ->
|
||||
CoverArt.requestCreator(maybeNormalizeUrl(coverUrl))
|
||||
.fit()
|
||||
.transform(RoundedCornersTransformation(8, 0))
|
||||
.into(holder.art)
|
||||
}
|
||||
}
|
||||
|
||||
holder.name.text = artist.name
|
||||
|
||||
|
|
|
@ -1,8 +1,7 @@
|
|||
package audio.funkwhale.ffa.adapters
|
||||
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.fragment.app.FragmentPagerAdapter
|
||||
import androidx.viewpager2.adapter.FragmentStateAdapter
|
||||
import audio.funkwhale.ffa.R
|
||||
import audio.funkwhale.ffa.fragments.AlbumsGridFragment
|
||||
import audio.funkwhale.ffa.fragments.ArtistsFragment
|
||||
|
@ -10,18 +9,10 @@ import audio.funkwhale.ffa.fragments.FavoritesFragment
|
|||
import audio.funkwhale.ffa.fragments.PlaylistsFragment
|
||||
import audio.funkwhale.ffa.fragments.RadiosFragment
|
||||
|
||||
class BrowseTabsAdapter(val context: Fragment, manager: FragmentManager) :
|
||||
FragmentPagerAdapter(manager, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) {
|
||||
var tabs = mutableListOf<Fragment>()
|
||||
class BrowseTabsAdapter(val context: Fragment) : FragmentStateAdapter(context) {
|
||||
override fun getItemCount() = 5
|
||||
|
||||
override fun getCount() = 5
|
||||
|
||||
override fun getItem(position: Int): Fragment {
|
||||
tabs.getOrNull(position)?.let {
|
||||
return it
|
||||
}
|
||||
|
||||
val fragment = when (position) {
|
||||
override fun createFragment(position: Int): Fragment = when (position) {
|
||||
0 -> ArtistsFragment()
|
||||
1 -> AlbumsGridFragment()
|
||||
2 -> PlaylistsFragment()
|
||||
|
@ -30,12 +21,7 @@ class BrowseTabsAdapter(val context: Fragment, manager: FragmentManager) :
|
|||
else -> ArtistsFragment()
|
||||
}
|
||||
|
||||
tabs.add(position, fragment)
|
||||
|
||||
return fragment
|
||||
}
|
||||
|
||||
override fun getPageTitle(position: Int): String {
|
||||
fun tabText(position: Int): String {
|
||||
return when (position) {
|
||||
0 -> context.getString(R.string.artists)
|
||||
1 -> context.getString(R.string.albums)
|
||||
|
|
|
@ -8,18 +8,19 @@ import android.view.Gravity
|
|||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.appcompat.content.res.AppCompatResources
|
||||
import androidx.appcompat.widget.PopupMenu
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import audio.funkwhale.ffa.R
|
||||
import audio.funkwhale.ffa.databinding.RowTrackBinding
|
||||
import audio.funkwhale.ffa.fragments.FFAAdapter
|
||||
import audio.funkwhale.ffa.model.Favorite
|
||||
import audio.funkwhale.ffa.model.Track
|
||||
import audio.funkwhale.ffa.utils.Command
|
||||
import audio.funkwhale.ffa.utils.CommandBus
|
||||
import audio.funkwhale.ffa.utils.maybeLoad
|
||||
import audio.funkwhale.ffa.utils.CoverArt
|
||||
import audio.funkwhale.ffa.utils.maybeNormalizeUrl
|
||||
import audio.funkwhale.ffa.utils.toast
|
||||
import com.squareup.picasso.Picasso
|
||||
import jp.wasabeef.picasso.transformations.RoundedCornersTransformation
|
||||
import java.util.Collections
|
||||
|
||||
|
@ -28,7 +29,7 @@ class FavoritesAdapter(
|
|||
private val context: Context?,
|
||||
private val favoriteListener: FavoriteListener,
|
||||
val fromQueue: Boolean = false,
|
||||
) : FFAAdapter<Track, FavoritesAdapter.ViewHolder>() {
|
||||
) : FFAAdapter<Favorite, FavoritesAdapter.ViewHolder>() {
|
||||
|
||||
init {
|
||||
this.stateRestorationPolicy = StateRestorationPolicy.PREVENT_WHEN_EMPTY
|
||||
|
@ -37,6 +38,7 @@ class FavoritesAdapter(
|
|||
private lateinit var binding: RowTrackBinding
|
||||
|
||||
var currentTrack: Track? = null
|
||||
var filter = ""
|
||||
|
||||
override fun getItemCount() = data.size
|
||||
|
||||
|
@ -44,6 +46,15 @@ class FavoritesAdapter(
|
|||
return data[position].id.toLong()
|
||||
}
|
||||
|
||||
override fun applyFilter() {
|
||||
data.clear()
|
||||
getUnfilteredData().map {
|
||||
if (it.track.matchesFilter(filter)) {
|
||||
data.add(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||
|
||||
binding = RowTrackBinding.inflate(layoutInflater, parent, false)
|
||||
|
@ -56,46 +67,42 @@ class FavoritesAdapter(
|
|||
@SuppressLint("NewApi")
|
||||
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||
val favorite = data[position]
|
||||
val track = favorite.track
|
||||
|
||||
Picasso.get()
|
||||
.maybeLoad(maybeNormalizeUrl(favorite.album?.cover()))
|
||||
CoverArt.requestCreator(maybeNormalizeUrl(track.cover()))
|
||||
.fit()
|
||||
.placeholder(R.drawable.cover)
|
||||
.transform(RoundedCornersTransformation(16, 0))
|
||||
.into(holder.cover)
|
||||
|
||||
holder.title.text = favorite.title
|
||||
holder.artist.text = favorite.artist.name
|
||||
holder.title.text = track.title
|
||||
holder.artist.text = track.artist.name
|
||||
|
||||
context?.let {
|
||||
holder.itemView.background = context.getDrawable(R.drawable.ripple)
|
||||
holder.itemView.background = AppCompatResources.getDrawable(context, R.drawable.ripple)
|
||||
}
|
||||
|
||||
if (favorite.id == currentTrack?.id) {
|
||||
if (track.id == currentTrack?.id) {
|
||||
context?.let {
|
||||
holder.itemView.background = context.getDrawable(R.drawable.current)
|
||||
holder.itemView.background = AppCompatResources.getDrawable(context, R.drawable.current)
|
||||
}
|
||||
}
|
||||
|
||||
context?.let {
|
||||
when (favorite.favorite) {
|
||||
true -> holder.favorite.setColorFilter(context.getColor(R.color.colorFavorite))
|
||||
false -> holder.favorite.setColorFilter(context.getColor(R.color.colorSelected))
|
||||
}
|
||||
holder.favorite.setColorFilter(context.getColor(R.color.colorFavorite))
|
||||
|
||||
when (favorite.cached || favorite.downloaded) {
|
||||
when (track.cached || track.downloaded) {
|
||||
true -> holder.title.setCompoundDrawablesWithIntrinsicBounds(R.drawable.downloaded, 0, 0, 0)
|
||||
false -> holder.title.setCompoundDrawablesWithIntrinsicBounds(0, 0, 0, 0)
|
||||
}
|
||||
|
||||
if (favorite.cached && !favorite.downloaded) {
|
||||
if (track.cached && !track.downloaded) {
|
||||
holder.title.compoundDrawables.forEach {
|
||||
it?.colorFilter =
|
||||
PorterDuffColorFilter(context.getColor(R.color.cached), PorterDuff.Mode.SRC_IN)
|
||||
}
|
||||
}
|
||||
|
||||
if (favorite.downloaded) {
|
||||
if (track.downloaded) {
|
||||
holder.title.compoundDrawables.forEach {
|
||||
it?.colorFilter =
|
||||
PorterDuffColorFilter(context.getColor(R.color.downloaded), PorterDuff.Mode.SRC_IN)
|
||||
|
@ -103,8 +110,7 @@ class FavoritesAdapter(
|
|||
}
|
||||
|
||||
holder.favorite.setOnClickListener {
|
||||
favoriteListener.onToggleFavorite(favorite.id, !favorite.favorite)
|
||||
|
||||
favoriteListener.onToggleFavorite(track.id, !track.favorite)
|
||||
data.remove(favorite)
|
||||
notifyItemRemoved(holder.bindingAdapterPosition)
|
||||
}
|
||||
|
@ -117,10 +123,10 @@ class FavoritesAdapter(
|
|||
|
||||
setOnMenuItemClickListener {
|
||||
when (it.itemId) {
|
||||
R.id.track_add_to_queue -> CommandBus.send(Command.AddToQueue(listOf(favorite)))
|
||||
R.id.track_play_next -> CommandBus.send(Command.PlayNext(favorite))
|
||||
R.id.track_pin -> CommandBus.send(Command.PinTrack(favorite))
|
||||
R.id.queue_remove -> CommandBus.send(Command.RemoveFromQueue(favorite))
|
||||
R.id.track_add_to_queue -> CommandBus.send(Command.AddToQueue(listOf(track)))
|
||||
R.id.track_play_next -> CommandBus.send(Command.PlayNext(track))
|
||||
R.id.track_pin -> CommandBus.send(Command.PinTrack(track))
|
||||
R.id.queue_remove -> CommandBus.send(Command.RemoveFromQueue(track))
|
||||
}
|
||||
|
||||
true
|
||||
|
@ -161,9 +167,11 @@ class FavoritesAdapter(
|
|||
when (fromQueue) {
|
||||
true -> CommandBus.send(Command.PlayTrack(layoutPosition))
|
||||
false -> {
|
||||
data.subList(layoutPosition, data.size).plus(data.subList(0, layoutPosition)).apply {
|
||||
data
|
||||
.subList(layoutPosition, data.size).plus(data.subList(0, layoutPosition))
|
||||
.map { it.track }
|
||||
.apply {
|
||||
CommandBus.send(Command.ReplaceQueue(this))
|
||||
|
||||
context.toast("All tracks were added to your queue")
|
||||
}
|
||||
}
|
||||
|
|
|
@ -20,10 +20,9 @@ import audio.funkwhale.ffa.model.PlaylistTrack
|
|||
import audio.funkwhale.ffa.model.Track
|
||||
import audio.funkwhale.ffa.utils.Command
|
||||
import audio.funkwhale.ffa.utils.CommandBus
|
||||
import audio.funkwhale.ffa.utils.maybeLoad
|
||||
import audio.funkwhale.ffa.utils.CoverArt
|
||||
import audio.funkwhale.ffa.utils.maybeNormalizeUrl
|
||||
import audio.funkwhale.ffa.utils.toast
|
||||
import com.squareup.picasso.Picasso
|
||||
import jp.wasabeef.picasso.transformations.RoundedCornersTransformation
|
||||
import java.util.Collections
|
||||
|
||||
|
@ -70,39 +69,37 @@ class PlaylistTracksAdapter(
|
|||
|
||||
@SuppressLint("NewApi")
|
||||
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||
val track = data[position]
|
||||
val playlistTrack = data[position]
|
||||
|
||||
Picasso.get()
|
||||
.maybeLoad(maybeNormalizeUrl(track.track.album?.cover()))
|
||||
CoverArt.requestCreator(maybeNormalizeUrl(playlistTrack.track.cover()))
|
||||
.fit()
|
||||
.placeholder(R.drawable.cover)
|
||||
.transform(RoundedCornersTransformation(16, 0))
|
||||
.into(holder.cover)
|
||||
|
||||
holder.title.text = track.track.title
|
||||
holder.artist.text = track.track.artist.name
|
||||
holder.title.text = playlistTrack.track.title
|
||||
holder.artist.text = playlistTrack.track.artist.name
|
||||
|
||||
context?.let {
|
||||
holder.itemView.background = ContextCompat.getDrawable(context, R.drawable.ripple)
|
||||
}
|
||||
|
||||
if (track.track == currentTrack || track.track.current) {
|
||||
if (playlistTrack.track == currentTrack || playlistTrack.track.current) {
|
||||
context?.let {
|
||||
holder.itemView.background = ContextCompat.getDrawable(context, R.drawable.current)
|
||||
}
|
||||
}
|
||||
|
||||
context?.let {
|
||||
when (track.track.favorite) {
|
||||
when (playlistTrack.track.favorite) {
|
||||
true -> holder.favorite.setColorFilter(context.getColor(R.color.colorFavorite))
|
||||
false -> holder.favorite.setColorFilter(context.getColor(R.color.colorSelected))
|
||||
}
|
||||
|
||||
holder.favorite.setOnClickListener {
|
||||
favoriteListener.let {
|
||||
favoriteListener.onToggleFavorite(track.track.id, !track.track.favorite)
|
||||
favoriteListener.onToggleFavorite(playlistTrack.track.id, !playlistTrack.track.favorite)
|
||||
|
||||
track.track.favorite = !track.track.favorite
|
||||
playlistTrack.track.favorite = !playlistTrack.track.favorite
|
||||
notifyItemChanged(position)
|
||||
}
|
||||
}
|
||||
|
@ -117,11 +114,11 @@ class PlaylistTracksAdapter(
|
|||
|
||||
setOnMenuItemClickListener {
|
||||
when (it.itemId) {
|
||||
R.id.track_add_to_queue -> CommandBus.send(Command.AddToQueue(listOf(track.track)))
|
||||
R.id.track_play_next -> CommandBus.send(Command.PlayNext(track.track))
|
||||
R.id.queue_remove -> CommandBus.send(Command.RemoveFromQueue(track.track))
|
||||
R.id.track_add_to_queue -> CommandBus.send(Command.AddToQueue(listOf(playlistTrack.track)))
|
||||
R.id.track_play_next -> CommandBus.send(Command.PlayNext(playlistTrack.track))
|
||||
R.id.queue_remove -> CommandBus.send(Command.RemoveFromQueue(playlistTrack.track))
|
||||
R.id.track_remove_from_playlist -> playlistListener.onRemoveTrackFromPlaylist(
|
||||
track.track,
|
||||
playlistTrack.track,
|
||||
position
|
||||
)
|
||||
}
|
||||
|
|
|
@ -10,8 +10,8 @@ import audio.funkwhale.ffa.R
|
|||
import audio.funkwhale.ffa.databinding.RowPlaylistBinding
|
||||
import audio.funkwhale.ffa.fragments.FFAAdapter
|
||||
import audio.funkwhale.ffa.model.Playlist
|
||||
import audio.funkwhale.ffa.utils.CoverArt
|
||||
import audio.funkwhale.ffa.utils.toDurationString
|
||||
import com.squareup.picasso.Picasso
|
||||
import jp.wasabeef.picasso.transformations.RoundedCornersTransformation
|
||||
|
||||
class PlaylistsAdapter(
|
||||
|
@ -79,8 +79,7 @@ class PlaylistsAdapter(
|
|||
else -> RoundedCornersTransformation.CornerType.TOP_LEFT
|
||||
}
|
||||
|
||||
Picasso.get()
|
||||
.load(url)
|
||||
CoverArt.requestCreator(url)
|
||||
.transform(RoundedCornersTransformation(32, 0, corner))
|
||||
.into(imageView)
|
||||
}
|
||||
|
|
|
@ -17,7 +17,6 @@ import audio.funkwhale.ffa.views.LoadingImageView
|
|||
import com.preference.PowerPreference
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers.Main
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class RadiosAdapter(
|
||||
|
@ -190,9 +189,8 @@ class RadiosAdapter(
|
|||
art.setColorFilter(context.getColor(R.color.controlForeground))
|
||||
|
||||
scope.launch(Main) {
|
||||
EventBus.get().collect { message ->
|
||||
when (message) {
|
||||
is Event.RadioStarted -> {
|
||||
EventBus.get().collect { event ->
|
||||
if (event is Event.RadioStarted) {
|
||||
art.colorFilter = originalColorFilter
|
||||
LoadingImageView.stop(context, originalDrawable, art, imageAnimator)
|
||||
}
|
||||
|
@ -200,7 +198,6 @@ class RadiosAdapter(
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onClick(view: View?) {
|
||||
listener.onClick(this, getRadioAt(layoutPosition))
|
||||
|
|
|
@ -7,10 +7,10 @@ import android.graphics.PorterDuffColorFilter
|
|||
import android.graphics.Typeface
|
||||
import android.os.Build
|
||||
import android.view.Gravity
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.appcompat.widget.PopupMenu
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import audio.funkwhale.ffa.R
|
||||
import audio.funkwhale.ffa.databinding.RowSearchHeaderBinding
|
||||
|
@ -20,16 +20,17 @@ import audio.funkwhale.ffa.model.Artist
|
|||
import audio.funkwhale.ffa.model.Track
|
||||
import audio.funkwhale.ffa.utils.Command
|
||||
import audio.funkwhale.ffa.utils.CommandBus
|
||||
import audio.funkwhale.ffa.utils.maybeLoad
|
||||
import audio.funkwhale.ffa.utils.CoverArt
|
||||
import audio.funkwhale.ffa.utils.maybeNormalizeUrl
|
||||
import audio.funkwhale.ffa.utils.onApi
|
||||
import audio.funkwhale.ffa.utils.toast
|
||||
import audio.funkwhale.ffa.viewmodel.SearchViewModel
|
||||
import com.squareup.picasso.Picasso
|
||||
import jp.wasabeef.picasso.transformations.RoundedCornersTransformation
|
||||
|
||||
class SearchAdapter(
|
||||
private val layoutInflater: LayoutInflater,
|
||||
private val context: Context?,
|
||||
viewModel: SearchViewModel,
|
||||
private val fragment: Fragment,
|
||||
private val listener: OnSearchResultClickListener,
|
||||
private val favoriteListener: FavoriteListener
|
||||
) : RecyclerView.Adapter<SearchAdapter.ViewHolder>() {
|
||||
|
@ -51,12 +52,27 @@ class SearchAdapter(
|
|||
|
||||
val sectionCount = 3
|
||||
|
||||
var artists: MutableList<Artist> = mutableListOf()
|
||||
var albums: MutableList<Album> = mutableListOf()
|
||||
var tracks: MutableList<Track> = mutableListOf()
|
||||
var artists = listOf<Artist>()
|
||||
var albums = listOf<Album>()
|
||||
var tracks = listOf<Track>()
|
||||
|
||||
var currentTrack: Track? = null
|
||||
|
||||
init {
|
||||
viewModel.artistResults.observe(fragment.viewLifecycleOwner) {
|
||||
artists = it
|
||||
this.notifyDataSetChanged()
|
||||
}
|
||||
viewModel.albumResults.observe(fragment.viewLifecycleOwner) {
|
||||
albums = it
|
||||
this.notifyDataSetChanged()
|
||||
}
|
||||
viewModel.trackResults.observe(fragment.viewLifecycleOwner) {
|
||||
tracks = it
|
||||
this.notifyDataSetChanged()
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemCount() = sectionCount + artists.size + albums.size + tracks.size
|
||||
|
||||
override fun getItemId(position: Int): Long {
|
||||
|
@ -68,7 +84,7 @@ class SearchAdapter(
|
|||
}
|
||||
|
||||
ResultType.Artist.ordinal -> artists[position].id.toLong()
|
||||
ResultType.Artist.ordinal -> albums[position - artists.size - 2].id.toLong()
|
||||
ResultType.Album.ordinal -> albums[position - artists.size - 2].id.toLong()
|
||||
ResultType.Track.ordinal ->
|
||||
tracks[position - artists.size - albums.size - sectionCount].id.toLong()
|
||||
else -> 0
|
||||
|
@ -87,12 +103,12 @@ class SearchAdapter(
|
|||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||
return when (viewType) {
|
||||
ResultType.Header.ordinal -> {
|
||||
searchHeaderBinding = RowSearchHeaderBinding.inflate(layoutInflater, parent, false)
|
||||
SearchHeaderViewHolder(searchHeaderBinding, context)
|
||||
searchHeaderBinding = RowSearchHeaderBinding.inflate(fragment.layoutInflater, parent, false)
|
||||
SearchHeaderViewHolder(searchHeaderBinding, fragment.requireContext())
|
||||
}
|
||||
else -> {
|
||||
rowTrackBinding = RowTrackBinding.inflate(layoutInflater, parent, false)
|
||||
RowTrackViewHolder(rowTrackBinding, context).also {
|
||||
rowTrackBinding = RowTrackBinding.inflate(fragment.layoutInflater, parent, false)
|
||||
RowTrackViewHolder(rowTrackBinding, fragment.requireContext()).also {
|
||||
rowTrackBinding.root.setOnClickListener(it)
|
||||
}
|
||||
}
|
||||
|
@ -106,9 +122,8 @@ class SearchAdapter(
|
|||
val rowTrackViewHolder = holder as? RowTrackViewHolder
|
||||
|
||||
if (resultType == ResultType.Header.ordinal) {
|
||||
context?.let { context ->
|
||||
if (position == 0) {
|
||||
searchHeaderViewHolder?.title?.text = context.getString(R.string.artists)
|
||||
searchHeaderViewHolder?.title?.text = fragment.requireContext().getString(R.string.artists)
|
||||
holder.itemView.visibility = View.VISIBLE
|
||||
holder.itemView.layoutParams = RecyclerView.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
|
@ -122,7 +137,7 @@ class SearchAdapter(
|
|||
}
|
||||
|
||||
if (position == (artists.size + 1)) {
|
||||
searchHeaderViewHolder?.title?.text = context.getString(R.string.albums)
|
||||
searchHeaderViewHolder?.title?.text = fragment.requireContext().getString(R.string.albums)
|
||||
holder.itemView.visibility = View.VISIBLE
|
||||
holder.itemView.layoutParams = RecyclerView.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
|
@ -136,7 +151,7 @@ class SearchAdapter(
|
|||
}
|
||||
|
||||
if (position == (artists.size + albums.size + 2)) {
|
||||
searchHeaderViewHolder?.title?.text = context.getString(R.string.tracks)
|
||||
searchHeaderViewHolder?.title?.text = fragment.requireContext().getString(R.string.tracks)
|
||||
holder.itemView.visibility = View.VISIBLE
|
||||
holder.itemView.layoutParams = RecyclerView.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
|
@ -148,7 +163,6 @@ class SearchAdapter(
|
|||
holder.itemView.layoutParams = RecyclerView.LayoutParams(0, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
@ -175,8 +189,7 @@ class SearchAdapter(
|
|||
else -> tracks[position]
|
||||
}
|
||||
|
||||
Picasso.get()
|
||||
.maybeLoad(maybeNormalizeUrl(item.cover()))
|
||||
CoverArt.requestCreator(maybeNormalizeUrl(item.cover()))
|
||||
.fit()
|
||||
.transform(RoundedCornersTransformation(16, 0))
|
||||
.into(rowTrackViewHolder?.cover)
|
||||
|
@ -218,7 +231,6 @@ class SearchAdapter(
|
|||
}
|
||||
ResultType.Track.ordinal -> {
|
||||
(item as? Track)?.let { track ->
|
||||
context?.let { context ->
|
||||
if (track == currentTrack || track.current) {
|
||||
searchHeaderViewHolder?.title?.setTypeface(
|
||||
searchHeaderViewHolder.title.typeface,
|
||||
|
@ -232,10 +244,10 @@ class SearchAdapter(
|
|||
|
||||
when (track.favorite) {
|
||||
true -> rowTrackViewHolder?.favorite?.setColorFilter(
|
||||
context.getColor(R.color.colorFavorite)
|
||||
fragment.requireContext().getColor(R.color.colorFavorite)
|
||||
)
|
||||
false -> rowTrackViewHolder?.favorite?.setColorFilter(
|
||||
context.getColor(R.color.colorSelected)
|
||||
fragment.requireContext().getColor(R.color.colorSelected)
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -262,7 +274,10 @@ class SearchAdapter(
|
|||
if (track.cached && !track.downloaded) {
|
||||
rowTrackViewHolder?.title?.compoundDrawables?.forEach {
|
||||
it?.colorFilter =
|
||||
PorterDuffColorFilter(context.getColor(R.color.cached), PorterDuff.Mode.SRC_IN)
|
||||
PorterDuffColorFilter(
|
||||
fragment.requireContext().getColor(R.color.cached),
|
||||
PorterDuff.Mode.SRC_IN
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -270,7 +285,7 @@ class SearchAdapter(
|
|||
rowTrackViewHolder?.title?.compoundDrawables?.forEach {
|
||||
it?.colorFilter =
|
||||
PorterDuffColorFilter(
|
||||
context.getColor(R.color.downloaded),
|
||||
fragment.requireContext().getColor(R.color.downloaded),
|
||||
PorterDuff.Mode.SRC_IN
|
||||
)
|
||||
}
|
||||
|
@ -278,7 +293,7 @@ class SearchAdapter(
|
|||
|
||||
rowTrackViewHolder?.actions?.setOnClickListener {
|
||||
PopupMenu(
|
||||
context,
|
||||
fragment.requireContext(),
|
||||
rowTrackViewHolder.actions,
|
||||
Gravity.START,
|
||||
R.attr.actionOverflowMenuStyle,
|
||||
|
@ -307,7 +322,6 @@ class SearchAdapter(
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getPositionOf(type: ResultType, position: Int): Int {
|
||||
return when (type) {
|
||||
|
@ -318,12 +332,12 @@ class SearchAdapter(
|
|||
}
|
||||
}
|
||||
|
||||
inner class SearchHeaderViewHolder(val binding: RowSearchHeaderBinding, context: Context?) :
|
||||
inner class SearchHeaderViewHolder(val binding: RowSearchHeaderBinding, context: Context) :
|
||||
ViewHolder(binding.root, context) {
|
||||
val title = binding.title
|
||||
}
|
||||
|
||||
inner class RowTrackViewHolder(val binding: RowTrackBinding, context: Context?) :
|
||||
inner class RowTrackViewHolder(val binding: RowTrackBinding, context: Context) :
|
||||
ViewHolder(binding.root, context), View.OnClickListener {
|
||||
val title = binding.title
|
||||
val cover = binding.cover
|
||||
|
|
|
@ -21,10 +21,9 @@ import audio.funkwhale.ffa.fragments.FFAAdapter
|
|||
import audio.funkwhale.ffa.model.Track
|
||||
import audio.funkwhale.ffa.utils.Command
|
||||
import audio.funkwhale.ffa.utils.CommandBus
|
||||
import audio.funkwhale.ffa.utils.maybeLoad
|
||||
import audio.funkwhale.ffa.utils.CoverArt
|
||||
import audio.funkwhale.ffa.utils.maybeNormalizeUrl
|
||||
import audio.funkwhale.ffa.utils.toast
|
||||
import com.squareup.picasso.Picasso
|
||||
import jp.wasabeef.picasso.transformations.RoundedCornersTransformation
|
||||
import java.util.Collections
|
||||
|
||||
|
@ -71,8 +70,7 @@ class TracksAdapter(
|
|||
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||
val track = data[position]
|
||||
|
||||
Picasso.get()
|
||||
.maybeLoad(maybeNormalizeUrl(track.album?.cover()))
|
||||
CoverArt.requestCreator(maybeNormalizeUrl(track.cover()))
|
||||
.fit()
|
||||
.transform(RoundedCornersTransformation(8, 0))
|
||||
.into(holder.cover)
|
||||
|
@ -193,7 +191,6 @@ class TracksAdapter(
|
|||
false -> {
|
||||
data.subList(layoutPosition, data.size).plus(data.subList(0, layoutPosition)).apply {
|
||||
CommandBus.send(Command.ReplaceQueue(this))
|
||||
|
||||
context.toast("All tracks were added to your queue")
|
||||
}
|
||||
}
|
||||
|
|
|
@ -106,7 +106,7 @@ object AddToPlaylistDialog {
|
|||
|
||||
fetch().untilNetwork(lifecycleScope) { data, isCache, _, hasMore ->
|
||||
if (isCache) {
|
||||
adapter.data = data.toMutableList()
|
||||
adapter.setUnfilteredData(data.toMutableList())
|
||||
adapter.notifyDataSetChanged()
|
||||
|
||||
return@untilNetwork
|
||||
|
@ -124,7 +124,7 @@ object AddToPlaylistDialog {
|
|||
FFACache.set(
|
||||
context,
|
||||
cacheId,
|
||||
Gson().toJson(cache(adapter.data)).toByteArray()
|
||||
Gson().toJson(cache(adapter.data)).toString()
|
||||
)
|
||||
} catch (e: ConcurrentModificationException) {
|
||||
}
|
||||
|
|
|
@ -1,37 +1,28 @@
|
|||
package audio.funkwhale.ffa.fragments
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.animation.AccelerateDecelerateInterpolator
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.appcompat.content.res.AppCompatResources
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.navigation.fragment.navArgs
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.swiperefreshlayout.widget.CircularProgressDrawable
|
||||
import androidx.transition.Fade
|
||||
import androidx.transition.Slide
|
||||
import audio.funkwhale.ffa.R
|
||||
import audio.funkwhale.ffa.activities.MainActivity
|
||||
import audio.funkwhale.ffa.adapters.AlbumsAdapter
|
||||
import audio.funkwhale.ffa.databinding.FragmentAlbumsBinding
|
||||
import audio.funkwhale.ffa.model.Album
|
||||
import audio.funkwhale.ffa.model.Artist
|
||||
import audio.funkwhale.ffa.repositories.AlbumsRepository
|
||||
import audio.funkwhale.ffa.repositories.ArtistTracksRepository
|
||||
import audio.funkwhale.ffa.repositories.Repository
|
||||
import audio.funkwhale.ffa.utils.AppContext
|
||||
import audio.funkwhale.ffa.utils.Command
|
||||
import audio.funkwhale.ffa.utils.CommandBus
|
||||
import audio.funkwhale.ffa.utils.maybeLoad
|
||||
import audio.funkwhale.ffa.utils.CoverArt
|
||||
import audio.funkwhale.ffa.utils.maybeNormalizeUrl
|
||||
import audio.funkwhale.ffa.utils.onViewPager
|
||||
import com.squareup.picasso.Picasso
|
||||
import com.preference.PowerPreference
|
||||
import jp.wasabeef.picasso.transformations.RoundedCornersTransformation
|
||||
import kotlinx.coroutines.Dispatchers.IO
|
||||
import kotlinx.coroutines.Dispatchers.Main
|
||||
|
@ -45,77 +36,22 @@ class AlbumsFragment : FFAFragment<Album, AlbumsAdapter>() {
|
|||
override val recycler: RecyclerView get() = binding.albums
|
||||
override val alwaysRefresh = false
|
||||
|
||||
private val args by navArgs<AlbumsFragmentArgs>()
|
||||
private val artistArt: String get() = when {
|
||||
!args.cover.isNullOrBlank() -> args.cover!!
|
||||
else -> args.artist.cover() ?: ""
|
||||
}
|
||||
|
||||
private var _binding: FragmentAlbumsBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
|
||||
private lateinit var artistTracksRepository: ArtistTracksRepository
|
||||
|
||||
private var artistId = 0
|
||||
private var artistName = ""
|
||||
private var artistArt = ""
|
||||
|
||||
companion object {
|
||||
fun new(artist: Artist, _art: String? = null): AlbumsFragment {
|
||||
val art = _art ?: if (artist.albums?.isNotEmpty() == true) artist.cover() else ""
|
||||
|
||||
return AlbumsFragment().apply {
|
||||
arguments = bundleOf(
|
||||
"artistId" to artist.id,
|
||||
"artistName" to artist.name,
|
||||
"artistArt" to art
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun openTracks(context: Context?, album: Album?, fragment: Fragment? = null) {
|
||||
if (album == null) {
|
||||
return
|
||||
}
|
||||
|
||||
(context as? MainActivity)?.let {
|
||||
fragment?.let { fragment ->
|
||||
fragment.onViewPager {
|
||||
exitTransition = Fade().apply {
|
||||
duration = AppContext.TRANSITION_DURATION
|
||||
interpolator = AccelerateDecelerateInterpolator()
|
||||
|
||||
view?.let {
|
||||
addTarget(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(context as? AppCompatActivity)?.let { activity ->
|
||||
val nextFragment = TracksFragment.new(album).apply {
|
||||
enterTransition = Slide().apply {
|
||||
duration = AppContext.TRANSITION_DURATION
|
||||
interpolator = AccelerateDecelerateInterpolator()
|
||||
}
|
||||
}
|
||||
|
||||
activity.supportFragmentManager
|
||||
.beginTransaction()
|
||||
.replace(R.id.container, nextFragment)
|
||||
.addToBackStack(null)
|
||||
.commit()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
arguments?.apply {
|
||||
artistId = getInt("artistId")
|
||||
artistName = getString("artistName") ?: ""
|
||||
artistArt = getString("artistArt") ?: ""
|
||||
}
|
||||
|
||||
adapter = AlbumsAdapter(layoutInflater, context, OnAlbumClickListener())
|
||||
repository = AlbumsRepository(context, artistId)
|
||||
artistTracksRepository = ArtistTracksRepository(context, artistId)
|
||||
repository = AlbumsRepository(context, args.artist.id)
|
||||
artistTracksRepository = ArtistTracksRepository(context, args.artist.id)
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
|
@ -125,6 +61,12 @@ class AlbumsFragment : FFAFragment<Album, AlbumsAdapter>() {
|
|||
): View {
|
||||
_binding = FragmentAlbumsBinding.inflate(inflater)
|
||||
swiper = binding.swiper
|
||||
|
||||
when (PowerPreference.getDefaultFile().getString("play_order")) {
|
||||
"in_order" -> binding.play.text = getString(R.string.playback_play)
|
||||
else -> binding.play.text = getString(R.string.playback_shuffle)
|
||||
}
|
||||
|
||||
return binding.root
|
||||
}
|
||||
|
||||
|
@ -137,8 +79,7 @@ class AlbumsFragment : FFAFragment<Album, AlbumsAdapter>() {
|
|||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
binding.cover.let { cover ->
|
||||
Picasso.get()
|
||||
.maybeLoad(maybeNormalizeUrl(artistArt))
|
||||
CoverArt.requestCreator(maybeNormalizeUrl(artistArt))
|
||||
.noFade()
|
||||
.fit()
|
||||
.centerCrop()
|
||||
|
@ -146,36 +87,7 @@ class AlbumsFragment : FFAFragment<Album, AlbumsAdapter>() {
|
|||
.into(cover)
|
||||
}
|
||||
|
||||
binding.artist.text = artistName
|
||||
|
||||
binding.play.setOnClickListener {
|
||||
val loader = CircularProgressDrawable(requireContext()).apply {
|
||||
setColorSchemeColors(ContextCompat.getColor(requireContext(), android.R.color.white))
|
||||
strokeWidth = 4f
|
||||
}
|
||||
|
||||
loader.start()
|
||||
|
||||
binding.play.icon = loader
|
||||
binding.play.isClickable = false
|
||||
|
||||
lifecycleScope.launch(IO) {
|
||||
artistTracksRepository.fetch(Repository.Origin.Network.origin)
|
||||
.map { it.data }
|
||||
.toList()
|
||||
.flatten()
|
||||
.shuffled()
|
||||
.also {
|
||||
CommandBus.send(Command.ReplaceQueue(it))
|
||||
|
||||
withContext(Main) {
|
||||
binding.play.icon =
|
||||
AppCompatResources.getDrawable(binding.root.context, R.drawable.play)
|
||||
binding.play.isClickable = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
binding.artist.text = args.artist.name
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
|
@ -194,11 +106,46 @@ class AlbumsFragment : FFAFragment<Album, AlbumsAdapter>() {
|
|||
binding.cover.alpha = (height - scrollY.toFloat()) / height
|
||||
}
|
||||
}
|
||||
|
||||
when (PowerPreference.getDefaultFile().getString("play_order")) {
|
||||
"in_order" -> binding.play.text = getString(R.string.playback_play)
|
||||
else -> binding.play.text = getString(R.string.playback_shuffle)
|
||||
}
|
||||
|
||||
binding.play.setOnClickListener {
|
||||
val loader = CircularProgressDrawable(requireContext()).apply {
|
||||
setColorSchemeColors(ContextCompat.getColor(requireContext(), android.R.color.white))
|
||||
strokeWidth = 4f
|
||||
}
|
||||
|
||||
loader.start()
|
||||
|
||||
binding.play.icon = loader
|
||||
binding.play.isClickable = false
|
||||
|
||||
lifecycleScope.launch(IO) {
|
||||
val tracks = artistTracksRepository.fetch(Repository.Origin.Network.origin)
|
||||
.map { it.data }
|
||||
.toList()
|
||||
.flatten()
|
||||
|
||||
when (PowerPreference.getDefaultFile().getString("play_order")) {
|
||||
"in_order" -> CommandBus.send(Command.ReplaceQueue(tracks))
|
||||
else -> CommandBus.send(Command.ReplaceQueue(tracks.shuffled()))
|
||||
}
|
||||
|
||||
withContext(Main) {
|
||||
binding.play.icon =
|
||||
AppCompatResources.getDrawable(binding.root.context, R.drawable.play)
|
||||
binding.play.isClickable = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inner class OnAlbumClickListener : AlbumsAdapter.OnAlbumClickListener {
|
||||
override fun onClick(view: View?, album: Album) {
|
||||
openTracks(context, album, fragment = this@AlbumsFragment)
|
||||
findNavController().navigate(AlbumsFragmentDirections.albumsToTracks(album))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,18 +4,13 @@ import android.os.Bundle
|
|||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.animation.AccelerateDecelerateInterpolator
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.transition.Fade
|
||||
import androidx.transition.Slide
|
||||
import audio.funkwhale.ffa.R
|
||||
import audio.funkwhale.ffa.activities.MainActivity
|
||||
import audio.funkwhale.ffa.adapters.AlbumsGridAdapter
|
||||
import audio.funkwhale.ffa.databinding.FragmentAlbumsGridBinding
|
||||
import audio.funkwhale.ffa.model.Album
|
||||
import audio.funkwhale.ffa.repositories.AlbumsRepository
|
||||
import audio.funkwhale.ffa.utils.AppContext
|
||||
|
||||
class AlbumsGridFragment : FFAFragment<Album, AlbumsGridAdapter>() {
|
||||
|
||||
|
@ -49,29 +44,7 @@ class AlbumsGridFragment : FFAFragment<Album, AlbumsGridAdapter>() {
|
|||
|
||||
inner class OnAlbumClickListener : AlbumsGridAdapter.OnAlbumClickListener {
|
||||
override fun onClick(view: View?, album: Album) {
|
||||
(context as? MainActivity)?.let { activity ->
|
||||
exitTransition = Fade().apply {
|
||||
duration = AppContext.TRANSITION_DURATION
|
||||
interpolator = AccelerateDecelerateInterpolator()
|
||||
|
||||
view?.let {
|
||||
addTarget(it)
|
||||
}
|
||||
}
|
||||
|
||||
val fragment = TracksFragment.new(album).apply {
|
||||
enterTransition = Slide().apply {
|
||||
duration = AppContext.TRANSITION_DURATION
|
||||
interpolator = AccelerateDecelerateInterpolator()
|
||||
}
|
||||
}
|
||||
|
||||
activity.supportFragmentManager
|
||||
.beginTransaction()
|
||||
.replace(R.id.container, fragment)
|
||||
.addToBackStack(null)
|
||||
.commit()
|
||||
}
|
||||
findNavController().navigate(BrowseFragmentDirections.browseToTracks(album))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,27 +1,17 @@
|
|||
package audio.funkwhale.ffa.fragments
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.animation.AccelerateDecelerateInterpolator
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.transition.Fade
|
||||
import androidx.transition.Slide
|
||||
import audio.funkwhale.ffa.R
|
||||
import audio.funkwhale.ffa.activities.MainActivity
|
||||
import audio.funkwhale.ffa.adapters.ArtistsAdapter
|
||||
import audio.funkwhale.ffa.databinding.FragmentArtistsBinding
|
||||
import audio.funkwhale.ffa.model.Artist
|
||||
import audio.funkwhale.ffa.repositories.ArtistsRepository
|
||||
import audio.funkwhale.ffa.utils.AppContext
|
||||
import audio.funkwhale.ffa.utils.onViewPager
|
||||
|
||||
class ArtistsFragment : FFAFragment<Artist, ArtistsAdapter>() {
|
||||
|
||||
private var _binding: FragmentArtistsBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
|
||||
|
@ -50,49 +40,9 @@ class ArtistsFragment : FFAFragment<Artist, ArtistsAdapter>() {
|
|||
_binding = null
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
fun openAlbums(
|
||||
context: Context?,
|
||||
artist: Artist,
|
||||
fragment: Fragment? = null,
|
||||
art: String? = null
|
||||
) {
|
||||
(context as? MainActivity)?.let {
|
||||
fragment?.let { fragment ->
|
||||
fragment.onViewPager {
|
||||
exitTransition = Fade().apply {
|
||||
duration = AppContext.TRANSITION_DURATION
|
||||
interpolator = AccelerateDecelerateInterpolator()
|
||||
|
||||
view?.let {
|
||||
addTarget(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(context as? AppCompatActivity)?.let { activity ->
|
||||
val nextFragment = AlbumsFragment.new(artist, art).apply {
|
||||
enterTransition = Slide().apply {
|
||||
duration = AppContext.TRANSITION_DURATION
|
||||
interpolator = AccelerateDecelerateInterpolator()
|
||||
}
|
||||
}
|
||||
|
||||
activity.supportFragmentManager
|
||||
.beginTransaction()
|
||||
.replace(R.id.container, nextFragment)
|
||||
.addToBackStack(null)
|
||||
.commit()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inner class OnArtistClickListener : ArtistsAdapter.OnArtistClickListener {
|
||||
override fun onClick(holder: View?, artist: Artist) {
|
||||
openAlbums(context, artist, fragment = this@ArtistsFragment)
|
||||
findNavController().navigate(BrowseFragmentDirections.browseToAlbums(artist))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,19 +7,13 @@ import android.view.ViewGroup
|
|||
import androidx.fragment.app.Fragment
|
||||
import audio.funkwhale.ffa.adapters.BrowseTabsAdapter
|
||||
import audio.funkwhale.ffa.databinding.FragmentBrowseBinding
|
||||
import com.google.android.material.tabs.TabLayoutMediator
|
||||
|
||||
class BrowseFragment : Fragment() {
|
||||
|
||||
private var _binding: FragmentBrowseBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
|
||||
private var adapter: BrowseTabsAdapter? = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
adapter = BrowseTabsAdapter(this, childFragmentManager)
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
|
@ -27,11 +21,14 @@ class BrowseFragment : Fragment() {
|
|||
): View {
|
||||
_binding = FragmentBrowseBinding.inflate(inflater)
|
||||
return binding.root.apply {
|
||||
binding.tabs.setupWithViewPager(binding.pager)
|
||||
binding.tabs.getTabAt(0)?.select()
|
||||
|
||||
val adapter = BrowseTabsAdapter(this@BrowseFragment)
|
||||
binding.pager.adapter = adapter
|
||||
binding.pager.offscreenPageLimit = 3
|
||||
TabLayoutMediator(binding.tabs, binding.pager) { tab, position ->
|
||||
tab.text = adapter.tabText(position)
|
||||
}.attach()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -39,8 +36,4 @@ class BrowseFragment : Fragment() {
|
|||
super.onDestroyView()
|
||||
_binding = null
|
||||
}
|
||||
|
||||
fun selectTabAt(position: Int) {
|
||||
binding.tabs.getTabAt(position)?.select()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -18,12 +18,26 @@ import com.google.gson.Gson
|
|||
import kotlinx.coroutines.Dispatchers.IO
|
||||
import kotlinx.coroutines.Dispatchers.Main
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
abstract class FFAAdapter<D, VH : RecyclerView.ViewHolder> : RecyclerView.Adapter<VH>() {
|
||||
var data: MutableList<D> = mutableListOf()
|
||||
private var unfilteredData: MutableList<D> = mutableListOf()
|
||||
|
||||
fun getUnfilteredData(): MutableList<D> {
|
||||
return unfilteredData
|
||||
}
|
||||
|
||||
fun setUnfilteredData(data: MutableList<D>) {
|
||||
unfilteredData = data
|
||||
applyFilter()
|
||||
}
|
||||
|
||||
open fun applyFilter() {
|
||||
data.clear()
|
||||
data.addAll(unfilteredData)
|
||||
}
|
||||
|
||||
init {
|
||||
super.setHasStableIds(true)
|
||||
|
@ -32,7 +46,7 @@ abstract class FFAAdapter<D, VH : RecyclerView.ViewHolder> : RecyclerView.Adapte
|
|||
abstract override fun getItemId(position: Int): Long
|
||||
}
|
||||
|
||||
abstract class FFAFragment<D : Any, A : FFAAdapter<D, *>>() : Fragment() {
|
||||
abstract class FFAFragment<D : Any, A : FFAAdapter<D, *>> : Fragment() {
|
||||
companion object {
|
||||
const val OFFSCREEN_PAGES = 20
|
||||
}
|
||||
|
@ -130,19 +144,20 @@ abstract class FFAFragment<D : Any, A : FFAAdapter<D, *>>() : Fragment() {
|
|||
if (isCache) {
|
||||
moreLoading = false
|
||||
|
||||
adapter.data = data.toMutableList()
|
||||
adapter.setUnfilteredData(data.toMutableList())
|
||||
adapter.notifyDataSetChanged()
|
||||
|
||||
return@launch
|
||||
}
|
||||
|
||||
if (first) {
|
||||
adapter.data.clear()
|
||||
adapter.getUnfilteredData().clear()
|
||||
}
|
||||
|
||||
onDataFetched(data)
|
||||
|
||||
adapter.data.addAll(data)
|
||||
adapter.getUnfilteredData().addAll(data)
|
||||
adapter.applyFilter()
|
||||
|
||||
withContext(IO) {
|
||||
try {
|
||||
|
@ -150,7 +165,7 @@ abstract class FFAFragment<D : Any, A : FFAAdapter<D, *>>() : Fragment() {
|
|||
FFACache.set(
|
||||
context,
|
||||
cacheId,
|
||||
Gson().toJson(repository.cache(adapter.data)).toByteArray()
|
||||
Gson().toJson(repository.cache(adapter.getUnfilteredData())).toString()
|
||||
)
|
||||
}
|
||||
} catch (e: ConcurrentModificationException) {
|
||||
|
@ -161,7 +176,7 @@ abstract class FFAFragment<D : Any, A : FFAAdapter<D, *>>() : Fragment() {
|
|||
(repository.upstream as? HttpUpstream<*, *>)?.let { upstream ->
|
||||
if (!isCache && upstream.behavior == HttpUpstream.Behavior.Progressive) {
|
||||
if (first || needsMoreOffscreenPages()) {
|
||||
fetch(Repository.Origin.Network.origin, adapter.data.size)
|
||||
fetch(Repository.Origin.Network.origin, adapter.getUnfilteredData().size)
|
||||
} else {
|
||||
moreLoading = false
|
||||
}
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
package audio.funkwhale.ffa.fragments
|
||||
|
||||
import android.os.Bundle
|
||||
import android.text.Editable
|
||||
import android.text.TextWatcher
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
|
@ -9,6 +11,7 @@ import androidx.recyclerview.widget.RecyclerView
|
|||
import audio.funkwhale.ffa.adapters.FavoriteListener
|
||||
import audio.funkwhale.ffa.adapters.FavoritesAdapter
|
||||
import audio.funkwhale.ffa.databinding.FragmentFavoritesBinding
|
||||
import audio.funkwhale.ffa.model.Favorite
|
||||
import audio.funkwhale.ffa.model.Track
|
||||
import audio.funkwhale.ffa.repositories.FavoritesRepository
|
||||
import audio.funkwhale.ffa.repositories.TracksRepository
|
||||
|
@ -25,12 +28,11 @@ import com.google.android.exoplayer2.offline.Download
|
|||
import com.google.android.exoplayer2.offline.DownloadManager
|
||||
import kotlinx.coroutines.Dispatchers.IO
|
||||
import kotlinx.coroutines.Dispatchers.Main
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koin.java.KoinJavaComponent.inject
|
||||
|
||||
class FavoritesFragment : FFAFragment<Track, FavoritesAdapter>() {
|
||||
class FavoritesFragment : FFAFragment<Favorite, FavoritesAdapter>() {
|
||||
|
||||
private val exoDownloadManager: DownloadManager by inject(DownloadManager::class.java)
|
||||
|
||||
|
@ -54,6 +56,20 @@ class FavoritesFragment : FFAFragment<Track, FavoritesAdapter>() {
|
|||
): View {
|
||||
_binding = FragmentFavoritesBinding.inflate(inflater)
|
||||
swiper = binding.swiper
|
||||
binding.filterTracks.addTextChangedListener(object : TextWatcher {
|
||||
|
||||
override fun afterTextChanged(s: Editable) {
|
||||
|
||||
adapter.applyFilter()
|
||||
adapter.notifyDataSetChanged()
|
||||
}
|
||||
|
||||
override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) {}
|
||||
|
||||
override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) {
|
||||
adapter.filter = s.toString()
|
||||
}
|
||||
})
|
||||
return binding.root
|
||||
}
|
||||
|
||||
|
@ -78,24 +94,20 @@ class FavoritesFragment : FFAFragment<Track, FavoritesAdapter>() {
|
|||
}
|
||||
|
||||
binding.play.setOnClickListener {
|
||||
CommandBus.send(Command.ReplaceQueue(adapter.data.shuffled()))
|
||||
CommandBus.send(Command.ReplaceQueue(adapter.data.map { it.track }.shuffled()))
|
||||
}
|
||||
}
|
||||
|
||||
private fun watchEventBus() {
|
||||
lifecycleScope.launch(Main) {
|
||||
EventBus.get().collect { message ->
|
||||
when (message) {
|
||||
is Event.DownloadChanged -> refreshDownloadedTrack(message.download)
|
||||
}
|
||||
EventBus.get().collect { event ->
|
||||
if (event is Event.DownloadChanged) refreshDownloadedTrack(event.download)
|
||||
}
|
||||
}
|
||||
|
||||
lifecycleScope.launch(Main) {
|
||||
CommandBus.get().collect { command ->
|
||||
when (command) {
|
||||
is Command.RefreshTrack -> refreshCurrentTrack(command.track)
|
||||
}
|
||||
if (command is Command.RefreshTrack) refreshCurrentTrack(command.track)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -110,11 +122,13 @@ class FavoritesFragment : FFAFragment<Track, FavoritesAdapter>() {
|
|||
val downloaded = TracksRepository.getDownloadedIds(exoDownloadManager) ?: listOf()
|
||||
|
||||
withContext(Main) {
|
||||
adapter.data = adapter.data.map {
|
||||
it.downloaded = downloaded.contains(it.id)
|
||||
val data = adapter.data.map {
|
||||
it.track.downloaded = downloaded.contains(it.id)
|
||||
it
|
||||
}.toMutableList()
|
||||
|
||||
adapter.setUnfilteredData(data)
|
||||
|
||||
adapter.notifyDataSetChanged()
|
||||
}
|
||||
}
|
||||
|
@ -125,7 +139,7 @@ class FavoritesFragment : FFAFragment<Track, FavoritesAdapter>() {
|
|||
adapter.data.withIndex().associate { it.value to it.index }.filter { it.key.id == info.id }
|
||||
.toList().getOrNull(0)?.let { match ->
|
||||
withContext(Main) {
|
||||
adapter.data[match.second].downloaded = true
|
||||
adapter.data[match.second].track.downloaded = true
|
||||
adapter.notifyItemChanged(match.second)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -35,6 +35,8 @@ class LandscapeQueueFragment : Fragment() {
|
|||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
favoritesRepository = FavoritesRepository(context)
|
||||
|
||||
watchEventBus()
|
||||
}
|
||||
|
||||
|
@ -44,6 +46,7 @@ class LandscapeQueueFragment : Fragment() {
|
|||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
_binding = PartialQueueBinding.inflate(inflater)
|
||||
|
||||
return binding.root.apply {
|
||||
adapter = TracksAdapter(
|
||||
layoutInflater,
|
||||
|
@ -89,7 +92,7 @@ class LandscapeQueueFragment : Fragment() {
|
|||
activity?.lifecycleScope?.launch(Main) {
|
||||
RequestBus.send(Request.GetQueue).wait<Response.Queue>()?.let { response ->
|
||||
adapter?.let {
|
||||
it.data = response.queue.toMutableList()
|
||||
it.setUnfilteredData(response.queue.toMutableList())
|
||||
it.notifyDataSetChanged()
|
||||
|
||||
if (it.data.isEmpty()) {
|
||||
|
@ -107,17 +110,13 @@ class LandscapeQueueFragment : Fragment() {
|
|||
private fun watchEventBus() {
|
||||
activity?.lifecycleScope?.launch(Main) {
|
||||
EventBus.get().collect { message ->
|
||||
when (message) {
|
||||
is Event.QueueChanged -> refresh()
|
||||
}
|
||||
if (message is Event.QueueChanged) refresh()
|
||||
}
|
||||
}
|
||||
|
||||
activity?.lifecycleScope?.launch(Main) {
|
||||
CommandBus.get().collect { command ->
|
||||
when (command) {
|
||||
is Command.RefreshTrack -> refresh()
|
||||
}
|
||||
if (command is Command.RefreshTrack) refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,244 @@
|
|||
package audio.funkwhale.ffa.fragments
|
||||
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.widget.SeekBar
|
||||
import android.widget.SeekBar.OnSeekBarChangeListener
|
||||
import androidx.appcompat.widget.PopupMenu
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.asLiveData
|
||||
import androidx.lifecycle.distinctUntilChanged
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.liveData
|
||||
import androidx.lifecycle.map
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import audio.funkwhale.ffa.MainNavDirections
|
||||
import audio.funkwhale.ffa.R
|
||||
import audio.funkwhale.ffa.databinding.FragmentNowPlayingBinding
|
||||
import audio.funkwhale.ffa.model.Track
|
||||
import audio.funkwhale.ffa.repositories.FavoritedRepository
|
||||
import audio.funkwhale.ffa.repositories.FavoritesRepository
|
||||
import audio.funkwhale.ffa.repositories.Repository
|
||||
import audio.funkwhale.ffa.utils.Command
|
||||
import audio.funkwhale.ffa.utils.CommandBus
|
||||
import audio.funkwhale.ffa.utils.CoverArt
|
||||
import audio.funkwhale.ffa.utils.Event
|
||||
import audio.funkwhale.ffa.utils.EventBus
|
||||
import audio.funkwhale.ffa.utils.FFACache
|
||||
import audio.funkwhale.ffa.utils.ProgressBus
|
||||
import audio.funkwhale.ffa.utils.maybeNormalizeUrl
|
||||
import audio.funkwhale.ffa.utils.toIntOrElse
|
||||
import audio.funkwhale.ffa.utils.untilNetwork
|
||||
import audio.funkwhale.ffa.viewmodel.NowPlayingViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import java.lang.Float.max
|
||||
|
||||
class NowPlayingFragment: Fragment(R.layout.fragment_now_playing) {
|
||||
private val binding by lazy { FragmentNowPlayingBinding.bind(requireView()) }
|
||||
private val viewModel by viewModels<NowPlayingViewModel>()
|
||||
private val favoriteRepository by lazy { FavoritesRepository(requireContext()) }
|
||||
private val favoritedRepository by lazy { FavoritedRepository(requireContext()) }
|
||||
|
||||
private var onDetailsMenuItemClickedCb: () -> Unit = {}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
binding.lifecycleOwner = viewLifecycleOwner
|
||||
|
||||
viewModel.currentTrack.distinctUntilChanged().observe(viewLifecycleOwner, ::onTrackChange)
|
||||
|
||||
with(binding.controls) {
|
||||
currentTrackTitle = viewModel.currentTrackTitle
|
||||
currentTrackArtist = viewModel.currentTrackArtist
|
||||
isCurrentTrackFavorite = viewModel.isCurrentTrackFavorite
|
||||
repeatModeResource = viewModel.repeatModeResource
|
||||
repeatModeAlpha = viewModel.repeatModeAlpha
|
||||
currentProgressText = viewModel.currentProgressText
|
||||
currentDurationText = viewModel.currentDurationText
|
||||
isPlaying = viewModel.isPlaying
|
||||
progress = viewModel.progress
|
||||
|
||||
nowPlayingDetailsPrevious.setOnClickListener {
|
||||
CommandBus.send(Command.PreviousTrack)
|
||||
}
|
||||
|
||||
nowPlayingDetailsNext.setOnClickListener {
|
||||
CommandBus.send(Command.NextTrack)
|
||||
}
|
||||
|
||||
nowPlayingDetailsToggle.setOnClickListener {
|
||||
CommandBus.send(Command.ToggleState)
|
||||
}
|
||||
|
||||
nowPlayingDetailsRepeat.setOnClickListener { toggleRepeatMode() }
|
||||
nowPlayingDetailsProgress.setOnSeekBarChangeListener(OnSeekBarChanged())
|
||||
nowPlayingDetailsFavorite.setOnClickListener { onFavorite() }
|
||||
nowPlayingDetailsAddToPlaylist.setOnClickListener { onAddToPlaylist() }
|
||||
}
|
||||
|
||||
binding.nowPlayingDetailsInfo.setOnClickListener { openInfoMenu() }
|
||||
|
||||
with(binding.header) {
|
||||
lifecycleOwner = viewLifecycleOwner
|
||||
isBuffering = viewModel.isBuffering
|
||||
isPlaying = viewModel.isPlaying
|
||||
progress = viewModel.progress
|
||||
currentTrackTitle = viewModel.currentTrackTitle
|
||||
currentTrackArtist = viewModel.currentTrackArtist
|
||||
|
||||
|
||||
nowPlayingNext.setOnClickListener {
|
||||
CommandBus.send(Command.NextTrack)
|
||||
}
|
||||
|
||||
nowPlayingToggle.setOnClickListener {
|
||||
CommandBus.send(Command.ToggleState)
|
||||
}
|
||||
}
|
||||
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
CommandBus.get().collect { onCommand(it) }
|
||||
}
|
||||
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
ProgressBus.get().collect { onProgress(it) }
|
||||
}
|
||||
}
|
||||
|
||||
fun onBottomSheetDrag(value: Float) {
|
||||
binding.nowPlayingRoot.progress = max(value, 0f)
|
||||
}
|
||||
|
||||
fun onDetailsMenuItemClicked(cb: () -> Unit) {
|
||||
onDetailsMenuItemClickedCb = cb
|
||||
}
|
||||
|
||||
|
||||
private fun toggleRepeatMode() {
|
||||
val cachedRepeatMode = FFACache.getLine(requireContext(), "repeat").toIntOrElse(0)
|
||||
val iteratedRepeatMode = (cachedRepeatMode + 1) % 3
|
||||
FFACache.set(requireContext(), "repeat", "$iteratedRepeatMode")
|
||||
CommandBus.send(Command.SetRepeatMode(iteratedRepeatMode))
|
||||
}
|
||||
|
||||
private fun onAddToPlaylist() {
|
||||
val currentTrack = viewModel.currentTrack.value ?: return
|
||||
CommandBus.send(Command.AddToPlaylist(listOf(currentTrack)))
|
||||
}
|
||||
|
||||
private fun onCommand(command: Command) = when (command) {
|
||||
is Command.RefreshTrack -> refreshCurrentTrack(command.track)
|
||||
is Command.SetRepeatMode -> viewModel.repeatMode.postValue(command.mode)
|
||||
else -> {}
|
||||
}
|
||||
|
||||
private fun onFavorite() {
|
||||
val currentTrack = viewModel.currentTrack.value ?: return
|
||||
|
||||
if (currentTrack.favorite) favoriteRepository.deleteFavorite(currentTrack.id)
|
||||
else favoriteRepository.addFavorite(currentTrack.id)
|
||||
|
||||
currentTrack.favorite = !currentTrack.favorite
|
||||
// Trigger UI refresh
|
||||
viewModel.currentTrack.postValue(viewModel.currentTrack.value)
|
||||
|
||||
favoritedRepository.fetch(Repository.Origin.Network.origin)
|
||||
}
|
||||
|
||||
private fun onProgress(state: Triple<Int, Int, Int>) {
|
||||
val (current, duration, percent) = state
|
||||
|
||||
val currentMins = (current / 1000) / 60
|
||||
val currentSecs = (current / 1000) % 60
|
||||
|
||||
val durationMins = duration / 60
|
||||
val durationSecs = duration % 60
|
||||
|
||||
viewModel.progress.postValue(percent)
|
||||
viewModel.currentProgressText.postValue("%02d:%02d".format(currentMins, currentSecs))
|
||||
viewModel.currentDurationText.postValue("%02d:%02d".format(durationMins, durationSecs))
|
||||
}
|
||||
|
||||
private fun onTrackChange(track: Track?) {
|
||||
if (track == null) {
|
||||
binding.header.nowPlayingCover.setImageResource(R.drawable.cover)
|
||||
return
|
||||
}
|
||||
|
||||
CoverArt.requestCreator(maybeNormalizeUrl(track.album?.cover()))
|
||||
.into(binding.header.nowPlayingCover)
|
||||
}
|
||||
|
||||
private fun openInfoMenu() {
|
||||
val currentTrack = viewModel.currentTrack.value ?: return
|
||||
|
||||
PopupMenu(
|
||||
requireContext(),
|
||||
binding.nowPlayingDetailsInfo,
|
||||
Gravity.START,
|
||||
R.attr.actionOverflowMenuStyle,
|
||||
0
|
||||
).apply {
|
||||
inflate(R.menu.track_info)
|
||||
|
||||
setOnMenuItemClickListener {
|
||||
onDetailsMenuItemClickedCb()
|
||||
|
||||
when (it.itemId) {
|
||||
R.id.track_info_artist -> findNavController().navigate(
|
||||
MainNavDirections.globalBrowseToAlbums(
|
||||
currentTrack.artist,
|
||||
currentTrack.album?.cover()
|
||||
)
|
||||
)
|
||||
R.id.track_info_album -> currentTrack.album?.let { album ->
|
||||
findNavController().navigate(MainNavDirections.globalBrowseTracks(album))
|
||||
}
|
||||
R.id.track_info_details -> TrackInfoDetailsFragment.new(currentTrack).show(
|
||||
requireActivity().supportFragmentManager, "dialog"
|
||||
)
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshCurrentTrack(track: Track?) {
|
||||
viewModel.currentTrack.postValue(track)
|
||||
|
||||
val cachedRepeatMode = FFACache.getLine(requireContext(), "repeat").toIntOrElse(0)
|
||||
viewModel.repeatMode.postValue(cachedRepeatMode % 3)
|
||||
|
||||
// At this point, a non-null track is required
|
||||
|
||||
if (track == null) return
|
||||
|
||||
favoritedRepository.fetch().untilNetwork(lifecycleScope, Dispatchers.IO) { favorites, _, _, _ ->
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
track.favorite = favorites.contains(track.id)
|
||||
// Trigger UI refresh
|
||||
viewModel.currentTrack.postValue(viewModel.currentTrack.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inner class OnSeekBarChanged : OnSeekBarChangeListener {
|
||||
override fun onStopTrackingTouch(view: SeekBar?) {}
|
||||
|
||||
override fun onStartTrackingTouch(view: SeekBar?) {}
|
||||
|
||||
override fun onProgressChanged(view: SeekBar?, progress: Int, fromUser: Boolean) {
|
||||
if (fromUser) {
|
||||
CommandBus.send(Command.Seek(progress))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -6,14 +6,13 @@ import android.view.LayoutInflater
|
|||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.appcompat.widget.PopupMenu
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.navigation.fragment.navArgs
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import audio.funkwhale.ffa.R
|
||||
import audio.funkwhale.ffa.adapters.FavoriteListener
|
||||
import audio.funkwhale.ffa.adapters.PlaylistTracksAdapter
|
||||
import audio.funkwhale.ffa.databinding.FragmentTracksBinding
|
||||
import audio.funkwhale.ffa.model.Playlist
|
||||
import audio.funkwhale.ffa.model.PlaylistTrack
|
||||
import audio.funkwhale.ffa.model.Track
|
||||
import audio.funkwhale.ffa.repositories.FavoritesRepository
|
||||
|
@ -21,62 +20,42 @@ import audio.funkwhale.ffa.repositories.ManagementPlaylistsRepository
|
|||
import audio.funkwhale.ffa.repositories.PlaylistTracksRepository
|
||||
import audio.funkwhale.ffa.utils.Command
|
||||
import audio.funkwhale.ffa.utils.CommandBus
|
||||
import audio.funkwhale.ffa.utils.CoverArt
|
||||
import audio.funkwhale.ffa.utils.Request
|
||||
import audio.funkwhale.ffa.utils.RequestBus
|
||||
import audio.funkwhale.ffa.utils.Response
|
||||
import audio.funkwhale.ffa.utils.maybeLoad
|
||||
import audio.funkwhale.ffa.utils.maybeNormalizeUrl
|
||||
import audio.funkwhale.ffa.utils.toast
|
||||
import audio.funkwhale.ffa.utils.wait
|
||||
import com.squareup.picasso.Picasso
|
||||
import jp.wasabeef.picasso.transformations.RoundedCornersTransformation
|
||||
import kotlinx.coroutines.Dispatchers.Main
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class PlaylistTracksFragment : FFAFragment<PlaylistTrack, PlaylistTracksAdapter>() {
|
||||
|
||||
override val recycler: RecyclerView get() = binding.tracks
|
||||
|
||||
private val args by navArgs<PlaylistTracksFragmentArgs>()
|
||||
|
||||
private var _binding: FragmentTracksBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
|
||||
lateinit var favoritesRepository: FavoritesRepository
|
||||
lateinit var playlistsRepository: ManagementPlaylistsRepository
|
||||
|
||||
var albumId = 0
|
||||
var albumArtist = ""
|
||||
var albumTitle = ""
|
||||
var albumCover = ""
|
||||
|
||||
companion object {
|
||||
fun new(playlist: Playlist): PlaylistTracksFragment {
|
||||
return PlaylistTracksFragment().apply {
|
||||
arguments = bundleOf(
|
||||
"albumId" to playlist.id,
|
||||
"albumArtist" to "N/A",
|
||||
"albumTitle" to playlist.name,
|
||||
"albumCover" to ""
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
arguments?.apply {
|
||||
albumId = getInt("albumId")
|
||||
albumArtist = getString("albumArtist") ?: ""
|
||||
albumTitle = getString("albumTitle") ?: ""
|
||||
albumCover = getString("albumCover") ?: ""
|
||||
}
|
||||
|
||||
adapter = PlaylistTracksAdapter(layoutInflater, context, FavoriteListener(favoritesRepository), PlaylistListener())
|
||||
repository = PlaylistTracksRepository(context, albumId)
|
||||
favoritesRepository = FavoritesRepository(context)
|
||||
playlistsRepository = ManagementPlaylistsRepository(context)
|
||||
|
||||
adapter = PlaylistTracksAdapter(
|
||||
layoutInflater,
|
||||
context,
|
||||
FavoriteListener(favoritesRepository),
|
||||
PlaylistListener()
|
||||
)
|
||||
repository = PlaylistTracksRepository(context, args.playlist.id)
|
||||
|
||||
watchEventBus()
|
||||
}
|
||||
|
||||
|
@ -101,8 +80,8 @@ class PlaylistTracksFragment : FFAFragment<PlaylistTrack, PlaylistTracksAdapter>
|
|||
binding.cover.visibility = View.INVISIBLE
|
||||
binding.covers.visibility = View.VISIBLE
|
||||
|
||||
binding.artist.text = "Playlist"
|
||||
binding.title.text = albumTitle
|
||||
binding.artist.text = getString(R.string.playlist)
|
||||
binding.title.text = args.playlist.name
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
|
@ -131,7 +110,6 @@ class PlaylistTracksFragment : FFAFragment<PlaylistTrack, PlaylistTracksAdapter>
|
|||
|
||||
binding.play.setOnClickListener {
|
||||
CommandBus.send(Command.ReplaceQueue(adapter.data.map { it.track }.shuffled()))
|
||||
|
||||
context.toast("All tracks were added to your queue")
|
||||
}
|
||||
|
||||
|
@ -167,7 +145,11 @@ class PlaylistTracksFragment : FFAFragment<PlaylistTrack, PlaylistTracksAdapter>
|
|||
}
|
||||
|
||||
override fun onDataFetched(data: List<PlaylistTrack>) {
|
||||
data.map { it.track.album }.toSet().map { it?.cover() }.take(4).forEachIndexed { index, url ->
|
||||
data.map { it.track.album }
|
||||
.toSet()
|
||||
.map { it?.cover() }
|
||||
.take(4)
|
||||
.forEachIndexed { index, url ->
|
||||
val imageView = when (index) {
|
||||
0 -> binding.coverTopLeft
|
||||
1 -> binding.coverTopRight
|
||||
|
@ -185,8 +167,7 @@ class PlaylistTracksFragment : FFAFragment<PlaylistTrack, PlaylistTracksAdapter>
|
|||
}
|
||||
|
||||
lifecycleScope.launch(Main) {
|
||||
Picasso.get()
|
||||
.maybeLoad(maybeNormalizeUrl(url))
|
||||
CoverArt.requestCreator(maybeNormalizeUrl(url))
|
||||
.fit()
|
||||
.centerCrop()
|
||||
.transform(RoundedCornersTransformation(16, 0, corner))
|
||||
|
@ -198,8 +179,8 @@ class PlaylistTracksFragment : FFAFragment<PlaylistTrack, PlaylistTracksAdapter>
|
|||
private fun watchEventBus() {
|
||||
lifecycleScope.launch(Main) {
|
||||
CommandBus.get().collect { command ->
|
||||
when (command) {
|
||||
is Command.RefreshTrack -> refreshCurrentTrack(command.track)
|
||||
if (command is Command.RefreshTrack) {
|
||||
refreshCurrentTrack(command.track)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -214,12 +195,12 @@ class PlaylistTracksFragment : FFAFragment<PlaylistTrack, PlaylistTracksAdapter>
|
|||
|
||||
inner class PlaylistListener : PlaylistTracksAdapter.OnPlaylistListener {
|
||||
override fun onMoveTrack(from: Int, to: Int) {
|
||||
playlistsRepository.move(albumId, from, to)
|
||||
playlistsRepository.move(args.playlist.id, from, to)
|
||||
}
|
||||
|
||||
override fun onRemoveTrackFromPlaylist(track: Track, index: Int) {
|
||||
lifecycleScope.launch(Main) {
|
||||
playlistsRepository.remove(albumId, index)
|
||||
playlistsRepository.remove(args.playlist.id, index)
|
||||
update()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,17 +4,12 @@ import android.os.Bundle
|
|||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.animation.AccelerateDecelerateInterpolator
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.transition.Fade
|
||||
import androidx.transition.Slide
|
||||
import audio.funkwhale.ffa.R
|
||||
import audio.funkwhale.ffa.activities.MainActivity
|
||||
import audio.funkwhale.ffa.adapters.PlaylistsAdapter
|
||||
import audio.funkwhale.ffa.databinding.FragmentPlaylistsBinding
|
||||
import audio.funkwhale.ffa.model.Playlist
|
||||
import audio.funkwhale.ffa.repositories.PlaylistsRepository
|
||||
import audio.funkwhale.ffa.utils.AppContext
|
||||
|
||||
class PlaylistsFragment : FFAFragment<Playlist, PlaylistsAdapter>() {
|
||||
|
||||
|
@ -48,29 +43,7 @@ class PlaylistsFragment : FFAFragment<Playlist, PlaylistsAdapter>() {
|
|||
|
||||
inner class OnPlaylistClickListener : PlaylistsAdapter.OnPlaylistClickListener {
|
||||
override fun onClick(holder: View?, playlist: Playlist) {
|
||||
(context as? MainActivity)?.let { activity ->
|
||||
exitTransition = Fade().apply {
|
||||
duration = AppContext.TRANSITION_DURATION
|
||||
interpolator = AccelerateDecelerateInterpolator()
|
||||
|
||||
view?.let {
|
||||
addTarget(it)
|
||||
}
|
||||
}
|
||||
|
||||
val fragment = PlaylistTracksFragment.new(playlist).apply {
|
||||
enterTransition = Slide().apply {
|
||||
duration = AppContext.TRANSITION_DURATION
|
||||
interpolator = AccelerateDecelerateInterpolator()
|
||||
}
|
||||
}
|
||||
|
||||
activity.supportFragmentManager
|
||||
.beginTransaction()
|
||||
.replace(R.id.container, fragment)
|
||||
.addToBackStack(null)
|
||||
.commit()
|
||||
}
|
||||
findNavController().navigate(BrowseFragmentDirections.browseToPlaylistTracks(playlist))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -50,7 +50,9 @@ class QueueFragment : BottomSheetDialogFragment() {
|
|||
return super.onCreateDialog(savedInstanceState).apply {
|
||||
setOnShowListener {
|
||||
findViewById<View>(com.google.android.material.R.id.design_bottom_sheet)?.let {
|
||||
BottomSheetBehavior.from(it).skipCollapsed = true
|
||||
val behavior = BottomSheetBehavior.from(it)
|
||||
behavior.skipCollapsed = true
|
||||
behavior.state = BottomSheetBehavior.STATE_EXPANDED
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -100,15 +102,15 @@ class QueueFragment : BottomSheetDialogFragment() {
|
|||
CommandBus.send(Command.ClearQueue)
|
||||
}
|
||||
|
||||
refresh()
|
||||
refresh(true)
|
||||
}
|
||||
|
||||
private fun refresh() {
|
||||
private fun refresh(scroll: Boolean) {
|
||||
lifecycleScope.launch(Main) {
|
||||
RequestBus.send(Request.GetQueue).wait<Response.Queue>()?.let { response ->
|
||||
binding.included.let { included ->
|
||||
adapter?.let {
|
||||
it.data = response.queue.toMutableList()
|
||||
it.setUnfilteredData(response.queue.toMutableList())
|
||||
it.notifyDataSetChanged()
|
||||
|
||||
if (it.data.isEmpty()) {
|
||||
|
@ -120,6 +122,11 @@ class QueueFragment : BottomSheetDialogFragment() {
|
|||
}
|
||||
}
|
||||
}
|
||||
if (scroll) {
|
||||
RequestBus.send(Request.GetCurrentTrackIndex).wait<Response.CurrentTrackIndex>()?.let { sresp ->
|
||||
binding.included.queue.scrollToPosition(sresp.index)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -127,16 +134,16 @@ class QueueFragment : BottomSheetDialogFragment() {
|
|||
private fun watchEventBus() {
|
||||
lifecycleScope.launch(Main) {
|
||||
EventBus.get().collect { message ->
|
||||
when (message) {
|
||||
is Event.QueueChanged -> refresh()
|
||||
if (message is Event.QueueChanged) {
|
||||
refresh(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lifecycleScope.launch(Main) {
|
||||
CommandBus.get().collect { command ->
|
||||
when (command) {
|
||||
is Command.RefreshTrack -> refresh()
|
||||
if (command is Command.RefreshTrack) {
|
||||
refresh(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -62,8 +62,7 @@ class RadiosFragment : FFAFragment<Radio, RadiosAdapter>() {
|
|||
|
||||
lifecycleScope.launch(Main) {
|
||||
EventBus.get().collect { message ->
|
||||
when (message) {
|
||||
is Event.RadioStarted ->
|
||||
if (message is Event.RadioStarted) {
|
||||
recycler.forEach {
|
||||
it.isEnabled = true
|
||||
it.isClickable = true
|
||||
|
|
|
@ -0,0 +1,136 @@
|
|||
package audio.funkwhale.ffa.fragments
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.appcompat.widget.SearchView
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import audio.funkwhale.ffa.adapters.FavoriteListener
|
||||
import audio.funkwhale.ffa.adapters.SearchAdapter
|
||||
import audio.funkwhale.ffa.databinding.FragmentSearchBinding
|
||||
import audio.funkwhale.ffa.model.Album
|
||||
import audio.funkwhale.ffa.model.Artist
|
||||
import audio.funkwhale.ffa.repositories.FavoritesRepository
|
||||
import audio.funkwhale.ffa.utils.Command
|
||||
import audio.funkwhale.ffa.utils.CommandBus
|
||||
import audio.funkwhale.ffa.utils.Event
|
||||
import audio.funkwhale.ffa.utils.EventBus
|
||||
import audio.funkwhale.ffa.utils.getMetadata
|
||||
import audio.funkwhale.ffa.viewmodel.SearchViewModel
|
||||
import com.google.android.exoplayer2.offline.Download
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class SearchFragment : Fragment() {
|
||||
private lateinit var adapter: SearchAdapter
|
||||
private lateinit var binding: FragmentSearchBinding
|
||||
private val viewModel by activityViewModels<SearchViewModel>()
|
||||
private val noSearchYet = MutableLiveData(true)
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
binding = FragmentSearchBinding.inflate(layoutInflater, container, false)
|
||||
binding.lifecycleOwner = this
|
||||
binding.isLoadingData = viewModel.isLoadingData
|
||||
binding.hasResults = viewModel.hasResults
|
||||
binding.noSearchYet = noSearchYet
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
binding.search.requestFocus()
|
||||
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
CommandBus.get().collect { command ->
|
||||
if (command is Command.AddToPlaylist) {
|
||||
|
||||
if (lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) {
|
||||
AddToPlaylistDialog.show(
|
||||
layoutInflater,
|
||||
requireActivity(),
|
||||
lifecycleScope,
|
||||
command.tracks
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
EventBus.get().collect { event ->
|
||||
if (event is Event.DownloadChanged) refreshDownloadedTrack(event.download)
|
||||
}
|
||||
}
|
||||
|
||||
adapter =
|
||||
SearchAdapter(
|
||||
viewModel,
|
||||
this,
|
||||
SearchResultClickListener(),
|
||||
FavoriteListener(FavoritesRepository(requireContext()))
|
||||
).also {
|
||||
binding.results.layoutManager = LinearLayoutManager(requireContext())
|
||||
binding.results.adapter = it
|
||||
}
|
||||
|
||||
binding.search.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
|
||||
|
||||
override fun onQueryTextSubmit(query: String): Boolean {
|
||||
binding.search.clearFocus()
|
||||
noSearchYet.value = false
|
||||
viewModel.query.postValue(query)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onQueryTextChange(newText: String) = true
|
||||
})
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
// Empty the research to prevent result recall the next time
|
||||
viewModel.query.value = ""
|
||||
}
|
||||
|
||||
private suspend fun refreshDownloadedTrack(download: Download) {
|
||||
if (download.state == Download.STATE_COMPLETED) {
|
||||
download.getMetadata()?.let { info ->
|
||||
adapter.tracks.withIndex().associate { it.value to it.index }
|
||||
.filter { it.key.id == info.id }.toList().getOrNull(0)?.let { match ->
|
||||
withContext(Dispatchers.Main) {
|
||||
adapter.tracks[match.second].downloaded = true
|
||||
adapter.notifyItemChanged(
|
||||
adapter.getPositionOf(
|
||||
SearchAdapter.ResultType.Track,
|
||||
match.second
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inner class SearchResultClickListener : SearchAdapter.OnSearchResultClickListener {
|
||||
override fun onArtistClick(holder: View?, artist: Artist) {
|
||||
findNavController().navigate(SearchFragmentDirections.searchToAlbums(artist))
|
||||
}
|
||||
|
||||
override fun onAlbumClick(holder: View?, album: Album) {
|
||||
findNavController().navigate(SearchFragmentDirections.searchToTracks(album))
|
||||
}
|
||||
}
|
||||
}
|
|
@ -8,44 +8,41 @@ import android.view.LayoutInflater
|
|||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.appcompat.widget.PopupMenu
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.navigation.fragment.navArgs
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import audio.funkwhale.ffa.R
|
||||
import audio.funkwhale.ffa.adapters.FavoriteListener
|
||||
import audio.funkwhale.ffa.adapters.TracksAdapter
|
||||
import audio.funkwhale.ffa.databinding.FragmentTracksBinding
|
||||
import audio.funkwhale.ffa.model.Album
|
||||
import audio.funkwhale.ffa.model.Track
|
||||
import audio.funkwhale.ffa.repositories.FavoritedRepository
|
||||
import audio.funkwhale.ffa.repositories.FavoritesRepository
|
||||
import audio.funkwhale.ffa.repositories.TracksRepository
|
||||
import audio.funkwhale.ffa.utils.Command
|
||||
import audio.funkwhale.ffa.utils.CommandBus
|
||||
import audio.funkwhale.ffa.utils.CoverArt
|
||||
import audio.funkwhale.ffa.utils.Event
|
||||
import audio.funkwhale.ffa.utils.EventBus
|
||||
import audio.funkwhale.ffa.utils.Request
|
||||
import audio.funkwhale.ffa.utils.RequestBus
|
||||
import audio.funkwhale.ffa.utils.Response
|
||||
import audio.funkwhale.ffa.utils.getMetadata
|
||||
import audio.funkwhale.ffa.utils.maybeLoad
|
||||
import audio.funkwhale.ffa.utils.maybeNormalizeUrl
|
||||
import audio.funkwhale.ffa.utils.toast
|
||||
import audio.funkwhale.ffa.utils.wait
|
||||
import com.google.android.exoplayer2.offline.Download
|
||||
import com.google.android.exoplayer2.offline.DownloadManager
|
||||
import com.preference.PowerPreference
|
||||
import com.squareup.picasso.Picasso
|
||||
import jp.wasabeef.picasso.transformations.RoundedCornersTransformation
|
||||
import kotlinx.coroutines.Dispatchers.IO
|
||||
import kotlinx.coroutines.Dispatchers.Main
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koin.java.KoinJavaComponent.inject
|
||||
|
||||
class TracksFragment : FFAFragment<Track, TracksAdapter>() {
|
||||
|
||||
private val args by navArgs<TracksFragmentArgs>()
|
||||
private val exoDownloadManager: DownloadManager by inject(DownloadManager::class.java)
|
||||
|
||||
override val recycler: RecyclerView get() = binding.tracks
|
||||
|
@ -56,37 +53,12 @@ class TracksFragment : FFAFragment<Track, TracksAdapter>() {
|
|||
private lateinit var favoritesRepository: FavoritesRepository
|
||||
private lateinit var favoritedRepository: FavoritedRepository
|
||||
|
||||
private var albumId = 0
|
||||
private var albumArtist = ""
|
||||
private var albumTitle = ""
|
||||
private var albumCover = ""
|
||||
|
||||
companion object {
|
||||
fun new(album: Album): TracksFragment {
|
||||
return TracksFragment().apply {
|
||||
arguments = bundleOf(
|
||||
"albumId" to album.id,
|
||||
"albumArtist" to album.artist.name,
|
||||
"albumTitle" to album.title,
|
||||
"albumCover" to album.cover()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
arguments?.apply {
|
||||
albumId = getInt("albumId")
|
||||
albumArtist = getString("albumArtist") ?: ""
|
||||
albumTitle = getString("albumTitle") ?: ""
|
||||
albumCover = getString("albumCover") ?: ""
|
||||
}
|
||||
|
||||
favoritesRepository = FavoritesRepository(context)
|
||||
favoritedRepository = FavoritedRepository(context)
|
||||
repository = TracksRepository(context, albumId)
|
||||
repository = TracksRepository(context, args.album.id)
|
||||
|
||||
adapter = TracksAdapter(layoutInflater, context, FavoriteListener(favoritesRepository))
|
||||
|
||||
|
@ -129,6 +101,12 @@ class TracksFragment : FFAFragment<Track, TracksAdapter>() {
|
|||
): View {
|
||||
_binding = FragmentTracksBinding.inflate(inflater)
|
||||
swiper = binding.swiper
|
||||
|
||||
when (PowerPreference.getDefaultFile().getString("play_order")) {
|
||||
"in_order" -> binding.play.text = getString(R.string.playback_play)
|
||||
else -> binding.play.text = getString(R.string.playback_shuffle)
|
||||
}
|
||||
|
||||
return binding.root
|
||||
}
|
||||
|
||||
|
@ -140,16 +118,15 @@ class TracksFragment : FFAFragment<Track, TracksAdapter>() {
|
|||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
Picasso.get()
|
||||
.maybeLoad(maybeNormalizeUrl(albumCover))
|
||||
CoverArt.requestCreator(maybeNormalizeUrl(args.album.cover()))
|
||||
.noFade()
|
||||
.fit()
|
||||
.centerCrop()
|
||||
.transform(RoundedCornersTransformation(16, 0))
|
||||
.into(binding.cover)
|
||||
|
||||
binding.artist.text = albumArtist
|
||||
binding.title.text = albumTitle
|
||||
binding.artist.text = args.album.artist.name
|
||||
binding.title.text = args.album.title
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
|
@ -188,7 +165,6 @@ class TracksFragment : FFAFragment<Track, TracksAdapter>() {
|
|||
"in_order" -> CommandBus.send(Command.ReplaceQueue(adapter.data))
|
||||
else -> CommandBus.send(Command.ReplaceQueue(adapter.data.shuffled()))
|
||||
}
|
||||
|
||||
context.toast("All tracks were added to your queue")
|
||||
}
|
||||
|
||||
|
@ -244,16 +220,16 @@ class TracksFragment : FFAFragment<Track, TracksAdapter>() {
|
|||
private fun watchEventBus() {
|
||||
lifecycleScope.launch(IO) {
|
||||
EventBus.get().collect { message ->
|
||||
when (message) {
|
||||
is Event.DownloadChanged -> refreshDownloadedTrack(message.download)
|
||||
if (message is Event.DownloadChanged) {
|
||||
refreshDownloadedTrack(message.download)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lifecycleScope.launch(Main) {
|
||||
CommandBus.get().collect { command ->
|
||||
when (command) {
|
||||
is Command.RefreshTrack -> refreshCurrentTrack(command.track)
|
||||
if (command is Command.RefreshTrack) {
|
||||
refreshCurrentTrack(command.track)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -263,10 +239,12 @@ class TracksFragment : FFAFragment<Track, TracksAdapter>() {
|
|||
val downloaded = TracksRepository.getDownloadedIds(exoDownloadManager) ?: listOf()
|
||||
|
||||
withContext(Main) {
|
||||
adapter.data = adapter.data.map {
|
||||
adapter.setUnfilteredData(
|
||||
adapter.data.map {
|
||||
it.downloaded = downloaded.contains(it.id)
|
||||
it
|
||||
}.toMutableList()
|
||||
)
|
||||
|
||||
adapter.notifyDataSetChanged()
|
||||
}
|
||||
|
|
|
@ -6,7 +6,7 @@ import audio.funkwhale.ffa.playback.MediaSession
|
|||
import audio.funkwhale.ffa.utils.AuthorizationServiceFactory
|
||||
import audio.funkwhale.ffa.utils.OAuth
|
||||
import com.google.android.exoplayer2.database.DatabaseProvider
|
||||
import com.google.android.exoplayer2.database.ExoDatabaseProvider
|
||||
import com.google.android.exoplayer2.database.StandaloneDatabaseProvider
|
||||
import com.google.android.exoplayer2.offline.DownloadManager
|
||||
import com.google.android.exoplayer2.upstream.cache.Cache
|
||||
import com.google.android.exoplayer2.upstream.cache.LeastRecentlyUsedCacheEvictor
|
||||
|
@ -19,7 +19,7 @@ import org.koin.dsl.module
|
|||
fun exoplayerModule(context: Context) = module {
|
||||
|
||||
single<DatabaseProvider>(named("exoDatabase")) {
|
||||
ExoDatabaseProvider(context)
|
||||
StandaloneDatabaseProvider(context)
|
||||
}
|
||||
|
||||
single {
|
||||
|
|
|
@ -1,13 +1,18 @@
|
|||
package audio.funkwhale.ffa.model
|
||||
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Parcelize
|
||||
data class Album(
|
||||
val id: Int,
|
||||
val artist: Artist,
|
||||
val title: String,
|
||||
val cover: Covers?,
|
||||
private val cover: Covers?,
|
||||
val release_date: String?
|
||||
) : SearchResult {
|
||||
data class Artist(val name: String)
|
||||
) : SearchResult, Parcelable {
|
||||
@Parcelize
|
||||
data class Artist(val name: String) : Parcelable
|
||||
|
||||
override fun cover() = cover?.urls?.original
|
||||
override fun title() = title
|
||||
|
|
|
@ -1,16 +1,31 @@
|
|||
package audio.funkwhale.ffa.model
|
||||
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import java.util.Calendar.DAY_OF_YEAR
|
||||
import java.util.GregorianCalendar
|
||||
|
||||
@Parcelize
|
||||
data class Artist(
|
||||
val id: Int,
|
||||
val name: String,
|
||||
val albums: List<Album>?
|
||||
) : SearchResult {
|
||||
) : SearchResult, Parcelable {
|
||||
@Parcelize
|
||||
data class Album(
|
||||
val title: String,
|
||||
val cover: Covers?
|
||||
)
|
||||
) : Parcelable
|
||||
|
||||
override fun cover(): String? = albums?.mapNotNull { it.cover?.urls?.original }?.let { covers ->
|
||||
if (covers.isEmpty()) {
|
||||
return@let null
|
||||
}
|
||||
// Inject a little whimsy: rotate through the album covers daily
|
||||
val index = GregorianCalendar().get(DAY_OF_YEAR) % covers.size
|
||||
covers.getOrNull(index)
|
||||
}
|
||||
|
||||
override fun cover(): String? = albums?.getOrNull(0)?.cover?.urls?.original
|
||||
override fun title() = name
|
||||
override fun subtitle() = "Artist"
|
||||
}
|
||||
|
|
|
@ -5,6 +5,7 @@ sealed class CacheItem<D : Any>(val data: List<D>)
|
|||
class ArtistsCache(data: List<Artist>) : CacheItem<Artist>(data)
|
||||
class AlbumsCache(data: List<Album>) : CacheItem<Album>(data)
|
||||
class TracksCache(data: List<Track>) : CacheItem<Track>(data)
|
||||
class FavoritesCache(data: List<Favorite>) : CacheItem<Favorite>(data)
|
||||
class PlaylistsCache(data: List<Playlist>) : CacheItem<Playlist>(data)
|
||||
class PlaylistTracksCache(data: List<PlaylistTrack>) : CacheItem<PlaylistTrack>(data)
|
||||
class RadiosCache(data: List<Radio>) : CacheItem<Radio>(data)
|
||||
|
|
|
@ -1,3 +1,7 @@
|
|||
package audio.funkwhale.ffa.model
|
||||
|
||||
data class CoverUrls(val original: String)
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Parcelize
|
||||
data class CoverUrls(val original: String) : Parcelable
|
||||
|
|
|
@ -1,3 +1,7 @@
|
|||
package audio.funkwhale.ffa.model
|
||||
|
||||
data class Covers(val urls: CoverUrls)
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Parcelize
|
||||
data class Covers(val urls: CoverUrls) : Parcelable
|
||||
|
|
|
@ -0,0 +1,10 @@
|
|||
package audio.funkwhale.ffa.model
|
||||
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Parcelize
|
||||
data class Favorite(
|
||||
val id: Int = 0,
|
||||
val track: Track
|
||||
) : Parcelable
|
|
@ -0,0 +1,9 @@
|
|||
package audio.funkwhale.ffa.model
|
||||
|
||||
data class FavoritesResponse(
|
||||
override val count: Int,
|
||||
override val next: String?,
|
||||
val results: List<Favorite>
|
||||
) : FFAResponse<Favorite>() {
|
||||
override fun getData() = results
|
||||
}
|
|
@ -1,9 +1,13 @@
|
|||
package audio.funkwhale.ffa.model
|
||||
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Parcelize
|
||||
data class Playlist(
|
||||
val id: Int,
|
||||
val name: String,
|
||||
val album_covers: List<String>,
|
||||
val tracks_count: Int,
|
||||
val duration: Int
|
||||
)
|
||||
) : Parcelable
|
||||
|
|
|
@ -1,10 +1,16 @@
|
|||
package audio.funkwhale.ffa.model
|
||||
|
||||
import android.os.Parcelable
|
||||
import audio.funkwhale.ffa.utils.containsIgnoringCase
|
||||
import com.preference.PowerPreference
|
||||
import kotlinx.parcelize.IgnoredOnParcel
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Parcelize
|
||||
data class Track(
|
||||
val id: Int = 0,
|
||||
val title: String,
|
||||
private val cover: Covers? ,
|
||||
val artist: Artist,
|
||||
val album: Album?,
|
||||
val disc_number: Int = 0,
|
||||
|
@ -12,10 +18,18 @@ data class Track(
|
|||
val uploads: List<Upload> = listOf(),
|
||||
val copyright: String? = null,
|
||||
val license: String? = null
|
||||
) : SearchResult {
|
||||
) : SearchResult, Parcelable {
|
||||
|
||||
@IgnoredOnParcel
|
||||
var current: Boolean = false
|
||||
|
||||
@IgnoredOnParcel
|
||||
var favorite: Boolean = false
|
||||
|
||||
@IgnoredOnParcel
|
||||
var cached: Boolean = false
|
||||
|
||||
@IgnoredOnParcel
|
||||
var downloaded: Boolean = false
|
||||
|
||||
companion object {
|
||||
|
@ -23,17 +37,21 @@ data class Track(
|
|||
fun fromDownload(download: DownloadInfo): Track = Track(
|
||||
id = download.id,
|
||||
title = download.title,
|
||||
cover = Covers(CoverUrls("")),
|
||||
artist = Artist(0, download.artist, listOf()),
|
||||
album = Album(0, Album.Artist(""), "", Covers(CoverUrls("")), ""),
|
||||
uploads = listOf(Upload(download.contentId, 0, 0))
|
||||
)
|
||||
}
|
||||
|
||||
data class Upload(
|
||||
val listen_url: String,
|
||||
val duration: Int,
|
||||
val bitrate: Int
|
||||
)
|
||||
@Parcelize
|
||||
data class Upload(val listen_url: String, val duration: Int, val bitrate: Int) : Parcelable
|
||||
|
||||
fun matchesFilter(filter: String): Boolean {
|
||||
return title.containsIgnoringCase(filter) ||
|
||||
artist.name.containsIgnoringCase(filter) ||
|
||||
album?.title.containsIgnoringCase(filter)
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
return when (other) {
|
||||
|
@ -49,14 +67,30 @@ data class Track(
|
|||
fun bestUpload(): Upload? {
|
||||
if (uploads.isEmpty()) return null
|
||||
|
||||
return when (PowerPreference.getDefaultFile().getString("media_cache_quality")) {
|
||||
var bestUpload = when (PowerPreference.getDefaultFile().getString("media_cache_quality")) {
|
||||
"quality" -> uploads.maxByOrNull { it.bitrate } ?: uploads[0]
|
||||
"size" -> uploads.minByOrNull { it.bitrate } ?: uploads[0]
|
||||
else -> uploads.maxByOrNull { it.bitrate } ?: uploads[0]
|
||||
}
|
||||
|
||||
return when (PowerPreference.getDefaultFile().getString("bandwidth_limitation")) {
|
||||
"unlimited" -> bestUpload
|
||||
"limited" -> {
|
||||
var listenUrl = bestUpload.listen_url
|
||||
Upload(listenUrl.plus("&to=mp3&max_bitrate=320"), uploads[0].duration, 320_000)
|
||||
}
|
||||
else -> bestUpload
|
||||
}
|
||||
}
|
||||
|
||||
override fun cover(): String? {
|
||||
return if (cover?.urls?.original != null) {
|
||||
cover.urls.original
|
||||
} else {
|
||||
album?.cover()
|
||||
}
|
||||
}
|
||||
|
||||
override fun cover() = album?.cover?.urls?.original
|
||||
override fun title() = title
|
||||
override fun subtitle() = artist.name
|
||||
|
||||
|
|
|
@ -2,6 +2,7 @@ package audio.funkwhale.ffa.playback
|
|||
|
||||
import android.app.Notification
|
||||
import android.app.PendingIntent
|
||||
import android.app.PendingIntent.FLAG_IMMUTABLE
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.support.v4.media.session.MediaSessionCompat
|
||||
|
@ -14,14 +15,18 @@ import audio.funkwhale.ffa.R
|
|||
import audio.funkwhale.ffa.activities.MainActivity
|
||||
import audio.funkwhale.ffa.model.Track
|
||||
import audio.funkwhale.ffa.utils.AppContext
|
||||
import audio.funkwhale.ffa.utils.CoverArt
|
||||
import audio.funkwhale.ffa.utils.maybeNormalizeUrl
|
||||
import com.squareup.picasso.Picasso
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers.Default
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.java.KoinJavaComponent.inject
|
||||
|
||||
class MediaControlsManager(val context: Service, private val scope: CoroutineScope, private val mediaSession: MediaSessionCompat) {
|
||||
class MediaControlsManager(
|
||||
val context: Service,
|
||||
private val scope: CoroutineScope,
|
||||
private val mediaSession: MediaSessionCompat
|
||||
) {
|
||||
|
||||
companion object {
|
||||
const val NOTIFICATION_ACTION_OPEN_QUEUE = 0
|
||||
|
@ -41,8 +46,10 @@ class MediaControlsManager(val context: Service, private val scope: CoroutineSco
|
|||
}
|
||||
|
||||
scope.launch(Default) {
|
||||
val openIntent = Intent(context, MainActivity::class.java).apply { action = NOTIFICATION_ACTION_OPEN_QUEUE.toString() }
|
||||
val openPendingIntent = PendingIntent.getActivity(context, 0, openIntent, 0)
|
||||
val openIntent = Intent(context, MainActivity::class.java).apply {
|
||||
action = NOTIFICATION_ACTION_OPEN_QUEUE.toString()
|
||||
}
|
||||
val openPendingIntent = PendingIntent.getActivity(context, 0, openIntent, FLAG_IMMUTABLE)
|
||||
|
||||
val coverUrl = maybeNormalizeUrl(track.album?.cover())
|
||||
|
||||
|
@ -61,7 +68,7 @@ class MediaControlsManager(val context: Service, private val scope: CoroutineSco
|
|||
.run {
|
||||
coverUrl?.let {
|
||||
try {
|
||||
setLargeIcon(Picasso.get().load(coverUrl).get())
|
||||
setLargeIcon(CoverArt.requestCreator(coverUrl).get())
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
|
||||
|
@ -98,7 +105,8 @@ class MediaControlsManager(val context: Service, private val scope: CoroutineSco
|
|||
if (playing) {
|
||||
context.startForeground(AppContext.NOTIFICATION_MEDIA_CONTROL, it)
|
||||
} else {
|
||||
NotificationManagerCompat.from(context).notify(AppContext.NOTIFICATION_MEDIA_CONTROL, it)
|
||||
NotificationManagerCompat.from(context)
|
||||
.notify(AppContext.NOTIFICATION_MEDIA_CONTROL, it)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -2,13 +2,13 @@ package audio.funkwhale.ffa.playback
|
|||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.ResultReceiver
|
||||
import android.support.v4.media.session.MediaSessionCompat
|
||||
import android.support.v4.media.session.PlaybackStateCompat
|
||||
import audio.funkwhale.ffa.utils.Command
|
||||
import audio.funkwhale.ffa.utils.CommandBus
|
||||
import com.google.android.exoplayer2.ControlDispatcher
|
||||
import com.google.android.exoplayer2.Player
|
||||
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector
|
||||
|
||||
|
@ -30,7 +30,6 @@ class MediaSession(private val context: Context) {
|
|||
|
||||
val session: MediaSessionCompat by lazy {
|
||||
MediaSessionCompat(context, context.packageName).apply {
|
||||
setFlags(MediaSessionCompat.FLAG_HANDLES_MEDIA_BUTTONS or MediaSessionCompat.FLAG_HANDLES_TRANSPORT_CONTROLS)
|
||||
setPlaybackState(playbackStateBuilder.build())
|
||||
|
||||
isActive = true
|
||||
|
@ -42,15 +41,19 @@ class MediaSession(private val context: Context) {
|
|||
MediaSessionConnector(session).also {
|
||||
it.setQueueNavigator(FFAQueueNavigator())
|
||||
|
||||
it.setMediaButtonEventHandler { _, _, intent ->
|
||||
it.setMediaButtonEventHandler { _, intent ->
|
||||
if (!active) {
|
||||
context.startService(
|
||||
Intent(context, PlayerService::class.java).apply {
|
||||
action = intent.action
|
||||
Intent(context, PlayerService::class.java).let { player ->
|
||||
player.action = intent.action
|
||||
|
||||
intent.extras?.let { extras -> putExtras(extras) }
|
||||
intent.extras?.let { extras -> player.putExtras(extras) }
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
context.startForegroundService(player)
|
||||
} else {
|
||||
context.startService(player)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return@setMediaButtonEventHandler true
|
||||
}
|
||||
|
@ -62,13 +65,11 @@ class MediaSession(private val context: Context) {
|
|||
}
|
||||
|
||||
class FFAQueueNavigator : MediaSessionConnector.QueueNavigator {
|
||||
override fun onSkipToQueueItem(player: Player, controlDispatcher: ControlDispatcher, id: Long) {
|
||||
override fun onSkipToQueueItem(player: Player, id: Long) {
|
||||
CommandBus.send(Command.PlayTrack(id.toInt()))
|
||||
}
|
||||
|
||||
override fun onCurrentWindowIndexChanged(player: Player) {}
|
||||
|
||||
override fun onCommand(player: Player, controlDispatcher: ControlDispatcher, command: String, extras: Bundle?, cb: ResultReceiver?) = true
|
||||
override fun onCommand(player: Player, command: String, extras: Bundle?, cb: ResultReceiver?) = true
|
||||
|
||||
override fun getSupportedQueueNavigatorActions(player: Player): Long {
|
||||
return PlaybackStateCompat.ACTION_PLAY_PAUSE or
|
||||
|
@ -77,13 +78,13 @@ class FFAQueueNavigator : MediaSessionConnector.QueueNavigator {
|
|||
PlaybackStateCompat.ACTION_SKIP_TO_QUEUE_ITEM
|
||||
}
|
||||
|
||||
override fun onSkipToNext(player: Player, controlDispatcher: ControlDispatcher) {
|
||||
override fun onSkipToNext(player: Player) {
|
||||
CommandBus.send(Command.NextTrack)
|
||||
}
|
||||
|
||||
override fun getActiveQueueItemId(player: Player?) = player?.currentWindowIndex?.toLong() ?: 0
|
||||
override fun getActiveQueueItemId(player: Player?) = player?.currentMediaItemIndex?.toLong() ?: 0
|
||||
|
||||
override fun onSkipToPrevious(player: Player, controlDispatcher: ControlDispatcher) {
|
||||
override fun onSkipToPrevious(player: Player) {
|
||||
CommandBus.send(Command.PreviousTrack)
|
||||
}
|
||||
|
||||
|
|
|
@ -24,7 +24,6 @@ import com.google.gson.Gson
|
|||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers.Main
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.java.KoinJavaComponent
|
||||
import java.util.Collections
|
||||
|
@ -35,6 +34,7 @@ class PinService : DownloadService(AppContext.NOTIFICATION_DOWNLOADS) {
|
|||
private val exoDownloadManager: DownloadManager by KoinJavaComponent.inject(DownloadManager::class.java)
|
||||
|
||||
companion object {
|
||||
|
||||
fun download(context: Context, track: Track) {
|
||||
track.bestUpload()?.let { upload ->
|
||||
val url = mustNormalizeUrl(upload.listen_url)
|
||||
|
@ -48,7 +48,7 @@ class PinService : DownloadService(AppContext.NOTIFICATION_DOWNLOADS) {
|
|||
)
|
||||
).toByteArray()
|
||||
|
||||
val request = DownloadRequest.Builder(track.id.toString(), url.toUri())
|
||||
val request = DownloadRequest.Builder(url.toUri().toString(), url.toUri())
|
||||
.setData(data)
|
||||
.setStreamKeys(Collections.emptyList())
|
||||
.build()
|
||||
|
@ -63,8 +63,8 @@ class PinService : DownloadService(AppContext.NOTIFICATION_DOWNLOADS) {
|
|||
|
||||
scope.launch(Main) {
|
||||
RequestBus.get().collect { request ->
|
||||
when (request) {
|
||||
is Request.GetDownloads -> request.channel?.trySend(Response.Downloads(getDownloads()))?.isSuccess
|
||||
if (request is Request.GetDownloads) {
|
||||
request.channel?.trySend(Response.Downloads(getDownloads()))?.isSuccess
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -72,20 +72,28 @@ class PinService : DownloadService(AppContext.NOTIFICATION_DOWNLOADS) {
|
|||
return super.onStartCommand(intent, flags, startId)
|
||||
}
|
||||
|
||||
override fun getDownloadManager() = exoDownloadManager.apply {
|
||||
override fun getDownloadManager(): DownloadManager {
|
||||
return exoDownloadManager.apply {
|
||||
addListener(DownloadListener())
|
||||
}
|
||||
}
|
||||
|
||||
override fun getScheduler(): Scheduler? = null
|
||||
|
||||
override fun getForegroundNotification(downloads: MutableList<Download>): Notification {
|
||||
override fun getForegroundNotification(
|
||||
downloads: MutableList<Download>,
|
||||
notMetRequirements: Int
|
||||
): Notification {
|
||||
val description =
|
||||
resources.getQuantityString(R.plurals.downloads_description, downloads.size, downloads.size)
|
||||
|
||||
return DownloadNotificationHelper(
|
||||
this,
|
||||
AppContext.NOTIFICATION_CHANNEL_DOWNLOADS
|
||||
).buildProgressNotification(this, R.drawable.downloads, null, description, downloads)
|
||||
).buildProgressNotification(
|
||||
this, R.drawable.downloads, null, description,
|
||||
downloads, notMetRequirements
|
||||
)
|
||||
}
|
||||
|
||||
private fun getDownloads() = downloadManager.downloadIndex.getDownloads()
|
||||
|
|
|
@ -12,6 +12,7 @@ import android.media.MediaMetadata
|
|||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.support.v4.media.MediaMetadataCompat
|
||||
import android.util.Log
|
||||
import android.view.KeyEvent
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.media.session.MediaButtonReceiver
|
||||
|
@ -19,6 +20,7 @@ import audio.funkwhale.ffa.R
|
|||
import audio.funkwhale.ffa.model.Track
|
||||
import audio.funkwhale.ffa.utils.Command
|
||||
import audio.funkwhale.ffa.utils.CommandBus
|
||||
import audio.funkwhale.ffa.utils.CoverArt
|
||||
import audio.funkwhale.ffa.utils.Event
|
||||
import audio.funkwhale.ffa.utils.EventBus
|
||||
import audio.funkwhale.ffa.utils.FFACache
|
||||
|
@ -31,19 +33,18 @@ import audio.funkwhale.ffa.utils.log
|
|||
import audio.funkwhale.ffa.utils.maybeNormalizeUrl
|
||||
import audio.funkwhale.ffa.utils.onApi
|
||||
import com.google.android.exoplayer2.C
|
||||
import com.google.android.exoplayer2.ExoPlaybackException
|
||||
import com.google.android.exoplayer2.ExoPlayer
|
||||
import com.google.android.exoplayer2.IllegalSeekPositionException
|
||||
import com.google.android.exoplayer2.PlaybackException
|
||||
import com.google.android.exoplayer2.Player
|
||||
import com.google.android.exoplayer2.SimpleExoPlayer
|
||||
import com.google.android.exoplayer2.source.TrackGroupArray
|
||||
import com.google.android.exoplayer2.trackselection.TrackSelectionArray
|
||||
import com.squareup.picasso.Picasso
|
||||
import com.google.android.exoplayer2.Tracks
|
||||
import com.preference.PowerPreference
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers.IO
|
||||
import kotlinx.coroutines.Dispatchers.Main
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.koin.java.KoinJavaComponent.inject
|
||||
|
@ -65,7 +66,7 @@ class PlayerService : Service() {
|
|||
|
||||
private lateinit var queue: QueueManager
|
||||
private lateinit var mediaControlsManager: MediaControlsManager
|
||||
private lateinit var player: SimpleExoPlayer
|
||||
private lateinit var player: ExoPlayer
|
||||
|
||||
private val mediaMetadataBuilder = MediaMetadataCompat.Builder()
|
||||
|
||||
|
@ -132,12 +133,13 @@ class PlayerService : Service() {
|
|||
|
||||
mediaControlsManager = MediaControlsManager(this, scope, mediaSession.session)
|
||||
|
||||
player = SimpleExoPlayer.Builder(this).build().apply {
|
||||
player = ExoPlayer.Builder(this).build().apply {
|
||||
playWhenReady = false
|
||||
|
||||
playerEventListener = PlayerEventListener().also {
|
||||
addListener(it)
|
||||
}
|
||||
EventBus.send(Event.StateChanged(this.isPlaying()))
|
||||
}
|
||||
|
||||
mediaSession.active = true
|
||||
|
@ -151,14 +153,20 @@ class PlayerService : Service() {
|
|||
}
|
||||
|
||||
if (queue.current > -1) {
|
||||
player.prepare(queue.dataSources)
|
||||
player.setMediaSource(queue.dataSources)
|
||||
player.prepare()
|
||||
|
||||
FFACache.get(this, "progress")?.let { progress ->
|
||||
player.seekTo(queue.current, progress.readLine().toLong())
|
||||
|
||||
|
||||
FFACache.getLine(this, "progress")?.let {
|
||||
try {
|
||||
player.seekTo(queue.current, it.toLong())
|
||||
val (current, duration, percent) = getProgress(true)
|
||||
|
||||
ProgressBus.send(current, duration, percent)
|
||||
} catch (e: IllegalSeekPositionException) {
|
||||
// The app remembered an incorrect position, let's reset it
|
||||
FFACache.set(this, "current", "-1")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -171,61 +179,60 @@ class PlayerService : Service() {
|
|||
private fun watchEventBus() {
|
||||
scope.launch(Main) {
|
||||
CommandBus.get().collect { command ->
|
||||
when (command) {
|
||||
is Command.RefreshService -> {
|
||||
if (command is Command.RefreshService) {
|
||||
if (queue.metadata.isNotEmpty()) {
|
||||
CommandBus.send(Command.RefreshTrack(queue.current()))
|
||||
EventBus.send(Event.StateChanged(player.playWhenReady))
|
||||
}
|
||||
}
|
||||
|
||||
is Command.ReplaceQueue -> {
|
||||
} else if (command is Command.ReplaceQueue) {
|
||||
if (!command.fromRadio) radioPlayer.stop()
|
||||
|
||||
queue.replace(command.queue)
|
||||
player.prepare(queue.dataSources, true, true)
|
||||
player.setMediaSource(queue.dataSources)
|
||||
player.prepare()
|
||||
|
||||
setPlaybackState(true)
|
||||
|
||||
CommandBus.send(Command.RefreshTrack(queue.current()))
|
||||
}
|
||||
|
||||
is Command.AddToQueue -> queue.append(command.tracks)
|
||||
is Command.PlayNext -> queue.insertNext(command.track)
|
||||
is Command.RemoveFromQueue -> queue.remove(command.track)
|
||||
is Command.MoveFromQueue -> queue.move(command.oldPosition, command.newPosition)
|
||||
|
||||
is Command.PlayTrack -> {
|
||||
} else if (command is Command.AddToQueue) {
|
||||
queue.append(command.tracks)
|
||||
} else if (command is Command.PlayNext) {
|
||||
queue.insertNext(command.track)
|
||||
} else if (command is Command.RemoveFromQueue) {
|
||||
queue.remove(command.track)
|
||||
} else if (command is Command.MoveFromQueue) {
|
||||
queue.move(command.oldPosition, command.newPosition)
|
||||
} else if (command is Command.PlayTrack) {
|
||||
queue.current = command.index
|
||||
player.seekTo(command.index, C.TIME_UNSET)
|
||||
|
||||
setPlaybackState(true)
|
||||
|
||||
CommandBus.send(Command.RefreshTrack(queue.current()))
|
||||
}
|
||||
|
||||
is Command.ToggleState -> togglePlayback()
|
||||
is Command.SetState -> setPlaybackState(command.state)
|
||||
|
||||
is Command.NextTrack -> skipToNextTrack()
|
||||
is Command.PreviousTrack -> skipToPreviousTrack()
|
||||
is Command.Seek -> seek(command.progress)
|
||||
|
||||
is Command.ClearQueue -> {
|
||||
} else if (command is Command.ToggleState) {
|
||||
togglePlayback()
|
||||
} else if (command is Command.SetState) {
|
||||
setPlaybackState(command.state)
|
||||
} else if (command is Command.NextTrack) {
|
||||
skipToNextTrack()
|
||||
} else if (command is Command.PreviousTrack) {
|
||||
skipToPreviousTrack()
|
||||
} else if (command is Command.Seek) {
|
||||
seek(command.progress)
|
||||
} else if (command is Command.ClearQueue) {
|
||||
queue.clear()
|
||||
player.stop()
|
||||
}
|
||||
is Command.ShuffleQueue -> queue.shuffle()
|
||||
|
||||
is Command.PlayRadio -> {
|
||||
} else if (command is Command.ShuffleQueue) {
|
||||
queue.shuffle()
|
||||
} else if (command is Command.PlayRadio) {
|
||||
queue.clear()
|
||||
radioPlayer.play(command.radio)
|
||||
}
|
||||
|
||||
is Command.SetRepeatMode -> player.repeatMode = command.mode
|
||||
|
||||
is Command.PinTrack -> PinService.download(this@PlayerService, command.track)
|
||||
is Command.PinTracks -> command.tracks.forEach {
|
||||
} else if (command is Command.SetRepeatMode) {
|
||||
player.repeatMode = command.mode
|
||||
} else if (command is Command.PinTrack) {
|
||||
PinService.download(this@PlayerService, command.track)
|
||||
} else if (command is Command.PinTracks) {
|
||||
command.tracks.forEach {
|
||||
PinService.download(
|
||||
this@PlayerService,
|
||||
it
|
||||
|
@ -237,10 +244,14 @@ class PlayerService : Service() {
|
|||
|
||||
scope.launch(Main) {
|
||||
RequestBus.get().collect { request ->
|
||||
when (request) {
|
||||
is Request.GetCurrentTrack -> request.channel?.trySend(Response.CurrentTrack(queue.current()))?.isSuccess
|
||||
is Request.GetState -> request.channel?.trySend(Response.State(player.playWhenReady))?.isSuccess
|
||||
is Request.GetQueue -> request.channel?.trySend(Response.Queue(queue.get()))?.isSuccess
|
||||
if (request is Request.GetCurrentTrack) {
|
||||
request.channel?.trySend(Response.CurrentTrack(queue.current()))?.isSuccess
|
||||
} else if (request is Request.GetCurrentTrackIndex) {
|
||||
request.channel?.trySend(Response.CurrentTrackIndex(queue.currentIndex()))?.isSuccess
|
||||
} else if (request is Request.GetState) {
|
||||
request.channel?.trySend(Response.State(player.playWhenReady))?.isSuccess
|
||||
} else if (request is Request.GetQueue) {
|
||||
request.channel?.trySend(Response.Queue(queue.get()))?.isSuccess
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -303,11 +314,12 @@ class PlayerService : Service() {
|
|||
if (!state) {
|
||||
val (progress, _, _) = getProgress()
|
||||
|
||||
FFACache.set(this@PlayerService, "progress", progress.toString().toByteArray())
|
||||
FFACache.set(this@PlayerService, "progress", progress.toString())
|
||||
}
|
||||
|
||||
if (state && player.playbackState == Player.STATE_IDLE) {
|
||||
player.prepare(queue.dataSources)
|
||||
player.setMediaSource(queue.dataSources)
|
||||
player.prepare()
|
||||
}
|
||||
|
||||
if (hasAudioFocus(state)) {
|
||||
|
@ -318,7 +330,7 @@ class PlayerService : Service() {
|
|||
}
|
||||
|
||||
private fun togglePlayback() {
|
||||
setPlaybackState(!player.playWhenReady)
|
||||
setPlaybackState(!player.isPlaying)
|
||||
}
|
||||
|
||||
private fun skipToPreviousTrack() {
|
||||
|
@ -326,13 +338,13 @@ class PlayerService : Service() {
|
|||
return player.seekTo(0)
|
||||
}
|
||||
|
||||
player.previous()
|
||||
player.seekToPrevious()
|
||||
}
|
||||
|
||||
private fun skipToNextTrack() {
|
||||
player.next()
|
||||
player.seekToNext()
|
||||
|
||||
FFACache.set(this@PlayerService, "progress", "0".toByteArray())
|
||||
FFACache.set(this@PlayerService, "progress", "0")
|
||||
ProgressBus.send(0, 0, 0)
|
||||
}
|
||||
|
||||
|
@ -373,10 +385,10 @@ class PlayerService : Service() {
|
|||
runBlocking(IO) {
|
||||
this@apply.putBitmap(
|
||||
MediaMetadataCompat.METADATA_KEY_ALBUM_ART,
|
||||
Picasso.get().load(coverUrl).get()
|
||||
CoverArt.requestCreator(coverUrl).get()
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}.build()
|
||||
}
|
||||
|
@ -418,10 +430,28 @@ class PlayerService : Service() {
|
|||
return allowed
|
||||
}
|
||||
|
||||
private fun skipBackwardsAfterPause(): Int {
|
||||
val deltaPref = PowerPreference.getDefaultFile().getString("auto_skip_backwards_on_pause")
|
||||
val delta = deltaPref.toFloatOrNull()
|
||||
return if (delta == null) 0 else (delta * 1000).toInt()
|
||||
}
|
||||
|
||||
@SuppressLint("NewApi")
|
||||
inner class PlayerEventListener : Player.EventListener {
|
||||
override fun onPlayerStateChanged(playWhenReady: Boolean, playbackState: Int) {
|
||||
super.onPlayerStateChanged(playWhenReady, playbackState)
|
||||
inner class PlayerEventListener : Player.Listener {
|
||||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||
super.onIsPlayingChanged(isPlaying)
|
||||
mediaControlsManager.updateNotification(queue.current(), isPlaying)
|
||||
if (!isPlaying) {
|
||||
val delta = skipBackwardsAfterPause()
|
||||
val (current, duration, _) = getProgress(true)
|
||||
val position = if (current > delta) current - delta else 0
|
||||
player.seekTo(position.toLong())
|
||||
ProgressBus.send(position, duration, ((position.toFloat()) / duration / 10).toInt())
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) {
|
||||
super.onPlayWhenReadyChanged(playWhenReady, reason)
|
||||
|
||||
EventBus.send(Event.StateChanged(playWhenReady))
|
||||
|
||||
|
@ -429,11 +459,21 @@ class PlayerService : Service() {
|
|||
CommandBus.send(Command.RefreshTrack(queue.current()))
|
||||
}
|
||||
|
||||
when (playWhenReady) {
|
||||
true -> {
|
||||
if (!playWhenReady) {
|
||||
Build.VERSION_CODES.N.onApi(
|
||||
{ stopForeground(STOP_FOREGROUND_DETACH) },
|
||||
{ stopForeground(false) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
super.onPlaybackStateChanged(playbackState)
|
||||
|
||||
when (playbackState) {
|
||||
Player.STATE_READY -> mediaControlsManager.updateNotification(queue.current(), true)
|
||||
Player.STATE_BUFFERING -> EventBus.send(Event.Buffering(true))
|
||||
Player.STATE_BUFFERING -> {
|
||||
EventBus.send(Event.Buffering(true))
|
||||
}
|
||||
Player.STATE_ENDED -> {
|
||||
setPlaybackState(false)
|
||||
|
||||
|
@ -446,42 +486,29 @@ class PlayerService : Service() {
|
|||
Player.STATE_IDLE -> {
|
||||
setPlaybackState(false)
|
||||
|
||||
return EventBus.send(Event.PlaybackStopped)
|
||||
EventBus.send(Event.PlaybackStopped)
|
||||
|
||||
if (!player.playWhenReady) {
|
||||
mediaControlsManager.remove()
|
||||
}
|
||||
}
|
||||
|
||||
if (playbackState != Player.STATE_BUFFERING) EventBus.send(Event.Buffering(false))
|
||||
}
|
||||
|
||||
false -> {
|
||||
Player.STATE_READY -> {
|
||||
EventBus.send(Event.Buffering(false))
|
||||
|
||||
Build.VERSION_CODES.N.onApi(
|
||||
{ stopForeground(STOP_FOREGROUND_DETACH) },
|
||||
{ stopForeground(false) }
|
||||
)
|
||||
|
||||
when (playbackState) {
|
||||
Player.STATE_READY -> mediaControlsManager.updateNotification(queue.current(), false)
|
||||
Player.STATE_IDLE -> mediaControlsManager.remove()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onTracksChanged(
|
||||
trackGroups: TrackGroupArray,
|
||||
trackSelections: TrackSelectionArray
|
||||
) {
|
||||
super.onTracksChanged(trackGroups, trackSelections)
|
||||
override fun onTracksChanged(tracks: Tracks) {
|
||||
super.onTracksChanged(tracks)
|
||||
|
||||
if (queue.current != player.currentWindowIndex) {
|
||||
queue.current = player.currentWindowIndex
|
||||
mediaControlsManager.updateNotification(queue.current(), player.playWhenReady)
|
||||
if (queue.current != player.currentMediaItemIndex) {
|
||||
queue.current = player.currentMediaItemIndex
|
||||
mediaControlsManager.updateNotification(queue.current(), player.isPlaying)
|
||||
}
|
||||
|
||||
if (queue.get().isNotEmpty() && queue.current() == queue.get()
|
||||
.last() && radioPlayer.isActive()
|
||||
if (queue.get().isNotEmpty() &&
|
||||
queue.current() == queue.get().last() && radioPlayer.isActive()
|
||||
) {
|
||||
scope.launch(IO) {
|
||||
if (radioPlayer.lock.tryAcquire()) {
|
||||
|
@ -491,7 +518,7 @@ class PlayerService : Service() {
|
|||
}
|
||||
}
|
||||
|
||||
FFACache.set(this@PlayerService, "current", queue.current.toString().toByteArray())
|
||||
FFACache.set(this@PlayerService, "current", queue.current.toString())
|
||||
|
||||
CommandBus.send(Command.RefreshTrack(queue.current()))
|
||||
}
|
||||
|
@ -510,13 +537,14 @@ class PlayerService : Service() {
|
|||
}
|
||||
}
|
||||
|
||||
override fun onPlayerError(error: ExoPlaybackException) {
|
||||
override fun onPlayerError(error: PlaybackException) {
|
||||
EventBus.send(Event.PlaybackError(getString(R.string.error_playback)))
|
||||
|
||||
if (player.playWhenReady) {
|
||||
queue.current++
|
||||
player.prepare(queue.dataSources, true, true)
|
||||
player.setMediaSource(queue.dataSources, true)
|
||||
player.seekTo(queue.current, 0)
|
||||
player.prepare()
|
||||
|
||||
CommandBus.send(Command.RefreshTrack(queue.current()))
|
||||
}
|
||||
|
|
|
@ -12,6 +12,7 @@ import audio.funkwhale.ffa.utils.FFACache
|
|||
import audio.funkwhale.ffa.utils.log
|
||||
import audio.funkwhale.ffa.utils.mustNormalizeUrl
|
||||
import com.github.kittinunf.fuel.gson.gsonDeserializerOf
|
||||
import com.google.android.exoplayer2.MediaItem
|
||||
import com.google.android.exoplayer2.source.ConcatenatingMediaSource
|
||||
import com.google.android.exoplayer2.source.ProgressiveMediaSource
|
||||
import com.google.gson.Gson
|
||||
|
@ -28,8 +29,8 @@ class QueueManager(val context: Context) {
|
|||
var current = -1
|
||||
|
||||
init {
|
||||
FFACache.get(context, "queue")?.let { json ->
|
||||
gsonDeserializerOf(QueueCache::class.java).deserialize(json)?.let { cache ->
|
||||
FFACache.getLine(context, "queue")?.let { json ->
|
||||
gsonDeserializerOf(QueueCache::class.java).deserialize(json.reader())?.let { cache ->
|
||||
metadata = cache.data.toMutableList()
|
||||
|
||||
val factory = cacheDataSourceFactoryProvider.create(context)
|
||||
|
@ -38,15 +39,15 @@ class QueueManager(val context: Context) {
|
|||
metadata.map { track ->
|
||||
val url = mustNormalizeUrl(track.bestUpload()?.listen_url ?: "")
|
||||
|
||||
ProgressiveMediaSource.Factory(factory).setTag(track.title)
|
||||
.createMediaSource(Uri.parse(url))
|
||||
val mediaItem = MediaItem.fromUri(Uri.parse(url)).buildUpon().setTag(track.title).build()
|
||||
ProgressiveMediaSource.Factory(factory).createMediaSource(mediaItem)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
FFACache.get(context, "current")?.let { string ->
|
||||
current = string.readLine().toInt()
|
||||
FFACache.getLine(context, "current")?.let {
|
||||
current = it.toInt()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -54,7 +55,7 @@ class QueueManager(val context: Context) {
|
|||
FFACache.set(
|
||||
context,
|
||||
"queue",
|
||||
Gson().toJson(QueueCache(metadata)).toByteArray()
|
||||
Gson().toJson(QueueCache(metadata)).toString()
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -63,8 +64,8 @@ class QueueManager(val context: Context) {
|
|||
val factory = cacheDataSourceFactoryProvider.create(context)
|
||||
val sources = tracks.map { track ->
|
||||
val url = mustNormalizeUrl(track.bestUpload()?.listen_url ?: "")
|
||||
|
||||
ProgressiveMediaSource.Factory(factory).setTag(track.title).createMediaSource(Uri.parse(url))
|
||||
val mediaItem = MediaItem.fromUri(Uri.parse(url)).buildUpon().setTag(track.title).build()
|
||||
ProgressiveMediaSource.Factory(factory).createMediaSource(mediaItem)
|
||||
}
|
||||
|
||||
metadata = tracks.toMutableList()
|
||||
|
@ -84,7 +85,8 @@ class QueueManager(val context: Context) {
|
|||
val sources = missingTracks.map { track ->
|
||||
val url = mustNormalizeUrl(track.bestUpload()?.listen_url ?: "")
|
||||
|
||||
ProgressiveMediaSource.Factory(factory).createMediaSource(Uri.parse(url))
|
||||
val mediaItem = MediaItem.fromUri(Uri.parse(url)).buildUpon().setTag(track.title).build()
|
||||
ProgressiveMediaSource.Factory(factory).createMediaSource(mediaItem)
|
||||
}
|
||||
|
||||
metadata.addAll(tracks)
|
||||
|
@ -101,7 +103,8 @@ class QueueManager(val context: Context) {
|
|||
val url = mustNormalizeUrl(track.bestUpload()?.listen_url ?: "")
|
||||
|
||||
if (metadata.indexOf(track) == -1) {
|
||||
ProgressiveMediaSource.Factory(factory).createMediaSource(Uri.parse(url)).let {
|
||||
val mediaItem = MediaItem.fromUri(Uri.parse(url)).buildUpon().setTag(track.title).build()
|
||||
ProgressiveMediaSource.Factory(factory).createMediaSource(mediaItem).let {
|
||||
dataSources.addMediaSource(current + 1, it)
|
||||
metadata.add(current + 1, track)
|
||||
}
|
||||
|
@ -164,6 +167,8 @@ class QueueManager(val context: Context) {
|
|||
return metadata.getOrNull(current)
|
||||
}
|
||||
|
||||
fun currentIndex(): Int = (if (current == -1) 0 else current)
|
||||
|
||||
fun clear() {
|
||||
metadata = mutableListOf()
|
||||
dataSources.clear()
|
||||
|
|
|
@ -53,10 +53,10 @@ class RadioPlayer(val context: Context, val scope: CoroutineScope) {
|
|||
private val favoritedRepository = FavoritedRepository(context)
|
||||
|
||||
init {
|
||||
FFACache.get(context, "radio_type")?.readLine()?.let { radio_type ->
|
||||
FFACache.get(context, "radio_id")?.readLine()?.toInt()?.let { radio_id ->
|
||||
FFACache.get(context, "radio_session")?.readLine()?.toInt()?.let { radio_session ->
|
||||
val cachedCookie = FFACache.get(context, "radio_cookie")?.readLine()
|
||||
FFACache.getLine(context, "radio_type")?.let { radio_type ->
|
||||
FFACache.getLine(context, "radio_id")?.toInt()?.let { radio_id ->
|
||||
FFACache.getLine(context, "radio_session")?.toInt()?.let { radio_session ->
|
||||
val cachedCookie = FFACache.getLine(context, "radio_cookie")
|
||||
|
||||
currentRadio = Radio(radio_id, radio_type, "", "")
|
||||
session = radio_session
|
||||
|
@ -107,10 +107,10 @@ class RadioPlayer(val context: Context, val scope: CoroutineScope) {
|
|||
session = result.get().id
|
||||
cookie = response.header("set-cookie").joinToString(";")
|
||||
|
||||
FFACache.set(context, "radio_type", radio.radio_type.toByteArray())
|
||||
FFACache.set(context, "radio_id", radio.id.toString().toByteArray())
|
||||
FFACache.set(context, "radio_session", session.toString().toByteArray())
|
||||
FFACache.set(context, "radio_cookie", cookie.toString().toByteArray())
|
||||
FFACache.set(context, "radio_type", radio.radio_type)
|
||||
FFACache.set(context, "radio_id", radio.id.toString())
|
||||
FFACache.set(context, "radio_session", session.toString())
|
||||
FFACache.set(context, "radio_cookie", cookie.toString())
|
||||
|
||||
prepareNextTrack(true)
|
||||
} catch (e: Exception) {
|
||||
|
|
|
@ -8,7 +8,6 @@ import audio.funkwhale.ffa.utils.OAuth
|
|||
import com.github.kittinunf.fuel.gson.gsonDeserializerOf
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import org.koin.java.KoinJavaComponent.inject
|
||||
import java.io.BufferedReader
|
||||
|
||||
class AlbumsRepository(override val context: Context?, artistId: Int? = null) :
|
||||
Repository<Album, AlbumsCache>() {
|
||||
|
@ -35,6 +34,6 @@ class AlbumsRepository(override val context: Context?, artistId: Int? = null) :
|
|||
}
|
||||
|
||||
override fun cache(data: List<Album>) = AlbumsCache(data)
|
||||
override fun uncache(reader: BufferedReader) =
|
||||
gsonDeserializerOf(AlbumsCache::class.java).deserialize(reader)
|
||||
override fun uncache(json: String) =
|
||||
gsonDeserializerOf(AlbumsCache::class.java).deserialize(json.reader())
|
||||
}
|
||||
|
|
|
@ -9,7 +9,6 @@ import audio.funkwhale.ffa.utils.OAuth
|
|||
import com.github.kittinunf.fuel.gson.gsonDeserializerOf
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import org.koin.java.KoinJavaComponent.inject
|
||||
import java.io.BufferedReader
|
||||
|
||||
class ArtistTracksRepository(override val context: Context?, private val artistId: Int) :
|
||||
Repository<Track, TracksCache>() {
|
||||
|
@ -27,6 +26,6 @@ class ArtistTracksRepository(override val context: Context?, private val artistI
|
|||
)
|
||||
|
||||
override fun cache(data: List<Track>) = TracksCache(data)
|
||||
override fun uncache(reader: BufferedReader) =
|
||||
gsonDeserializerOf(TracksCache::class.java).deserialize(reader)
|
||||
override fun uncache(json: String) =
|
||||
gsonDeserializerOf(TracksCache::class.java).deserialize(json.reader())
|
||||
}
|
||||
|
|
|
@ -9,7 +9,6 @@ import audio.funkwhale.ffa.utils.OAuth
|
|||
import com.github.kittinunf.fuel.gson.gsonDeserializerOf
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import org.koin.java.KoinJavaComponent.inject
|
||||
import java.io.BufferedReader
|
||||
|
||||
class ArtistsRepository(override val context: Context?) : Repository<Artist, ArtistsCache>() {
|
||||
|
||||
|
@ -26,6 +25,6 @@ class ArtistsRepository(override val context: Context?) : Repository<Artist, Art
|
|||
)
|
||||
|
||||
override fun cache(data: List<Artist>) = ArtistsCache(data)
|
||||
override fun uncache(reader: BufferedReader) =
|
||||
gsonDeserializerOf(ArtistsCache::class.java).deserialize(reader)
|
||||
override fun uncache(json: String) =
|
||||
gsonDeserializerOf(ArtistsCache::class.java).deserialize(json.reader())
|
||||
}
|
||||
|
|
|
@ -2,11 +2,11 @@ package audio.funkwhale.ffa.repositories
|
|||
|
||||
import android.content.Context
|
||||
import audio.funkwhale.ffa.model.FFAResponse
|
||||
import audio.funkwhale.ffa.model.Favorite
|
||||
import audio.funkwhale.ffa.model.FavoritesResponse
|
||||
import audio.funkwhale.ffa.model.FavoritedCache
|
||||
import audio.funkwhale.ffa.model.FavoritedResponse
|
||||
import audio.funkwhale.ffa.model.Track
|
||||
import audio.funkwhale.ffa.model.TracksCache
|
||||
import audio.funkwhale.ffa.model.TracksResponse
|
||||
import audio.funkwhale.ffa.model.FavoritesCache
|
||||
import audio.funkwhale.ffa.utils.FFACache
|
||||
import audio.funkwhale.ffa.utils.OAuth
|
||||
import audio.funkwhale.ffa.utils.Settings
|
||||
|
@ -27,9 +27,8 @@ import kotlinx.coroutines.launch
|
|||
import kotlinx.coroutines.runBlocking
|
||||
import org.koin.core.qualifier.named
|
||||
import org.koin.java.KoinJavaComponent.inject
|
||||
import java.io.BufferedReader
|
||||
|
||||
class FavoritesRepository(override val context: Context?) : Repository<Track, TracksCache>() {
|
||||
class FavoritesRepository(override val context: Context?) : Repository<Favorite, FavoritesCache>() {
|
||||
|
||||
private val exoDownloadManager: DownloadManager by inject(DownloadManager::class.java)
|
||||
private val exoCache: Cache by inject(Cache::class.java, named("exoCache"))
|
||||
|
@ -37,34 +36,34 @@ class FavoritesRepository(override val context: Context?) : Repository<Track, Tr
|
|||
|
||||
override val cacheId = "favorites.v2"
|
||||
|
||||
override val upstream = HttpUpstream<Track, FFAResponse<Track>>(
|
||||
override val upstream = HttpUpstream<Favorite, FFAResponse<Favorite>>(
|
||||
context!!,
|
||||
HttpUpstream.Behavior.AtOnce,
|
||||
"/api/v1/tracks/?favorites=true&playable=true&ordering=title",
|
||||
object : TypeToken<TracksResponse>() {}.type,
|
||||
"/api/v1/favorites/tracks/?scope=all&ordering=-creation_date",
|
||||
object : TypeToken<FavoritesResponse>() {}.type,
|
||||
oAuth
|
||||
)
|
||||
|
||||
override fun cache(data: List<Track>) = TracksCache(data)
|
||||
override fun uncache(reader: BufferedReader) =
|
||||
gsonDeserializerOf(TracksCache::class.java).deserialize(reader)
|
||||
override fun cache(data: List<Favorite>) = FavoritesCache(data)
|
||||
override fun uncache(json: String) =
|
||||
gsonDeserializerOf(FavoritesCache::class.java).deserialize(json.reader())
|
||||
|
||||
private val favoritedRepository = FavoritedRepository(context!!)
|
||||
|
||||
override fun onDataFetched(data: List<Track>): List<Track> = runBlocking {
|
||||
override fun onDataFetched(data: List<Favorite>): List<Favorite> = runBlocking {
|
||||
val downloaded = TracksRepository.getDownloadedIds(exoDownloadManager) ?: listOf()
|
||||
|
||||
data.map { track ->
|
||||
track.favorite = true
|
||||
track.downloaded = downloaded.contains(track.id)
|
||||
data.map { favorite ->
|
||||
favorite.track.favorite = true
|
||||
favorite.track.downloaded = downloaded.contains(favorite.track.id)
|
||||
|
||||
track.bestUpload()?.let { upload ->
|
||||
favorite.track.bestUpload()?.let { upload ->
|
||||
maybeNormalizeUrl(upload.listen_url)?.let { url ->
|
||||
track.cached = exoCache.isCached(url, 0, upload.duration * 1000L)
|
||||
favorite.track.cached = exoCache.isCached(url, 0, upload.duration * 1000L)
|
||||
}
|
||||
}
|
||||
|
||||
track
|
||||
favorite
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -127,12 +126,12 @@ class FavoritedRepository(override val context: Context?) : Repository<Int, Favo
|
|||
)
|
||||
|
||||
override fun cache(data: List<Int>) = FavoritedCache(data)
|
||||
override fun uncache(reader: BufferedReader) =
|
||||
gsonDeserializerOf(FavoritedCache::class.java).deserialize(reader)
|
||||
override fun uncache(json: String) =
|
||||
gsonDeserializerOf(FavoritedCache::class.java).deserialize(json.reader())
|
||||
|
||||
fun update(context: Context?, scope: CoroutineScope) {
|
||||
fetch(Origin.Network.origin).untilNetwork(scope, IO) { favorites, _, _, _ ->
|
||||
FFACache.set(context, cacheId, Gson().toJson(cache(favorites)).toByteArray())
|
||||
FFACache.set(context, cacheId, Gson().toJson(cache(favorites)).toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,7 +12,6 @@ import kotlinx.coroutines.flow.map
|
|||
import kotlinx.coroutines.flow.toList
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.koin.java.KoinJavaComponent.inject
|
||||
import java.io.BufferedReader
|
||||
|
||||
class PlaylistTracksRepository(override val context: Context?, playlistId: Int) :
|
||||
Repository<PlaylistTrack, PlaylistTracksCache>() {
|
||||
|
@ -30,8 +29,8 @@ class PlaylistTracksRepository(override val context: Context?, playlistId: Int)
|
|||
)
|
||||
|
||||
override fun cache(data: List<PlaylistTrack>) = PlaylistTracksCache(data)
|
||||
override fun uncache(reader: BufferedReader) =
|
||||
gsonDeserializerOf(PlaylistTracksCache::class.java).deserialize(reader)
|
||||
override fun uncache(json: String) =
|
||||
gsonDeserializerOf(PlaylistTracksCache::class.java).deserialize(json.reader())
|
||||
|
||||
override fun onDataFetched(data: List<PlaylistTrack>): List<PlaylistTrack> = runBlocking {
|
||||
val favorites = FavoritedRepository(context).fetch(Origin.Network.origin)
|
||||
|
|
|
@ -19,7 +19,6 @@ import com.google.gson.reflect.TypeToken
|
|||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.java.KoinJavaComponent.inject
|
||||
import java.io.BufferedReader
|
||||
|
||||
data class PlaylistAdd(val tracks: List<Int>, val allow_duplicates: Boolean)
|
||||
|
||||
|
@ -38,8 +37,8 @@ class PlaylistsRepository(override val context: Context?) : Repository<Playlist,
|
|||
)
|
||||
|
||||
override fun cache(data: List<Playlist>) = PlaylistsCache(data)
|
||||
override fun uncache(reader: BufferedReader) =
|
||||
gsonDeserializerOf(PlaylistsCache::class.java).deserialize(reader)
|
||||
override fun uncache(json: String) =
|
||||
gsonDeserializerOf(PlaylistsCache::class.java).deserialize(json.reader())
|
||||
}
|
||||
|
||||
class ManagementPlaylistsRepository(override val context: Context?) :
|
||||
|
@ -58,8 +57,8 @@ class ManagementPlaylistsRepository(override val context: Context?) :
|
|||
)
|
||||
|
||||
override fun cache(data: List<Playlist>) = PlaylistsCache(data)
|
||||
override fun uncache(reader: BufferedReader) =
|
||||
gsonDeserializerOf(PlaylistsCache::class.java).deserialize(reader)
|
||||
override fun uncache(json: String) =
|
||||
gsonDeserializerOf(PlaylistsCache::class.java).deserialize(json.reader())
|
||||
|
||||
suspend fun new(name: String): Int? {
|
||||
context?.let {
|
||||
|
@ -108,7 +107,7 @@ class ManagementPlaylistsRepository(override val context: Context?) :
|
|||
}
|
||||
|
||||
suspend fun remove(albumId: Int, index: Int) {
|
||||
context?.let {
|
||||
if (context != null) {
|
||||
val body = mapOf("index" to index)
|
||||
|
||||
val request = Fuel.post(mustNormalizeUrl("/api/v1/playlists/$albumId/remove/")).apply {
|
||||
|
@ -122,12 +121,13 @@ class ManagementPlaylistsRepository(override val context: Context?) :
|
|||
.header("Content-Type", "application/json")
|
||||
.body(Gson().toJson(body))
|
||||
.awaitByteArrayResponseResult()
|
||||
}
|
||||
} else {
|
||||
throw IllegalStateException("Illegal state: context is null")
|
||||
}
|
||||
}
|
||||
|
||||
fun move(id: Int, from: Int, to: Int) {
|
||||
context?.let {
|
||||
if (context != null) {
|
||||
val body = mapOf("from" to from, "to" to to)
|
||||
|
||||
val request = Fuel.post(mustNormalizeUrl("/api/v1/playlists/$id/move/")).apply {
|
||||
|
@ -143,7 +143,8 @@ class ManagementPlaylistsRepository(override val context: Context?) :
|
|||
.body(Gson().toJson(body))
|
||||
.awaitByteArrayResponseResult()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw IllegalStateException("Illegal state: context is null")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,7 +9,6 @@ import audio.funkwhale.ffa.utils.OAuth
|
|||
import com.github.kittinunf.fuel.gson.gsonDeserializerOf
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import org.koin.java.KoinJavaComponent.inject
|
||||
import java.io.BufferedReader
|
||||
|
||||
class RadiosRepository(override val context: Context?) : Repository<Radio, RadiosCache>() {
|
||||
|
||||
|
@ -26,8 +25,8 @@ class RadiosRepository(override val context: Context?) : Repository<Radio, Radio
|
|||
)
|
||||
|
||||
override fun cache(data: List<Radio>) = RadiosCache(data)
|
||||
override fun uncache(reader: BufferedReader) =
|
||||
gsonDeserializerOf(RadiosCache::class.java).deserialize(reader)
|
||||
override fun uncache(json: String) =
|
||||
gsonDeserializerOf(RadiosCache::class.java).deserialize(json.reader())
|
||||
|
||||
override fun onDataFetched(data: List<Radio>): List<Radio> {
|
||||
return data
|
||||
|
|
|
@ -8,11 +8,9 @@ import kotlinx.coroutines.CoroutineScope
|
|||
import kotlinx.coroutines.Dispatchers.IO
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import java.io.BufferedReader
|
||||
import kotlin.math.ceil
|
||||
|
||||
interface Upstream<D> {
|
||||
|
@ -34,7 +32,7 @@ abstract class Repository<D : Any, C : CacheItem<D>> {
|
|||
abstract val upstream: Upstream<D>
|
||||
|
||||
open fun cache(data: List<D>): C? = null
|
||||
protected open fun uncache(reader: BufferedReader): C? = null
|
||||
protected open fun uncache(json: String): C? = null
|
||||
|
||||
fun fetch(
|
||||
upstreams: Int = Origin.Cache.origin and Origin.Network.origin,
|
||||
|
@ -46,8 +44,8 @@ abstract class Repository<D : Any, C : CacheItem<D>> {
|
|||
|
||||
private fun fromCache() = flow {
|
||||
cacheId?.let { cacheId ->
|
||||
FFACache.get(context, cacheId)?.let { reader ->
|
||||
uncache(reader)?.let { cache ->
|
||||
FFACache.getLine(context, cacheId)?.let { line ->
|
||||
uncache(line)?.let { cache ->
|
||||
return@flow emit(
|
||||
Response(
|
||||
Origin.Cache,
|
||||
|
|
|
@ -21,7 +21,6 @@ import kotlinx.coroutines.flow.toList
|
|||
import kotlinx.coroutines.runBlocking
|
||||
import org.koin.core.qualifier.named
|
||||
import org.koin.java.KoinJavaComponent.inject
|
||||
import java.io.BufferedReader
|
||||
|
||||
class TracksSearchRepository(override val context: Context?, var query: String) :
|
||||
Repository<Track, TracksCache>() {
|
||||
|
@ -42,8 +41,8 @@ class TracksSearchRepository(override val context: Context?, var query: String)
|
|||
)
|
||||
|
||||
override fun cache(data: List<Track>) = TracksCache(data)
|
||||
override fun uncache(reader: BufferedReader) =
|
||||
gsonDeserializerOf(TracksCache::class.java).deserialize(reader)
|
||||
override fun uncache(json: String) =
|
||||
gsonDeserializerOf(TracksCache::class.java).deserialize(json.reader())
|
||||
|
||||
override fun onDataFetched(data: List<Track>): List<Track> = runBlocking {
|
||||
val favorites = FavoritedRepository(context).fetch(Origin.Cache.origin)
|
||||
|
@ -84,8 +83,8 @@ class ArtistsSearchRepository(override val context: Context?, var query: String)
|
|||
)
|
||||
|
||||
override fun cache(data: List<Artist>) = ArtistsCache(data)
|
||||
override fun uncache(reader: BufferedReader) =
|
||||
gsonDeserializerOf(ArtistsCache::class.java).deserialize(reader)
|
||||
override fun uncache(json: String) =
|
||||
gsonDeserializerOf(ArtistsCache::class.java).deserialize(json.reader())
|
||||
}
|
||||
|
||||
class AlbumsSearchRepository(override val context: Context?, var query: String) :
|
||||
|
@ -104,6 +103,6 @@ class AlbumsSearchRepository(override val context: Context?, var query: String)
|
|||
)
|
||||
|
||||
override fun cache(data: List<Album>) = AlbumsCache(data)
|
||||
override fun uncache(reader: BufferedReader) =
|
||||
gsonDeserializerOf(AlbumsCache::class.java).deserialize(reader)
|
||||
override fun uncache(json: String) =
|
||||
gsonDeserializerOf(AlbumsCache::class.java).deserialize(json.reader())
|
||||
}
|
||||
|
|
|
@ -18,7 +18,6 @@ import kotlinx.coroutines.flow.toList
|
|||
import kotlinx.coroutines.runBlocking
|
||||
import org.koin.core.qualifier.named
|
||||
import org.koin.java.KoinJavaComponent.inject
|
||||
import java.io.BufferedReader
|
||||
|
||||
class TracksRepository(override val context: Context?, albumId: Int) :
|
||||
Repository<Track, TracksCache>() {
|
||||
|
@ -38,24 +37,23 @@ class TracksRepository(override val context: Context?, albumId: Int) :
|
|||
)
|
||||
|
||||
override fun cache(data: List<Track>) = TracksCache(data)
|
||||
override fun uncache(reader: BufferedReader) =
|
||||
gsonDeserializerOf(TracksCache::class.java).deserialize(reader)
|
||||
override fun uncache(json: String) =
|
||||
gsonDeserializerOf(TracksCache::class.java).deserialize(json.reader())
|
||||
|
||||
companion object {
|
||||
fun getDownloadedIds(exoDownloadManager: DownloadManager): List<Int>? {
|
||||
val cursor = exoDownloadManager.downloadIndex.getDownloads()
|
||||
val ids: MutableList<Int> = mutableListOf()
|
||||
|
||||
exoDownloadManager.downloadIndex.getDownloads()
|
||||
.use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
val download = cursor.download
|
||||
|
||||
download.getMetadata()?.let {
|
||||
if (download.state == Download.STATE_COMPLETED) {
|
||||
ids.add(it.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
return ids
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
package audio.funkwhale.ffa.utils
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Activity
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.content.BroadcastReceiver
|
||||
|
@ -23,7 +22,7 @@ object AppContext {
|
|||
const val PAGE_SIZE = 50
|
||||
const val TRANSITION_DURATION = 300L
|
||||
|
||||
fun init(context: Activity) {
|
||||
fun init(context: Context) {
|
||||
setupNotificationChannels(context)
|
||||
|
||||
// CastContext.getSharedInstance(context)
|
||||
|
|
|
@ -0,0 +1,89 @@
|
|||
package audio.funkwhale.ffa.utils
|
||||
|
||||
import java.lang.ref.WeakReference
|
||||
import java.util.WeakHashMap
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* Similar to a Map, but with the semantic that operations single-thread on a per-key basis.
|
||||
* That is: given concurrent accesses to keys "apple" and "banana", one "apple" thread
|
||||
* will block all other "apple" threads, but not any "banana" threads.
|
||||
* In practical terms, we use this to make sure we don't get weird edge cases when working
|
||||
* with the filesystem cache.
|
||||
*/
|
||||
class Bottleneck<T> {
|
||||
// It would be nice to use LruCache here, but its behavior of
|
||||
// replacing values doesn't get us the right results.
|
||||
// As it is, this should be a trivial amount of memory compared to
|
||||
// images and media.
|
||||
// We single-thread this, so it doesn't need to be concurrent.
|
||||
private val keys = WeakHashMap<String, String>()
|
||||
|
||||
// This one needs to be concurrent, as we don't want to single-thread it.
|
||||
private val values = ConcurrentHashMap<String, WeakReference<T>>()
|
||||
|
||||
/**
|
||||
* As you would expect from the Map function of the same name, except concurrent
|
||||
* accesses to the same key will block on each other. If the first call succeeds,
|
||||
* all other calls will fall through with the same result. (Unlike LRUCache.)
|
||||
*/
|
||||
fun getOrCompute(key: String, materialize: (key: String) -> T?): T? {
|
||||
// First, get the lockable version of the key, no matter how
|
||||
// many copies of the key exist.
|
||||
// This map doesn't need to be a synchronized collection, because
|
||||
// we single-thread access to it. (And there's no compute, so
|
||||
// it should be low-contention.)
|
||||
val sharedKey: String = canonical(key)
|
||||
synchronized(sharedKey) {
|
||||
val ref = values[sharedKey]
|
||||
var value = ref?.get()
|
||||
if (value == null) {
|
||||
if (ref != null) {
|
||||
values.remove(sharedKey) // empty ref
|
||||
}
|
||||
value = materialize(sharedKey)
|
||||
if (value != null) {
|
||||
values[sharedKey] = WeakReference(value)
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The beating heart of this system: each key is is "upgraded" to
|
||||
* the one which we use for locking. This does mean we block on
|
||||
* access to `keys` for all concurrent access, but as it's so light-
|
||||
* weight, this shouldn't be much of a problem in practical terms.
|
||||
* The hope here is that this is slightly better than interning.
|
||||
* In theory we could convert this over to also use WeakReference.
|
||||
*/
|
||||
private fun canonical(key: String): String {
|
||||
val sharedKey: String
|
||||
synchronized(keys) {
|
||||
val maybeShared = keys[key]
|
||||
if (maybeShared == null) {
|
||||
keys[key] = key // first key of its value becomes canonical
|
||||
sharedKey = key
|
||||
} else {
|
||||
sharedKey = maybeShared
|
||||
}
|
||||
}
|
||||
return sharedKey
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate a key and run the supplied bi-consumer with the old value.
|
||||
* Note that this will <em>always</em> run the supplied block, even if
|
||||
* the value is not in the cache.
|
||||
*/
|
||||
fun remove(key: String, andDo: ((T?, String) -> Unit)?) {
|
||||
val sharedKey = canonical(key)
|
||||
synchronized(sharedKey) {
|
||||
val oldValue = values.remove(sharedKey)
|
||||
if (andDo != null) {
|
||||
andDo(oldValue?.get(), sharedKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
package audio.funkwhale.ffa.utils
|
||||
|
||||
import androidx.customview.widget.Openable
|
||||
|
||||
interface BottomSheetIneractable: Openable {
|
||||
val isHidden: Boolean
|
||||
fun show()
|
||||
fun hide()
|
||||
fun toggle()
|
||||
}
|
|
@ -1,6 +1,5 @@
|
|||
package audio.funkwhale.ffa.utils
|
||||
|
||||
import audio.funkwhale.ffa.FFA
|
||||
import audio.funkwhale.ffa.model.Radio
|
||||
import audio.funkwhale.ffa.model.Track
|
||||
import com.google.android.exoplayer2.offline.Download
|
||||
|
@ -8,8 +7,10 @@ import com.google.android.exoplayer2.offline.DownloadCursor
|
|||
import kotlinx.coroutines.Dispatchers.IO
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.asFlow
|
||||
import kotlinx.coroutines.flow.conflate
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
sealed class Command {
|
||||
|
@ -60,6 +61,7 @@ sealed class Request(var channel: Channel<Response>? = null) {
|
|||
object GetState : Request()
|
||||
object GetQueue : Request()
|
||||
object GetCurrentTrack : Request()
|
||||
object GetCurrentTrackIndex : Request()
|
||||
object GetDownloads : Request()
|
||||
}
|
||||
|
||||
|
@ -67,51 +69,59 @@ sealed class Response {
|
|||
class State(val playing: Boolean) : Response()
|
||||
class Queue(val queue: List<Track>) : Response()
|
||||
class CurrentTrack(val track: Track?) : Response()
|
||||
class CurrentTrackIndex(val index: Int) : Response()
|
||||
class Downloads(val cursor: DownloadCursor) : Response()
|
||||
}
|
||||
|
||||
object EventBus {
|
||||
private var _events = MutableSharedFlow<Event>()
|
||||
val events = _events.asSharedFlow()
|
||||
fun send(event: Event) {
|
||||
GlobalScope.launch(IO) {
|
||||
FFA.get().eventBus.trySend(event).isSuccess
|
||||
_events.emit(event)
|
||||
}
|
||||
}
|
||||
|
||||
fun get() = FFA.get().eventBus.asFlow()
|
||||
fun get() = events
|
||||
}
|
||||
|
||||
object CommandBus {
|
||||
private var _commands = MutableSharedFlow<Command>()
|
||||
var commands = _commands.asSharedFlow()
|
||||
fun send(command: Command) {
|
||||
GlobalScope.launch(IO) {
|
||||
FFA.get().commandBus.trySend(command).isSuccess
|
||||
_commands.emit(command)
|
||||
}
|
||||
}
|
||||
|
||||
fun get() = FFA.get().commandBus.asFlow()
|
||||
fun get() = commands
|
||||
}
|
||||
|
||||
object RequestBus {
|
||||
// `replay` allows send requests before the PlayerService starts listening
|
||||
private var _requests = MutableSharedFlow<Request>(replay = 100)
|
||||
var requests = _requests.asSharedFlow()
|
||||
fun send(request: Request): Channel<Response> {
|
||||
return Channel<Response>().also {
|
||||
GlobalScope.launch(IO) {
|
||||
request.channel = it
|
||||
|
||||
FFA.get().requestBus.trySend(request).isSuccess
|
||||
_requests.emit(request)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun get() = FFA.get().requestBus.asFlow()
|
||||
fun get() = requests
|
||||
}
|
||||
|
||||
object ProgressBus {
|
||||
private var _progress = MutableStateFlow(Triple(0, 0, 0))
|
||||
val progress = _progress.asStateFlow()
|
||||
fun send(current: Int, duration: Int, percent: Int) {
|
||||
GlobalScope.launch(IO) {
|
||||
FFA.get().progressBus.send(Triple(current, duration, percent))
|
||||
}
|
||||
_progress.value = Triple(current, duration, percent)
|
||||
}
|
||||
|
||||
fun get() = FFA.get().progressBus.asFlow().conflate()
|
||||
fun get() = progress
|
||||
}
|
||||
|
||||
suspend inline fun <reified T> Channel<Response>.wait(): T? {
|
||||
|
|
|
@ -0,0 +1,266 @@
|
|||
package audio.funkwhale.ffa.utils
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.transition.CircularPropagation
|
||||
import android.util.Log
|
||||
import androidx.swiperefreshlayout.widget.CircularProgressDrawable
|
||||
import audio.funkwhale.ffa.BuildConfig
|
||||
import audio.funkwhale.ffa.FFA
|
||||
import audio.funkwhale.ffa.R
|
||||
import com.squareup.picasso.Downloader
|
||||
import com.squareup.picasso.NetworkPolicy
|
||||
import com.squareup.picasso.OkHttp3Downloader
|
||||
import com.squareup.picasso.Picasso
|
||||
import com.squareup.picasso.Picasso.LoadedFrom
|
||||
import com.squareup.picasso.Request
|
||||
import com.squareup.picasso.RequestCreator
|
||||
import com.squareup.picasso.RequestHandler
|
||||
import okhttp3.CacheControl
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.OkHttpClient
|
||||
import okio.Okio
|
||||
import java.io.File
|
||||
import java.security.MessageDigest
|
||||
|
||||
/**
|
||||
* Represent bytes as hex values.
|
||||
*/
|
||||
fun ByteArray.toHex(): String = joinToString("") { b -> "%02x".format(b) }
|
||||
|
||||
/**
|
||||
* Convert the string to its SHA-256 hash in hex format.
|
||||
*/
|
||||
fun String.sha256(): String =
|
||||
let { MessageDigest.getInstance("SHA-256").digest(it.encodeToByteArray()).toHex() }
|
||||
|
||||
/**
|
||||
* Remove the query string and fragment from a URI.
|
||||
* Mostly, this is to get rid of pre-signed URL silliness.
|
||||
* If we ever need to keep some query params, we'll need a more robust approach.
|
||||
*/
|
||||
fun Uri.asStableKey(): String = buildUpon().clearQuery().fragment("").build().toString()
|
||||
|
||||
/**
|
||||
* Try to extract a file suffix from the URI. This isn't strictly
|
||||
* necessary, but it can make debugging easier when you're going through
|
||||
* the app cache with a filesystem browser.
|
||||
*/
|
||||
fun Uri.fileSuffix(): String = let {
|
||||
val p = it.path
|
||||
val ext = p?.substringAfterLast(".", "")?.lowercase() ?: ""
|
||||
if (ext == "") ext else ".$ext"
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper around Picasso with some smarter caching of image files.
|
||||
*/
|
||||
open class CoverArt private constructor() {
|
||||
companion object {
|
||||
// For logging
|
||||
val TAG: String = CoverArt::class.java.simpleName
|
||||
|
||||
// This is just a nice-to-have for API admins
|
||||
private const val userAgent =
|
||||
"${BuildConfig.APPLICATION_ID} ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})"
|
||||
|
||||
// This client has the UA above, and has caching intentionally disabled.
|
||||
// (Because we cache the images ourselves and cannot rely on replaying requests.)
|
||||
private var httpClient: OkHttpClient? = null
|
||||
|
||||
// Same: this has caching disabled.
|
||||
private var downloader: OkHttp3Downloader? = null
|
||||
|
||||
// Cache with some useful concurrency semantics. See its docs for details.
|
||||
val fileCache = Bottleneck<File>()
|
||||
|
||||
private val picasso = with (FFA.get()) {
|
||||
Picasso.Builder(this)
|
||||
.addRequestHandler(CoverNetworkRequestHandler(this))
|
||||
// Be careful with this. There's at least one place in Picasso where it
|
||||
// doesn't null-check when logging, so it'll throw errors in places you
|
||||
// wouldn't get them with logging turned off. /sigh
|
||||
.loggingEnabled(false) // (BuildConfig.DEBUG)
|
||||
// Occasionally, we may get transient HTTP issues, or bogus files.
|
||||
// Listen for Picasso errors and invalidate those files
|
||||
.listener(invalidateIn(this))
|
||||
.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* We don't need to hang onto the Context, just the Path it gets us.
|
||||
*/
|
||||
fun cacheDirForContext(context: Context): File {
|
||||
return context.applicationContext.cacheDir.resolve("covers")
|
||||
}
|
||||
|
||||
/**
|
||||
* Shim for Picasso which acts like a NetworkRequestHandler, but is opinionated
|
||||
* about how we want to use it.
|
||||
*/
|
||||
open class CoverNetworkRequestHandler(context: Context) : RequestHandler() {
|
||||
/**
|
||||
* Path to the actual cache directory.
|
||||
*/
|
||||
val coverCacheDir: File
|
||||
|
||||
/**
|
||||
* This goes out with every request and never changes.
|
||||
*/
|
||||
val noCacheControl: CacheControl = CacheControl.Builder()
|
||||
.noCache()
|
||||
.noStore()
|
||||
.noTransform()
|
||||
.build()
|
||||
|
||||
init {
|
||||
coverCacheDir = cacheDirForContext(context)
|
||||
// Make the cache directory if it doesn't already exist.
|
||||
if (!coverCacheDir.isDirectory) {
|
||||
coverCacheDir.mkdir()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The primary logic of going from a Request to a usable File.
|
||||
* tl;dr: Use a local file if you can, otherwise download it and use that.
|
||||
*/
|
||||
private fun materializeFile(request: Request): (String) -> File? {
|
||||
return fun(fileName: String): File? {
|
||||
val existing = coverCacheDir.resolve(fileName)
|
||||
if (existing.isFile) {
|
||||
return existing
|
||||
}
|
||||
val key = request.stableKey ?: request.uri.asStableKey()
|
||||
val httpUrl = HttpUrl.parse(request.uri.toString()) ?: return null
|
||||
return fetchToFile(httpUrl, fileName, key)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Required by Picasso, we only want to handle HTTP traffic.
|
||||
*/
|
||||
override fun canHandleRequest(data: Request?): Boolean {
|
||||
return data != null && ("http" == data.uri.scheme || "https" == data.uri.scheme)
|
||||
}
|
||||
|
||||
/**
|
||||
* Required by Picasso, this is the main entrypoint.
|
||||
*/
|
||||
override fun load(request: Request?, networkPolicy: Int): Result? {
|
||||
if (request == null || !NetworkPolicy.shouldReadFromDiskCache(networkPolicy)) {
|
||||
return null
|
||||
}
|
||||
// Ditch any query params.
|
||||
val key = request.stableKey ?: request.uri.asStableKey()
|
||||
// Convert to a short, stable filename.
|
||||
val fileName =
|
||||
key.sha256() + request.uri.fileSuffix() // file extension for easier forensics
|
||||
// Actually find or fetch the file.
|
||||
val file = fileCache.getOrCompute(fileName, materializeFile(request))
|
||||
// Hand it back to Picasso in a way it can understand.
|
||||
return if (file == null) null else Result(Okio.source(file), LoadedFrom.DISK)
|
||||
}
|
||||
|
||||
/**
|
||||
* The actual fetch logic is straightforward: download to a file.
|
||||
* Sadly, this is more manual than you might expect.
|
||||
*/
|
||||
private fun fetchToFile(httpUrl: HttpUrl, fileName: String, cacheKey: String): File? {
|
||||
val httpRequest = okhttp3.Request.Builder()
|
||||
.get()
|
||||
.url(httpUrl)
|
||||
.cacheControl(noCacheControl)
|
||||
.build()
|
||||
val response = nonCachingDownloader().load(httpRequest)
|
||||
if (!response.isSuccessful) {
|
||||
return null
|
||||
}
|
||||
val body = response.body() ?: return null
|
||||
val file = coverCacheDir.resolve(fileName)
|
||||
if (BuildConfig.DEBUG) {
|
||||
Log.d(TAG, "fetchToFile($cacheKey) <- $fileName <- NETWORK")
|
||||
}
|
||||
val bytesWritten: Long
|
||||
body.use { b ->
|
||||
Okio.buffer(Okio.sink(file)).use { sink ->
|
||||
bytesWritten = sink.writeAll(b.source())
|
||||
}
|
||||
}
|
||||
return if (bytesWritten > 0) file else null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Picasso can send back notification that files are busted.
|
||||
* In those cases, it could be a transient problem, or credentials, etc.
|
||||
* We probably don't want to trust the file, so we invalidate it
|
||||
* from the memory cache and delete it from the filesystem.
|
||||
* This uses Bottleneck, so it's thread-safe.
|
||||
*/
|
||||
fun invalidateIn(context: Context): (Picasso, Uri, Exception) -> Unit {
|
||||
val coverCacheDir = cacheDirForContext(context)
|
||||
return fun(_, uri: Uri, _) {
|
||||
val key = uri.asStableKey()
|
||||
val fileName = key.sha256() + uri.fileSuffix()
|
||||
fileCache.remove(fileName) { f, _ ->
|
||||
val file = f ?: coverCacheDir.resolve(fileName)
|
||||
if (file.isFile) {
|
||||
if (BuildConfig.DEBUG) {
|
||||
Log.d(TAG, "Deleting failed cover: $file")
|
||||
}
|
||||
file.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Low-level Picasso wiring.
|
||||
*/
|
||||
|
||||
/**
|
||||
* We don't want to cache the HTTP part of the flow, because:
|
||||
* 1. It's double-caching, since we're saving the images already.
|
||||
* 2. The URL may include pre-signed credentials, which expire, making the URL useless.
|
||||
*/
|
||||
protected fun nonCachingDownloader(): Downloader {
|
||||
val downloader = this.downloader ?: OkHttp3Downloader(nonCachingHttpClient())
|
||||
if (this.downloader == null) {
|
||||
this.downloader = downloader
|
||||
}
|
||||
return downloader
|
||||
}
|
||||
|
||||
/**
|
||||
* Same here: build a non-caching version just for cover art.
|
||||
*/
|
||||
protected fun nonCachingHttpClient(): OkHttpClient {
|
||||
val hc = httpClient ?: OkHttpClient.Builder()
|
||||
.addInterceptor { chain ->
|
||||
chain.proceed(
|
||||
chain.request()
|
||||
.newBuilder()
|
||||
.addHeader("User-Agent", userAgent)
|
||||
.build()
|
||||
)
|
||||
}
|
||||
.cache(null) // No cache here, intentionally
|
||||
.build()
|
||||
if (httpClient == null) {
|
||||
httpClient = hc
|
||||
}
|
||||
return hc
|
||||
}
|
||||
|
||||
/**
|
||||
* The primary entrypoint for the codebase.
|
||||
*/
|
||||
fun requestCreator(url: String?): RequestCreator {
|
||||
val request = picasso.load(url)
|
||||
if(url == null) request.placeholder(R.drawable.cover)
|
||||
else request.placeholder(CircularProgressDrawable(FFA.get()))
|
||||
return request.error(R.drawable.cover)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -3,22 +3,18 @@ package audio.funkwhale.ffa.utils
|
|||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.fragment.app.Fragment
|
||||
import audio.funkwhale.ffa.R
|
||||
import audio.funkwhale.ffa.fragments.BrowseFragment
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MediatorLiveData
|
||||
import audio.funkwhale.ffa.model.DownloadInfo
|
||||
import audio.funkwhale.ffa.repositories.Repository
|
||||
import com.github.kittinunf.fuel.core.FuelError
|
||||
import com.github.kittinunf.fuel.core.Request
|
||||
import com.google.android.exoplayer2.offline.Download
|
||||
import com.google.gson.Gson
|
||||
import com.squareup.picasso.Picasso
|
||||
import com.squareup.picasso.RequestCreator
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers.Main
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import net.openid.appauth.ClientSecretPost
|
||||
|
@ -38,14 +34,6 @@ inline fun <D> Flow<Repository.Response<D>>.untilNetwork(
|
|||
}
|
||||
}
|
||||
|
||||
fun Fragment.onViewPager(block: Fragment.() -> Unit) {
|
||||
for (f in activity?.supportFragmentManager?.fragments ?: listOf()) {
|
||||
if (f is BrowseFragment) {
|
||||
f.block()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun <T> Int.onApi(block: () -> T) {
|
||||
if (Build.VERSION.SDK_INT >= this) {
|
||||
block()
|
||||
|
@ -60,26 +48,23 @@ fun <T, U> Int.onApi(block: () -> T, elseBlock: (() -> U)) {
|
|||
}
|
||||
}
|
||||
|
||||
fun Picasso.maybeLoad(url: String?): RequestCreator {
|
||||
return if (url == null) load(R.drawable.cover)
|
||||
else load(url)
|
||||
}
|
||||
|
||||
fun Request.authorize(context: Context, oAuth: OAuth): Request {
|
||||
return runBlocking {
|
||||
this@authorize.apply {
|
||||
if (!Settings.isAnonymous()) {
|
||||
oAuth.state().let { state ->
|
||||
state.accessTokenExpirationTime?.let {
|
||||
Log.i("Request.authorize()", "Accesstoken expiration: ${Date(it).format()}")
|
||||
}
|
||||
val old = state.accessToken
|
||||
val auth = ClientSecretPost(oAuth.state().clientSecret)
|
||||
val done = CompletableDeferred<Boolean>()
|
||||
val tokenService = oAuth.service(context)
|
||||
|
||||
state.performActionWithFreshTokens(oAuth.service(context), auth) { token, _, _ ->
|
||||
if (token == old) {
|
||||
Log.i("Request.authorize()", "Accesstoken not renewed")
|
||||
state.performActionWithFreshTokens(tokenService, auth) { token, _, e ->
|
||||
if (e != null) {
|
||||
Log.e("Request.authorize()", "performActionWithFreshToken failed: $e")
|
||||
if (e.type != 2 || e.code != 2002) {
|
||||
Log.e("Request.authorize()", Log.getStackTraceString(e))
|
||||
EventBus.send(Event.LogOut)
|
||||
}
|
||||
}
|
||||
if (token != old && token != null) {
|
||||
state.save()
|
||||
|
@ -88,6 +73,7 @@ fun Request.authorize(context: Context, oAuth: OAuth): Request {
|
|||
done.complete(true)
|
||||
}
|
||||
done.await()
|
||||
tokenService.dispose()
|
||||
return@runBlocking this
|
||||
}
|
||||
}
|
||||
|
@ -107,3 +93,58 @@ val ISO_8601_DATE_TIME_FORMAT = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ")
|
|||
fun Date.format(): String {
|
||||
return ISO_8601_DATE_TIME_FORMAT.format(this)
|
||||
}
|
||||
|
||||
fun String?.containsIgnoringCase(candidate: String): Boolean =
|
||||
this != null && this.lowercase().contains(candidate.lowercase())
|
||||
|
||||
inline fun <T, U, V, R> LiveData<T>.mergeWith(
|
||||
u: LiveData<U>,
|
||||
v: LiveData<V>,
|
||||
crossinline block: (valT: T, valU: U, valV: V) -> R
|
||||
): LiveData<R> = MediatorLiveData<R>().apply {
|
||||
addSource(this@mergeWith) {
|
||||
if (u.value != null && v.value != null) {
|
||||
postValue(block(it, u.value!!, v.value!!))
|
||||
}
|
||||
}
|
||||
addSource(u) {
|
||||
if (this@mergeWith.value != null && u.value != null) {
|
||||
postValue(block(this@mergeWith.value!!, it, v.value!!))
|
||||
}
|
||||
}
|
||||
addSource(v) {
|
||||
if (this@mergeWith.value != null && u.value != null) {
|
||||
postValue(block(this@mergeWith.value!!, u.value!!, it))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inline fun <T, U, V, W, R> LiveData<T>.mergeWith(
|
||||
u: LiveData<U>,
|
||||
v: LiveData<V>,
|
||||
w: LiveData<W>,
|
||||
crossinline block: (valT: T, valU: U, valV: V, valW: W) -> R
|
||||
): LiveData<R> = MediatorLiveData<R>().apply {
|
||||
addSource(this@mergeWith) {
|
||||
if (u.value != null && v.value != null && w.value != null) {
|
||||
postValue(block(it, u.value!!, v.value!!, w.value!!))
|
||||
}
|
||||
}
|
||||
addSource(u) {
|
||||
if (this@mergeWith.value != null && v.value != null && w.value != null) {
|
||||
postValue(block(this@mergeWith.value!!, it, v.value!!, w.value!!))
|
||||
}
|
||||
}
|
||||
addSource(v) {
|
||||
if (this@mergeWith.value != null && u.value != null && w.value != null) {
|
||||
postValue(block(this@mergeWith.value!!, u.value!!, it, w.value!!))
|
||||
}
|
||||
}
|
||||
addSource(w) {
|
||||
if (this@mergeWith.value != null && u.value != null && v.value != null) {
|
||||
postValue(block(this@mergeWith.value!!, u.value!!, v.value!!, it))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public fun String?.toIntOrElse(default: Int): Int = this?.toIntOrNull(radix = 10) ?: default
|
||||
|
|
|
@ -12,16 +12,41 @@ object FFACache {
|
|||
val md = MessageDigest.getInstance("SHA-1")
|
||||
val digest = md.digest(key.toByteArray(Charset.defaultCharset()))
|
||||
|
||||
return digest.fold("", { acc, it -> acc + "%02x".format(it) })
|
||||
return digest.fold("") { acc, it -> acc + "%02x".format(it) }
|
||||
}
|
||||
|
||||
fun set(context: Context?, key: String, value: ByteArray) = context?.let {
|
||||
fun set(context: Context?, key: String, value: String) {
|
||||
set(context, key, value.toByteArray())
|
||||
}
|
||||
|
||||
fun set(context: Context?, key: String, value: ByteArray) {
|
||||
context?.let {
|
||||
with(File(it.cacheDir, key(key))) {
|
||||
writeBytes(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun get(context: Context?, key: String): BufferedReader? = context?.let {
|
||||
fun getLine(context: Context?, key: String): String? = get(context, key)?.let {
|
||||
val line = it.readLine()
|
||||
it.close()
|
||||
line
|
||||
}
|
||||
|
||||
fun getLines(context: Context?, key: String): List<String>? = get(context, key)
|
||||
?.let { reader ->
|
||||
val lines = reader.readLines()
|
||||
reader.close()
|
||||
lines
|
||||
}
|
||||
|
||||
fun delete(context: Context?, key: String) = context?.let {
|
||||
with(File(it.cacheDir, key(key))) {
|
||||
delete()
|
||||
}
|
||||
}
|
||||
|
||||
private fun get(context: Context?, key: String): BufferedReader? = context?.let {
|
||||
try {
|
||||
with(File(it.cacheDir, key(key))) {
|
||||
bufferedReader()
|
||||
|
@ -30,10 +55,4 @@ object FFACache {
|
|||
return null
|
||||
}
|
||||
}
|
||||
|
||||
fun delete(context: Context?, key: String) = context?.let {
|
||||
with(File(it.cacheDir, key(key))) {
|
||||
delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -71,9 +71,8 @@ class OAuth(private val authorizationServiceFactory: AuthorizationServiceFactory
|
|||
} else {
|
||||
false
|
||||
}
|
||||
).also {
|
||||
it.logInfo("isAuthorized()")
|
||||
}
|
||||
)
|
||||
.also { it.logInfo("isAuthorized()") }
|
||||
}
|
||||
|
||||
private fun AuthState.validAuthorization() = this.isAuthorized && !this.needsTokenRefresh
|
||||
|
@ -84,7 +83,7 @@ class OAuth(private val authorizationServiceFactory: AuthorizationServiceFactory
|
|||
refreshAccessToken(state, context)
|
||||
} else {
|
||||
state.isAuthorized
|
||||
}.also { it.logInfo("tryRefreshAccessToken()") }
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
@ -98,8 +97,14 @@ class OAuth(private val authorizationServiceFactory: AuthorizationServiceFactory
|
|||
return if (state.refreshToken != null) {
|
||||
val refreshRequest = state.createTokenRefreshRequest()
|
||||
val auth = ClientSecretPost(state.clientSecret)
|
||||
val refreshService = service(context)
|
||||
runBlocking {
|
||||
service(context).performTokenRequest(refreshRequest, auth) { response, e ->
|
||||
refreshService.performTokenRequest(refreshRequest, auth) { response, e ->
|
||||
if (e != null) {
|
||||
Log.e("OAuth", "performTokenRequest failed: $e")
|
||||
Log.e("OAuth", Log.getStackTraceString(e))
|
||||
EventBus.send(Event.LogOut)
|
||||
} else {
|
||||
state.apply {
|
||||
Log.i("OAuth", "applying new authState")
|
||||
update(response, e)
|
||||
|
@ -107,6 +112,8 @@ class OAuth(private val authorizationServiceFactory: AuthorizationServiceFactory
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
refreshService.dispose()
|
||||
true
|
||||
} else {
|
||||
false
|
||||
|
@ -178,11 +185,10 @@ class OAuth(private val authorizationServiceFactory: AuthorizationServiceFactory
|
|||
)
|
||||
}
|
||||
|
||||
fun authorize(activity: Activity) {
|
||||
fun authorizeIntent(activity: Activity): Intent? {
|
||||
val authService = service(activity)
|
||||
authorizationRequest()?.let { it ->
|
||||
val intent = authService.getAuthorizationRequestIntent(it)
|
||||
activity.startActivityForResult(intent, 0)
|
||||
return authorizationRequest()?.let { it ->
|
||||
authService.getAuthorizationRequestIntent(it)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -202,17 +208,23 @@ class OAuth(private val authorizationServiceFactory: AuthorizationServiceFactory
|
|||
|
||||
AuthorizationResponse.fromIntent(authorization)?.let {
|
||||
val auth = ClientSecretPost(state().clientSecret)
|
||||
val requestService = service(context)
|
||||
|
||||
service(context).performTokenRequest(it.createTokenExchangeRequest(), auth) { response, e ->
|
||||
state
|
||||
.apply {
|
||||
requestService.performTokenRequest(it.createTokenExchangeRequest(), auth) { response, e ->
|
||||
if (e != null) {
|
||||
Log.e("FFA", "performTokenRequest failed: $e")
|
||||
Log.e("FFA", Log.getStackTraceString(e))
|
||||
} else {
|
||||
state.apply {
|
||||
update(response, e)
|
||||
save()
|
||||
}
|
||||
}
|
||||
|
||||
if (response != null) success()
|
||||
else Log.e("FFA", "performTokenRequest() not successful")
|
||||
}
|
||||
requestService.dispose()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,25 @@
|
|||
package audio.funkwhale.ffa.utils
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Color
|
||||
import android.graphics.drawable.ColorDrawable
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.util.Log
|
||||
import android.widget.ImageButton
|
||||
import androidx.annotation.ColorRes
|
||||
import androidx.appcompat.widget.AppCompatImageView
|
||||
import androidx.databinding.BindingAdapter
|
||||
|
||||
|
||||
@BindingAdapter("srcCompat")
|
||||
fun setImageViewResource(imageView: AppCompatImageView, resource: Any?) = when (resource) {
|
||||
is Bitmap -> imageView.setImageBitmap(resource)
|
||||
is Int -> imageView.setImageResource(resource)
|
||||
is Drawable -> imageView.setImageDrawable(resource)
|
||||
else -> imageView.setImageDrawable(ColorDrawable(Color.TRANSPARENT))
|
||||
}
|
||||
|
||||
@BindingAdapter("tint")
|
||||
fun setTint(imageView: ImageButton, @ColorRes resource: Int) = resource.let {
|
||||
imageView.setColorFilter(resource)
|
||||
}
|
|
@ -0,0 +1,78 @@
|
|||
package audio.funkwhale.ffa.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.appcompat.content.res.AppCompatResources
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.asLiveData
|
||||
import androidx.lifecycle.distinctUntilChanged
|
||||
import androidx.lifecycle.liveData
|
||||
import androidx.lifecycle.map
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import audio.funkwhale.ffa.FFA
|
||||
import audio.funkwhale.ffa.R
|
||||
import audio.funkwhale.ffa.model.Track
|
||||
import audio.funkwhale.ffa.utils.Event
|
||||
import audio.funkwhale.ffa.utils.EventBus
|
||||
import com.google.android.exoplayer2.Player
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.emitAll
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
class NowPlayingViewModel(app: Application) : AndroidViewModel(app) {
|
||||
val isBuffering = EventBus.get()
|
||||
.filter { it is Event.Buffering }
|
||||
.map { (it as Event.Buffering).value }
|
||||
.stateIn(viewModelScope, SharingStarted.Lazily, false)
|
||||
.asLiveData(viewModelScope.coroutineContext)
|
||||
.distinctUntilChanged()
|
||||
|
||||
val isPlaying = EventBus.get()
|
||||
.filter { it is Event.StateChanged }
|
||||
.map { (it as Event.StateChanged).playing }
|
||||
.stateIn(viewModelScope, SharingStarted.Lazily, false)
|
||||
.asLiveData(viewModelScope.coroutineContext)
|
||||
.distinctUntilChanged()
|
||||
|
||||
val repeatMode = MutableLiveData(0)
|
||||
val progress = MutableLiveData(0)
|
||||
val currentTrack = MutableLiveData<Track?>(null)
|
||||
val currentProgressText = MutableLiveData("")
|
||||
val currentDurationText = MutableLiveData("")
|
||||
|
||||
// Calling distinctUntilChanged() prevents triggering an event when the track hasn't changed
|
||||
val currentTrackTitle = currentTrack.distinctUntilChanged().map { it?.title ?: "" }
|
||||
val currentTrackArtist = currentTrack.distinctUntilChanged().map { it?.artist?.name ?: "" }
|
||||
|
||||
// Not calling distinctUntilChanged() here as we need to process every event
|
||||
val isCurrentTrackFavorite = currentTrack.map {
|
||||
it?.favorite ?: false
|
||||
}
|
||||
|
||||
val repeatModeResource = repeatMode.distinctUntilChanged().map {
|
||||
when (it) {
|
||||
Player.REPEAT_MODE_ONE -> AppCompatResources.getDrawable(context, R.drawable.repeat_one)
|
||||
else -> AppCompatResources.getDrawable(context, R.drawable.repeat)
|
||||
}
|
||||
}
|
||||
|
||||
val repeatModeAlpha = repeatMode.distinctUntilChanged().map {
|
||||
when (it) {
|
||||
Player.REPEAT_MODE_OFF -> 0.2f
|
||||
else -> 1f
|
||||
}
|
||||
}
|
||||
|
||||
private val context: Context
|
||||
get() = getApplication<FFA>().applicationContext
|
||||
}
|
|
@ -0,0 +1,118 @@
|
|||
package audio.funkwhale.ffa.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.Observer
|
||||
import androidx.lifecycle.distinctUntilChanged
|
||||
import androidx.lifecycle.map
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import audio.funkwhale.ffa.FFA
|
||||
import audio.funkwhale.ffa.model.Album
|
||||
import audio.funkwhale.ffa.model.Artist
|
||||
import audio.funkwhale.ffa.model.Track
|
||||
import audio.funkwhale.ffa.repositories.AlbumsSearchRepository
|
||||
import audio.funkwhale.ffa.repositories.ArtistsSearchRepository
|
||||
import audio.funkwhale.ffa.repositories.Repository
|
||||
import audio.funkwhale.ffa.repositories.TracksSearchRepository
|
||||
import audio.funkwhale.ffa.utils.mergeWith
|
||||
import audio.funkwhale.ffa.utils.untilNetwork
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import java.net.URLEncoder
|
||||
import java.util.Locale
|
||||
|
||||
class SearchViewModel(app: Application) : AndroidViewModel(app), Observer<String> {
|
||||
private val artistResultsLoading = MutableLiveData(false)
|
||||
private val albumResultsLoading = MutableLiveData(false)
|
||||
private val tackResultsLoading = MutableLiveData(false)
|
||||
|
||||
private val artistsRepository =
|
||||
ArtistsSearchRepository(getApplication<FFA>().applicationContext, "")
|
||||
private val albumsRepository =
|
||||
AlbumsSearchRepository(getApplication<FFA>().applicationContext, "")
|
||||
private val tracksRepository =
|
||||
TracksSearchRepository(getApplication<FFA>().applicationContext, "")
|
||||
|
||||
private val dedupQuery: LiveData<String>
|
||||
|
||||
val query = MutableLiveData("")
|
||||
|
||||
val artistResults: LiveData<List<Artist>> = MutableLiveData(listOf())
|
||||
val albumResults: LiveData<List<Album>> = MutableLiveData(listOf())
|
||||
val trackResults: LiveData<List<Track>> = MutableLiveData(listOf())
|
||||
|
||||
val isLoadingData: LiveData<Boolean> = artistResultsLoading.mergeWith(
|
||||
albumResultsLoading, tackResultsLoading
|
||||
) { b1, b2, b3 -> b1 || b2 || b3 }
|
||||
|
||||
val hasResults: LiveData<Boolean> = isLoadingData.mergeWith(
|
||||
artistResults, albumResults, trackResults
|
||||
) { b, r1, r2, r3 -> b || r1.isNotEmpty() || r2.isNotEmpty() || r3.isNotEmpty() }
|
||||
|
||||
init {
|
||||
dedupQuery = query.map { it.trim().lowercase(Locale.ROOT) }.distinctUntilChanged()
|
||||
dedupQuery.observeForever(this)
|
||||
}
|
||||
|
||||
override fun onChanged(token: String) {
|
||||
if (token.isBlank()) { // Empty search
|
||||
(artistResults as MutableLiveData).postValue(listOf())
|
||||
(albumResults as MutableLiveData).postValue(listOf())
|
||||
(trackResults as MutableLiveData).postValue(listOf())
|
||||
return
|
||||
}
|
||||
|
||||
artistResultsLoading.postValue(true)
|
||||
albumResultsLoading.postValue(true)
|
||||
tackResultsLoading.postValue(true)
|
||||
|
||||
val encoded = URLEncoder.encode(token, "UTF-8")
|
||||
|
||||
(artistResults as MutableLiveData).postValue(listOf())
|
||||
artistsRepository.apply {
|
||||
query = encoded
|
||||
fetch(Repository.Origin.Network.origin).untilNetwork(
|
||||
viewModelScope,
|
||||
Dispatchers.IO
|
||||
) { data, _, _, hasMore ->
|
||||
artistResults.postValue(artistResults.value!! + data)
|
||||
if (!hasMore) {
|
||||
artistResultsLoading.postValue(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(albumResults as MutableLiveData).postValue(listOf())
|
||||
albumsRepository.apply {
|
||||
query = encoded
|
||||
fetch(Repository.Origin.Network.origin).untilNetwork(
|
||||
viewModelScope,
|
||||
Dispatchers.IO
|
||||
) { data, _, _, hasMore ->
|
||||
albumResults.postValue(albumResults.value!! + data)
|
||||
if (!hasMore) {
|
||||
albumResultsLoading.postValue(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(trackResults as MutableLiveData).postValue(listOf())
|
||||
tracksRepository.apply {
|
||||
query = encoded
|
||||
fetch(Repository.Origin.Network.origin).untilNetwork(
|
||||
viewModelScope,
|
||||
Dispatchers.IO
|
||||
) { data, _, _, hasMore ->
|
||||
trackResults.postValue(trackResults.value!! + data)
|
||||
if (!hasMore) {
|
||||
tackResultsLoading.postValue(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
dedupQuery.removeObserver(this)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,91 @@
|
|||
package audio.funkwhale.ffa.views
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.util.TypedValue
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import androidx.cardview.widget.CardView
|
||||
import androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
import androidx.core.content.res.use
|
||||
import audio.funkwhale.ffa.R
|
||||
import audio.funkwhale.ffa.utils.BottomSheetIneractable
|
||||
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||
import com.google.android.material.bottomsheet.BottomSheetBehavior.BottomSheetCallback
|
||||
|
||||
|
||||
class NowPlayingBottomSheet @JvmOverloads constructor(
|
||||
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
|
||||
) : CardView(context, attrs, defStyleAttr), BottomSheetIneractable {
|
||||
private val behavior = BottomSheetBehavior<NowPlayingBottomSheet>()
|
||||
private val targetHeaderId: Int
|
||||
|
||||
val peekHeight get() = behavior.peekHeight
|
||||
|
||||
init {
|
||||
targetHeaderId = context.theme.obtainStyledAttributes(
|
||||
attrs, R.styleable.NowPlaying, defStyleAttr, 0
|
||||
).use {
|
||||
it.getResourceId(R.styleable.NowPlaying_target_header, NO_ID)
|
||||
}
|
||||
|
||||
// Put default peek height to actionBarSize so it is not 0
|
||||
val tv = TypedValue()
|
||||
if (context.theme.resolveAttribute(android.R.attr.actionBarSize, tv, true)) {
|
||||
behavior.peekHeight = TypedValue.complexToDimensionPixelSize(
|
||||
tv.data, resources.displayMetrics
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun setLayoutParams(params: ViewGroup.LayoutParams?) {
|
||||
super.setLayoutParams(params)
|
||||
(params as CoordinatorLayout.LayoutParams).behavior = behavior
|
||||
}
|
||||
|
||||
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
||||
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
|
||||
findViewById<View>(targetHeaderId)?.apply {
|
||||
behavior.setPeekHeight(this.height, false)
|
||||
this.setOnClickListener { this@NowPlayingBottomSheet.toggle() }
|
||||
} ?: hide()
|
||||
}
|
||||
|
||||
override fun onTouchEvent(event: MotionEvent): Boolean = true
|
||||
|
||||
fun addBottomSheetCallback(callback: BottomSheetCallback) {
|
||||
behavior.addBottomSheetCallback(callback)
|
||||
}
|
||||
|
||||
// Bottom sheet interactions
|
||||
override val isHidden: Boolean get() = behavior.state == BottomSheetBehavior.STATE_HIDDEN
|
||||
|
||||
override fun isOpen(): Boolean = behavior.state == BottomSheetBehavior.STATE_EXPANDED
|
||||
|
||||
override fun open() {
|
||||
behavior.state = BottomSheetBehavior.STATE_EXPANDED
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
behavior.state = BottomSheetBehavior.STATE_COLLAPSED
|
||||
}
|
||||
|
||||
override fun show() {
|
||||
behavior.isHideable = false
|
||||
if (behavior.state == BottomSheetBehavior.STATE_HIDDEN) {
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
override fun hide() {
|
||||
behavior.isHideable = true
|
||||
behavior.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
}
|
||||
|
||||
override fun toggle() {
|
||||
if (isHidden) return
|
||||
if (isOpen) close() else open()
|
||||
}
|
||||
}
|
|
@ -1,255 +0,0 @@
|
|||
package audio.funkwhale.ffa.views
|
||||
|
||||
import android.animation.ValueAnimator
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.util.TypedValue
|
||||
import android.view.GestureDetector
|
||||
import android.view.LayoutInflater
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.ViewTreeObserver
|
||||
import android.view.animation.DecelerateInterpolator
|
||||
import audio.funkwhale.ffa.R
|
||||
import audio.funkwhale.ffa.databinding.PartialNowPlayingBinding
|
||||
import com.google.android.material.card.MaterialCardView
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.min
|
||||
|
||||
class NowPlayingView : MaterialCardView {
|
||||
val activity: Context
|
||||
var gestureDetector: GestureDetector? = null
|
||||
var gestureDetectorCallback: OnGestureDetection? = null
|
||||
|
||||
private val binding =
|
||||
PartialNowPlayingBinding.inflate(LayoutInflater.from(context), this, true)
|
||||
|
||||
constructor(context: Context) : super(context) {
|
||||
activity = context
|
||||
}
|
||||
|
||||
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) {
|
||||
activity = context
|
||||
}
|
||||
|
||||
constructor(context: Context, attrs: AttributeSet?, style: Int) : super(context, attrs, style) {
|
||||
activity = context
|
||||
}
|
||||
|
||||
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
||||
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
|
||||
|
||||
binding.nowPlayingRoot.measure(
|
||||
widthMeasureSpec,
|
||||
MeasureSpec.makeMeasureSpec(MeasureSpec.getSize(heightMeasureSpec), MeasureSpec.UNSPECIFIED)
|
||||
)
|
||||
}
|
||||
|
||||
override fun onVisibilityChanged(changedView: View, visibility: Int) {
|
||||
super.onVisibilityChanged(changedView, visibility)
|
||||
|
||||
if (visibility == View.VISIBLE && gestureDetector == null) {
|
||||
viewTreeObserver.addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener {
|
||||
override fun onGlobalLayout() {
|
||||
gestureDetectorCallback = OnGestureDetection()
|
||||
gestureDetector = GestureDetector(context, gestureDetectorCallback)
|
||||
|
||||
setOnTouchListener { _, motionEvent ->
|
||||
val ret = gestureDetector?.onTouchEvent(motionEvent) ?: false
|
||||
|
||||
if (motionEvent.actionMasked == MotionEvent.ACTION_UP) {
|
||||
if (gestureDetectorCallback?.isScrolling == true) {
|
||||
gestureDetectorCallback?.onUp()
|
||||
}
|
||||
}
|
||||
performClick()
|
||||
ret
|
||||
}
|
||||
|
||||
viewTreeObserver.removeOnGlobalLayoutListener(this)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fun isOpened(): Boolean = gestureDetectorCallback?.isOpened() ?: false
|
||||
|
||||
fun close() {
|
||||
gestureDetectorCallback?.close()
|
||||
}
|
||||
|
||||
inner class OnGestureDetection : GestureDetector.SimpleOnGestureListener() {
|
||||
private var maxHeight = 0
|
||||
private var minHeight = 0
|
||||
private var maxMargin = 0
|
||||
|
||||
private var initialTouchY = 0f
|
||||
private var lastTouchY = 0f
|
||||
|
||||
var isScrolling = false
|
||||
private var flingAnimator: ValueAnimator? = null
|
||||
|
||||
init {
|
||||
(layoutParams as? MarginLayoutParams)?.let {
|
||||
maxMargin = it.marginStart
|
||||
}
|
||||
|
||||
minHeight = TypedValue().let {
|
||||
activity.theme.resolveAttribute(R.attr.actionBarSize, it, true)
|
||||
|
||||
TypedValue.complexToDimensionPixelSize(it.data, resources.displayMetrics)
|
||||
}
|
||||
|
||||
maxHeight = binding.nowPlayingDetails.measuredHeight + (2 * maxMargin)
|
||||
}
|
||||
|
||||
override fun onDown(e: MotionEvent): Boolean {
|
||||
initialTouchY = e.rawY
|
||||
lastTouchY = e.rawY
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
fun onUp(): Boolean {
|
||||
isScrolling = false
|
||||
|
||||
layoutParams.let {
|
||||
val offsetToMax = maxHeight - height
|
||||
val offsetToMin = height - minHeight
|
||||
|
||||
flingAnimator =
|
||||
if (offsetToMin < offsetToMax) ValueAnimator.ofInt(it.height, minHeight)
|
||||
else ValueAnimator.ofInt(it.height, maxHeight)
|
||||
|
||||
animateFling(500)
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFling(
|
||||
firstMotionEvent: MotionEvent?,
|
||||
secondMotionEvent: MotionEvent?,
|
||||
velocityX: Float,
|
||||
velocityY: Float
|
||||
): Boolean {
|
||||
isScrolling = false
|
||||
|
||||
layoutParams.let {
|
||||
val diff =
|
||||
if (velocityY < 0) maxHeight - it.height
|
||||
else it.height - minHeight
|
||||
|
||||
flingAnimator =
|
||||
if (velocityY < 0) ValueAnimator.ofInt(it.height, maxHeight)
|
||||
else ValueAnimator.ofInt(it.height, minHeight)
|
||||
|
||||
animateFling(min(abs((diff.toFloat() / velocityY * 1000).toLong()), 600))
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onScroll(
|
||||
firstMotionEvent: MotionEvent,
|
||||
secondMotionEvent: MotionEvent,
|
||||
distanceX: Float,
|
||||
distanceY: Float
|
||||
): Boolean {
|
||||
isScrolling = true
|
||||
|
||||
layoutParams.let {
|
||||
val newHeight = it.height + lastTouchY - secondMotionEvent.rawY
|
||||
val progress = (newHeight - minHeight) / (maxHeight - minHeight)
|
||||
val newMargin = maxMargin - (maxMargin * progress)
|
||||
|
||||
(layoutParams as? MarginLayoutParams)?.let { params ->
|
||||
params.marginStart = newMargin.toInt()
|
||||
params.marginEnd = newMargin.toInt()
|
||||
params.bottomMargin = newMargin.toInt()
|
||||
}
|
||||
|
||||
layoutParams = layoutParams.apply {
|
||||
when {
|
||||
newHeight <= minHeight -> {
|
||||
height = minHeight
|
||||
return true
|
||||
}
|
||||
newHeight >= maxHeight -> {
|
||||
height = maxHeight
|
||||
return true
|
||||
}
|
||||
else -> height = newHeight.toInt()
|
||||
}
|
||||
}
|
||||
|
||||
binding.summary.alpha = 1f - progress
|
||||
|
||||
binding.summary.layoutParams = binding.summary.layoutParams.apply {
|
||||
height = (minHeight * (1f - progress)).toInt()
|
||||
}
|
||||
}
|
||||
|
||||
lastTouchY = secondMotionEvent.rawY
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onSingleTapUp(e: MotionEvent?): Boolean {
|
||||
layoutParams.let {
|
||||
if (height != minHeight) return true
|
||||
|
||||
flingAnimator = ValueAnimator.ofInt(it.height, maxHeight)
|
||||
|
||||
animateFling(300)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
fun isOpened(): Boolean = layoutParams.height == maxHeight
|
||||
|
||||
fun close(): Boolean {
|
||||
layoutParams.let {
|
||||
if (it.height == minHeight) return true
|
||||
|
||||
flingAnimator = ValueAnimator.ofInt(it.height, minHeight)
|
||||
|
||||
animateFling(300)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private fun animateFling(dur: Long) {
|
||||
flingAnimator?.apply {
|
||||
duration = dur
|
||||
interpolator = DecelerateInterpolator()
|
||||
|
||||
addUpdateListener { valueAnimator ->
|
||||
layoutParams = layoutParams.apply {
|
||||
val newHeight = valueAnimator.animatedValue as Int
|
||||
val progress = (newHeight.toFloat() - minHeight) / (maxHeight - minHeight)
|
||||
val newMargin = maxMargin - (maxMargin * progress)
|
||||
|
||||
(layoutParams as? MarginLayoutParams)?.let {
|
||||
it.marginStart = newMargin.toInt()
|
||||
it.marginEnd = newMargin.toInt()
|
||||
it.bottomMargin = newMargin.toInt()
|
||||
}
|
||||
|
||||
height = newHeight
|
||||
|
||||
binding.summary.alpha = 1f - progress
|
||||
|
||||
binding.summary.layoutParams = binding.summary.layoutParams.apply {
|
||||
height = (minHeight * (1f - progress)).toInt()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
start()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,17 +0,0 @@
|
|||
package audio.funkwhale.ffa.views
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import androidx.appcompat.widget.AppCompatImageView
|
||||
|
||||
class SquareImageView : AppCompatImageView {
|
||||
constructor(context: Context) : super(context)
|
||||
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)
|
||||
constructor(context: Context, attrs: AttributeSet?, style: Int) : super(context, attrs, style)
|
||||
|
||||
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
||||
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
|
||||
|
||||
setMeasuredDimension(measuredWidth, measuredWidth)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,36 @@
|
|||
package audio.funkwhale.ffa.views
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import androidx.appcompat.widget.AppCompatImageButton
|
||||
import androidx.appcompat.widget.AppCompatImageView
|
||||
|
||||
open class SquareView : View {
|
||||
constructor(context: Context) : super(context)
|
||||
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)
|
||||
constructor(context: Context, attrs: AttributeSet?, style: Int) : super(context, attrs, style)
|
||||
|
||||
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
||||
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
|
||||
|
||||
val dimension = if(measuredWidth == 0 && measuredHeight > 0) measuredHeight else measuredWidth
|
||||
|
||||
setMeasuredDimension(dimension, dimension)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
open class SquareImageView : AppCompatImageView {
|
||||
constructor(context: Context) : super(context)
|
||||
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)
|
||||
constructor(context: Context, attrs: AttributeSet?, style: Int) : super(context, attrs, style)
|
||||
|
||||
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
||||
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
|
||||
|
||||
val dimension = if(measuredWidth == 0 && measuredHeight > 0) measuredHeight else measuredWidth
|
||||
|
||||
setMeasuredDimension(dimension, dimension)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<alpha
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:startOffset="@integer/transitionDuration"
|
||||
android:interpolator="@android:interpolator/accelerate_decelerate"
|
||||
android:fromAlpha="1.0"
|
||||
android:toAlpha="0.0"
|
||||
android:duration="@integer/transitionDuration"
|
||||
/>
|
|
@ -0,0 +1,8 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<alpha
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:interpolator="@android:interpolator/accelerate_decelerate"
|
||||
android:fromAlpha="0.0"
|
||||
android:toAlpha="1.0"
|
||||
android:duration="@integer/transitionDuration"
|
||||
/>
|
|
@ -0,0 +1,8 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<alpha
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:interpolator="@android:interpolator/accelerate_decelerate"
|
||||
android:toAlpha="0.0"
|
||||
android:fromAlpha="1.0"
|
||||
android:duration="@integer/transitionDuration"
|
||||
/>
|
|
@ -0,0 +1,7 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<alpha
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:interpolator="@android:interpolator/accelerate_decelerate"
|
||||
android:fromAlpha="1.0"
|
||||
android:toAlpha="1.0"
|
||||
/>
|
|
@ -0,0 +1,9 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
|
||||
<translate
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:interpolator="@android:interpolator/accelerate_decelerate"
|
||||
android:fromYDelta="0"
|
||||
android:toYDelta="100%"
|
||||
android:duration="@integer/transitionDuration"
|
||||
/>
|
|
@ -0,0 +1,9 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
|
||||
<translate
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:interpolator="@android:interpolator/accelerate_decelerate"
|
||||
android:fromYDelta="100%"
|
||||
android:toYDelta="0"
|
||||
android:duration="@integer/transitionDuration"
|
||||
/>
|
|
@ -0,0 +1,4 @@
|
|||
<vector android:height="24dp" android:viewportHeight="48"
|
||||
android:viewportWidth="48" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<path android:fillColor="#FF000000" android:pathData="m4.05,44 l40,-40v40ZM34.3,41h6.75L41.05,11.2l-6.75,6.75Z"/>
|
||||
</vector>
|
|
@ -0,0 +1,6 @@
|
|||
<vector android:height="24dp" android:viewportHeight="48"
|
||||
android:viewportWidth="48" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<path android:fillColor="#FF000000" android:pathData="M44.051,4L4.051,44L17.098,44L17.098,32.91L34.301,32.91L34.301,17.949L41.051,11.199L41.051,32.91L44.051,32.91L44.051,4z"/>
|
||||
<path android:fillColor="#FF000000"
|
||||
android:pathData="M17.873,33.639L17.873,47.316L47.16,47.316L47.16,33.639L17.873,33.639zM20.283,35.08L21.391,35.08L21.391,38.76C21.897,37.995 22.615,37.613 23.549,37.613C24.473,37.613 25.203,37.942 25.736,38.6C26.27,39.257 26.537,40.15 26.537,41.279C26.537,42.435 26.26,43.364 25.709,44.066C25.158,44.76 24.421,45.105 23.496,45.105C22.545,45.105 21.808,44.706 21.283,43.906L21.283,44.799L20.283,44.799L20.283,35.08zM35.256,35.893L36.363,35.893L36.363,37.813L37.51,37.813L37.51,38.719L36.363,38.719L36.363,43.506C36.363,43.755 36.402,43.923 36.482,44.012C36.571,44.092 36.737,44.133 36.977,44.133C37.199,44.133 37.376,44.116 37.51,44.08L37.51,45.012C37.163,45.074 36.861,45.105 36.604,45.105C36.168,45.105 35.835,45.008 35.604,44.813C35.372,44.626 35.256,44.356 35.256,44L35.256,38.719L34.311,38.719L34.311,37.813L35.256,37.813L35.256,35.893zM30.537,37.613C32.608,37.613 33.643,38.969 33.643,41.68L28.496,41.68C28.514,42.409 28.701,42.99 29.057,43.426C29.421,43.861 29.918,44.08 30.549,44.08C31.455,44.08 32.066,43.613 32.377,42.68L33.496,42.68C33.354,43.444 33.021,44.04 32.496,44.467C31.972,44.893 31.31,45.105 30.51,45.105C29.532,45.105 28.758,44.777 28.189,44.119C27.621,43.452 27.336,42.545 27.336,41.398C27.336,40.252 27.625,39.337 28.203,38.652C28.79,37.959 29.568,37.613 30.537,37.613zM41.283,37.613C42.145,37.613 42.798,37.777 43.242,38.105C43.687,38.425 43.91,38.897 43.91,39.52L43.91,43.625C43.91,43.989 44.11,44.172 44.51,44.172C44.59,44.172 44.67,44.164 44.75,44.146L44.75,44.986C44.439,45.066 44.186,45.105 43.99,45.105C43.635,45.105 43.362,45.022 43.176,44.854C42.998,44.694 42.888,44.436 42.844,44.08C42.097,44.765 41.304,45.105 40.469,45.105C39.767,45.105 39.207,44.92 38.789,44.547C38.38,44.174 38.176,43.67 38.176,43.039C38.176,42.835 38.195,42.647 38.23,42.479C38.275,42.31 38.319,42.164 38.363,42.039C38.417,41.906 38.504,41.786 38.629,41.68C38.753,41.564 38.856,41.47 38.936,41.398C39.024,41.327 39.168,41.261 39.363,41.199C39.568,41.128 39.723,41.079 39.83,41.053C39.937,41.017 40.124,40.978 40.391,40.934C40.657,40.889 40.852,40.858 40.977,40.84C41.101,40.822 41.323,40.791 41.643,40.746C42.078,40.693 42.38,40.608 42.549,40.492C42.718,40.377 42.803,40.204 42.803,39.973L42.803,39.68C42.803,39.342 42.666,39.084 42.391,38.906C42.124,38.728 41.74,38.639 41.242,38.639C40.727,38.639 40.337,38.741 40.07,38.945C39.804,39.141 39.648,39.452 39.604,39.879L38.482,39.879C38.536,38.368 39.47,37.613 41.283,37.613zM30.523,38.639C29.963,38.639 29.501,38.835 29.137,39.227C28.772,39.609 28.568,40.125 28.523,40.773L32.457,40.773C32.457,40.169 32.275,39.661 31.91,39.252C31.546,38.843 31.083,38.639 30.523,38.639zM23.336,38.652C22.749,38.652 22.279,38.901 21.924,39.398C21.568,39.887 21.391,40.542 21.391,41.359C21.391,42.177 21.568,42.834 21.924,43.332C22.279,43.821 22.749,44.066 23.336,44.066C23.94,44.066 24.429,43.821 24.803,43.332C25.185,42.834 25.377,42.19 25.377,41.398C25.377,40.563 25.19,39.896 24.816,39.398C24.452,38.901 23.958,38.652 23.336,38.652zM42.803,41.346C42.581,41.452 42.242,41.542 41.789,41.613C41.345,41.684 40.958,41.745 40.629,41.799C40.3,41.852 40.003,41.981 39.736,42.186C39.47,42.381 39.336,42.656 39.336,43.012C39.336,43.367 39.457,43.644 39.697,43.84C39.937,44.035 40.273,44.133 40.709,44.133C41.322,44.133 41.826,43.972 42.217,43.652C42.608,43.323 42.803,42.973 42.803,42.6L42.803,41.346z" android:strokeWidth="0.935758"/>
|
||||
</vector>
|
|
@ -1,61 +1,67 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintBottom_toTopOf="@id/appbar">
|
||||
<LinearLayout
|
||||
android:id="@+id/nav_host_fragment_wrapper"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:baselineAligned="false"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<FrameLayout
|
||||
android:id="@+id/container"
|
||||
<androidx.fragment.app.FragmentContainerView
|
||||
android:id="@+id/nav_host_fragment"
|
||||
android:name="androidx.navigation.fragment.NavHostFragment"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_marginBottom="?attr/actionBarSize"
|
||||
android:layout_weight="1"
|
||||
app:layout_behavior="@string/appbar_scrolling_view_behavior" />
|
||||
app:defaultNavHost="true"
|
||||
app:layout_behavior="@string/appbar_scrolling_view_behavior"
|
||||
app:navGraph="@navigation/main_nav"
|
||||
tools:layout="@layout/fragment_artists" />
|
||||
|
||||
<FrameLayout
|
||||
<androidx.fragment.app.FragmentContainerView
|
||||
android:id="@+id/landscape_queue"
|
||||
android:name="audio.funkwhale.ffa.fragments.LandscapeQueueFragment"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_marginBottom="?attr/actionBarSize"
|
||||
android:layout_weight="1"
|
||||
app:layout_behavior="@string/appbar_scrolling_view_behavior" />
|
||||
|
||||
app:layout_behavior="@string/appbar_scrolling_view_behavior"
|
||||
tools:layout="@layout/partial_queue" />
|
||||
</LinearLayout>
|
||||
|
||||
<audio.funkwhale.ffa.views.NowPlayingView
|
||||
android:id="@+id/now_playing"
|
||||
<audio.funkwhale.ffa.views.NowPlayingBottomSheet
|
||||
android:id="@+id/now_playing_bottom_sheet"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="?attr/actionBarSize"
|
||||
android:layout_gravity="bottom"
|
||||
android:layout_margin="8dp"
|
||||
android:alpha="0"
|
||||
android:visibility="gone"
|
||||
app:cardCornerRadius="8dp"
|
||||
app:cardElevation="12dp"
|
||||
app:layout_dodgeInsetEdges="bottom"
|
||||
tools:alpha="1"
|
||||
tools:visibility="visible">
|
||||
android:layout_height="match_parent"
|
||||
android:background="@color/elevatedSurface"
|
||||
app:cardElevation="8dp"
|
||||
app:target_header="@id/constraint_layout_placeholder">
|
||||
<androidx.fragment.app.FragmentContainerView
|
||||
android:id="@+id/now_playing"
|
||||
android:name="audio.funkwhale.ffa.fragments.NowPlayingFragment"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
tools:layout="@layout/fragment_now_playing" />
|
||||
</audio.funkwhale.ffa.views.NowPlayingBottomSheet>
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||
|
||||
<include layout="@layout/partial_now_playing" />
|
||||
|
||||
</audio.funkwhale.ffa.views.NowPlayingView>
|
||||
|
||||
<com.google.android.material.bottomappbar.BottomAppBar
|
||||
<androidx.appcompat.widget.Toolbar
|
||||
android:id="@+id/appbar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="bottom"
|
||||
android:layout_height="?attr/actionBarSize"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
android:theme="@style/AppTheme.AppBar"
|
||||
app:backgroundTint="@color/colorPrimaryDark"
|
||||
app:layout_insetEdge="bottom"
|
||||
app:navigationIcon="@drawable/funkwhaleshape"
|
||||
tools:menu="@menu/toolbar" />
|
||||
tools:menu="@menu/toolbar"
|
||||
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||
/>
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
|
|
@ -0,0 +1,65 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<layout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<data>
|
||||
<import type="androidx.lifecycle.LiveData" />
|
||||
<import type="android.view.View" />
|
||||
<import type="android.graphics.drawable.Drawable" />
|
||||
<variable name="isBuffering" type="LiveData<Boolean>" />
|
||||
<variable name="isPlaying" type="LiveData<Boolean>" />
|
||||
<variable name="progress" type="LiveData<Integer>" />
|
||||
<variable name="currentTrackTitle" type="LiveData<String>" />
|
||||
<variable name="currentTrackArtist" type="LiveData<String>" />
|
||||
</data>
|
||||
|
||||
<androidx.constraintlayout.motion.widget.MotionLayout
|
||||
android:id="@+id/now_playing_root"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
app:layoutDescription="@xml/fragment_now_playing_scene">
|
||||
|
||||
<include android:id="@+id/header" layout="@layout/partial_now_playing_header" />
|
||||
|
||||
<audio.funkwhale.ffa.views.SquareView
|
||||
android:id="@+id/detail_image_placeholder"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
app:layout_constraintTop_toBottomOf="@id/now_playing_progress"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
/>
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/now_playing_details_info"
|
||||
style="@style/IconButton"
|
||||
android:layout_width="32dp"
|
||||
android:layout_height="32dp"
|
||||
android:layout_margin="8dp"
|
||||
android:background="@drawable/circle"
|
||||
android:contentDescription="@string/alt_track_info"
|
||||
android:src="@drawable/more"
|
||||
app:layout_constraintEnd_toEndOf="@id/detail_image_placeholder"
|
||||
app:layout_constraintTop_toTopOf="@id/detail_image_placeholder"
|
||||
app:tint="@color/controlForeground"
|
||||
/>
|
||||
|
||||
<include
|
||||
android:id="@+id/controls"
|
||||
layout="@layout/partial_now_playing_controls"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:layout_marginStart="8dp"
|
||||
android:layout_marginEnd="8dp"
|
||||
app:layout_constraintTop_toTopOf="@id/detail_image_placeholder"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toEndOf="@id/detail_image_placeholder"
|
||||
android:alpha="0"
|
||||
android:background="@color/elevatedSurface"
|
||||
/>
|
||||
|
||||
</androidx.constraintlayout.motion.widget.MotionLayout>
|
||||
</layout>
|
|
@ -1,251 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:id="@+id/now_playing_root"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@color/elevatedSurface"
|
||||
android:orientation="vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/summary"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="?attr/actionBarSize"
|
||||
android:orientation="vertical">
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/now_playing_progress"
|
||||
style="@android:style/Widget.Material.ProgressBar.Horizontal"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="-6dp"
|
||||
android:layout_marginBottom="-6dp"
|
||||
android:progress="40"
|
||||
android:progressTint="@color/colorPrimary" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="?attr/actionBarSize"
|
||||
android:layout_height="?attr/actionBarSize"
|
||||
android:layout_marginEnd="16dp">
|
||||
|
||||
<audio.funkwhale.ffa.views.SquareImageView
|
||||
android:id="@+id/now_playing_cover"
|
||||
android:layout_width="?attr/actionBarSize"
|
||||
android:layout_height="?attr/actionBarSize"
|
||||
tools:src="@tools:sample/avatars" />
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/now_playing_buffering"
|
||||
android:layout_width="?attr/actionBarSize"
|
||||
android:layout_height="?attr/actionBarSize"
|
||||
android:indeterminate="true"
|
||||
android:indeterminateTint="@color/controlForeground"
|
||||
android:visibility="gone" />
|
||||
|
||||
</FrameLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center_vertical"
|
||||
android:layout_weight="2"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/now_playing_title"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="@color/itemTitle"
|
||||
tools:text="Supermassive Black Hole" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/now_playing_album"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
tools:text="Muse" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/now_playing_toggle"
|
||||
style="@style/AppTheme.OutlinedButton"
|
||||
android:layout_width="?attr/actionBarSize"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_marginEnd="16dp"
|
||||
app:icon="@drawable/play" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/now_playing_next"
|
||||
style="@style/IconButton"
|
||||
android:layout_width="32dp"
|
||||
android:layout_height="32dp"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:contentDescription="@string/control_next"
|
||||
android:src="@drawable/next" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/now_playing_details"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:paddingStart="32dp"
|
||||
android:paddingTop="16dp"
|
||||
android:paddingEnd="32dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/now_playing_details_title"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="@color/itemTitle"
|
||||
android:textSize="18sp"
|
||||
tools:text="Supermassive Black Hole" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/now_playing_details_artist"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
tools:text="Muse" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/now_playing_details_add_to_playlist"
|
||||
style="@style/IconButton"
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:layout_margin="8dp"
|
||||
android:contentDescription="@string/alt_album_cover"
|
||||
android:src="@drawable/add_to_playlist" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/now_playing_details_favorite"
|
||||
style="@style/IconButton"
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:layout_margin="8dp"
|
||||
android:contentDescription="@string/alt_album_cover"
|
||||
android:src="@drawable/favorite" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/now_playing_details_info"
|
||||
style="@style/IconButton"
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:layout_margin="8dp"
|
||||
android:contentDescription="@string/alt_track_info"
|
||||
android:src="@drawable/more"
|
||||
app:tint="@color/controlForeground" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<SeekBar
|
||||
android:id="@+id/now_playing_details_progress"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:max="100"
|
||||
android:progressBackgroundTint="#cacaca"
|
||||
android:progressTint="@color/controlForeground"
|
||||
android:thumbOffset="3dp"
|
||||
android:paddingStart="0dp"
|
||||
android:paddingEnd="0dp"
|
||||
android:thumbTint="@color/controlForeground" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/now_playing_details_progress_current"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/now_playing_details_progress_duration"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:textAlignment="textEnd" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="64dp"
|
||||
android:layout_marginBottom="8dp"
|
||||
android:gravity="center"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/now_playing_details_previous"
|
||||
style="@style/IconButton"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:contentDescription="@string/control_previous"
|
||||
android:src="@drawable/previous" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/now_playing_details_toggle"
|
||||
style="@style/AppTheme.OutlinedButton"
|
||||
android:layout_width="64dp"
|
||||
android:layout_height="64dp"
|
||||
app:cornerRadius="64dp"
|
||||
app:icon="@drawable/play"
|
||||
app:iconSize="32dp" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/now_playing_details_next"
|
||||
style="@style/IconButton"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_marginStart="16dp"
|
||||
android:contentDescription="@string/control_next"
|
||||
android:src="@drawable/next" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:gravity="center"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/now_playing_details_repeat"
|
||||
style="@style/IconButton"
|
||||
android:layout_width="28dp"
|
||||
android:layout_height="28dp"
|
||||
android:contentDescription="@string/control_next"
|
||||
android:src="@drawable/repeat" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
|
@ -1,47 +1,58 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@color/surface">
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<audio.funkwhale.ffa.views.DisableableFrameLayout
|
||||
android:id="@+id/container"
|
||||
<androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintBottom_toTopOf="@id/appbar">
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_marginBottom="?attr/actionBarSize"
|
||||
app:layout_behavior="@string/appbar_scrolling_view_behavior" />
|
||||
android:id="@+id/nav_host_fragment_wrapper">
|
||||
<androidx.fragment.app.FragmentContainerView
|
||||
android:id="@+id/nav_host_fragment"
|
||||
android:name="androidx.navigation.fragment.NavHostFragment"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
app:defaultNavHost="true"
|
||||
app:layout_behavior="@string/appbar_scrolling_view_behavior"
|
||||
app:navGraph="@navigation/main_nav"
|
||||
tools:layout="@layout/fragment_artists"
|
||||
/>
|
||||
</FrameLayout>
|
||||
|
||||
<audio.funkwhale.ffa.views.NowPlayingView
|
||||
<audio.funkwhale.ffa.views.NowPlayingBottomSheet
|
||||
android:id="@+id/now_playing_bottom_sheet"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:backgroundTint="@color/elevatedSurface"
|
||||
app:cardElevation="16dp"
|
||||
app:target_header="@id/constraint_layout_placeholder">
|
||||
<androidx.fragment.app.FragmentContainerView
|
||||
android:id="@+id/now_playing"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="?attr/actionBarSize"
|
||||
android:layout_gravity="bottom"
|
||||
android:layout_margin="8dp"
|
||||
android:alpha="0"
|
||||
android:visibility="gone"
|
||||
app:cardCornerRadius="3dp"
|
||||
app:cardElevation="12dp"
|
||||
app:layout_dodgeInsetEdges="bottom"
|
||||
tools:alpha="1"
|
||||
tools:visibility="visible">
|
||||
android:layout_height="match_parent"
|
||||
android:name="audio.funkwhale.ffa.fragments.NowPlayingFragment"
|
||||
tools:layout="@layout/fragment_now_playing"
|
||||
/>
|
||||
</audio.funkwhale.ffa.views.NowPlayingBottomSheet>
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||
|
||||
<include
|
||||
android:id="@+id/now_playing_container"
|
||||
layout="@layout/partial_now_playing" />
|
||||
|
||||
</audio.funkwhale.ffa.views.NowPlayingView>
|
||||
|
||||
<com.google.android.material.bottomappbar.BottomAppBar
|
||||
<androidx.appcompat.widget.Toolbar
|
||||
android:id="@+id/appbar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="bottom"
|
||||
android:layout_height="?attr/actionBarSize"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
android:theme="@style/AppTheme.AppBar"
|
||||
app:backgroundTint="@color/elevatedSurface"
|
||||
app:layout_insetEdge="bottom"
|
||||
android:background="@color/elevatedSurface"
|
||||
app:navigationIcon="@drawable/funkwhaleshape"
|
||||
tools:menu="@menu/toolbar" />
|
||||
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||
tools:menu="@menu/toolbar"
|
||||
/>
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
|
|
@ -1,100 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
tools:context=".activities.SearchActivity">
|
||||
|
||||
<androidx.cardview.widget.CardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
android:layout_marginTop="16dp"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:layout_marginBottom="0dp"
|
||||
android:elevation="4dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<androidx.appcompat.widget.SearchView
|
||||
android:id="@+id/search"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:iconifiedByDefault="false"
|
||||
app:queryBackground="@android:color/transparent"
|
||||
app:queryHint="@string/search_placeholder" />
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/search_spinner"
|
||||
style="?android:attr/progressBarStyleHorizontal"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="-12dp"
|
||||
android:layout_marginBottom="-12dp"
|
||||
android:indeterminate="true"
|
||||
android:visibility="invisible" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</androidx.cardview.widget.CardView>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/search_empty"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="16dp"
|
||||
android:layout_marginTop="16dp"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:drawablePadding="16dp"
|
||||
android:text="@string/search_welcome"
|
||||
android:textAlignment="center"
|
||||
android:textSize="14sp"
|
||||
app:drawableTint="#525252"
|
||||
app:drawableTopCompat="@drawable/funkwhaleshape" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/search_no_results"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="16dp"
|
||||
android:layout_marginTop="16dp"
|
||||
android:layout_marginEnd="16dp"
|
||||
app:drawableTopCompat="@drawable/funkwhaleshape"
|
||||
android:drawablePadding="16dp"
|
||||
app:drawableTint="#525252"
|
||||
android:text="@string/search_no_results"
|
||||
android:textAlignment="center"
|
||||
android:textSize="14sp"
|
||||
android:visibility="gone" />
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/results"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
tools:itemCount="10"
|
||||
tools:listitem="@layout/row_track" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<FrameLayout
|
||||
android:id="@+id/container"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
|
@ -17,7 +17,7 @@
|
|||
app:tabSelectedTextColor="@color/controlColor"
|
||||
app:tabTextColor="@color/colorPrimary" />
|
||||
|
||||
<androidx.viewpager.widget.ViewPager
|
||||
<androidx.viewpager2.widget.ViewPager2
|
||||
android:id="@+id/pager"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
|
|
|
@ -39,14 +39,27 @@
|
|||
android:clipChildren="false"
|
||||
app:layout_collapseMode="parallax">
|
||||
|
||||
<EditText
|
||||
android:id="@+id/filter_tracks"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_below="@id/favorites_title"
|
||||
android:layout_marginStart="16dp"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:ems="10"
|
||||
android:inputType="text"
|
||||
android:hint="@string/filters" />
|
||||
|
||||
<TextView
|
||||
style="@style/AppTheme.Title"
|
||||
android:id="@+id/favorites_title"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="16dp"
|
||||
android:layout_marginTop="64dp"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:layout_marginBottom="6dp"
|
||||
android:text="@string/favorites" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
|
|
|
@ -0,0 +1,59 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<layout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||
|
||||
<data>
|
||||
<import type="androidx.lifecycle.LiveData" />
|
||||
<import type="android.view.View" />
|
||||
<import type="android.graphics.drawable.Drawable" />
|
||||
<variable name="isBuffering" type="LiveData<Boolean>" />
|
||||
<variable name="isPlaying" type="LiveData<Boolean>" />
|
||||
<variable name="progress" type="LiveData<Integer>" />
|
||||
<variable name="currentTrackTitle" type="LiveData<String>" />
|
||||
<variable name="currentTrackArtist" type="LiveData<String>" />
|
||||
</data>
|
||||
|
||||
<androidx.constraintlayout.motion.widget.MotionLayout
|
||||
android:id="@+id/now_playing_root"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
app:layoutDescription="@xml/fragment_now_playing_scene">
|
||||
|
||||
<include
|
||||
android:id="@+id/header"
|
||||
layout="@layout/partial_now_playing_header"
|
||||
/>
|
||||
|
||||
<audio.funkwhale.ffa.views.SquareView
|
||||
android:id="@+id/detail_image_placeholder"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
app:layout_constraintTop_toBottomOf="@id/now_playing_progress"
|
||||
/>
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/now_playing_details_info"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_margin="8dp"
|
||||
app:layout_constraintEnd_toEndOf="@id/detail_image_placeholder"
|
||||
app:layout_constraintTop_toTopOf="@id/detail_image_placeholder"
|
||||
style="@style/IconButton"
|
||||
android:background="@drawable/circle"
|
||||
android:contentDescription="@string/alt_track_info"
|
||||
android:src="@drawable/more"
|
||||
app:tint="@color/controlForeground"
|
||||
/>
|
||||
|
||||
<include
|
||||
android:id="@+id/controls"
|
||||
layout="@layout/partial_now_playing_controls"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_margin="8dp"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/detail_image_placeholder"
|
||||
/>
|
||||
</androidx.constraintlayout.motion.widget.MotionLayout>
|
||||
</layout>
|
|
@ -0,0 +1,96 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<layout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<data>
|
||||
<import type="androidx.lifecycle.LiveData" />
|
||||
<import type="android.view.View" />
|
||||
<variable name="noSearchYet" type="LiveData<Boolean>" />
|
||||
<variable name="isLoadingData" type="LiveData<Boolean>" />
|
||||
<variable name="hasResults" type="LiveData<Boolean>" />
|
||||
</data>
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@color/surface">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/search_bar_and_messages"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
app:layout_constraintTop_toTopOf="parent">
|
||||
|
||||
<androidx.cardview.widget.CardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
android:layout_marginTop="16dp"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:layout_marginBottom="0dp"
|
||||
android:elevation="4dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<androidx.appcompat.widget.SearchView
|
||||
android:id="@+id/search"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:iconifiedByDefault="false"
|
||||
app:queryBackground="@android:color/transparent"
|
||||
app:queryHint="@string/search_placeholder" />
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/search_spinner"
|
||||
style="?android:attr/progressBarStyleHorizontal"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="-12dp"
|
||||
android:layout_marginBottom="-12dp"
|
||||
android:indeterminate="true"
|
||||
android:visibility="@{isLoadingData ? View.VISIBLE : View.INVISIBLE, default=invisible}" />
|
||||
</LinearLayout>
|
||||
</androidx.cardview.widget.CardView>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/search_empty"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_margin="16dp"
|
||||
android:text="@string/search_welcome"
|
||||
android:textAlignment="center"
|
||||
android:textSize="14sp"
|
||||
android:visibility="@{noSearchYet ? View.VISIBLE : View.GONE, default=visible}"
|
||||
app:drawableTint="#525252"
|
||||
app:drawableTopCompat="@drawable/funkwhaleshape" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/search_no_results"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_margin="16dp"
|
||||
android:text="@string/search_no_results"
|
||||
android:textAlignment="center"
|
||||
android:textSize="14sp"
|
||||
android:visibility="@{noSearchYet || hasResults ? View.GONE : View.VISIBLE, default=gone}"
|
||||
app:drawableTint="#525252"
|
||||
app:drawableTopCompat="@drawable/funkwhaleshape" />
|
||||
</LinearLayout>
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/results"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:background="@color/surface"
|
||||
app:layout_constraintTop_toBottomOf="@+id/search_bar_and_messages"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
tools:itemCount="10"
|
||||
tools:listitem="@layout/row_track" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
</layout>
|
|
@ -0,0 +1,7 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<merge
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
</merge>
|
|
@ -1,283 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:id="@+id/now_playing_root"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@color/elevatedSurface"
|
||||
android:orientation="vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/summary"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="?attr/actionBarSize"
|
||||
android:orientation="vertical">
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/now_playing_progress"
|
||||
style="@android:style/Widget.Material.ProgressBar.Horizontal"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="-6dp"
|
||||
android:layout_marginBottom="-6dp"
|
||||
android:progress="40"
|
||||
android:progressTint="@color/colorPrimaryDark" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="?attr/actionBarSize"
|
||||
android:layout_height="?attr/actionBarSize"
|
||||
android:layout_marginEnd="16dp">
|
||||
|
||||
<audio.funkwhale.ffa.views.SquareImageView
|
||||
android:id="@+id/now_playing_cover"
|
||||
android:layout_width="?attr/actionBarSize"
|
||||
android:layout_height="?attr/actionBarSize"
|
||||
tools:src="@tools:sample/avatars" />
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/now_playing_buffering"
|
||||
android:layout_width="?attr/actionBarSize"
|
||||
android:layout_height="?attr/actionBarSize"
|
||||
android:indeterminate="true"
|
||||
android:indeterminateTint="@color/controlForeground"
|
||||
android:visibility="gone" />
|
||||
|
||||
</FrameLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center_vertical"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:layout_weight="2"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/now_playing_title"
|
||||
style="@style/AppTheme.ItemTitle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:ellipsize="end"
|
||||
android:lines="1"
|
||||
tools:text="Supermassive Black Hole" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/now_playing_album"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:ellipsize="end"
|
||||
android:lines="1"
|
||||
tools:text="Muse" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/now_playing_toggle"
|
||||
style="@style/AppTheme.OutlinedButton"
|
||||
android:layout_width="?attr/actionBarSize"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_marginEnd="16dp"
|
||||
app:icon="@drawable/play" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/now_playing_next"
|
||||
style="@style/IconButton"
|
||||
android:layout_width="32dp"
|
||||
android:layout_height="32dp"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:contentDescription="@string/control_next"
|
||||
android:src="@drawable/next" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/now_playing_details"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"
|
||||
android:padding="8dp">
|
||||
|
||||
<audio.funkwhale.ffa.views.SquareImageView
|
||||
android:id="@+id/now_playing_details_cover"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_gravity="center"
|
||||
android:adjustViewBounds="true"
|
||||
android:src="@drawable/funkwhaleshape"
|
||||
tools:src="@tools:sample/avatars" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/now_playing_details_info"
|
||||
style="@style/IconButton"
|
||||
android:layout_width="32dp"
|
||||
android:layout_height="32dp"
|
||||
android:layout_gravity="top|end"
|
||||
android:layout_margin="8dp"
|
||||
android:background="@drawable/circle"
|
||||
android:contentDescription="@string/alt_track_info"
|
||||
android:src="@drawable/more"
|
||||
app:tint="@color/controlForeground" />
|
||||
|
||||
</FrameLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/now_playing_details_controls"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="32dp"
|
||||
android:layout_marginEnd="32dp"
|
||||
android:orientation="vertical"
|
||||
android:paddingTop="16dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/now_playing_details_title"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="@color/itemTitle"
|
||||
android:textSize="18sp"
|
||||
tools:text="Supermassive Black Hole" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/now_playing_details_artist"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
tools:text="Muse" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/now_playing_details_add_to_playlist"
|
||||
style="@style/IconButton"
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:layout_margin="8dp"
|
||||
android:contentDescription="@string/playlist_add_to"
|
||||
android:src="@drawable/add_to_playlist" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/now_playing_details_favorite"
|
||||
style="@style/IconButton"
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:layout_margin="8dp"
|
||||
android:contentDescription="@string/alt_album_cover"
|
||||
android:src="@drawable/favorite" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<SeekBar
|
||||
android:id="@+id/now_playing_details_progress"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:max="100"
|
||||
android:paddingStart="0dp"
|
||||
android:paddingEnd="0dp"
|
||||
android:progressBackgroundTint="#cacaca"
|
||||
android:progressTint="@color/controlForeground"
|
||||
android:thumbOffset="3dp"
|
||||
android:thumbTint="@color/controlForeground" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/now_playing_details_progress_current"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/now_playing_details_progress_duration"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:textAlignment="textEnd" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="64dp"
|
||||
android:layout_marginBottom="8dp"
|
||||
android:gravity="center"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/now_playing_details_previous"
|
||||
style="@style/IconButton"
|
||||
android:layout_width="32dp"
|
||||
android:layout_height="32dp"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:contentDescription="@string/control_previous"
|
||||
android:src="@drawable/previous" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/now_playing_details_toggle"
|
||||
style="@style/AppTheme.OutlinedButton"
|
||||
android:layout_width="64dp"
|
||||
android:layout_height="64dp"
|
||||
app:cornerRadius="64dp"
|
||||
app:icon="@drawable/play"
|
||||
app:iconSize="32dp" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/now_playing_details_next"
|
||||
style="@style/IconButton"
|
||||
android:layout_width="32dp"
|
||||
android:layout_height="32dp"
|
||||
android:layout_marginStart="16dp"
|
||||
android:contentDescription="@string/control_next"
|
||||
android:src="@drawable/next" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:gravity="center"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/now_playing_details_repeat"
|
||||
style="@style/IconButton"
|
||||
android:layout_width="28dp"
|
||||
android:layout_height="28dp"
|
||||
android:contentDescription="@string/control_next"
|
||||
android:src="@drawable/repeat" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
|
@ -0,0 +1,161 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<layout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
<data>
|
||||
<import type="androidx.lifecycle.LiveData" />
|
||||
<import type="android.graphics.drawable.Drawable" />
|
||||
<variable name="currentTrackTitle" type="LiveData<String>" />
|
||||
<variable name="currentTrackArtist" type="LiveData<String>" />
|
||||
<variable name="isCurrentTrackFavorite" type="LiveData<Boolean>" />
|
||||
<variable name="repeatModeResource" type="LiveData<Drawable>" />
|
||||
<variable name="repeatModeAlpha" type="LiveData<Float>" />
|
||||
<variable name="currentProgressText" type="LiveData<String>" />
|
||||
<variable name="currentDurationText" type="LiveData<String>" />
|
||||
<variable name="isPlaying" type="LiveData<Boolean>" />
|
||||
<variable name="progress" type="LiveData<Integer>" />
|
||||
</data>
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:alpha="0">
|
||||
<TextView
|
||||
android:id="@+id/current_playing_details_title"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingTop="2dp"
|
||||
android:paddingBottom="2dp"
|
||||
android:text="@{currentTrackTitle}"
|
||||
android:textColor="@color/itemTitle"
|
||||
android:textSize="18sp"
|
||||
app:layout_constraintEnd_toStartOf="@+id/now_playing_details_add_to_playlist"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" tools:text="Supermassive Black Hole" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/current_playing_details_artist"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingTop="2dp"
|
||||
android:paddingBottom="2dp"
|
||||
android:text="@{currentTrackArtist}"
|
||||
app:layout_constraintEnd_toStartOf="@+id/now_playing_details_add_to_playlist"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/current_playing_details_title"
|
||||
tools:text="Muse" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/now_playing_details_add_to_playlist"
|
||||
style="@style/IconButton"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_margin="8dp"
|
||||
android:contentDescription="@string/playlist_add_to"
|
||||
android:src="@drawable/add_to_playlist"
|
||||
app:layout_constraintBottom_toBottomOf="@+id/current_playing_details_artist"
|
||||
|
||||
app:layout_constraintEnd_toStartOf="@+id/now_playing_details_favorite"
|
||||
app:layout_constraintTop_toTopOf="@+id/current_playing_details_title" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/now_playing_details_favorite"
|
||||
style="@style/IconButton"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_margin="8dp"
|
||||
android:contentDescription="@string/control_add_to_favorites"
|
||||
android:src="@drawable/favorite"
|
||||
app:layout_constraintBottom_toBottomOf="@+id/current_playing_details_artist"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toTopOf="@+id/current_playing_details_title"
|
||||
app:tint="@{isCurrentTrackFavorite ? @color/colorFavorite : @color/controlForeground, default=@color/controlForeground}" />
|
||||
|
||||
|
||||
<TextView
|
||||
android:id="@+id/now_playing_details_progress_current"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text='@{currentProgressText, default="5:04"}'
|
||||
app:layout_constraintBottom_toBottomOf="@+id/now_playing_details_progress"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="@+id/now_playing_details_progress" />
|
||||
|
||||
<SeekBar
|
||||
android:id="@+id/now_playing_details_progress"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_margin="8dp"
|
||||
android:max="100"
|
||||
android:progress="@{progress, default=40}"
|
||||
android:progressBackgroundTint="#cacaca"
|
||||
android:progressTint="@color/controlForeground"
|
||||
android:thumbOffset="3dp"
|
||||
android:thumbTint="@color/controlForeground"
|
||||
app:layout_constraintEnd_toStartOf="@+id/now_playing_details_progress_duration"
|
||||
app:layout_constraintStart_toEndOf="@+id/now_playing_details_progress_current"
|
||||
app:layout_constraintTop_toBottomOf="@+id/current_playing_details_artist" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/now_playing_details_progress_duration"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text='@{currentDurationText, default="5:04"}'
|
||||
android:textAlignment="textEnd"
|
||||
app:layout_constraintBottom_toBottomOf="@+id/now_playing_details_progress"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toTopOf="@+id/now_playing_details_progress" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/now_playing_details_previous"
|
||||
style="@style/IconButton"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_margin="8dp"
|
||||
android:contentDescription="@string/control_previous"
|
||||
android:src="@drawable/previous"
|
||||
app:layout_constraintBottom_toBottomOf="@+id/now_playing_details_toggle"
|
||||
app:layout_constraintEnd_toStartOf="@+id/now_playing_details_toggle"
|
||||
app:layout_constraintTop_toBottomOf="@+id/now_playing_details_progress" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/now_playing_details_toggle"
|
||||
style="@style/AppTheme.OutlinedButton"
|
||||
android:layout_width="64dp"
|
||||
android:layout_height="64dp"
|
||||
android:layout_margin="8dp"
|
||||
app:cornerRadius="64dp"
|
||||
app:icon="@{isPlaying ? @drawable/pause : @drawable/play, default=@drawable/play}"
|
||||
app:iconSize="32dp"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/now_playing_details_progress" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/now_playing_details_next"
|
||||
style="@style/IconButton"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_margin="8dp"
|
||||
android:contentDescription="@string/control_next"
|
||||
android:src="@drawable/next"
|
||||
app:layout_constraintBottom_toBottomOf="@+id/now_playing_details_toggle"
|
||||
app:layout_constraintStart_toEndOf="@+id/now_playing_details_toggle"
|
||||
app:layout_constraintTop_toBottomOf="@+id/now_playing_details_progress" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/now_playing_details_repeat"
|
||||
style="@style/IconButton"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_margin="8dp"
|
||||
android:alpha="@{repeatModeAlpha, default=1}"
|
||||
android:contentDescription="@string/control_repeat_mode"
|
||||
android:src="@{repeatModeResource, default=@drawable/repeat}"
|
||||
app:layout_constraintBottom_toBottomOf="@+id/now_playing_details_toggle"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/now_playing_details_progress"
|
||||
app:tint="@color/controlForeground" />
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
</layout>
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue