Compare commits
445 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 | |
Ryan Harg | 84d6be2714 | |
Ryan Harg | 7524b29313 | |
Ryan Harg | 1dd20292b4 | |
Ryan Harg | 9d77c906fd | |
Ryan Harg | 43a7a10df2 | |
Ryan Harg | 5231798db1 | |
Ryan Harg | 5d2556ca58 | |
Ryan Harg | fe0ecff8f2 | |
Ryan Harg | e67f7a787b | |
Ryan Harg | 2891214eac | |
SpcCw | 19c3bd9081 | |
Ryan Harg | c557ddf1c2 | |
Ryan Harg | d8f8c3c193 | |
Ryan Harg | 687c61ed13 | |
Ryan Harg | 4140fd43be | |
Ryan Harg | ea662af120 | |
Ryan Harg | 898d2b437b | |
Ryan Harg | 81dea70ebe | |
Ryan Harg | 9d857de99a | |
Ryan Harg | 6be60afdfb | |
Ryan Harg | b4b381edb5 | |
Ryan Harg | 70feb589fa | |
Ryan Harg | ef3387dee0 | |
Ryan Harg | 1b6a246411 | |
RenovateBot | 650d73dcb6 | |
Ryan Harg | da41874b66 | |
Renovate Bot | 0410b4d7db | |
Ryan Harg | 4351258f46 | |
Renovate Bot | 13be764dc8 | |
Ryan Harg | 9f59c7c51b | |
Renovate Bot | 69ea85fc57 | |
Ryan Harg | dce85d5e97 | |
Renovate Bot | 6189f459cb | |
Ryan Harg | 9bb682fd8a | |
Keunes | 6c78e711c4 | |
Ryan Harg | 20dec7ceeb | |
Renovate Bot | 5bb3f4fe6d | |
Ryan Harg | 3dd322de51 | |
Renovate Bot | 15407d4fc3 | |
Ryan Harg | d8678d798c | |
Renovate Bot | 4c20ed9448 | |
Ryan Harg | b921408f9d | |
Ryan Harg | dfe88bd298 | |
Ryan Harg | ee5be36f2c | |
Ryan Harg | 63292f07c5 | |
Luka Filipović | 7ff24104fa | |
Georg Krause | a61041e4a2 | |
Ryan Harg | fe971f2cfc | |
Ryan Harg | 2706dcaa8e | |
Ryan Harg | fec95a7332 | |
Ryan Harg | 4e0279a75a | |
Ryan Harg | 07511d1d9e | |
ghose | 66345724ba | |
Ryan Harg | f338e755e2 | |
Ryan Harg | e98a29a7a0 | |
Georg Krause | 50dd95e21a | |
Georg Krause | cdad605bd5 | |
Georg Krause | e01ea2d800 | |
Georg Krause | 0c5c846244 | |
Ryan Harg | cae39754db | |
Georg Krause | d3a641072e |
|
@ -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,42 +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: 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'
|
||||
tags:
|
||||
- shell
|
||||
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/app-release.apk fdroid@apps.funkwhale.audio:/srv/fdroid/fdroid/develop/repo/audio.funkwhale.ffa.-0.0.1.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'
|
||||
tags:
|
||||
- shell
|
||||
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,12 +1,3 @@
|
|||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
|
||||
A clear and concise description of what the bug is.
|
||||
|
@ -22,6 +13,10 @@ A clear and concise description of what the bug is.
|
|||
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Actual behavior**
|
||||
|
||||
A clear and consise description of what actually happened, instead.
|
||||
|
||||
**Screenshots**
|
||||
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
@ -36,3 +31,5 @@ If applicable, add screenshots to help explain your problem.
|
|||
**Logs**
|
||||
|
||||
Add any related logs from ADB or from the "Copy logs" setting.
|
||||
|
||||
/label ~"Type: Bug"
|
|
@ -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
|
|
@ -0,0 +1,101 @@
|
|||
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)
|
||||
- Fix handling of hostname 'https://' scheme prefix (#88)
|
||||
- Remember scroll positions in list views (Artists/Albums/...) (#95)
|
||||
- Remove trailing slash from hostname (#92)
|
||||
- Use correct radio identifier for user radio (#90)
|
||||
|
||||
|
||||
Other:
|
||||
|
||||
- Add hard coded version information for F-Droid deployment (#97)
|
||||
- Automatically update the favorites list view (#28)
|
||||
- Update Fastlane metadata for app store deployments (#89)
|
||||
|
||||
|
||||
0.1.1 (2021-08-31)
|
||||
Features:
|
||||
|
||||
- Display if the whole album is offline available (#50)
|
||||
- Implement OAuth as Funkwhale's new authentication system (#48)
|
||||
- Increase size of buttons in queue (#39)
|
||||
- Migrate from deprecated Kotlin synthetics to Jetpack view binding (#67)
|
||||
- Rebrand Otter to Funkwhale for Android (#3)
|
||||
- Show error messages to user when login failes (#80)
|
||||
- Stretch timeline of the "now playing" view to entire width (#25)
|
||||
|
||||
|
||||
Bugfixes:
|
||||
|
||||
- Artist, Album and Track title is missing in search results (#70)
|
||||
- Fix application crash when adding song to playlist (#83)
|
||||
- Show pause icon when song is playing (#87)
|
||||
|
||||
|
||||
Other:
|
||||
|
||||
- Host a F-Droid repository for development artefacts (#5)
|
||||
- Initial testing support, test coverage calculation and integration with gitlab (#7)
|
||||
- Migrate open Otter issues to the Funkwhale for Android Gitlab repository (#2)
|
||||
- Rename debug build to Funkwhale (preview) (#71)
|
||||
- Show Funkwhale logo on the selfhosted F-Droid repository application page (#69)
|
||||
- Upgrade ExoPlayer version to 2.14.2 (#65)
|
||||
- Use towncrier to manage changelog (#6)
|
25
README.md
25
README.md
|
@ -1,4 +1,4 @@
|
|||
# Funkwhale for Android
|
||||
# Funkwhale for Android™
|
||||
|
||||
This is the official Android music player for [Funkwhale](https://funkwhale.audio), native to both Android (developed in Kotlin) and to Funkwhale (uses its native API instead of Subsonic).
|
||||
It is based on the amazing [Otter](https://github.com/apognu/otter) made by [apognu](https://github.com/apognu) and would not be possible without his groundwork!
|
||||
|
@ -7,15 +7,14 @@ 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
|
||||
|
||||
Funkwhale for Android is work in Progress. Please bear with us, there will be bugs, there will be crashes and there will be performance and UX issues.
|
||||
Funkwhale for Android™ is work in Progress. Please bear with us, there will be bugs, there will be crashes and there will be performance and UX issues.
|
||||
|
||||
Here is the list of Funkwhale for Android's features:
|
||||
Here is the list of Funkwhale for Android™'s features:
|
||||
|
||||
* Basic collection browsing (artists, albums and tracks)
|
||||
* Playlists listing
|
||||
|
@ -27,20 +26,16 @@ Here is the list of Funkwhale for Android's features:
|
|||
* Radios playback
|
||||
* Dark mode! 🎉
|
||||
|
||||
Funkwhale for Android will try to behave as you would expect a mobile music player to. That means it integrates with the OS's media controls (including headset controls) or pause on incoming calls. If there is anything you would like it to do, please [open an issue](https://dev.funkwhale.audio/funkwhale/funkwhale-android/-/issues/new).
|
||||
Funkwhale for Android™ will try to behave as you would expect a mobile music player to. That means it integrates with the OS's media controls (including headset controls) or pause on incoming calls. If there is anything you would like it to do, please [open an issue](https://dev.funkwhale.audio/funkwhale/funkwhale-android/-/issues/new).
|
||||
|
||||
## 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
|
||||
|
||||
Funkwhale for Android is being translated by the community through [Weblate](https://translate.funkwhale.audio/settings/funkwhale/ffa). If you would like to contribute to its localization or add a new language, you can help out there.
|
||||
Funkwhale for Android™ is being translated by the community through [Weblate](https://translate.funkwhale.audio/settings/funkwhale/ffa). If you would like to contribute to its localization or add a new language, you can help out there.
|
||||
|
||||
[![Translation status](https://translate.funkwhale.audio/widgets/funkwhale/-/ffa/multi-auto.svg)](https://translate.funkwhale.audio/engage/funkwhale/)
|
||||
|
|
|
@ -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 "8.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 "2.4.2"
|
||||
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
|
||||
}
|
||||
|
||||
|
@ -26,33 +30,41 @@ unMock {
|
|||
}
|
||||
|
||||
androidGitVersion {
|
||||
codeFormat = "MMNNPPBBB"
|
||||
codeFormat = "MMNNPPBBB" // Keep in sync with version_code() in dist/create_release.sh
|
||||
format = "%tag%%-count%%-commit%%-branch%"
|
||||
}
|
||||
|
||||
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"
|
||||
|
||||
|
@ -141,64 +153,75 @@ ktlint {
|
|||
}
|
||||
|
||||
play {
|
||||
isEnabled = props.hasProperty("play.credentials")
|
||||
enabled.set(props.hasProperty("play.credentials"))
|
||||
|
||||
if (isEnabled) {
|
||||
serviceAccountCredentials = file(props.getProperty("play.credentials"))
|
||||
defaultToAppBundles = true
|
||||
track = "beta"
|
||||
if (enabled.get()) {
|
||||
serviceAccountCredentials.set(file(props.getProperty("play.credentials")))
|
||||
defaultToAppBundles.set(true)
|
||||
track.set("beta")
|
||||
}
|
||||
}
|
||||
|
||||
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.1")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.1")
|
||||
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 {
|
||||
|
@ -241,13 +264,15 @@ project.afterEvaluate {
|
|||
sourceDirectories.setFrom(files(listOf(mainSrc)))
|
||||
classDirectories.setFrom(files(listOf(debugTree)))
|
||||
|
||||
executionData.setFrom(fileTree(project.buildDir) {
|
||||
setIncludes(
|
||||
listOf(
|
||||
"outputs/unit_test_code_coverage/debugUnitTest/*.exec",
|
||||
"outputs/code_coverage/debugAndroidTest/connected/**/*.ec"
|
||||
executionData.setFrom(
|
||||
fileTree(project.buildDir) {
|
||||
setIncludes(
|
||||
listOf(
|
||||
"outputs/unit_test_code_coverage/debugUnitTest/*.exec",
|
||||
"outputs/code_coverage/debugAndroidTest/connected/**/*.ec"
|
||||
)
|
||||
)
|
||||
)
|
||||
})
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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" />
|
||||
|
||||
|
@ -21,7 +22,8 @@
|
|||
<activity
|
||||
android:name=".activities.SplashActivity"
|
||||
android:launchMode="singleInstance"
|
||||
android:noHistory="true">
|
||||
android:noHistory="true"
|
||||
android:exported="true">
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
@ -35,23 +37,33 @@
|
|||
<activity
|
||||
android:name=".activities.LoginActivity"
|
||||
android:configChanges="screenSize|orientation"
|
||||
android:launchMode="singleInstance" />
|
||||
|
||||
<activity android:name=".activities.MainActivity" />
|
||||
android:launchMode="singleInstance"
|
||||
android:screenOrientation="portrait" />
|
||||
|
||||
<activity
|
||||
android:name=".activities.SearchActivity"
|
||||
android:launchMode="singleTop" />
|
||||
android:name=".activities.MainActivity" />
|
||||
|
||||
<activity android:name=".activities.DownloadsActivity" />
|
||||
<activity
|
||||
android:name=".activities.DownloadsActivity"
|
||||
android:screenOrientation="portrait" />
|
||||
|
||||
<activity android:name=".activities.SettingsActivity" />
|
||||
<activity
|
||||
android:name=".activities.SettingsActivity"
|
||||
android:screenOrientation="portrait" />
|
||||
|
||||
<activity android:name=".activities.LicencesActivity" />
|
||||
<activity
|
||||
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" />
|
||||
|
@ -70,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>
|
||||
|
|
|
@ -5,13 +5,13 @@ import android.content.Context
|
|||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import audio.funkwhale.ffa.koin.authModule
|
||||
import audio.funkwhale.ffa.koin.exoplayerModule
|
||||
import audio.funkwhale.ffa.utils.*
|
||||
import audio.funkwhale.ffa.utils.AppContext
|
||||
import audio.funkwhale.ffa.utils.FFACache
|
||||
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.*
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
class FFA : Application() {
|
||||
|
||||
|
@ -23,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()
|
||||
|
||||
|
@ -78,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,20 +64,19 @@ 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
|
||||
|
||||
while (cursor.moveToNext()) {
|
||||
val download = cursor.download
|
||||
|
||||
download.getMetadata()?.let { info ->
|
||||
adapter.downloads.add(info.apply {
|
||||
this.download = download
|
||||
})
|
||||
download.getMetadata()?.let { info ->
|
||||
adapter.downloads.add(
|
||||
info.apply { this.download = download }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
adapter.notifyDataSetChanged()
|
||||
}
|
||||
}
|
||||
|
@ -101,26 +99,29 @@ class DownloadsActivity : AppCompatActivity() {
|
|||
}
|
||||
|
||||
private suspend fun refreshProgress() {
|
||||
val cursor = exoDownloadManager.downloadIndex.getDownloads()
|
||||
exoDownloadManager.downloadIndex.getDownloads()
|
||||
.use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
val download = cursor.download
|
||||
|
||||
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)
|
||||
) {
|
||||
withContext(Main) {
|
||||
adapter.downloads[match.second] = info.apply {
|
||||
this.download = 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) {
|
||||
withContext(Main) {
|
||||
adapter.downloads[match.second] = info.apply {
|
||||
this.download = download
|
||||
adapter.notifyItemChanged(match.second)
|
||||
}
|
||||
}
|
||||
|
||||
adapter.notifyItemChanged(match.second)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inner class DownloadChangedListener : DownloadsAdapter.OnDownloadChangedListener {
|
||||
|
|
|
@ -90,7 +90,8 @@ class LicencesActivity : AppCompatActivity() {
|
|||
holder.licence.text = item.licence
|
||||
}
|
||||
|
||||
inner class ViewHolder(binding: RowLicenceBinding) : RecyclerView.ViewHolder(binding.root),
|
||||
inner class ViewHolder(binding: RowLicenceBinding) :
|
||||
RecyclerView.ViewHolder(binding.root),
|
||||
View.OnClickListener {
|
||||
val name = binding.name
|
||||
val licence = binding.licence
|
||||
|
|
|
@ -1,17 +1,23 @@
|
|||
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
|
||||
import audio.funkwhale.ffa.R
|
||||
import audio.funkwhale.ffa.databinding.ActivityLoginBinding
|
||||
import audio.funkwhale.ffa.fragments.LoginDialog
|
||||
import audio.funkwhale.ffa.utils.*
|
||||
import audio.funkwhale.ffa.utils.AppContext
|
||||
import audio.funkwhale.ffa.utils.FuelResult
|
||||
import audio.funkwhale.ffa.utils.OAuth
|
||||
import audio.funkwhale.ffa.utils.Userinfo
|
||||
import com.github.kittinunf.fuel.Fuel
|
||||
import com.github.kittinunf.fuel.coroutines.awaitObjectResponseResult
|
||||
import com.github.kittinunf.fuel.gson.gsonDeserializerOf
|
||||
|
@ -37,36 +43,38 @@ class LoginActivity : AppCompatActivity() {
|
|||
limitContainerWidth()
|
||||
}
|
||||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
super.onActivityResult(requestCode, resultCode, data)
|
||||
private var resultLauncher =
|
||||
registerForActivityResult(StartActivityForResult()) { result ->
|
||||
result.data?.let {
|
||||
oAuth.exchange(this, it) {
|
||||
PowerPreference
|
||||
.getFileByName(AppContext.PREFS_CREDENTIALS)
|
||||
.setBoolean("anonymous", false)
|
||||
|
||||
data?.let {
|
||||
when (requestCode) {
|
||||
0 -> {
|
||||
oAuth.exchange(this, data) {
|
||||
PowerPreference
|
||||
.getFileByName(AppContext.PREFS_CREDENTIALS)
|
||||
.setBoolean("anonymous", false)
|
||||
lifecycleScope.launch(Main) {
|
||||
Userinfo.get(this@LoginActivity, oAuth)?.let {
|
||||
startActivity(Intent(this@LoginActivity, MainActivity::class.java))
|
||||
|
||||
lifecycleScope.launch(Main) {
|
||||
Userinfo.get(this@LoginActivity, oAuth)?.let {
|
||||
startActivity(Intent(this@LoginActivity, MainActivity::class.java))
|
||||
|
||||
return@launch finish()
|
||||
}
|
||||
throw Exception(getString(R.string.login_error_userinfo))
|
||||
return@launch finish()
|
||||
}
|
||||
throw Exception(getString(R.string.login_error_userinfo))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
var hostname = hostname.text.toString().trim().trim('/')
|
||||
|
||||
try {
|
||||
validateHostname(hostname, cleartext.isChecked)?.let {
|
||||
|
@ -97,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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -121,7 +135,7 @@ class LoginActivity : AppCompatActivity() {
|
|||
if (hostname.isEmpty()) {
|
||||
return getString(R.string.login_error_hostname)
|
||||
}
|
||||
if (!cleartext && hostname.startsWith("http")) {
|
||||
if (!cleartext && hostname.startsWith("http://")) {
|
||||
return getString(R.string.login_error_hostname_https)
|
||||
}
|
||||
return null
|
||||
|
@ -131,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,208 +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.*
|
||||
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.*
|
||||
import audio.funkwhale.ffa.fragments.AddToPlaylistDialog
|
||||
import audio.funkwhale.ffa.fragments.BrowseFragmentDirections
|
||||
import audio.funkwhale.ffa.fragments.NowPlayingFragment
|
||||
import audio.funkwhale.ffa.fragments.QueueFragment
|
||||
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.*
|
||||
import audio.funkwhale.ffa.views.DisableableFrameLayout
|
||||
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.OAuth
|
||||
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.mustNormalizeUrl
|
||||
import audio.funkwhale.ffa.utils.toast
|
||||
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()
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
startService(Intent(applicationContext, PlayerService::class.java))
|
||||
DownloadService.start(applicationContext, PinService::class.java)
|
||||
|
||||
true
|
||||
}
|
||||
CommandBus.send(Command.RefreshService)
|
||||
|
||||
favoritedRepository.update(this, lifecycleScope)
|
||||
|
||||
startService(Intent(this, PlayerService::class.java))
|
||||
DownloadService.start(this, 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()
|
||||
lifecycleScope.launch(IO) {
|
||||
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
|
||||
|
@ -251,116 +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 launchFragment(fragment: Fragment) {
|
||||
supportFragmentManager.fragments.lastOrNull()?.also { oldFragment ->
|
||||
oldFragment.enterTransition = null
|
||||
oldFragment.exitTransition = null
|
||||
|
||||
supportFragmentManager.popBackStackImmediate(null, FragmentManager.POP_BACK_STACK_INCLUSIVE)
|
||||
private fun addSiblingFragmentPadding() {
|
||||
val anim = if (binding.nowPlayingBottomSheet.isHidden) {
|
||||
ValueAnimator.ofInt(binding.nowPlayingBottomSheet.peekHeight, 0)
|
||||
} else {
|
||||
ValueAnimator.ofInt(0, binding.nowPlayingBottomSheet.peekHeight)
|
||||
}
|
||||
|
||||
supportFragmentManager
|
||||
.beginTransaction()
|
||||
.setCustomAnimations(0, 0, 0, 0)
|
||||
.replace(R.id.container, fragment)
|
||||
.commit()
|
||||
anim.duration = 200
|
||||
anim.addUpdateListener {
|
||||
binding.navHostFragmentWrapper.setPadding(0, 0, 0, it.animatedValue as Int)
|
||||
}
|
||||
anim.start()
|
||||
}
|
||||
|
||||
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
|
||||
|
@ -369,260 +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(
|
||||
layoutInflater,
|
||||
this@MainActivity,
|
||||
lifecycleScope,
|
||||
command.tracks
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 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(
|
||||
is Command.StartService -> startService(command.command)
|
||||
is Command.RefreshTrack -> refreshTrack(command.track)
|
||||
is Command.AddToPlaylist -> AddToPlaylistDialog.show(
|
||||
layoutInflater,
|
||||
this@MainActivity,
|
||||
nowPlayingDetailsInfo,
|
||||
Gravity.START,
|
||||
R.attr.actionOverflowMenuStyle,
|
||||
0
|
||||
).apply {
|
||||
inflate(R.menu.track_info)
|
||||
lifecycleScope,
|
||||
command.tracks
|
||||
)
|
||||
|
||||
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)))
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun changeRepeatMode(index: Int) {
|
||||
when (index) {
|
||||
// From no repeat to repeat all
|
||||
0 -> {
|
||||
FFACache.set(this@MainActivity, "repeat", "0".toByteArray())
|
||||
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)
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -633,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()
|
||||
|
@ -643,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,190 +0,0 @@
|
|||
package audio.funkwhale.ffa.activities
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
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.*
|
||||
import audio.funkwhale.ffa.utils.*
|
||||
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.*
|
||||
|
||||
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)
|
||||
|
||||
adapter =
|
||||
SearchAdapter(layoutInflater, this, SearchResultClickListener(), FavoriteListener()).also {
|
||||
binding.results.layoutManager = LinearLayoutManager(this)
|
||||
binding.results.adapter = it
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
binding.search.setOnQueryTextListener(object :
|
||||
androidx.appcompat.widget.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)
|
||||
}
|
||||
}
|
||||
|
||||
inner class FavoriteListener : SearchAdapter.OnFavoriteListener {
|
||||
override fun onToggleFavorite(id: Int, state: Boolean) {
|
||||
when (state) {
|
||||
true -> favoritesRepository.addFavorite(id)
|
||||
false -> favoritesRepository.deleteFavorite(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,6 +1,10 @@
|
|||
package audio.funkwhale.ffa.activities
|
||||
|
||||
import android.content.*
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.SharedPreferences
|
||||
import android.os.Bundle
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
|
@ -36,8 +40,6 @@ class SettingsActivity : AppCompatActivity() {
|
|||
)
|
||||
.commit()
|
||||
}
|
||||
|
||||
fun getThemeResId(): Int = R.style.AppTheme
|
||||
}
|
||||
|
||||
class SettingsFragment :
|
||||
|
@ -47,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?) {
|
||||
|
@ -56,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(
|
||||
|
@ -112,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)
|
||||
|
@ -146,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 {
|
||||
|
|
|
@ -6,7 +6,9 @@ import android.os.Bundle
|
|||
import android.util.Log
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import audio.funkwhale.ffa.FFA
|
||||
import audio.funkwhale.ffa.utils.*
|
||||
import audio.funkwhale.ffa.utils.AppContext
|
||||
import audio.funkwhale.ffa.utils.OAuth
|
||||
import audio.funkwhale.ffa.utils.Settings
|
||||
import org.koin.java.KoinJavaComponent.inject
|
||||
|
||||
class SplashActivity : AppCompatActivity() {
|
||||
|
|
|
@ -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(
|
||||
|
@ -19,6 +17,10 @@ class AlbumsAdapter(
|
|||
private val listener: OnAlbumClickListener
|
||||
) : FFAAdapter<Album, AlbumsAdapter.ViewHolder>() {
|
||||
|
||||
init {
|
||||
this.stateRestorationPolicy = StateRestorationPolicy.PREVENT_WHEN_EMPTY
|
||||
}
|
||||
|
||||
interface OnAlbumClickListener {
|
||||
fun onClick(view: View?, album: Album)
|
||||
}
|
||||
|
@ -41,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(
|
||||
|
@ -42,6 +41,8 @@ class ArtistsAdapter(
|
|||
super.onItemRangeInserted(positionStart, itemCount)
|
||||
}
|
||||
})
|
||||
|
||||
this.stateRestorationPolicy = StateRestorationPolicy.PREVENT_WHEN_EMPTY
|
||||
}
|
||||
|
||||
override fun getItemCount() = active.size
|
||||
|
@ -60,14 +61,11 @@ 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))
|
||||
.fit()
|
||||
.transform(RoundedCornersTransformation(8, 0))
|
||||
.into(holder.art)
|
||||
}
|
||||
artist.cover()?.let { coverUrl ->
|
||||
CoverArt.requestCreator(maybeNormalizeUrl(coverUrl))
|
||||
.fit()
|
||||
.transform(RoundedCornersTransformation(8, 0))
|
||||
.into(holder.art)
|
||||
}
|
||||
|
||||
holder.name.text = artist.name
|
||||
|
|
|
@ -1,36 +1,27 @@
|
|||
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.*
|
||||
import audio.funkwhale.ffa.fragments.AlbumsGridFragment
|
||||
import audio.funkwhale.ffa.fragments.ArtistsFragment
|
||||
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) {
|
||||
0 -> ArtistsFragment()
|
||||
1 -> AlbumsGridFragment()
|
||||
2 -> PlaylistsFragment()
|
||||
3 -> RadiosFragment()
|
||||
4 -> FavoritesFragment()
|
||||
else -> ArtistsFragment()
|
||||
}
|
||||
|
||||
tabs.add(position, fragment)
|
||||
|
||||
return fragment
|
||||
override fun createFragment(position: Int): Fragment = when (position) {
|
||||
0 -> ArtistsFragment()
|
||||
1 -> AlbumsGridFragment()
|
||||
2 -> PlaylistsFragment()
|
||||
3 -> RadiosFragment()
|
||||
4 -> FavoritesFragment()
|
||||
else -> ArtistsFragment()
|
||||
}
|
||||
|
||||
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,9 +8,9 @@ import android.view.ViewGroup
|
|||
import androidx.recyclerview.widget.RecyclerView
|
||||
import audio.funkwhale.ffa.R
|
||||
import audio.funkwhale.ffa.databinding.RowDownloadBinding
|
||||
import audio.funkwhale.ffa.playback.PinService
|
||||
import audio.funkwhale.ffa.model.DownloadInfo
|
||||
import audio.funkwhale.ffa.model.Track
|
||||
import audio.funkwhale.ffa.playback.PinService
|
||||
import com.google.android.exoplayer2.offline.Download
|
||||
import com.google.android.exoplayer2.offline.DownloadService
|
||||
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
package audio.funkwhale.ffa.adapters
|
||||
|
||||
import audio.funkwhale.ffa.repositories.FavoritesRepository
|
||||
|
||||
class FavoriteListener(private val repository: FavoritesRepository) {
|
||||
|
||||
fun onToggleFavorite(id: Int, state: Boolean) {
|
||||
when (state) {
|
||||
true -> repository.addFavorite(id)
|
||||
false -> repository.deleteFavorite(id)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -8,35 +8,37 @@ 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.model.Track
|
||||
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
|
||||
|
||||
class FavoritesAdapter(
|
||||
private val layoutInflater: LayoutInflater,
|
||||
private val context: Context?,
|
||||
private val favoriteListener: OnFavoriteListener,
|
||||
val fromQueue: Boolean = false
|
||||
) : FFAAdapter<Track, FavoritesAdapter.ViewHolder>() {
|
||||
private val favoriteListener: FavoriteListener,
|
||||
val fromQueue: Boolean = false,
|
||||
) : FFAAdapter<Favorite, FavoritesAdapter.ViewHolder>() {
|
||||
|
||||
interface OnFavoriteListener {
|
||||
fun onToggleFavorite(id: Int, state: Boolean)
|
||||
init {
|
||||
this.stateRestorationPolicy = StateRestorationPolicy.PREVENT_WHEN_EMPTY
|
||||
}
|
||||
|
||||
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,11 +167,13 @@ class FavoritesAdapter(
|
|||
when (fromQueue) {
|
||||
true -> CommandBus.send(Command.PlayTrack(layoutPosition))
|
||||
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")
|
||||
}
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -16,28 +16,23 @@ 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.utils.Command
|
||||
import audio.funkwhale.ffa.utils.CommandBus
|
||||
import audio.funkwhale.ffa.model.PlaylistTrack
|
||||
import audio.funkwhale.ffa.model.Track
|
||||
import audio.funkwhale.ffa.utils.maybeLoad
|
||||
import audio.funkwhale.ffa.utils.Command
|
||||
import audio.funkwhale.ffa.utils.CommandBus
|
||||
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
|
||||
|
||||
class PlaylistTracksAdapter(
|
||||
private val layoutInflater: LayoutInflater,
|
||||
private val context: Context?,
|
||||
private val favoriteListener: OnFavoriteListener? = null,
|
||||
private val playlistListener: OnPlaylistListener? = null
|
||||
private val favoriteListener: FavoriteListener,
|
||||
private val playlistListener: OnPlaylistListener
|
||||
) : FFAAdapter<PlaylistTrack, PlaylistTracksAdapter.ViewHolder>() {
|
||||
|
||||
interface OnFavoriteListener {
|
||||
fun onToggleFavorite(id: Int, state: Boolean)
|
||||
}
|
||||
|
||||
private lateinit var binding: RowTrackBinding
|
||||
|
||||
interface OnPlaylistListener {
|
||||
|
@ -74,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.let {
|
||||
favoriteListener.onToggleFavorite(playlistTrack.track.id, !playlistTrack.track.favorite)
|
||||
|
||||
track.track.favorite = !track.track.favorite
|
||||
playlistTrack.track.favorite = !playlistTrack.track.favorite
|
||||
notifyItemChanged(position)
|
||||
}
|
||||
}
|
||||
|
@ -121,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_remove_from_playlist -> playlistListener?.onRemoveTrackFromPlaylist(
|
||||
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(
|
||||
playlistTrack.track,
|
||||
position
|
||||
)
|
||||
}
|
||||
|
@ -223,7 +216,7 @@ class PlaylistTracksAdapter(
|
|||
viewHolder.itemView.background = ColorDrawable(Color.TRANSPARENT)
|
||||
|
||||
if (from != -1 && to != -1 && from != to) {
|
||||
playlistListener?.onMoveTrack(from, to)
|
||||
playlistListener.onMoveTrack(from, to)
|
||||
|
||||
from = -1
|
||||
to = -1
|
||||
|
|
|
@ -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(
|
||||
|
@ -20,6 +20,10 @@ class PlaylistsAdapter(
|
|||
private val listener: OnPlaylistClickListener
|
||||
) : FFAAdapter<Playlist, PlaylistsAdapter.ViewHolder>() {
|
||||
|
||||
init {
|
||||
this.stateRestorationPolicy = StateRestorationPolicy.PREVENT_WHEN_EMPTY
|
||||
}
|
||||
|
||||
interface OnPlaylistClickListener {
|
||||
fun onClick(holder: View?, playlist: Playlist)
|
||||
}
|
||||
|
@ -75,8 +79,7 @@ class PlaylistsAdapter(
|
|||
else -> RoundedCornersTransformation.CornerType.TOP_LEFT
|
||||
}
|
||||
|
||||
Picasso.get()
|
||||
.load(url)
|
||||
CoverArt.requestCreator(url)
|
||||
.transform(RoundedCornersTransformation(32, 0, corner))
|
||||
.into(imageView)
|
||||
}
|
||||
|
|
|
@ -9,15 +9,14 @@ import audio.funkwhale.ffa.R
|
|||
import audio.funkwhale.ffa.databinding.RowRadioBinding
|
||||
import audio.funkwhale.ffa.databinding.RowRadioHeaderBinding
|
||||
import audio.funkwhale.ffa.fragments.FFAAdapter
|
||||
import audio.funkwhale.ffa.model.Radio
|
||||
import audio.funkwhale.ffa.utils.AppContext
|
||||
import audio.funkwhale.ffa.utils.Event
|
||||
import audio.funkwhale.ffa.utils.EventBus
|
||||
import audio.funkwhale.ffa.model.Radio
|
||||
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(
|
||||
|
@ -27,6 +26,10 @@ class RadiosAdapter(
|
|||
private val listener: OnRadioClickListener
|
||||
) : FFAAdapter<Radio, RadiosAdapter.ViewHolder>() {
|
||||
|
||||
init {
|
||||
this.stateRestorationPolicy = StateRestorationPolicy.PREVENT_WHEN_EMPTY
|
||||
}
|
||||
|
||||
interface OnRadioClickListener {
|
||||
fun onClick(holder: RowRadioViewHolder, radio: Radio)
|
||||
}
|
||||
|
@ -42,8 +45,10 @@ class RadiosAdapter(
|
|||
|
||||
private val instanceRadios: List<Radio> by lazy {
|
||||
context?.let {
|
||||
return@lazy when (val username =
|
||||
PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).getString("actor_username")) {
|
||||
return@lazy when (
|
||||
val username =
|
||||
PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).getString("actor_username")
|
||||
) {
|
||||
"" -> listOf(
|
||||
Radio(
|
||||
0,
|
||||
|
@ -56,7 +61,7 @@ class RadiosAdapter(
|
|||
else -> listOf(
|
||||
Radio(
|
||||
0,
|
||||
"actor_content",
|
||||
"actor-content",
|
||||
context.getString(R.string.radio_your_content_title),
|
||||
context.getString(R.string.radio_your_content_description),
|
||||
username
|
||||
|
@ -133,8 +138,9 @@ class RadiosAdapter(
|
|||
context?.let {
|
||||
when (position) {
|
||||
0 -> holder.label.text = context.getString(R.string.radio_instance_radios)
|
||||
instanceRadios.size + 1 -> holder.label.text =
|
||||
context.getString(R.string.radio_user_radios)
|
||||
instanceRadios.size + 1 ->
|
||||
holder.label.text =
|
||||
context.getString(R.string.radio_user_radios)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -183,12 +189,10 @@ class RadiosAdapter(
|
|||
art.setColorFilter(context.getColor(R.color.controlForeground))
|
||||
|
||||
scope.launch(Main) {
|
||||
EventBus.get().collect { message ->
|
||||
when (message) {
|
||||
is Event.RadioStarted -> {
|
||||
art.colorFilter = originalColorFilter
|
||||
LoadingImageView.stop(context, originalDrawable, art, imageAnimator)
|
||||
}
|
||||
EventBus.get().collect { event ->
|
||||
if (event is Event.RadioStarted) {
|
||||
art.colorFilter = originalColorFilter
|
||||
LoadingImageView.stop(context, originalDrawable, art, imageAnimator)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,31 +7,32 @@ 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
|
||||
import audio.funkwhale.ffa.databinding.RowTrackBinding
|
||||
import audio.funkwhale.ffa.model.Album
|
||||
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.model.Track
|
||||
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?,
|
||||
private val listener: OnSearchResultClickListener? = null,
|
||||
private val favoriteListener: OnFavoriteListener? = null
|
||||
viewModel: SearchViewModel,
|
||||
private val fragment: Fragment,
|
||||
private val listener: OnSearchResultClickListener,
|
||||
private val favoriteListener: FavoriteListener
|
||||
) : RecyclerView.Adapter<SearchAdapter.ViewHolder>() {
|
||||
|
||||
interface OnSearchResultClickListener {
|
||||
|
@ -39,10 +40,6 @@ class SearchAdapter(
|
|||
fun onAlbumClick(holder: View?, album: Album)
|
||||
}
|
||||
|
||||
interface OnFavoriteListener {
|
||||
fun onToggleFavorite(id: Int, state: Boolean)
|
||||
}
|
||||
|
||||
enum class ResultType {
|
||||
Header,
|
||||
Artist,
|
||||
|
@ -55,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 {
|
||||
|
@ -72,32 +84,31 @@ class SearchAdapter(
|
|||
}
|
||||
|
||||
ResultType.Artist.ordinal -> artists[position].id.toLong()
|
||||
ResultType.Artist.ordinal -> albums[position - artists.size - 2].id.toLong()
|
||||
ResultType.Track.ordinal -> tracks[position - artists.size - albums.size - sectionCount].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
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemViewType(position: Int): Int {
|
||||
if (position == 0) return ResultType.Header.ordinal // Artists header
|
||||
if (position == (artists.size + 1)) return ResultType.Header.ordinal // Albums header
|
||||
if (position == (artists.size + albums.size + 2)) return ResultType.Header.ordinal // Tracks header
|
||||
|
||||
if (position <= artists.size) return ResultType.Artist.ordinal
|
||||
if (position <= artists.size + albums.size + 2) return ResultType.Album.ordinal
|
||||
|
||||
return ResultType.Track.ordinal
|
||||
override fun getItemViewType(position: Int): Int = when {
|
||||
position == 0 ||
|
||||
position == (artists.size + 1) ||
|
||||
position == (artists.size + albums.size + 2) -> ResultType.Header.ordinal
|
||||
position <= artists.size -> ResultType.Artist.ordinal
|
||||
position <= artists.size + albums.size + 2 -> ResultType.Album.ordinal
|
||||
else -> ResultType.Track.ordinal
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
@ -111,47 +122,45 @@ 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)
|
||||
holder.itemView.visibility = View.VISIBLE
|
||||
holder.itemView.layoutParams = RecyclerView.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
if (position == 0) {
|
||||
searchHeaderViewHolder?.title?.text = fragment.requireContext().getString(R.string.artists)
|
||||
holder.itemView.visibility = View.VISIBLE
|
||||
holder.itemView.layoutParams = RecyclerView.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
|
||||
if (artists.isEmpty()) {
|
||||
holder.itemView.visibility = View.GONE
|
||||
holder.itemView.layoutParams = RecyclerView.LayoutParams(0, 0)
|
||||
}
|
||||
if (artists.isEmpty()) {
|
||||
holder.itemView.visibility = View.GONE
|
||||
holder.itemView.layoutParams = RecyclerView.LayoutParams(0, 0)
|
||||
}
|
||||
}
|
||||
|
||||
if (position == (artists.size + 1)) {
|
||||
searchHeaderViewHolder?.title?.text = context.getString(R.string.albums)
|
||||
holder.itemView.visibility = View.VISIBLE
|
||||
holder.itemView.layoutParams = RecyclerView.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
if (position == (artists.size + 1)) {
|
||||
searchHeaderViewHolder?.title?.text = fragment.requireContext().getString(R.string.albums)
|
||||
holder.itemView.visibility = View.VISIBLE
|
||||
holder.itemView.layoutParams = RecyclerView.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
|
||||
if (albums.isEmpty()) {
|
||||
holder.itemView.visibility = View.GONE
|
||||
holder.itemView.layoutParams = RecyclerView.LayoutParams(0, 0)
|
||||
}
|
||||
if (albums.isEmpty()) {
|
||||
holder.itemView.visibility = View.GONE
|
||||
holder.itemView.layoutParams = RecyclerView.LayoutParams(0, 0)
|
||||
}
|
||||
}
|
||||
|
||||
if (position == (artists.size + albums.size + 2)) {
|
||||
searchHeaderViewHolder?.title?.text = context.getString(R.string.tracks)
|
||||
holder.itemView.visibility = View.VISIBLE
|
||||
holder.itemView.layoutParams = RecyclerView.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
if (position == (artists.size + albums.size + 2)) {
|
||||
searchHeaderViewHolder?.title?.text = fragment.requireContext().getString(R.string.tracks)
|
||||
holder.itemView.visibility = View.VISIBLE
|
||||
holder.itemView.layoutParams = RecyclerView.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
|
||||
if (tracks.isEmpty()) {
|
||||
holder.itemView.visibility = View.GONE
|
||||
holder.itemView.layoutParams = RecyclerView.LayoutParams(0, 0)
|
||||
}
|
||||
if (tracks.isEmpty()) {
|
||||
holder.itemView.visibility = View.GONE
|
||||
holder.itemView.layoutParams = RecyclerView.LayoutParams(0, 0)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -180,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)
|
||||
|
@ -205,102 +213,113 @@ class SearchAdapter(
|
|||
Typeface.create(searchHeaderViewHolder?.title?.typeface, Typeface.NORMAL)
|
||||
rowTrackViewHolder?.artist?.typeface =
|
||||
Typeface.create(rowTrackViewHolder?.artist?.typeface, Typeface.NORMAL)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
searchHeaderViewHolder?.title?.setCompoundDrawablesWithIntrinsicBounds(0, 0, 0, 0)
|
||||
|
||||
when (resultType) {
|
||||
ResultType.Artist.ordinal -> {
|
||||
rowTrackViewHolder?.title?.setCompoundDrawablesWithIntrinsicBounds(
|
||||
0, 0, 0, 0
|
||||
)
|
||||
}
|
||||
ResultType.Album.ordinal -> {
|
||||
rowTrackViewHolder?.title?.setCompoundDrawablesWithIntrinsicBounds(
|
||||
0, 0, 0, 0
|
||||
)
|
||||
}
|
||||
ResultType.Track.ordinal -> {
|
||||
(item as? Track)?.let { track ->
|
||||
context?.let { context ->
|
||||
if (track == currentTrack || track.current) {
|
||||
searchHeaderViewHolder?.title?.setTypeface(
|
||||
searchHeaderViewHolder.title.typeface,
|
||||
Typeface.BOLD
|
||||
ResultType.Artist.ordinal -> {
|
||||
rowTrackViewHolder?.title?.setCompoundDrawablesWithIntrinsicBounds(
|
||||
0, 0, 0, 0
|
||||
)
|
||||
}
|
||||
ResultType.Album.ordinal -> {
|
||||
rowTrackViewHolder?.title?.setCompoundDrawablesWithIntrinsicBounds(
|
||||
0, 0, 0, 0
|
||||
)
|
||||
}
|
||||
ResultType.Track.ordinal -> {
|
||||
(item as? Track)?.let { track ->
|
||||
if (track == currentTrack || track.current) {
|
||||
searchHeaderViewHolder?.title?.setTypeface(
|
||||
searchHeaderViewHolder.title.typeface,
|
||||
Typeface.BOLD
|
||||
)
|
||||
rowTrackViewHolder?.artist?.setTypeface(
|
||||
rowTrackViewHolder.artist.typeface,
|
||||
Typeface.BOLD
|
||||
)
|
||||
}
|
||||
|
||||
when (track.favorite) {
|
||||
true -> rowTrackViewHolder?.favorite?.setColorFilter(
|
||||
fragment.requireContext().getColor(R.color.colorFavorite)
|
||||
)
|
||||
false -> rowTrackViewHolder?.favorite?.setColorFilter(
|
||||
fragment.requireContext().getColor(R.color.colorSelected)
|
||||
)
|
||||
}
|
||||
|
||||
rowTrackViewHolder?.favorite?.setOnClickListener {
|
||||
favoriteListener.let {
|
||||
favoriteListener.onToggleFavorite(track.id, !track.favorite)
|
||||
|
||||
tracks[position - artists.size - albums.size - sectionCount].favorite =
|
||||
!track.favorite
|
||||
|
||||
notifyItemChanged(position)
|
||||
}
|
||||
}
|
||||
|
||||
when (track.cached || track.downloaded) {
|
||||
true -> rowTrackViewHolder?.title?.setCompoundDrawablesWithIntrinsicBounds(
|
||||
R.drawable.downloaded, 0, 0, 0
|
||||
)
|
||||
false -> rowTrackViewHolder?.title?.setCompoundDrawablesWithIntrinsicBounds(
|
||||
0, 0, 0, 0
|
||||
)
|
||||
}
|
||||
|
||||
if (track.cached && !track.downloaded) {
|
||||
rowTrackViewHolder?.title?.compoundDrawables?.forEach {
|
||||
it?.colorFilter =
|
||||
PorterDuffColorFilter(
|
||||
fragment.requireContext().getColor(R.color.cached),
|
||||
PorterDuff.Mode.SRC_IN
|
||||
)
|
||||
rowTrackViewHolder?.artist?.setTypeface(
|
||||
rowTrackViewHolder.artist.typeface,
|
||||
Typeface.BOLD
|
||||
}
|
||||
}
|
||||
|
||||
if (track.downloaded) {
|
||||
rowTrackViewHolder?.title?.compoundDrawables?.forEach {
|
||||
it?.colorFilter =
|
||||
PorterDuffColorFilter(
|
||||
fragment.requireContext().getColor(R.color.downloaded),
|
||||
PorterDuff.Mode.SRC_IN
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
when (track.favorite) {
|
||||
true -> rowTrackViewHolder?.favorite?.setColorFilter(context.getColor(R.color.colorFavorite))
|
||||
false -> rowTrackViewHolder?.favorite?.setColorFilter(context.getColor(R.color.colorSelected))
|
||||
}
|
||||
rowTrackViewHolder?.actions?.setOnClickListener {
|
||||
PopupMenu(
|
||||
fragment.requireContext(),
|
||||
rowTrackViewHolder.actions,
|
||||
Gravity.START,
|
||||
R.attr.actionOverflowMenuStyle,
|
||||
0
|
||||
).apply {
|
||||
inflate(R.menu.row_track)
|
||||
|
||||
rowTrackViewHolder?.favorite?.setOnClickListener {
|
||||
favoriteListener?.let {
|
||||
favoriteListener.onToggleFavorite(track.id, !track.favorite)
|
||||
|
||||
tracks[position - artists.size - albums.size - sectionCount].favorite =
|
||||
!track.favorite
|
||||
|
||||
notifyItemChanged(position)
|
||||
setOnMenuItemClickListener {
|
||||
when (it.itemId) {
|
||||
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.track_add_to_playlist -> CommandBus.send(
|
||||
Command.AddToPlaylist(listOf(track))
|
||||
)
|
||||
R.id.queue_remove -> CommandBus.send(Command.RemoveFromQueue(track))
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
when (track.cached || track.downloaded) {
|
||||
true -> rowTrackViewHolder?.title?.setCompoundDrawablesWithIntrinsicBounds(
|
||||
R.drawable.downloaded, 0, 0, 0
|
||||
)
|
||||
false -> rowTrackViewHolder?.title?.setCompoundDrawablesWithIntrinsicBounds(
|
||||
0, 0, 0, 0
|
||||
)
|
||||
}
|
||||
|
||||
if (track.cached && !track.downloaded) {
|
||||
rowTrackViewHolder?.title?.compoundDrawables?.forEach {
|
||||
it?.colorFilter =
|
||||
PorterDuffColorFilter(context.getColor(R.color.cached), PorterDuff.Mode.SRC_IN)
|
||||
}
|
||||
}
|
||||
|
||||
if (track.downloaded) {
|
||||
rowTrackViewHolder?.title?.compoundDrawables?.forEach {
|
||||
it?.colorFilter =
|
||||
PorterDuffColorFilter(context.getColor(R.color.downloaded), PorterDuff.Mode.SRC_IN)
|
||||
}
|
||||
}
|
||||
|
||||
rowTrackViewHolder?.actions?.setOnClickListener {
|
||||
PopupMenu(
|
||||
context,
|
||||
rowTrackViewHolder.actions,
|
||||
Gravity.START,
|
||||
R.attr.actionOverflowMenuStyle,
|
||||
0
|
||||
).apply {
|
||||
inflate(R.menu.row_track)
|
||||
|
||||
setOnMenuItemClickListener {
|
||||
when (it.itemId) {
|
||||
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.track_add_to_playlist -> CommandBus.send(Command.AddToPlaylist(listOf(track)))
|
||||
R.id.queue_remove -> CommandBus.send(Command.RemoveFromQueue(track))
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
show()
|
||||
}
|
||||
}
|
||||
show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -313,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
|
||||
|
@ -332,13 +351,13 @@ class SearchAdapter(
|
|||
ResultType.Artist.ordinal -> {
|
||||
val position = layoutPosition - 1
|
||||
|
||||
listener?.onArtistClick(view, artists[position])
|
||||
listener.onArtistClick(view, artists[position])
|
||||
}
|
||||
|
||||
ResultType.Album.ordinal -> {
|
||||
val position = layoutPosition - artists.size - 2
|
||||
|
||||
listener?.onAlbumClick(view, albums[position])
|
||||
listener.onAlbumClick(view, albums[position])
|
||||
}
|
||||
|
||||
ResultType.Track.ordinal -> {
|
||||
|
|
|
@ -18,25 +18,24 @@ 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.Track
|
||||
import audio.funkwhale.ffa.utils.Command
|
||||
import audio.funkwhale.ffa.utils.CommandBus
|
||||
import audio.funkwhale.ffa.model.Track
|
||||
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
|
||||
|
||||
class TracksAdapter(
|
||||
private val layoutInflater: LayoutInflater,
|
||||
private val context: Context?,
|
||||
private val favoriteListener: OnFavoriteListener? = null,
|
||||
private val favoriteListener: FavoriteListener,
|
||||
val fromQueue: Boolean = false
|
||||
) : FFAAdapter<Track, TracksAdapter.ViewHolder>() {
|
||||
|
||||
interface OnFavoriteListener {
|
||||
fun onToggleFavorite(id: Int, state: Boolean)
|
||||
init {
|
||||
this.stateRestorationPolicy = StateRestorationPolicy.PREVENT_WHEN_EMPTY
|
||||
}
|
||||
|
||||
private lateinit var binding: RowTrackBinding
|
||||
|
@ -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)
|
||||
|
@ -97,7 +95,7 @@ class TracksAdapter(
|
|||
}
|
||||
|
||||
holder.favorite.setOnClickListener {
|
||||
favoriteListener?.let {
|
||||
favoriteListener.let {
|
||||
favoriteListener.onToggleFavorite(track.id, !track.favorite)
|
||||
|
||||
data[position].favorite = !track.favorite
|
||||
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
@ -14,7 +14,8 @@ import audio.funkwhale.ffa.databinding.DialogAddToPlaylistBinding
|
|||
import audio.funkwhale.ffa.model.Playlist
|
||||
import audio.funkwhale.ffa.model.Track
|
||||
import audio.funkwhale.ffa.repositories.ManagementPlaylistsRepository
|
||||
import audio.funkwhale.ffa.utils.*
|
||||
import audio.funkwhale.ffa.utils.FFACache
|
||||
import audio.funkwhale.ffa.utils.untilNetwork
|
||||
import com.google.gson.Gson
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers.IO
|
||||
|
@ -80,19 +81,22 @@ object AddToPlaylistDialog {
|
|||
}
|
||||
|
||||
val adapter =
|
||||
PlaylistsAdapter(layoutInflater, activity, object : PlaylistsAdapter.OnPlaylistClickListener {
|
||||
override fun onClick(holder: View?, playlist: Playlist) {
|
||||
repository.add(playlist.id, tracks)
|
||||
PlaylistsAdapter(
|
||||
layoutInflater, activity,
|
||||
object : PlaylistsAdapter.OnPlaylistClickListener {
|
||||
override fun onClick(holder: View?, playlist: Playlist) {
|
||||
repository.add(playlist.id, tracks)
|
||||
|
||||
Toast.makeText(
|
||||
activity,
|
||||
activity.getString(R.string.playlist_added_to, playlist.name),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
Toast.makeText(
|
||||
activity,
|
||||
activity.getString(R.string.playlist_added_to, playlist.name),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
|
||||
dialog.dismiss()
|
||||
dialog.dismiss()
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
binding.playlists.layoutManager = LinearLayoutManager(activity)
|
||||
binding.playlists.adapter = adapter
|
||||
|
@ -102,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
|
||||
|
@ -120,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,32 +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.*
|
||||
import com.squareup.picasso.Picasso
|
||||
import audio.funkwhale.ffa.utils.Command
|
||||
import audio.funkwhale.ffa.utils.CommandBus
|
||||
import audio.funkwhale.ffa.utils.CoverArt
|
||||
import audio.funkwhale.ffa.utils.maybeNormalizeUrl
|
||||
import com.preference.PowerPreference
|
||||
import jp.wasabeef.picasso.transformations.RoundedCornersTransformation
|
||||
import kotlinx.coroutines.Dispatchers.IO
|
||||
import kotlinx.coroutines.Dispatchers.Main
|
||||
|
@ -40,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(
|
||||
|
@ -120,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
|
||||
}
|
||||
|
||||
|
@ -132,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()
|
||||
|
@ -141,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() {
|
||||
|
@ -189,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.repositories.AlbumsRepository
|
||||
import audio.funkwhale.ffa.model.Album
|
||||
import audio.funkwhale.ffa.utils.AppContext
|
||||
import audio.funkwhale.ffa.repositories.AlbumsRepository
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,17 +10,34 @@ import androidx.recyclerview.widget.SimpleItemAnimator
|
|||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
import audio.funkwhale.ffa.repositories.HttpUpstream
|
||||
import audio.funkwhale.ffa.repositories.Repository
|
||||
import audio.funkwhale.ffa.utils.*
|
||||
import audio.funkwhale.ffa.utils.Event
|
||||
import audio.funkwhale.ffa.utils.EventBus
|
||||
import audio.funkwhale.ffa.utils.FFACache
|
||||
import audio.funkwhale.ffa.utils.untilNetwork
|
||||
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)
|
||||
|
@ -29,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
|
||||
}
|
||||
|
@ -45,6 +62,8 @@ abstract class FFAFragment<D : Any, A : FFAAdapter<D, *>>() : Fragment() {
|
|||
private var moreLoading = false
|
||||
private var listener: Job? = null
|
||||
|
||||
fun <T> repository() = repository as T
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
|
@ -125,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 {
|
||||
|
@ -145,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) {
|
||||
|
@ -156,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,27 +1,38 @@
|
|||
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
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
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
|
||||
import audio.funkwhale.ffa.utils.*
|
||||
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.Request
|
||||
import audio.funkwhale.ffa.utils.RequestBus
|
||||
import audio.funkwhale.ffa.utils.Response
|
||||
import audio.funkwhale.ffa.utils.getMetadata
|
||||
import audio.funkwhale.ffa.utils.wait
|
||||
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)
|
||||
|
||||
|
@ -33,8 +44,8 @@ class FavoritesFragment : FFAFragment<Track, FavoritesAdapter>() {
|
|||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
adapter = FavoritesAdapter(layoutInflater, context, FavoriteListener())
|
||||
repository = FavoritesRepository(context)
|
||||
adapter = FavoritesAdapter(layoutInflater, context, FavoriteListener(repository()))
|
||||
watchEventBus()
|
||||
}
|
||||
|
||||
|
@ -45,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
|
||||
}
|
||||
|
||||
|
@ -64,41 +89,46 @@ class FavoritesFragment : FFAFragment<Track, FavoritesAdapter>() {
|
|||
}
|
||||
}
|
||||
|
||||
refreshFavoritedTracks()
|
||||
refreshDownloadedTracks()
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshFavoritedTracks() {
|
||||
lifecycleScope.launch(Main) {
|
||||
update()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun refreshDownloadedTracks() {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
@ -109,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)
|
||||
}
|
||||
}
|
||||
|
@ -126,15 +156,4 @@ class FavoritesFragment : FFAFragment<Track, FavoritesAdapter>() {
|
|||
adapter.notifyDataSetChanged()
|
||||
}
|
||||
}
|
||||
|
||||
inner class FavoriteListener : FavoritesAdapter.OnFavoriteListener {
|
||||
override fun onToggleFavorite(id: Int, state: Boolean) {
|
||||
(repository as? FavoritesRepository)?.let { repository ->
|
||||
when (state) {
|
||||
true -> repository.addFavorite(id)
|
||||
false -> repository.deleteFavorite(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,8 +7,10 @@ import android.view.ViewGroup
|
|||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import audio.funkwhale.ffa.adapters.FavoriteListener
|
||||
import audio.funkwhale.ffa.adapters.TracksAdapter
|
||||
import audio.funkwhale.ffa.databinding.PartialQueueBinding
|
||||
import audio.funkwhale.ffa.repositories.FavoritesRepository
|
||||
import audio.funkwhale.ffa.utils.Command
|
||||
import audio.funkwhale.ffa.utils.CommandBus
|
||||
import audio.funkwhale.ffa.utils.Event
|
||||
|
@ -26,11 +28,15 @@ class LandscapeQueueFragment : Fragment() {
|
|||
private var _binding: PartialQueueBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
|
||||
lateinit var favoritesRepository: FavoritesRepository
|
||||
|
||||
private var adapter: TracksAdapter? = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
favoritesRepository = FavoritesRepository(context)
|
||||
|
||||
watchEventBus()
|
||||
}
|
||||
|
||||
|
@ -40,8 +46,14 @@ class LandscapeQueueFragment : Fragment() {
|
|||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
_binding = PartialQueueBinding.inflate(inflater)
|
||||
|
||||
return binding.root.apply {
|
||||
adapter = TracksAdapter(layoutInflater, context, fromQueue = true).also {
|
||||
adapter = TracksAdapter(
|
||||
layoutInflater,
|
||||
context,
|
||||
fromQueue = true,
|
||||
favoriteListener = FavoriteListener(favoritesRepository)
|
||||
).also {
|
||||
binding.queue.layoutManager = LinearLayoutManager(context)
|
||||
binding.queue.adapter = it
|
||||
}
|
||||
|
@ -80,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()) {
|
||||
|
@ -98,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,68 +6,56 @@ 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
|
||||
import audio.funkwhale.ffa.repositories.ManagementPlaylistsRepository
|
||||
import audio.funkwhale.ffa.repositories.PlaylistTracksRepository
|
||||
import audio.funkwhale.ffa.utils.*
|
||||
import com.squareup.picasso.Picasso
|
||||
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.maybeNormalizeUrl
|
||||
import audio.funkwhale.ffa.utils.toast
|
||||
import audio.funkwhale.ffa.utils.wait
|
||||
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(), 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()
|
||||
}
|
||||
|
||||
|
@ -92,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() {
|
||||
|
@ -122,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")
|
||||
}
|
||||
|
||||
|
@ -158,40 +145,42 @@ 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 ->
|
||||
val imageView = when (index) {
|
||||
0 -> binding.coverTopLeft
|
||||
1 -> binding.coverTopRight
|
||||
2 -> binding.coverBottomLeft
|
||||
3 -> binding.coverBottomRight
|
||||
else -> binding.coverTopLeft
|
||||
}
|
||||
data.map { it.track.album }
|
||||
.toSet()
|
||||
.map { it?.cover() }
|
||||
.take(4)
|
||||
.forEachIndexed { index, url ->
|
||||
val imageView = when (index) {
|
||||
0 -> binding.coverTopLeft
|
||||
1 -> binding.coverTopRight
|
||||
2 -> binding.coverBottomLeft
|
||||
3 -> binding.coverBottomRight
|
||||
else -> binding.coverTopLeft
|
||||
}
|
||||
|
||||
val corner = when (index) {
|
||||
0 -> RoundedCornersTransformation.CornerType.TOP_LEFT
|
||||
1 -> RoundedCornersTransformation.CornerType.TOP_RIGHT
|
||||
2 -> RoundedCornersTransformation.CornerType.BOTTOM_LEFT
|
||||
3 -> RoundedCornersTransformation.CornerType.BOTTOM_RIGHT
|
||||
else -> RoundedCornersTransformation.CornerType.TOP_LEFT
|
||||
}
|
||||
val corner = when (index) {
|
||||
0 -> RoundedCornersTransformation.CornerType.TOP_LEFT
|
||||
1 -> RoundedCornersTransformation.CornerType.TOP_RIGHT
|
||||
2 -> RoundedCornersTransformation.CornerType.BOTTOM_LEFT
|
||||
3 -> RoundedCornersTransformation.CornerType.BOTTOM_RIGHT
|
||||
else -> RoundedCornersTransformation.CornerType.TOP_LEFT
|
||||
}
|
||||
|
||||
|
||||
lifecycleScope.launch(Main) {
|
||||
Picasso.get()
|
||||
.maybeLoad(maybeNormalizeUrl(url))
|
||||
.fit()
|
||||
.centerCrop()
|
||||
.transform(RoundedCornersTransformation(16, 0, corner))
|
||||
.into(imageView)
|
||||
lifecycleScope.launch(Main) {
|
||||
CoverArt.requestCreator(maybeNormalizeUrl(url))
|
||||
.fit()
|
||||
.centerCrop()
|
||||
.transform(RoundedCornersTransformation(16, 0, corner))
|
||||
.into(imageView)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -204,23 +193,14 @@ class PlaylistTracksFragment : FFAFragment<PlaylistTrack, PlaylistTracksAdapter>
|
|||
}
|
||||
}
|
||||
|
||||
inner class FavoriteListener : PlaylistTracksAdapter.OnFavoriteListener {
|
||||
override fun onToggleFavorite(id: Int, state: Boolean) {
|
||||
when (state) {
|
||||
true -> favoritesRepository.addFavorite(id)
|
||||
false -> favoritesRepository.deleteFavorite(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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.repositories.PlaylistsRepository
|
||||
import audio.funkwhale.ffa.utils.AppContext
|
||||
import audio.funkwhale.ffa.model.Playlist
|
||||
import audio.funkwhale.ffa.repositories.PlaylistsRepository
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,6 +9,7 @@ import androidx.fragment.app.DialogFragment
|
|||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import audio.funkwhale.ffa.R
|
||||
import audio.funkwhale.ffa.adapters.FavoriteListener
|
||||
import audio.funkwhale.ffa.adapters.TracksAdapter
|
||||
import audio.funkwhale.ffa.databinding.FragmentQueueBinding
|
||||
import audio.funkwhale.ffa.repositories.FavoritesRepository
|
||||
|
@ -49,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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -62,7 +65,12 @@ class QueueFragment : BottomSheetDialogFragment() {
|
|||
): View {
|
||||
_binding = FragmentQueueBinding.inflate(inflater)
|
||||
return binding.root.apply {
|
||||
adapter = TracksAdapter(layoutInflater, context, FavoriteListener(), fromQueue = true).also {
|
||||
adapter = TracksAdapter(
|
||||
layoutInflater,
|
||||
context,
|
||||
FavoriteListener(favoritesRepository),
|
||||
fromQueue = true
|
||||
).also {
|
||||
binding.included.queue.layoutManager = LinearLayoutManager(context)
|
||||
binding.included.queue.adapter = it
|
||||
}
|
||||
|
@ -94,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()) {
|
||||
|
@ -114,6 +122,11 @@ class QueueFragment : BottomSheetDialogFragment() {
|
|||
}
|
||||
}
|
||||
}
|
||||
if (scroll) {
|
||||
RequestBus.send(Request.GetCurrentTrackIndex).wait<Response.CurrentTrackIndex>()?.let { sresp ->
|
||||
binding.included.queue.scrollToPosition(sresp.index)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -121,27 +134,18 @@ 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inner class FavoriteListener : TracksAdapter.OnFavoriteListener {
|
||||
override fun onToggleFavorite(id: Int, state: Boolean) {
|
||||
when (state) {
|
||||
true -> favoritesRepository.addFavorite(id)
|
||||
false -> favoritesRepository.deleteFavorite(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,12 +9,12 @@ import androidx.lifecycle.lifecycleScope
|
|||
import androidx.recyclerview.widget.RecyclerView
|
||||
import audio.funkwhale.ffa.adapters.RadiosAdapter
|
||||
import audio.funkwhale.ffa.databinding.FragmentRadiosBinding
|
||||
import audio.funkwhale.ffa.model.Radio
|
||||
import audio.funkwhale.ffa.repositories.RadiosRepository
|
||||
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.model.Radio
|
||||
import kotlinx.coroutines.Dispatchers.Main
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.launch
|
||||
|
@ -62,12 +62,11 @@ class RadiosFragment : FFAFragment<Radio, RadiosAdapter>() {
|
|||
|
||||
lifecycleScope.launch(Main) {
|
||||
EventBus.get().collect { message ->
|
||||
when (message) {
|
||||
is Event.RadioStarted ->
|
||||
recycler.forEach {
|
||||
it.isEnabled = true
|
||||
it.isClickable = true
|
||||
}
|
||||
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,32 +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.*
|
||||
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.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
|
||||
|
@ -44,38 +53,14 @@ 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") ?: ""
|
||||
}
|
||||
|
||||
adapter = TracksAdapter(layoutInflater, context, FavoriteListener())
|
||||
repository = TracksRepository(context, albumId)
|
||||
favoritesRepository = FavoritesRepository(context)
|
||||
favoritedRepository = FavoritedRepository(context)
|
||||
repository = TracksRepository(context, args.album.id)
|
||||
|
||||
adapter = TracksAdapter(layoutInflater, context, FavoriteListener(favoritesRepository))
|
||||
|
||||
watchEventBus()
|
||||
}
|
||||
|
@ -116,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
|
||||
}
|
||||
|
||||
|
@ -127,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() {
|
||||
|
@ -175,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")
|
||||
}
|
||||
|
||||
|
@ -199,8 +188,10 @@ class TracksFragment : FFAFragment<Track, TracksAdapter>() {
|
|||
|
||||
setOnMenuItemClickListener {
|
||||
when (it.itemId) {
|
||||
R.id.play_secondary -> when (PowerPreference.getDefaultFile()
|
||||
.getString("play_order")) {
|
||||
R.id.play_secondary -> when (
|
||||
PowerPreference.getDefaultFile()
|
||||
.getString("play_order")
|
||||
) {
|
||||
"in_order" -> CommandBus.send(Command.ReplaceQueue(adapter.data.shuffled()))
|
||||
else -> CommandBus.send(Command.ReplaceQueue(adapter.data))
|
||||
}
|
||||
|
@ -229,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -248,10 +239,12 @@ class TracksFragment : FFAFragment<Track, TracksAdapter>() {
|
|||
val downloaded = TracksRepository.getDownloadedIds(exoDownloadManager) ?: listOf()
|
||||
|
||||
withContext(Main) {
|
||||
adapter.data = adapter.data.map {
|
||||
it.downloaded = downloaded.contains(it.id)
|
||||
it
|
||||
}.toMutableList()
|
||||
adapter.setUnfilteredData(
|
||||
adapter.data.map {
|
||||
it.downloaded = downloaded.contains(it.id)
|
||||
it
|
||||
}.toMutableList()
|
||||
)
|
||||
|
||||
adapter.notifyDataSetChanged()
|
||||
}
|
||||
|
@ -281,13 +274,4 @@ class TracksFragment : FFAFragment<Track, TracksAdapter>() {
|
|||
adapter.notifyDataSetChanged()
|
||||
}
|
||||
}
|
||||
|
||||
inner class FavoriteListener : TracksAdapter.OnFavoriteListener {
|
||||
override fun onToggleFavorite(id: Int, state: Boolean) {
|
||||
when (state) {
|
||||
true -> favoritesRepository.addFavorite(id)
|
||||
false -> favoritesRepository.deleteFavorite(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,13 +1,12 @@
|
|||
package audio.funkwhale.ffa.koin
|
||||
|
||||
import android.content.Context
|
||||
import audio.funkwhale.ffa.R
|
||||
import audio.funkwhale.ffa.playback.CacheDataSourceFactoryProvider
|
||||
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
|
||||
|
@ -20,7 +19,7 @@ import org.koin.dsl.module
|
|||
fun exoplayerModule(context: Context) = module {
|
||||
|
||||
single<DatabaseProvider>(named("exoDatabase")) {
|
||||
ExoDatabaseProvider(context)
|
||||
StandaloneDatabaseProvider(context)
|
||||
}
|
||||
|
||||
single {
|
||||
|
@ -65,4 +64,4 @@ fun exoplayerModule(context: Context) = module {
|
|||
fun authModule() = module {
|
||||
single { OAuth(get()) }
|
||||
single { AuthorizationServiceFactory() }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -6,4 +6,4 @@ data class AlbumsResponse(
|
|||
val results: AlbumList
|
||||
) : FFAResponse<Album>() {
|
||||
override fun getData() = results
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,4 +6,4 @@ data class ArtistsResponse(
|
|||
val results: List<Artist>
|
||||
) : FFAResponse<Artist>() {
|
||||
override fun getData() = results
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,8 +5,9 @@ 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)
|
||||
class FavoritedCache(data: List<Int>) : CacheItem<Int>(data)
|
||||
class QueueCache(data: List<Track>) : CacheItem<Track>(data)
|
||||
class QueueCache(data: List<Track>) : CacheItem<Track>(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
|
||||
|
|
|
@ -8,4 +8,4 @@ data class DownloadInfo(
|
|||
val title: String,
|
||||
val artist: String,
|
||||
var download: Download?
|
||||
)
|
||||
)
|
||||
|
|
|
@ -5,4 +5,4 @@ abstract class FFAResponse<D : Any> {
|
|||
abstract val next: String?
|
||||
|
||||
abstract fun getData(): List<D>
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
|
@ -1,3 +1,3 @@
|
|||
package audio.funkwhale.ffa.model
|
||||
|
||||
data class Favorited(val track: Int)
|
||||
data class Favorited(val track: Int)
|
||||
|
|
|
@ -6,4 +6,4 @@ data class FavoritedResponse(
|
|||
val results: List<Favorited>
|
||||
) : FFAResponse<Int>() {
|
||||
override fun getData() = results.map { it.track }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,3 +1,3 @@
|
|||
package audio.funkwhale.ffa.model
|
||||
|
||||
data class PlaylistTrack(val track: Track)
|
||||
data class PlaylistTrack(val track: Track)
|
||||
|
|
|
@ -6,4 +6,4 @@ data class PlaylistTracksResponse(
|
|||
val results: List<PlaylistTrack>
|
||||
) : FFAResponse<PlaylistTrack>() {
|
||||
override fun getData() = results
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,4 +6,4 @@ data class PlaylistsResponse(
|
|||
val results: List<Playlist>
|
||||
) : FFAResponse<Playlist>() {
|
||||
override fun getData() = results
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,4 +6,4 @@ data class Radio(
|
|||
val name: String,
|
||||
val description: String,
|
||||
var related_object_id: String? = null
|
||||
)
|
||||
)
|
||||
|
|
|
@ -6,4 +6,4 @@ data class RadiosResponse(
|
|||
val results: List<Radio>
|
||||
) : FFAResponse<Radio>() {
|
||||
override fun getData() = results
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,4 +4,4 @@ interface SearchResult {
|
|||
fun cover(): String?
|
||||
fun title(): String
|
||||
fun subtitle(): String
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,16 +67,32 @@ 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
|
||||
|
||||
val formatted: String get() = "$id $artist ($album): $title"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,4 +6,4 @@ data class TracksResponse(
|
|||
val results: List<Track>
|
||||
) : FFAResponse<Track>() {
|
||||
override fun getData() = results
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,7 +5,7 @@ import audio.funkwhale.ffa.R
|
|||
import audio.funkwhale.ffa.utils.OAuth
|
||||
import audio.funkwhale.ffa.utils.Settings
|
||||
import com.google.android.exoplayer2.upstream.DataSource
|
||||
import com.google.android.exoplayer2.upstream.DefaultHttpDataSourceFactory
|
||||
import com.google.android.exoplayer2.upstream.DefaultHttpDataSource
|
||||
import com.google.android.exoplayer2.upstream.FileDataSource
|
||||
import com.google.android.exoplayer2.upstream.cache.Cache
|
||||
import com.google.android.exoplayer2.upstream.cache.CacheDataSource
|
||||
|
@ -33,13 +33,13 @@ class CacheDataSourceFactoryProvider(
|
|||
}
|
||||
|
||||
private fun createDatasourceFactory(context: Context, oAuth: OAuth): DataSource.Factory {
|
||||
val http = DefaultHttpDataSourceFactory(
|
||||
Util.getUserAgent(context, context.getString(R.string.app_name))
|
||||
)
|
||||
val http = DefaultHttpDataSource.Factory().apply {
|
||||
setUserAgent(Util.getUserAgent(context, context.getString(R.string.app_name)))
|
||||
}
|
||||
return if (!Settings.isAnonymous()) {
|
||||
OAuth2DatasourceFactory(context, http, oAuth)
|
||||
} else {
|
||||
http
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
@ -12,16 +13,20 @@ import androidx.media.app.NotificationCompat.MediaStyle
|
|||
import androidx.media.session.MediaButtonReceiver
|
||||
import audio.funkwhale.ffa.R
|
||||
import audio.funkwhale.ffa.activities.MainActivity
|
||||
import audio.funkwhale.ffa.utils.AppContext
|
||||
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,13 +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
|
||||
}
|
||||
|
@ -60,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
|
||||
|
@ -75,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)
|
||||
}
|
||||
|
||||
|
|
|
@ -3,7 +3,11 @@ package audio.funkwhale.ffa.playback
|
|||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import audio.funkwhale.ffa.utils.OAuth
|
||||
import com.google.android.exoplayer2.upstream.*
|
||||
import com.google.android.exoplayer2.upstream.DataSource
|
||||
import com.google.android.exoplayer2.upstream.DataSpec
|
||||
import com.google.android.exoplayer2.upstream.DefaultHttpDataSource
|
||||
import com.google.android.exoplayer2.upstream.HttpDataSource
|
||||
import com.google.android.exoplayer2.upstream.TransferListener
|
||||
|
||||
class OAuthDatasource(
|
||||
private val context: Context,
|
||||
|
@ -38,7 +42,7 @@ class OAuthDatasource(
|
|||
|
||||
class OAuth2DatasourceFactory(
|
||||
private val context: Context,
|
||||
private val http: DefaultHttpDataSourceFactory,
|
||||
private val http: DefaultHttpDataSource.Factory,
|
||||
private val oauth: OAuth
|
||||
) : DataSource.Factory {
|
||||
|
||||
|
|
|
@ -7,7 +7,13 @@ import androidx.core.net.toUri
|
|||
import audio.funkwhale.ffa.R
|
||||
import audio.funkwhale.ffa.model.DownloadInfo
|
||||
import audio.funkwhale.ffa.model.Track
|
||||
import audio.funkwhale.ffa.utils.*
|
||||
import audio.funkwhale.ffa.utils.AppContext
|
||||
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.mustNormalizeUrl
|
||||
import com.google.android.exoplayer2.offline.Download
|
||||
import com.google.android.exoplayer2.offline.DownloadManager
|
||||
import com.google.android.exoplayer2.offline.DownloadRequest
|
||||
|
@ -18,10 +24,9 @@ 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.*
|
||||
import java.util.Collections
|
||||
|
||||
class PinService : DownloadService(AppContext.NOTIFICATION_DOWNLOADS) {
|
||||
|
||||
|
@ -29,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)
|
||||
|
@ -42,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()
|
||||
|
@ -57,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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -66,20 +72,28 @@ class PinService : DownloadService(AppContext.NOTIFICATION_DOWNLOADS) {
|
|||
return super.onStartCommand(intent, flags, startId)
|
||||
}
|
||||
|
||||
override fun getDownloadManager() = exoDownloadManager.apply {
|
||||
addListener(DownloadListener())
|
||||
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,23 +12,41 @@ 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
|
||||
import audio.funkwhale.ffa.R
|
||||
import audio.funkwhale.ffa.model.Track
|
||||
import audio.funkwhale.ffa.utils.*
|
||||
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.HeadphonesUnpluggedReceiver
|
||||
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.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 kotlinx.coroutines.*
|
||||
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.flow.collect
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.koin.java.KoinJavaComponent.inject
|
||||
|
||||
class PlayerService : Service() {
|
||||
|
@ -48,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()
|
||||
|
||||
|
@ -97,15 +115,17 @@ class PlayerService : Service() {
|
|||
|
||||
Build.VERSION_CODES.O.onApi {
|
||||
audioFocusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN).run {
|
||||
setAudioAttributes(AudioAttributes.Builder().run {
|
||||
setUsage(AudioAttributes.USAGE_MEDIA)
|
||||
setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
|
||||
setAudioAttributes(
|
||||
AudioAttributes.Builder().run {
|
||||
setUsage(AudioAttributes.USAGE_MEDIA)
|
||||
setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
|
||||
|
||||
setAcceptsDelayedFocusGain(true)
|
||||
setOnAudioFocusChangeListener(audioFocusChangeListener)
|
||||
setAcceptsDelayedFocusGain(true)
|
||||
setOnAudioFocusChangeListener(audioFocusChangeListener)
|
||||
|
||||
build()
|
||||
})
|
||||
build()
|
||||
}
|
||||
)
|
||||
|
||||
build()
|
||||
}
|
||||
|
@ -113,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
|
||||
|
@ -132,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())
|
||||
|
||||
val (current, duration, percent) = getProgress(true)
|
||||
|
||||
ProgressBus.send(current, duration, percent)
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -152,61 +179,60 @@ class PlayerService : Service() {
|
|||
private fun watchEventBus() {
|
||||
scope.launch(Main) {
|
||||
CommandBus.get().collect { command ->
|
||||
when (command) {
|
||||
is Command.RefreshService -> {
|
||||
if (queue.metadata.isNotEmpty()) {
|
||||
CommandBus.send(Command.RefreshTrack(queue.current()))
|
||||
EventBus.send(Event.StateChanged(player.playWhenReady))
|
||||
}
|
||||
}
|
||||
|
||||
is Command.ReplaceQueue -> {
|
||||
if (!command.fromRadio) radioPlayer.stop()
|
||||
|
||||
queue.replace(command.queue)
|
||||
player.prepare(queue.dataSources, true, true)
|
||||
|
||||
setPlaybackState(true)
|
||||
|
||||
if (command is Command.RefreshService) {
|
||||
if (queue.metadata.isNotEmpty()) {
|
||||
CommandBus.send(Command.RefreshTrack(queue.current()))
|
||||
EventBus.send(Event.StateChanged(player.playWhenReady))
|
||||
}
|
||||
} else if (command is Command.ReplaceQueue) {
|
||||
if (!command.fromRadio) radioPlayer.stop()
|
||||
|
||||
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)
|
||||
queue.replace(command.queue)
|
||||
player.setMediaSource(queue.dataSources)
|
||||
player.prepare()
|
||||
|
||||
is Command.PlayTrack -> {
|
||||
queue.current = command.index
|
||||
player.seekTo(command.index, C.TIME_UNSET)
|
||||
setPlaybackState(true)
|
||||
|
||||
setPlaybackState(true)
|
||||
CommandBus.send(Command.RefreshTrack(queue.current()))
|
||||
} 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)
|
||||
|
||||
CommandBus.send(Command.RefreshTrack(queue.current()))
|
||||
}
|
||||
setPlaybackState(true)
|
||||
|
||||
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 -> {
|
||||
queue.clear()
|
||||
player.stop()
|
||||
}
|
||||
is Command.ShuffleQueue -> queue.shuffle()
|
||||
|
||||
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 {
|
||||
CommandBus.send(Command.RefreshTrack(queue.current()))
|
||||
} 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()
|
||||
} else if (command is Command.ShuffleQueue) {
|
||||
queue.shuffle()
|
||||
} else if (command is Command.PlayRadio) {
|
||||
queue.clear()
|
||||
radioPlayer.play(command.radio)
|
||||
} 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
|
||||
|
@ -218,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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -268,7 +298,8 @@ class PlayerService : Service() {
|
|||
{
|
||||
@Suppress("DEPRECATION")
|
||||
audioManager.abandonAudioFocus(audioFocusChangeListener)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
player.removeListener(playerEventListener)
|
||||
setPlaybackState(false)
|
||||
|
@ -283,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)) {
|
||||
|
@ -298,7 +330,7 @@ class PlayerService : Service() {
|
|||
}
|
||||
|
||||
private fun togglePlayback() {
|
||||
setPlaybackState(!player.playWhenReady)
|
||||
setPlaybackState(!player.isPlaying)
|
||||
}
|
||||
|
||||
private fun skipToPreviousTrack() {
|
||||
|
@ -306,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)
|
||||
}
|
||||
|
||||
|
@ -353,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()
|
||||
}
|
||||
|
@ -398,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))
|
||||
|
||||
|
@ -409,59 +459,56 @@ class PlayerService : Service() {
|
|||
CommandBus.send(Command.RefreshTrack(queue.current()))
|
||||
}
|
||||
|
||||
when (playWhenReady) {
|
||||
true -> {
|
||||
when (playbackState) {
|
||||
Player.STATE_READY -> mediaControlsManager.updateNotification(queue.current(), true)
|
||||
Player.STATE_BUFFERING -> EventBus.send(Event.Buffering(true))
|
||||
Player.STATE_ENDED -> {
|
||||
setPlaybackState(false)
|
||||
if (!playWhenReady) {
|
||||
Build.VERSION_CODES.N.onApi(
|
||||
{ stopForeground(STOP_FOREGROUND_DETACH) },
|
||||
{ stopForeground(false) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
queue.current = 0
|
||||
player.seekTo(0, C.TIME_UNSET)
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
super.onPlaybackStateChanged(playbackState)
|
||||
|
||||
ProgressBus.send(0, 0, 0)
|
||||
}
|
||||
when (playbackState) {
|
||||
Player.STATE_BUFFERING -> {
|
||||
EventBus.send(Event.Buffering(true))
|
||||
}
|
||||
Player.STATE_ENDED -> {
|
||||
setPlaybackState(false)
|
||||
|
||||
Player.STATE_IDLE -> {
|
||||
setPlaybackState(false)
|
||||
queue.current = 0
|
||||
player.seekTo(0, C.TIME_UNSET)
|
||||
|
||||
return EventBus.send(Event.PlaybackStopped)
|
||||
}
|
||||
}
|
||||
|
||||
if (playbackState != Player.STATE_BUFFERING) EventBus.send(Event.Buffering(false))
|
||||
ProgressBus.send(0, 0, 0)
|
||||
}
|
||||
|
||||
false -> {
|
||||
EventBus.send(Event.Buffering(false))
|
||||
Player.STATE_IDLE -> {
|
||||
setPlaybackState(false)
|
||||
|
||||
Build.VERSION_CODES.N.onApi(
|
||||
{ stopForeground(STOP_FOREGROUND_DETACH) },
|
||||
{ stopForeground(false) }
|
||||
)
|
||||
EventBus.send(Event.PlaybackStopped)
|
||||
|
||||
when (playbackState) {
|
||||
Player.STATE_READY -> mediaControlsManager.updateNotification(queue.current(), false)
|
||||
Player.STATE_IDLE -> mediaControlsManager.remove()
|
||||
if (!player.playWhenReady) {
|
||||
mediaControlsManager.remove()
|
||||
}
|
||||
}
|
||||
|
||||
Player.STATE_READY -> {
|
||||
EventBus.send(Event.Buffering(false))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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()) {
|
||||
|
@ -471,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()))
|
||||
}
|
||||
|
@ -490,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()))
|
||||
}
|
||||
|
|
|
@ -4,8 +4,15 @@ import android.content.Context
|
|||
import android.net.Uri
|
||||
import audio.funkwhale.ffa.model.QueueCache
|
||||
import audio.funkwhale.ffa.model.Track
|
||||
import audio.funkwhale.ffa.utils.*
|
||||
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.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
|
||||
|
@ -22,23 +29,25 @@ 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)
|
||||
|
||||
dataSources.addMediaSources(metadata.map { track ->
|
||||
val url = mustNormalizeUrl(track.bestUpload()?.listen_url ?: "")
|
||||
dataSources.addMediaSources(
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -46,7 +55,7 @@ class QueueManager(val context: Context) {
|
|||
FFACache.set(
|
||||
context,
|
||||
"queue",
|
||||
Gson().toJson(QueueCache(metadata)).toByteArray()
|
||||
Gson().toJson(QueueCache(metadata)).toString()
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -55,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()
|
||||
|
@ -76,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)
|
||||
|
@ -93,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)
|
||||
}
|
||||
|
@ -156,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()
|
||||
|
|
|
@ -6,7 +6,16 @@ import audio.funkwhale.ffa.model.Radio
|
|||
import audio.funkwhale.ffa.model.Track
|
||||
import audio.funkwhale.ffa.repositories.FavoritedRepository
|
||||
import audio.funkwhale.ffa.repositories.Repository
|
||||
import audio.funkwhale.ffa.utils.*
|
||||
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.authorize
|
||||
import audio.funkwhale.ffa.utils.logError
|
||||
import audio.funkwhale.ffa.utils.mustNormalizeUrl
|
||||
import audio.funkwhale.ffa.utils.toast
|
||||
import com.github.kittinunf.fuel.Fuel
|
||||
import com.github.kittinunf.fuel.coroutines.awaitObjectResponseResult
|
||||
import com.github.kittinunf.fuel.coroutines.awaitObjectResult
|
||||
|
@ -44,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
|
||||
|
@ -98,13 +107,14 @@ 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) {
|
||||
e.logError()
|
||||
withContext(Main) {
|
||||
context.toast(context.getString(R.string.radio_playback_error))
|
||||
}
|
||||
|
|
|
@ -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())
|
||||
}
|
||||
|
|
|
@ -1,8 +1,19 @@
|
|||
package audio.funkwhale.ffa.repositories
|
||||
|
||||
import android.content.Context
|
||||
import audio.funkwhale.ffa.model.*
|
||||
import audio.funkwhale.ffa.utils.*
|
||||
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.FavoritesCache
|
||||
import audio.funkwhale.ffa.utils.FFACache
|
||||
import audio.funkwhale.ffa.utils.OAuth
|
||||
import audio.funkwhale.ffa.utils.Settings
|
||||
import audio.funkwhale.ffa.utils.authorize
|
||||
import audio.funkwhale.ffa.utils.maybeNormalizeUrl
|
||||
import audio.funkwhale.ffa.utils.mustNormalizeUrl
|
||||
import audio.funkwhale.ffa.utils.untilNetwork
|
||||
import com.github.kittinunf.fuel.Fuel
|
||||
import com.github.kittinunf.fuel.coroutines.awaitByteArrayResponseResult
|
||||
import com.github.kittinunf.fuel.gson.gsonDeserializerOf
|
||||
|
@ -16,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"))
|
||||
|
@ -26,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
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -116,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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,7 +4,14 @@ import android.content.Context
|
|||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import audio.funkwhale.ffa.model.FFAResponse
|
||||
import audio.funkwhale.ffa.utils.*
|
||||
import audio.funkwhale.ffa.utils.AppContext
|
||||
import audio.funkwhale.ffa.utils.Event
|
||||
import audio.funkwhale.ffa.utils.EventBus
|
||||
import audio.funkwhale.ffa.utils.OAuth
|
||||
import audio.funkwhale.ffa.utils.RefreshError
|
||||
import audio.funkwhale.ffa.utils.Settings
|
||||
import audio.funkwhale.ffa.utils.authorize
|
||||
import audio.funkwhale.ffa.utils.mustNormalizeUrl
|
||||
import com.github.kittinunf.fuel.Fuel
|
||||
import com.github.kittinunf.fuel.core.FuelError
|
||||
import com.github.kittinunf.fuel.core.ResponseDeserializable
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -1,7 +1,11 @@
|
|||
package audio.funkwhale.ffa.repositories
|
||||
|
||||
import android.content.Context
|
||||
import audio.funkwhale.ffa.model.*
|
||||
import audio.funkwhale.ffa.model.FFAResponse
|
||||
import audio.funkwhale.ffa.model.Playlist
|
||||
import audio.funkwhale.ffa.model.PlaylistsCache
|
||||
import audio.funkwhale.ffa.model.PlaylistsResponse
|
||||
import audio.funkwhale.ffa.model.Track
|
||||
import audio.funkwhale.ffa.utils.OAuth
|
||||
import audio.funkwhale.ffa.utils.Settings
|
||||
import audio.funkwhale.ffa.utils.authorize
|
||||
|
@ -15,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)
|
||||
|
||||
|
@ -34,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?) :
|
||||
|
@ -54,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 {
|
||||
|
@ -104,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 {
|
||||
|
@ -118,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")
|
||||
}
|
||||
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 {
|
||||
|
@ -139,7 +143,8 @@ class ManagementPlaylistsRepository(override val context: Context?) :
|
|||
.body(Gson().toJson(body))
|
||||
.awaitByteArrayResponseResult()
|
||||
}
|
||||
} else {
|
||||
throw IllegalStateException("Illegal state: context is null")
|
||||
}
|
||||
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
|
||||
|
|
|
@ -1,14 +1,16 @@
|
|||
package audio.funkwhale.ffa.repositories
|
||||
|
||||
import android.content.Context
|
||||
import audio.funkwhale.ffa.utils.AppContext
|
||||
import audio.funkwhale.ffa.model.CacheItem
|
||||
import audio.funkwhale.ffa.utils.AppContext
|
||||
import audio.funkwhale.ffa.utils.FFACache
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers.IO
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.*
|
||||
import java.io.BufferedReader
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlin.math.ceil
|
||||
|
||||
interface Upstream<D> {
|
||||
|
@ -30,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,
|
||||
|
@ -42,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,
|
||||
|
@ -59,7 +61,7 @@ abstract class Repository<D : Any, C : CacheItem<D>> {
|
|||
}
|
||||
}.flowOn(IO)
|
||||
|
||||
private fun fromNetwork(size: Int) = flow {
|
||||
private fun fromNetwork(size: Int): Flow<Response<D>> = flow {
|
||||
upstream
|
||||
.fetch(size)
|
||||
.map { response ->
|
||||
|
|
|
@ -1,8 +1,17 @@
|
|||
package audio.funkwhale.ffa.repositories
|
||||
|
||||
import android.content.Context
|
||||
import audio.funkwhale.ffa.model.*
|
||||
import audio.funkwhale.ffa.utils.*
|
||||
import audio.funkwhale.ffa.model.Album
|
||||
import audio.funkwhale.ffa.model.AlbumsCache
|
||||
import audio.funkwhale.ffa.model.AlbumsResponse
|
||||
import audio.funkwhale.ffa.model.Artist
|
||||
import audio.funkwhale.ffa.model.ArtistsCache
|
||||
import audio.funkwhale.ffa.model.ArtistsResponse
|
||||
import audio.funkwhale.ffa.model.Track
|
||||
import audio.funkwhale.ffa.model.TracksCache
|
||||
import audio.funkwhale.ffa.model.TracksResponse
|
||||
import audio.funkwhale.ffa.utils.OAuth
|
||||
import audio.funkwhale.ffa.utils.mustNormalizeUrl
|
||||
import com.github.kittinunf.fuel.gson.gsonDeserializerOf
|
||||
import com.google.android.exoplayer2.offline.DownloadManager
|
||||
import com.google.android.exoplayer2.upstream.cache.Cache
|
||||
|
@ -12,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>() {
|
||||
|
@ -33,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)
|
||||
|
@ -75,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) :
|
||||
|
@ -95,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()
|
||||
|
||||
while (cursor.moveToNext()) {
|
||||
val download = cursor.download
|
||||
|
||||
download.getMetadata()?.let {
|
||||
if (download.state == Download.STATE_COMPLETED) {
|
||||
ids.add(it.id)
|
||||
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,27 +3,23 @@ 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
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
import java.util.Date
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
|
||||
inline fun <D> Flow<Repository.Response<D>>.untilNetwork(
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
@ -106,4 +92,59 @@ 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 {
|
||||
with(File(it.cacheDir, key(key))) {
|
||||
writeBytes(value)
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -19,4 +19,4 @@ data class FuelResult(val httpStatus: Int? = null, val message: String? = null)
|
|||
return FuelResult()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -13,7 +13,16 @@ import com.github.kittinunf.fuel.gson.jsonBody
|
|||
import com.github.kittinunf.result.Result
|
||||
import com.preference.PowerPreference
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import net.openid.appauth.*
|
||||
import net.openid.appauth.AuthState
|
||||
import net.openid.appauth.AuthorizationException
|
||||
import net.openid.appauth.AuthorizationRequest
|
||||
import net.openid.appauth.AuthorizationResponse
|
||||
import net.openid.appauth.AuthorizationService
|
||||
import net.openid.appauth.AuthorizationServiceConfiguration
|
||||
import net.openid.appauth.ClientSecretPost
|
||||
import net.openid.appauth.RegistrationRequest
|
||||
import net.openid.appauth.RegistrationResponse
|
||||
import net.openid.appauth.ResponseTypeValues
|
||||
|
||||
fun AuthState.save() {
|
||||
PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).apply {
|
||||
|
@ -56,13 +65,14 @@ class OAuth(private val authorizationServiceFactory: AuthorizationServiceFactory
|
|||
|
||||
fun isAuthorized(context: Context): Boolean {
|
||||
val state = tryState()
|
||||
return (if (state != null) {
|
||||
state.validAuthorization() || refreshAccessToken(state, context)
|
||||
} else {
|
||||
false
|
||||
}).also {
|
||||
it.logInfo("isAuthorized()")
|
||||
}
|
||||
return (
|
||||
if (state != null) {
|
||||
state.validAuthorization() || refreshAccessToken(state, context)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
)
|
||||
.also { it.logInfo("isAuthorized()") }
|
||||
}
|
||||
|
||||
private fun AuthState.validAuthorization() = this.isAuthorized && !this.needsTokenRefresh
|
||||
|
@ -73,7 +83,7 @@ class OAuth(private val authorizationServiceFactory: AuthorizationServiceFactory
|
|||
refreshAccessToken(state, context)
|
||||
} else {
|
||||
state.isAuthorized
|
||||
}.also { it.logInfo("tryRefreshAccessToken()") }
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
@ -87,15 +97,23 @@ 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 ->
|
||||
state.apply {
|
||||
Log.i("OAuth", "applying new authState")
|
||||
update(response, e)
|
||||
save()
|
||||
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)
|
||||
save()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
refreshService.dispose()
|
||||
true
|
||||
} else {
|
||||
false
|
||||
|
@ -167,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)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -191,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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue