Compare commits

...

133 Commits

Author SHA1 Message Date
Maxence G
e284cd1fa1
Merge remote-tracking branch 'base/develop' into AddShortcutCapability 2023-05-21 14:35:53 +02:00
Maxence G
9f4858becd
Merge remote-tracking branch 'base/develop' into AddShortcutCapability 2023-05-21 14:34:51 +02:00
birdbird
0f18b20fa3 Merge branch 'cur' into 'develop'
Merge 4.4.1 to dev

See merge request ultrasonic/ultrasonic!1024
2023-05-21 12:18:11 +00:00
birdbird
a8961e8e96 Merge 4.4.1 to dev 2023-05-21 12:18:11 +00:00
birdbird
291528a309 Merge branch 'selectQuality' into 'develop'
Add setting to control the max bitrate when pinning

Closes #894

See merge request ultrasonic/ultrasonic!1022
2023-05-20 19:38:21 +00:00
birdbird
2a7cdbeded Merge branch 'fixStars' into 'develop'
Fix StarRating when setting a rating through AutoMediaController

See merge request ultrasonic/ultrasonic!1023
2023-05-20 19:37:27 +00:00
birdbird
c09739cea4 Fix StarRating when setting a rating through AutoMediaController 2023-05-20 19:37:27 +00:00
tzugen
315271390f
Add setting to control the max bitrate when pinning 2023-05-20 21:28:28 +02:00
birdbird
2df8d049d0 Merge branch 'Binder' into 'develop'
Refactor rating controls in Session

Closes #1235

See merge request ultrasonic/ultrasonic!1020
2023-05-20 14:32:27 +00:00
birdbird
0643b1bd1c Refactor rating controls in Session 2023-05-20 14:32:27 +00:00
birdbird
2b1291ae51 Merge branch 'userdata' into 'develop'
Add hasFragileUserData=true

See merge request ultrasonic/ultrasonic!1021
2023-05-20 13:38:13 +00:00
birdbird
5ec0d8a96b Add hasFragileUserData=true 2023-05-20 13:38:12 +00:00
birdbird
71168983b6 Merge branch 'cast' into 'develop'
Use the JukeboxPlayer as a Player instead of an Controller

See merge request ultrasonic/ultrasonic!1019
2023-05-19 21:37:31 +00:00
birdbird
bdcb1a505b Use the JukeboxPlayer as a Player instead of an Controller 2023-05-19 21:37:31 +00:00
birdbird
238d91c167 Merge branch 'renovate/media3' into 'develop'
Update media3 to v1.0.2

See merge request ultrasonic/ultrasonic!1013
2023-05-18 13:45:04 +00:00
birdbird
376748b298 Merge branch 'detekt' into 'develop'
Use default locations for Detekt config and baseline.

See merge request ultrasonic/ultrasonic!1017
2023-05-18 10:54:16 +00:00
birdbird
13091948ea Use default locations for Detekt config and baseline. 2023-05-18 10:54:16 +00:00
birdbird
0cfd8e8240 Merge branch 'prefs' into 'develop'
Modernize Activity launching to set custom cache location

See merge request ultrasonic/ultrasonic!1015
2023-05-18 10:32:17 +00:00
birdbird
7a17936855 Modernize Activity launching to set custom cache location 2023-05-18 10:32:17 +00:00
birdbird
1d7328c03e Merge branch 'kotlim' into 'develop'
Apply suggested Kotlin Gradle updates

See merge request ultrasonic/ultrasonic!1016
2023-05-18 10:29:10 +00:00
birdbird
76da209c6d Merge branch 'renovate/robolectric' into 'develop'
Update dependency org.robolectric:robolectric to v4.10.3

See merge request ultrasonic/ultrasonic!1014
2023-05-18 10:26:19 +00:00
tzugen
ddd9c29d7a
Apply suggested Kotlin Gradle updates 2023-05-18 12:22:40 +02:00
Renovate Bot
fe696943a4 Update dependency org.robolectric:robolectric to v4.10.3 2023-05-17 21:32:36 +00:00
Renovate Bot
a5bfc08264 Update media3 to v1.0.2 2023-05-17 17:31:43 +00:00
birdbird
4c2c7252c3 Merge branch 'showLoading' into 'develop'
Add loading indicator to playlist view

See merge request ultrasonic/ultrasonic!1011
2023-05-16 18:54:13 +00:00
tzugen
b5dd0fdca2
Add loading indicator to playlist view 2023-05-16 20:39:17 +02:00
birdbird
a7ee33c7c0 Merge branch 'id3' into 'develop'
Clarify the naming around the ID3 settings and methods,

See merge request ultrasonic/ultrasonic!1010
2023-05-16 18:09:04 +00:00
birdbird
7b56017844 Clarify the naming around the ID3 settings and methods, 2023-05-16 18:09:03 +00:00
birdbird
0fb345dd24 Merge branch 'revertMaterial' into 'develop'
Revert Material to 1.8.0

See merge request ultrasonic/ultrasonic!1008
2023-05-16 15:58:21 +00:00
tzugen
4faf2db11f
Revert Material to 1.8.0 2023-05-16 17:56:14 +02:00
birdbird
c118bd70f9 Update README.md 2023-05-16 15:47:03 +00:00
birdbird
b0e850d17e Merge branch 'playlistSorting' into 'develop'
Don't sort playlists even when Sort by Disc is activated

Closes #1229

See merge request ultrasonic/ultrasonic!1007
2023-05-16 10:42:25 +00:00
tzugen
a97c6e15e9
Don't sort playlists even when Sort by Disc is activated 2023-05-16 09:59:20 +02:00
birdbird
d084a35316 Merge branch 'blue' into 'develop'
Fix missing bluetooth permissions

Closes #791

See merge request ultrasonic/ultrasonic!1006
2023-05-16 07:37:37 +00:00
birdbird
e8bfa5dc04 Fix missing bluetooth permissions 2023-05-16 07:37:36 +00:00
birdbird
e729e3b063 Merge branch 'renovate/kotlinxcoroutines' into 'develop'
Update dependency org.jetbrains.kotlinx:kotlinx-coroutines-android to v1.7.1

See merge request ultrasonic/ultrasonic!1003
2023-05-15 08:20:16 +00:00
Renovate Bot
8337f4a7e4 Update dependency org.jetbrains.kotlinx:kotlinx-coroutines-android to v1.7.1 2023-05-15 08:20:16 +00:00
birdbird
0ae32c3cfc Merge branch 'renovate/okhttp' into 'develop'
Update okhttp to v4.11.0

See merge request ultrasonic/ultrasonic!976
2023-05-15 08:08:44 +00:00
birdbird
49f3bd27ed Merge branch 'renovate/materialdesign' into 'develop'
Update dependency com.google.android.material:material to v1.9.0

See merge request ultrasonic/ultrasonic!990
2023-05-15 08:03:28 +00:00
birdbird
1acfa917c9 Merge branch 'renovate/androidxcore' into 'develop'
Update dependency androidx.core:core-ktx to v1.10.1

See merge request ultrasonic/ultrasonic!1001
2023-05-15 08:03:14 +00:00
birdbird
5ab2ec08f0 Merge branch 'fixCI' into 'develop'
Use fixed version of the CI image

See merge request ultrasonic/ultrasonic!1002
2023-05-14 14:28:06 +00:00
birdbird
e21477a5ee Use fixed version of the CI image 2023-05-14 14:28:05 +00:00
Renovate Bot
5daeddcc63 Update okhttp to v4.11.0 2023-05-11 05:32:21 +00:00
Renovate Bot
70d02f4493 Update dependency com.google.android.material:material to v1.9.0 2023-05-11 05:32:15 +00:00
Renovate Bot
58de991d64 Update dependency androidx.core:core-ktx to v1.10.1 2023-05-10 17:32:09 +00:00
birdbird
90ffa32246 Merge branch 'FixIDNull' into 'develop'
Fix the warning 'ID must not be null'

See merge request ultrasonic/ultrasonic!999
2023-05-09 10:08:13 +00:00
birdbird
3d94de9e46 Merge branch 'mergeback' into 'develop'
Mergeback

See merge request ultrasonic/ultrasonic!1000
2023-05-09 10:05:18 +00:00
birdbird
50aa2d0a2d Mergeback 2023-05-09 10:05:18 +00:00
tzugen
0e2171b872
Fix the warning 'ID must not be null' 2023-05-09 11:48:10 +02:00
birdbird
2c3f43f139 Merge branch 'weblate-ultrasonic-app' into 'develop'
Translations update from Hosted Weblate

See merge request ultrasonic/ultrasonic!997
2023-05-09 09:34:35 +00:00
birdbird
fd8afe0231 Merge branch 'RefactorContextActions' into 'develop'
Use Coroutines for triggering the download or playback of music through the context menus

See merge request ultrasonic/ultrasonic!998
2023-05-09 09:34:15 +00:00
birdbird
cd982814cf Use Coroutines for triggering the download or playback of music through the context menus 2023-05-09 09:34:15 +00:00
gallegonovato
338fb618b9
Translated using Weblate (Spanish)
Currently translated at 100.0% (426 of 426 strings)

Translation: Ultrasonic/app
Translate-URL: https://hosted.weblate.org/projects/ultrasonic/app/es/
2023-05-08 17:52:45 +02:00
birdbird
842cb36ecb Merge branch 'RevertJackson' into 'develop'
Revert Jackson to 2.13.5 for compatibility with older APIs

See merge request ultrasonic/ultrasonic!993
2023-05-07 15:24:16 +00:00
birdbird
e06b8bc22e Merge branch 'FixExceptions' into 'develop'
Fix a bunch of Exceptions collected through Play Store reporting

See merge request ultrasonic/ultrasonic!994
2023-05-07 15:23:57 +00:00
birdbird
82fb45bd55 Fix a bunch of Exceptions collected through Play Store reporting 2023-05-07 15:23:57 +00:00
tzugen
751b946092
Revert Jackson to 2.13.5 for compatibility with older APIs 2023-05-07 12:57:15 +02:00
birdbird
39085f68b1 Merge branch 'renovate/kotlinxguava' into 'develop'
Update dependency org.jetbrains.kotlinx:kotlinx-coroutines-guava to v1.7.0

See merge request ultrasonic/ultrasonic!992
2023-05-07 10:51:05 +00:00
Renovate Bot
1beb67c497 Update dependency org.jetbrains.kotlinx:kotlinx-coroutines-guava to v1.7.0 2023-05-07 10:32:39 +00:00
birdbird
2ba001894a Merge branch 'weblate-ultrasonic-app' into 'develop'
Translations update from Hosted Weblate

See merge request ultrasonic/ultrasonic!985
2023-05-07 10:21:22 +00:00
Kaiyang Wu
0650ce0bba
Translated using Weblate (Chinese (Traditional))
Currently translated at 54.2% (231 of 426 strings)

Translation: Ultrasonic/app
Translate-URL: https://hosted.weblate.org/projects/ultrasonic/app/zh_Hant/
2023-05-07 11:56:24 +02:00
Kaiyang Wu
218f144848
Translated using Weblate (Chinese (Traditional))
Currently translated at 53.0% (226 of 426 strings)

Translation: Ultrasonic/app
Translate-URL: https://hosted.weblate.org/projects/ultrasonic/app/zh_Hant/
2023-05-07 11:56:24 +02:00
Kaiyang Wu
83c9c188e9
Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (426 of 426 strings)

Translation: Ultrasonic/app
Translate-URL: https://hosted.weblate.org/projects/ultrasonic/app/zh_Hans/
2023-05-07 11:56:24 +02:00
birdbird
a4e8a7f94d Merge branch 'renovate/mockito-monorepo' into 'develop'
Update dependency org.mockito:mockito-core to v5.3.1

See merge request ultrasonic/ultrasonic!956
2023-05-07 09:56:19 +00:00
Renovate Bot
4f5d503ceb Update dependency org.mockito:mockito-core to v5.3.1 2023-05-07 09:56:19 +00:00
birdbird
381e2e4b86 Merge branch 'renovate/kotlinxcoroutines' into 'develop'
Update dependency org.jetbrains.kotlinx:kotlinx-coroutines-android to v1.7.0

See merge request ultrasonic/ultrasonic!991
2023-05-07 09:49:19 +00:00
birdbird
2a90fe4aab Merge branch 'master' into 'develop'
Merge changes from master back to dev. (4.3.3 release)

See merge request ultrasonic/ultrasonic!989
2023-05-07 09:47:24 +00:00
birdbird
f37301e738 Merge changes from master back to dev. (4.3.3 release) 2023-05-07 09:47:24 +00:00
birdbird
fca5ffaa0c Merge branch 'correctDefaults' into 'develop'
Correctly enable Artists pictures by default (was enabled in Settings mit not in Code)

See merge request ultrasonic/ultrasonic!988
2023-05-07 09:33:54 +00:00
Renovate Bot
a0314a865c Update dependency org.jetbrains.kotlinx:kotlinx-coroutines-android to v1.7.0 2023-05-07 09:32:30 +00:00
birdbird
5da9a2819c Merge branch 'ratingManager' into 'develop'
Introduce a RatingManager that takes care of receiving and passing ratings...

See merge request ultrasonic/ultrasonic!981
2023-05-07 09:27:24 +00:00
birdbird
2a02c94c8f Introduce a RatingManager that takes care of receiving and passing ratings... 2023-05-07 09:27:24 +00:00
tzugen
96073125ca
Correctly enable Artists pictures by default (was enabled in Settings mit not in Code) 2023-05-07 11:19:01 +02:00
birdbird
58bd663ac0 Merge branch 'renovate/junit5-monorepo' into 'develop'
Update dependency org.junit.vintage:junit-vintage-engine to v5.9.3

See merge request ultrasonic/ultrasonic!983
2023-05-07 09:16:04 +00:00
birdbird
e689193df1 Merge branch 'renovate/robolectric' into 'develop'
Update dependency org.robolectric:robolectric to v4.10.2

See merge request ultrasonic/ultrasonic!987
2023-05-07 09:15:26 +00:00
birdbird
1aa388d48f Merge branch 'renovate/kotlin-monorepo' into 'develop'
Update kotlin monorepo to v1.8.21

See merge request ultrasonic/ultrasonic!980
2023-05-07 09:15:13 +00:00
birdbird
8f84020cfa Merge branch 'renovate/kluent' into 'develop'
Update kluent to v1.73

See merge request ultrasonic/ultrasonic!984
2023-05-07 09:14:39 +00:00
birdbird
db88ff8431 Merge branch 'renovate/ktlintgradle' into 'develop'
Update dependency org.jlleitschuh.gradle:ktlint-gradle to v11.3.2

See merge request ultrasonic/ultrasonic!982
2023-05-07 09:14:12 +00:00
birdbird
2d1642170a Merge branch 'renovate/gradleplugin' into 'develop'
Update dependency com.android.tools.build:gradle to v8.0.1

See merge request ultrasonic/ultrasonic!986
2023-05-07 09:14:03 +00:00
Renovate Bot
0cb7952943 Update dependency org.robolectric:robolectric to v4.10.2 2023-05-04 19:32:12 +00:00
Renovate Bot
d750c84606 Update dependency com.android.tools.build:gradle to v8.0.1 2023-05-01 16:32:12 +00:00
Renovate Bot
7abca537c9 Update kluent to v1.73 2023-04-29 14:32:22 +00:00
Renovate Bot
ca2c5483c0 Update dependency org.junit.vintage:junit-vintage-engine to v5.9.3 2023-04-26 07:32:20 +00:00
Renovate Bot
7b414a3a23 Update dependency org.jlleitschuh.gradle:ktlint-gradle to v11.3.2 2023-04-25 14:32:15 +00:00
Renovate Bot
10767d2d5b Update kotlin monorepo to v1.8.21 2023-04-25 05:32:25 +00:00
birdbird
138db03667 Merge branch 'fixContext' into 'develop'
Fix missing context

See merge request ultrasonic/ultrasonic!977
2023-04-23 10:12:42 +00:00
tzugen
f59e039c49
Fix missing context 2023-04-23 11:55:08 +02:00
birdbird
e0679f99cf Merge branch 'renovate/gradle-8.x' into 'develop'
Update dependency gradle to v8.1.1

See merge request ultrasonic/ultrasonic!974
2023-04-22 10:25:18 +00:00
birdbird
ffb78b166b Merge branch 'rmJitpack' into 'develop'
Remove Jitpack repo (was from custom media3 build)

See merge request ultrasonic/ultrasonic!975
2023-04-22 09:51:43 +00:00
tzugen
5fcbf59e0e
Remove Jitpack repo (was from custom media3 build) 2023-04-22 11:25:31 +02:00
Renovate Bot
e62b8972e7 Update dependency gradle to v8.1.1 2023-04-21 13:34:02 +00:00
birdbird
322457910c Merge branch 'renovate/media3' into 'develop'
Update media3 to v1.0.1

See merge request ultrasonic/ultrasonic!971
2023-04-20 12:05:48 +00:00
Renovate Bot
e9b602890a Update media3 to v1.0.1 2023-04-20 11:34:31 +00:00
birdbird
08d3618eb3 Merge branch 'fixShuffle' into 'develop'
Fix shuffle

Closes #876 and #877

See merge request ultrasonic/ultrasonic!966
2023-04-20 11:25:25 +00:00
birdbird
4f59c4d3ad Fix shuffle 2023-04-20 11:25:25 +00:00
birdbird
9ca5a9257d Merge branch 'gradle8' into 'develop'
Update Gradle plugin to v8

See merge request ultrasonic/ultrasonic!973
2023-04-20 11:24:28 +00:00
birdbird
6694d6f60b Update Gradle plugin to v8 2023-04-20 11:24:28 +00:00
birdbird
df7ff21cc9 Merge branch 'weblate-ultrasonic-app' into 'develop'
Translations update from Hosted Weblate

See merge request ultrasonic/ultrasonic!972
2023-04-20 07:30:35 +00:00
Eryk Michalak
732d44cb73
Translated using Weblate (Polish)
Currently translated at 100.0% (426 of 426 strings)

Translation: Ultrasonic/app
Translate-URL: https://hosted.weblate.org/projects/ultrasonic/app/pl/
2023-04-20 08:37:09 +02:00
birdbird
76eb89f5eb Merge branch 'flamingo' into 'develop'
Sets compileJava target to 17 to work with Flamingo

See merge request ultrasonic/ultrasonic!969
2023-04-20 06:37:03 +00:00
birdbird
185762e164 Merge branch 'weblate-ultrasonic-app' into 'develop'
Translations update from Hosted Weblate

See merge request ultrasonic/ultrasonic!968
2023-04-19 09:24:36 +00:00
Óscar García Amor
33e4913761
Sets compileJava target to 17 to work with Flamingo 2023-04-18 14:34:05 +02:00
Kaiyang Wu
aede9be97c
Translated using Weblate (Chinese (Traditional))
Currently translated at 49.7% (212 of 426 strings)

Translation: Ultrasonic/app
Translate-URL: https://hosted.weblate.org/projects/ultrasonic/app/zh_Hant/
2023-04-17 21:49:30 +02:00
Kaiyang Wu
97556a36e5
Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (426 of 426 strings)

Translation: Ultrasonic/app
Translate-URL: https://hosted.weblate.org/projects/ultrasonic/app/zh_Hans/
2023-04-17 21:49:30 +02:00
birdbird
fb970ffb80 Merge branch 'renovate/gradle-8.x' into 'develop'
Update dependency gradle to v8.1

See merge request ultrasonic/ultrasonic!957
2023-04-17 15:57:52 +00:00
birdbird
6de6cda7a4 Merge branch 'renovate/robolectric' into 'develop'
Update dependency org.robolectric:robolectric to v4.10

See merge request ultrasonic/ultrasonic!955
2023-04-17 15:56:50 +00:00
birdbird
5eed5c70b5 Merge branch 'weblate-ultrasonic-app' into 'develop'
Translations update from Hosted Weblate

See merge request ultrasonic/ultrasonic!967
2023-04-17 15:56:28 +00:00
Óscar García Amor
6e1078a256
Translated using Weblate (Galician)
Currently translated at 6.3% (27 of 426 strings)

Translation: Ultrasonic/app
Translate-URL: https://hosted.weblate.org/projects/ultrasonic/app/gl/
2023-04-16 12:49:02 +02:00
Óscar García Amor
c6c58262af Merge branch 'galician' into 'develop'
Adds galician to supported languages list

See merge request ultrasonic/ultrasonic!965
2023-04-15 10:24:03 +00:00
Óscar García Amor
2df89f4d81
Adds galician to supported languages list 2023-04-15 12:15:37 +02:00
Óscar García Amor
e83026f29a Merge branch 'norwegian' into 'develop'
Adds norwegian to supported languages list

See merge request ultrasonic/ultrasonic!964
2023-04-15 10:13:49 +00:00
Óscar García Amor
233e4f7a67
Adds norwegian to supported languages list 2023-04-15 12:05:56 +02:00
Óscar García Amor
dba12d147f Merge branch 'big-in-japan' into 'develop'
Adds japanese to supported languages list

See merge request ultrasonic/ultrasonic!963
2023-04-15 10:02:35 +00:00
Óscar García Amor
ccdd994756
Adds japanese to supported languages list 2023-04-15 11:53:13 +02:00
Óscar García Amor
dfcac45669 Merge branch 'changelog' into 'develop'
Translates changelog to spanish

See merge request ultrasonic/ultrasonic!962
2023-04-15 09:14:04 +00:00
Óscar García Amor
f72fc1885c Merge branch 'weblate-ultrasonic-app' into 'develop'
Translations update from Hosted Weblate

See merge request ultrasonic/ultrasonic!961
2023-04-15 09:04:43 +00:00
Óscar García Amor
db40d95215
Translates changelog to spanish 2023-04-15 11:02:09 +02:00
aorinngoDo
c2f4b58088
Translated using Weblate (Japanese)
Currently translated at 100.0% (426 of 426 strings)

Translation: Ultrasonic/app
Translate-URL: https://hosted.weblate.org/projects/ultrasonic/app/ja/
2023-04-14 17:52:47 +02:00
birdbird
82d2596c66 Merge branch '432' into 'develop'
Release 4.3.2

See merge request ultrasonic/ultrasonic!960
2023-04-14 12:06:09 +00:00
oiu
ccd7f5881d
Release 4.3.2 2023-04-14 14:03:44 +02:00
birdbird
c7edfbcae6 Merge branch 'StrictMode' into 'develop'
Fix a bunch of StrictMode warnings by executing methods on the right threads

See merge request ultrasonic/ultrasonic!958
2023-04-14 08:01:54 +00:00
tzugen
b1839c9562
Fix a bunch of StrictMode warnings by executing methods on the right threads 2023-04-13 16:20:45 +02:00
Renovate Bot
dbef8307ea Update dependency gradle to v8.1 2023-04-12 12:34:09 +00:00
Renovate Bot
ee52070925 Update dependency org.robolectric:robolectric to v4.10 2023-04-11 16:32:18 +00:00
Óscar García Amor
a406b8d211 Merge branch 'changelog' into 'develop'
Translate changelog to spanish

See merge request ultrasonic/ultrasonic!954
2023-04-11 15:04:07 +00:00
Óscar García Amor
367c1508b5
Translate changelog to spanish 2023-04-11 16:52:06 +02:00
Óscar García Amor
e5fce6a832 Merge branch 'weblate-ultrasonic-app' into 'develop'
Translations update from Hosted Weblate

See merge request ultrasonic/ultrasonic!953
2023-04-11 14:32:06 +00:00
Weblate
6b5c96ea74 Translations update from Hosted Weblate 2023-04-11 14:32:06 +00:00
Óscar García Amor
d2faad60ca
Adds missing newline EOF 2023-04-11 16:06:48 +02:00
birdbird
2d7c26f13d Merge branch 'Changelog' into 'develop'
Format changelog correctly

See merge request ultrasonic/ultrasonic!952
2023-04-11 13:43:15 +00:00
tzugen
116e5aa4cf
Format changelog correctly 2023-04-11 15:33:59 +02:00
birdbird
3e8f45a073 Merge branch 'release431' into 'develop'
Release 4.3.1

See merge request ultrasonic/ultrasonic!951
2023-04-11 06:15:27 +00:00
birdbird
8090d4e039 Release 4.3.1 2023-04-11 06:15:27 +00:00
108 changed files with 2937 additions and 2211 deletions

View File

@ -1,5 +1,5 @@
default:
image: registry.gitlab.com/ultrasonic/ci-android:latest
image: registry.gitlab.com/ultrasonic/ci-android:1.1.0
cache: &global_cache
key:
files:

2
.idea/compiler.xml generated
View File

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="11" />
<bytecodeTargetLevel target="17" />
</component>
</project>

View File

@ -31,12 +31,6 @@ If you want to use the version downloaded from F-Droid or from GitLab with
First, see if your issue havent been yet reported [here][issues], otherwise
open [a new issue][newissue].
### Known (not our) bugs
If you are using *Madsonic 5.1.X* several sections of Ultrasonic will not
work. This is caused by bad implementation of Subsonic API by Madsonic. For
more info about this you can read [this bug][madbug].
## Contributing
See [CONTRIBUTING](CONTRIBUTING.md).
@ -62,7 +56,6 @@ Full text of the license is available in the [LICENSE](LICENSE) file and
[wikiaa]: https://gitlab.com/ultrasonic/ultrasonic/-/wikis/Using-Ultrasonic-with-Android-Auto
[issues]: https://gitlab.com/ultrasonic/ultrasonic/-/issues
[newissue]: https://gitlab.com/ultrasonic/ultrasonic/-/issues/new
[madbug]: https://gitlab.com/ultrasonic/ultrasonic/-/issues/129
[subsonic]: http://www.subsonic.org/
[subapi]: http://www.subsonic.org/pages/api.jsp
[airsonic]: https://github.com/airsonic-advanced/airsonic-advanced

View File

@ -11,7 +11,6 @@ buildscript {
google()
mavenCentral()
maven { url "https://plugins.gradle.org/m2/" }
maven { url 'https://jitpack.io' }
}
dependencies {
classpath libs.gradle
@ -34,13 +33,12 @@ allprojects {
repositories {
mavenCentral()
google()
maven { url 'https://jitpack.io' }
}
// Set Kotlin JVM target to the same for all subprojects
tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).all {
kotlinOptions {
jvmTarget = "11"
jvmTarget = "17"
}
}
}

View File

@ -2,6 +2,7 @@ apply from: bootstrap.androidModule
apply plugin: 'kotlin-kapt'
dependencies {
implementation libs.core
implementation libs.roomRuntime
implementation libs.roomKtx
kapt libs.room

View File

@ -13,7 +13,6 @@ dependencies {
testImplementation libs.kotlinJunit
testImplementation libs.mockito
testImplementation libs.mockitoInline
testImplementation libs.mockitoKotlin
testImplementation libs.kluent
testImplementation libs.mockWebServer

View File

@ -0,0 +1,4 @@
Bug fixes
- Fix a crash when a ID3 tag date is in a wrong format.
- Fix a crash on API 31 (newest Android).
- Fix empty search results.

View File

@ -0,0 +1,2 @@
Bug fixes
- Fix a crash when downloading the album art.

View File

@ -0,0 +1,8 @@
Bug fixes
- Fix various crashes
Changes since 4.2.0
- #827: Make app full compliant Android Auto to publish in Play Store.
- #878: "Play shuffled" option for playlists always begins with the first track.
- #891: Dump config to log file when logging is enabled.
- #854: Remove Videos menu option for servers which don't support it.

View File

@ -0,0 +1,8 @@
Bug fixes
- Fix more exceptions
Changes since 4.2.0
- #827: Make app full compliant Android Auto to publish in Play Store.
- #878: "Play shuffled" option for playlists always begins with the first track.
- #891: Dump config to log file when logging is enabled.
- #854: Remove Videos menu option for servers which don't support it.

View File

@ -0,0 +1,10 @@
Features:
- This releases focuses on shuffled playback. The view of the playlist will now present itself in the order it will actually play. You can toggle the shuffle mode to create a new order, while the past playback history will be preserved.
- Use Coroutines for triggering the download or playback of music through the context menus
- Enable Artists pictures by Default
Bug fixes:
- Remove an unhelpful popup that "ID must be set"
- Shuffle mode doesn't always play all tracks
- Shuffle mode starts with the first track most of the time

View File

@ -0,0 +1,10 @@
Features:
- This releases focuses on shuffled playback. The view of the playlist will now present itself in the order it will actually play. You can toggle the shuffle mode to create a new order, while the past playback history will be preserved.
- Use Coroutines for triggering the download or playback of music through the context menus
- Enable Artists pictures by Default
Bug fixes:
- Remove an unhelpful popup that "ID must be set"
- Shuffle mode doesn't always play all tracks
- Shuffle mode starts with the first track most of the time

View File

@ -3,12 +3,12 @@ Ultrasonic is a Subsonic (and compatible servers) client to Android. You can use
Main features:
* Thin
* Fast
* Dark and light theme
* Material theme with dark and light variants
* Multiple server support
* Offline Mode
* Bookmarks
* Playlists on server
* Ramdom play
* Random play
* Jukebox mode
* Server chat
* And much more!!!

View File

@ -0,0 +1,4 @@
Corrección de errores
- Corrección de un fallo cuando la fecha de una etiqueta ID3 tiene un formato incorrecto.
- Corrección de un fallo en la API 31 (versión de Android más reciente).
- Corrección de resultados de búsqueda vacíos.

View File

@ -0,0 +1,2 @@
Corrección de errores
- Corrección de un fallo al descargar la carátula del álbum.

View File

@ -4,10 +4,22 @@ org.gradle.configureondemand=true
org.gradle.caching=true
org.gradle.jvmargs=-Xmx2g -XX:+UseParallelGC
kotlin.incremental=true
kotlin.caching.enabled=true
kotlin.incremental.usePreciseJavaTracking=true
android.useAndroidX=true
android.enableJetifier=false
# This properties enables transitive Resource classes, which decreases build time,
# but could lead to problems referencing Resources. Set them to false if needed.
android.nonTransitiveRClass=true
android.nonFinalResIds=true
# This config was suggested by Android Studio to reduce build time
# It can be removed if it makes problems
org.gradle.unsafe.configuration-cache=true
# TODO Renable on day (check that Retrofit, Jackson, and Imageloader are working)
android.enableR8.fullMode=false

View File

@ -1,40 +1,40 @@
[versions]
# You need to run ./gradlew wrapper after updating the version
gradle = "7.6"
gradle = "8.1.1"
navigation = "2.5.3"
gradlePlugin = "7.4.2"
androidxcore = "1.10.0"
gradlePlugin = "8.0.1"
androidxcore = "1.10.1"
ktlint = "0.43.2"
ktlintGradle = "11.3.1"
ktlintGradle = "11.3.2"
detekt = "1.22.0"
preferences = "1.2.0"
media3 = "1.0.0"
media3 = "1.0.2"
androidSupport = "1.6.0"
materialDesign = "1.8.0"
constraintLayout = "2.1.4"
multidex = "2.0.1"
room = "2.5.1"
kotlin = "1.8.20"
kotlinxCoroutines = "1.6.4"
kotlinxGuava = "1.6.4"
kotlin = "1.8.21"
kotlinxCoroutines = "1.7.1"
viewModelKtx = "2.6.1"
swipeRefresh = "1.1.0"
retrofit = "2.9.0"
jackson = "2.14.2"
okhttp = "4.10.0"
## KEEP ON 2.13 branch (https://github.com/FasterXML/jackson-databind/issues/3658#issuecomment-1312633064) for compatibility with API 24
jackson = "2.13.5"
okhttp = "4.11.0"
koin = "3.3.2"
picasso = "2.8"
junit4 = "4.13.2"
junit5 = "5.9.2"
mockito = "5.2.0"
junit5 = "5.9.3"
mockito = "5.3.1"
mockitoKotlin = "4.1.0"
kluent = "1.72"
kluent = "1.73"
apacheCodecs = "1.15"
robolectric = "4.9.2"
robolectric = "4.10.3"
timber = "5.0.1"
fastScroll = "2.0.1"
colorPicker = "2.2.4"
@ -73,7 +73,7 @@ swipeRefresh = { module = "androidx.swiperefreshlayout:swiperefreshla
kotlinStdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" }
kotlinReflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" }
kotlinxCoroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" }
kotlinxGuava = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-guava", version.ref = "kotlinxGuava"}
kotlinxGuava = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-guava", version.ref = "kotlinxCoroutines"}
retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" }
gsonConverter = { module = "com.squareup.retrofit2:converter-gson", version.ref = "retrofit" }
jacksonConverter = { module = "com.squareup.retrofit2:converter-jackson", version.ref = "retrofit" }
@ -95,7 +95,6 @@ junitVintage = { module = "org.junit.vintage:junit-vintage-engine", v
kotlinJunit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" }
mockitoKotlin = { module = "org.mockito.kotlin:mockito-kotlin", version.ref = "mockitoKotlin" }
mockito = { module = "org.mockito:mockito-core", version.ref = "mockito" }
mockitoInline = { module = "org.mockito:mockito-inline", version.ref = "mockito" }
kluent = { module = "org.amshove.kluent:kluent", version.ref = "kluent" }
kluentAndroid = { module = "org.amshove.kluent:kluent-android", version.ref = "kluent" }
mockWebServer = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" }

Binary file not shown.

View File

@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.0.2-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.1.1-all.zip
networkTimeout=10000
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

View File

@ -2,9 +2,9 @@
* This module provides a base for for submodules which depend on the Android runtime
*/
apply plugin: 'com.android.library'
apply plugin: 'kotlin-android'
apply plugin: 'org.jetbrains.kotlin.android'
apply from: "${project.rootDir}/gradle_scripts/code_quality.gradle"
apply plugin: 'kotlin-kapt'
apply plugin: 'org.jetbrains.kotlin.kapt'
android {
compileSdkVersion versions.compileSdk
@ -16,8 +16,8 @@ android {
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_11
targetCompatibility JavaVersion.VERSION_11
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
sourceSets {

View File

@ -25,10 +25,8 @@ if (isCodeQualityEnabled) {
// Builds the AST in parallel. Rules are always executed in parallel.
// Can lead to speedups in larger projects.
parallel = true
baseline = file("${rootProject.projectDir}/detekt-baseline.xml")
config = files("${rootProject.projectDir}/detekt-config.yml")
}
}
tasks.detekt.jvmTarget = "11"
tasks.detekt.jvmTarget = "17"
}
}

View File

@ -2,7 +2,7 @@
* This module provides a base for for pure kotlin modules
*/
apply plugin: 'kotlin'
apply plugin: 'kotlin-kapt'
apply plugin: 'org.jetbrains.kotlin.kapt'
apply from: "${project.rootDir}/gradle_scripts/code_quality.gradle"
sourceSets {

7
gradlew vendored
View File

@ -85,9 +85,6 @@ done
APP_BASE_NAME=${0##*/}
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
@ -197,6 +194,10 @@ if "$cygwin" || "$msys" ; then
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command;
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
# shell script including quotes and variable substitutions, so put them in

View File

@ -1,6 +1,6 @@
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-kapt'
apply plugin: 'org.jetbrains.kotlin.android'
apply plugin: 'org.jetbrains.kotlin.kapt'
apply plugin: "androidx.navigation.safeargs.kotlin"
apply from: "../gradle_scripts/code_quality.gradle"
@ -9,12 +9,12 @@ android {
defaultConfig {
applicationId "org.moire.ultrasonic"
versionCode 113
versionName "4.3.0"
versionCode 120
versionName "4.4.1"
minSdkVersion versions.minSdk
targetSdkVersion versions.targetSdk
resConfigs 'cs', 'de', 'en', 'es', 'fr', 'hu', 'it', 'nl', 'pl', 'pt', 'pt-rBR', 'ru', 'zh-rCN', 'zh-rTW'
resConfigs 'cs', 'de', 'en', 'es', 'fr', 'gl', 'hu', 'it', 'ja', 'nb-rNO', 'nl', 'pl', 'pt', 'pt-rBR', 'ru', 'zh-rCN', 'zh-rTW'
}
bundle.language.enableSplit = false
@ -34,7 +34,7 @@ android {
minifyEnabled false
multiDexEnabled true
testCoverageEnabled true
applicationIdSuffix ".debug"
applicationIdSuffix '.debug'
}
}
@ -50,17 +50,18 @@ android {
}
kotlinOptions {
jvmTarget = "11"
jvmTarget = "17"
}
buildFeatures {
viewBinding true
dataBinding true
buildConfig true
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_11
targetCompatibility JavaVersion.VERSION_11
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kapt {
@ -136,7 +137,6 @@ dependencies {
testImplementation libs.kotlinJunit
testImplementation libs.kluent
testImplementation libs.mockito
testImplementation libs.mockitoInline
testImplementation libs.mockitoKotlin
testImplementation libs.robolectric

View File

@ -1,9 +1,9 @@
<?xml version='1.0' encoding='UTF-8'?>
<?xml version="1.0" ?>
<SmellBaseline>
<ManuallySuppressedIssues>
<ID>TooManyFunctions:PlaybackService.kt$PlaybackService : MediaLibraryServiceKoinComponentCoroutineScope</ID>
<ID>UnusedPrivateMember:UApp.kt$private fun VmPolicy.Builder.detectAllExceptSocket(): VmPolicy.Builder</ID>
<ID>ImplicitDefaultLocale:EditServerFragment.kt$EditServerFragment.&lt;no name provided>$String.format( "%s %s", resources.getString(R.string.settings_connection_failure), getErrorMessage(error) )</ID>
<ID>ImplicitDefaultLocale:EditServerFragment.kt$EditServerFragment.&lt;no name provided&gt;$String.format( "%s %s", resources.getString(R.string.settings_connection_failure), getErrorMessage(error) )</ID>
<ID>ImplicitDefaultLocale:FileLoggerTree.kt$FileLoggerTree$String.format("Failed to write log to %s", file)</ID>
<ID>ImplicitDefaultLocale:FileLoggerTree.kt$FileLoggerTree$String.format("Log file rotated, logging into file %s", file?.name)</ID>
<ID>ImplicitDefaultLocale:FileLoggerTree.kt$FileLoggerTree$String.format("Logging into file %s", file?.name)</ID>
@ -13,7 +13,7 @@
<ID>LongMethod:PlaylistsFragment.kt$PlaylistsFragment$override fun onContextItemSelected(menuItem: MenuItem): Boolean</ID>
<ID>LongMethod:ShareHandler.kt$ShareHandler$private fun showDialog( fragment: Fragment, shareDetails: ShareDetails, swipe: SwipeRefreshLayout?, cancellationToken: CancellationToken, additionalId: String? )</ID>
<ID>LongMethod:SharesFragment.kt$SharesFragment$override fun onContextItemSelected(menuItem: MenuItem): Boolean</ID>
<ID>LongParameterList:ServerRowAdapter.kt$ServerRowAdapter$( private var context: Context, passedData: Array&lt;ServerSetting>, private val model: ServerSettingsModel, private val activeServerProvider: ActiveServerProvider, private val manageMode: Boolean, private val serverDeletedCallback: ((Int) -> Unit), private val serverEditRequestedCallback: ((Int) -> Unit) )</ID>
<ID>LongParameterList:ServerRowAdapter.kt$ServerRowAdapter$( private var context: Context, passedData: Array&lt;ServerSetting&gt;, private val model: ServerSettingsModel, private val activeServerProvider: ActiveServerProvider, private val manageMode: Boolean, private val serverDeletedCallback: ((Int) -&gt; Unit), private val serverEditRequestedCallback: ((Int) -&gt; Unit) )</ID>
<ID>MagicNumber:ActiveServerProvider.kt$ActiveServerProvider$8192</ID>
<ID>MagicNumber:JukeboxMediaPlayer.kt$JukeboxMediaPlayer$0.05f</ID>
<ID>MagicNumber:JukeboxMediaPlayer.kt$JukeboxMediaPlayer$50</ID>

View File

@ -1,27 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<issues format="6" by="lint 7.4.0" type="baseline" client="gradle" dependencies="true" name="AGP (7.4.0)" variant="all" version="7.4.0">
<issue
id="MissingPermission"
message="Call requires permission which may be rejected by user: code should explicitly check to see if permission is available (with `checkPermission`) or explicitly handle a potential `SecurityException`"
errorLine1=" manager.notify(NOTIFICATION_ID, notification)"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/kotlin/org/moire/ultrasonic/service/DownloadService.kt"
line="260"
column="17"/>
</issue>
<issue
id="MissingPermission"
message="Call requires permission which may be rejected by user: code should explicitly check to see if permission is available (with `checkPermission`) or explicitly handle a potential `SecurityException`"
errorLine1=" notificationManagerCompat.notify(notification.notificationId, notification.notification)"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/kotlin/org/moire/ultrasonic/service/JukeboxMediaPlayer.kt"
line="194"
column="9"/>
</issue>
<issues format="6" by="lint 8.0.1" type="baseline" client="gradle" dependencies="true" name="AGP (8.0.1)" variant="all" version="8.0.1">
<issue
id="PluralsCandidate"
@ -30,7 +8,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="152"
line="151"
column="5"/>
</issue>
@ -48,50 +26,6 @@
file="../core/subsonic-api/build/libs/subsonic-api.jar"/>
</issue>
<issue
id="ExportedContentProvider"
message="Exported content providers can provide access to potentially sensitive data"
errorLine1=" &lt;provider"
errorLine2=" ~~~~~~~~">
<location
file="src/main/AndroidManifest.xml"
line="128"
column="10"/>
</issue>
<issue
id="ExportedContentProvider"
message="Exported content providers can provide access to potentially sensitive data"
errorLine1=" &lt;provider"
errorLine2=" ~~~~~~~~">
<location
file="src/main/AndroidManifest.xml"
line="133"
column="10"/>
</issue>
<issue
id="ExportedReceiver"
message="Exported receiver does not require permission"
errorLine1=" &lt;receiver android:name=&quot;.receiver.UltrasonicIntentReceiver&quot;"
errorLine2=" ~~~~~~~~">
<location
file="src/main/AndroidManifest.xml"
line="88"
column="10"/>
</issue>
<issue
id="ExportedService"
message="Exported service does not require permission"
errorLine1=" &lt;service android:name=&quot;.playback.PlaybackService&quot;"
errorLine2=" ~~~~~~~">
<location
file="src/main/AndroidManifest.xml"
line="77"
column="10"/>
</issue>
<issue
id="UnusedResources"
message="The resource `R.drawable.media3_notification_pause` appears to be unused"
@ -136,17 +70,6 @@
column="1"/>
</issue>
<issue
id="UnusedResources"
message="The resource `R.drawable.media3_notification_small_icon` appears to be unused"
errorLine1="&lt;vector xmlns:android=&quot;http://schemas.android.com/apk/res/android&quot;"
errorLine2="^">
<location
file="src/main/res/drawable/media3_notification_small_icon.xml"
line="1"
column="1"/>
</issue>
<issue
id="Autofill"
message="Missing `autofillHints` attribute"

View File

@ -1,10 +1,41 @@
#### From retrofit
# Retrofit does reflection on generic parameters. InnerClasses is required to use Signature and
# EnclosingMethod is required to use InnerClasses.
-keepattributes Signature, InnerClasses, EnclosingMethod
# Retain generic type information for use by reflection by converters and adapters.
-keepattributes Signature
# Retain service method parameters.
-keepclassmembernames,allowobfuscation interface * {
# Retrofit does reflection on method and parameter annotations.
-keepattributes RuntimeVisibleAnnotations, RuntimeVisibleParameterAnnotations
# Keep annotation default values (e.g., retrofit2.http.Field.encoded).
-keepattributes AnnotationDefault
# Retain service method parameters when optimizing.
-keepclassmembers,allowshrinking,allowobfuscation interface * {
@retrofit2.http.* <methods>;
}
# Ignore annotation used for build tooling.
-dontwarn org.codehaus.mojo.animal_sniffer.IgnoreJRERequirement
# Ignore JSR 305 annotations for embedding nullability information.
-dontwarn javax.annotation.**
# Guarded by a NoClassDefFoundError try/catch and only used when on the classpath.
-dontwarn kotlin.Unit
# Top-level functions that can only be used by Kotlin.
-dontwarn retrofit2.KotlinExtensions
-dontwarn retrofit2.KotlinExtensions$*
# With R8 full mode, it sees no subtypes of Retrofit interfaces since they are created with a Proxy
# and replaces all potential values with null. Explicitly keeping the interfaces prevents this.
-if interface * { @retrofit2.http.* <methods>; }
-keep,allowobfuscation interface <1>
# Keep generic signature of Call, Response (R8 full mode strips signatures from non-kept items).
-keep,allowobfuscation,allowshrinking interface retrofit2.Call
-keep,allowobfuscation,allowshrinking class retrofit2.Response
# With R8 full mode generic signatures are stripped for classes that are not
# kept. Suspend functions are wrapped in continuations where the type argument
# is used.
-keep,allowobfuscation,allowshrinking class kotlin.coroutines.Continuation

View File

@ -3,11 +3,11 @@
xmlns:tools="http://schemas.android.com/tools"
android:installLocation="auto">
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS"/>
<uses-permission android:name="android.permission.BLUETOOTH"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
@ -22,6 +22,7 @@
<application
android:allowBackup="true"
android:fullBackupContent="@xml/backup_descriptor"
android:hasFragileUserData="true" tools:targetApi="q"
android:dataExtractionRules="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
@ -66,13 +67,6 @@
android:exported="false">
</service>
<service
android:name=".service.JukeboxMediaPlayer"
android:label="Ultrasonic Jukebox Media Player Service"
android:foregroundServiceType="mediaPlayback"
android:exported="false">
</service>
<!-- Needs to be exported: https://android.googlesource.com/platform/developers/build/+/4de32d4/prebuilts/gradle/MediaBrowserService/README.md -->
<service android:name=".playback.PlaybackService"
android:label="@string/common.appname"

View File

@ -1,100 +0,0 @@
/*
This file is part of Subsonic.
Subsonic is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Subsonic is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
Copyright 2010 (C) Sindre Mehus
*/
package org.moire.ultrasonic.receiver;
import android.annotation.SuppressLint;
import android.bluetooth.BluetoothDevice;
import android.bluetooth.BluetoothProfile;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import org.moire.ultrasonic.util.Constants;
import org.moire.ultrasonic.util.Settings;
import timber.log.Timber;
/**
* Resume or pause playback on Bluetooth A2DP connect/disconnect.
*
* @author Sindre Mehus
*/
@SuppressLint("MissingPermission")
public class BluetoothIntentReceiver extends BroadcastReceiver
{
@Override
public void onReceive(Context context, Intent intent)
{
int state = intent.getIntExtra(BluetoothProfile.EXTRA_STATE, -1);
BluetoothDevice device = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE);
String action = intent.getAction();
String name = device != null ? device.getName() : "Unknown";
String address = device != null ? device.getAddress() : "Unknown";
Timber.d("A2DP State: %d; Action: %s; Device: %s; Address: %s", state, action, name, address);
boolean actionBluetoothDeviceConnected = false;
boolean actionBluetoothDeviceDisconnected = false;
boolean actionA2dpConnected = false;
boolean actionA2dpDisconnected = false;
if (BluetoothDevice.ACTION_ACL_CONNECTED.equals(action))
{
actionBluetoothDeviceConnected = true;
}
else if (BluetoothDevice.ACTION_ACL_DISCONNECTED.equals(action) || BluetoothDevice.ACTION_ACL_DISCONNECT_REQUESTED.equals(action))
{
actionBluetoothDeviceDisconnected = true;
}
if (state == android.bluetooth.BluetoothA2dp.STATE_CONNECTED) actionA2dpConnected = true;
else if (state == android.bluetooth.BluetoothA2dp.STATE_DISCONNECTED) actionA2dpDisconnected = true;
boolean resume = false;
boolean pause = false;
switch (Settings.getResumeOnBluetoothDevice())
{
case Constants.PREFERENCE_VALUE_ALL: resume = actionA2dpConnected || actionBluetoothDeviceConnected;
break;
case Constants.PREFERENCE_VALUE_A2DP: resume = actionA2dpConnected;
break;
}
switch (Settings.getPauseOnBluetoothDevice())
{
case Constants.PREFERENCE_VALUE_ALL: pause = actionA2dpDisconnected || actionBluetoothDeviceDisconnected;
break;
case Constants.PREFERENCE_VALUE_A2DP: pause = actionA2dpDisconnected;
break;
}
if (resume)
{
Timber.i("Connected to Bluetooth device %s address %s, resuming playback.", name, address);
context.sendBroadcast(new Intent(Constants.CMD_RESUME_OR_PLAY).setPackage(context.getPackageName()));
}
if (pause)
{
Timber.i("Disconnected from Bluetooth device %s address %s, requesting pause.", name, address);
context.sendBroadcast(new Intent(Constants.CMD_PAUSE).setPackage(context.getPackageName()));
}
}
}

View File

@ -0,0 +1,128 @@
/*
* BluetoothIntentReceiver.kt
* Copyright (C) 2009-2022 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
package org.moire.ultrasonic.receiver
import android.Manifest
import android.bluetooth.BluetoothA2dp
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothDevice.ACTION_ACL_CONNECTED
import android.bluetooth.BluetoothDevice.ACTION_ACL_DISCONNECTED
import android.bluetooth.BluetoothDevice.ACTION_ACL_DISCONNECT_REQUESTED
import android.bluetooth.BluetoothProfile
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.app.ActivityCompat
import org.moire.ultrasonic.app.UApp
import org.moire.ultrasonic.util.Constants
import org.moire.ultrasonic.util.Constants.PREFERENCE_VALUE_A2DP
import org.moire.ultrasonic.util.Constants.PREFERENCE_VALUE_ALL
import org.moire.ultrasonic.util.Constants.PREFERENCE_VALUE_DISABLED
import org.moire.ultrasonic.util.Settings
import timber.log.Timber
/**
* Resume or pause playback on Bluetooth A2DP connect/disconnect.
*/
class BluetoothIntentReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val state = intent.getIntExtra(BluetoothProfile.EXTRA_STATE, -1)
val device = intent.getBluetoothDevice()
val action = intent.action
// Whether to log the name of the bluetooth device
val name = device.getNameSafely()
Timber.d("Bluetooth device: $name; State: $state; Action: $action")
// In these flags we store what kind of device (any or a2dp) has (dis)connected
var connectionStatus = PREFERENCE_VALUE_DISABLED
var disconnectionStatus = PREFERENCE_VALUE_DISABLED
// First check for general devices
when (action) {
ACTION_ACL_CONNECTED -> {
connectionStatus = PREFERENCE_VALUE_ALL
}
ACTION_ACL_DISCONNECTED,
ACTION_ACL_DISCONNECT_REQUESTED -> {
disconnectionStatus = PREFERENCE_VALUE_ALL
}
}
// Then check for A2DP devices
when (state) {
BluetoothA2dp.STATE_CONNECTED -> {
connectionStatus = PREFERENCE_VALUE_A2DP
}
BluetoothA2dp.STATE_DISCONNECTED -> {
disconnectionStatus = PREFERENCE_VALUE_A2DP
}
}
// Flags to store which action should be performed
var shouldResume = false
var shouldPause = false
// Now check the settings and set the appropriate flags
when (Settings.resumeOnBluetoothDevice) {
PREFERENCE_VALUE_ALL -> {
shouldResume = (connectionStatus != PREFERENCE_VALUE_DISABLED)
}
PREFERENCE_VALUE_A2DP -> {
shouldResume = (connectionStatus == PREFERENCE_VALUE_A2DP)
}
}
when (Settings.pauseOnBluetoothDevice) {
PREFERENCE_VALUE_ALL -> {
shouldPause = (disconnectionStatus != PREFERENCE_VALUE_DISABLED)
}
PREFERENCE_VALUE_A2DP -> {
shouldPause = (disconnectionStatus == PREFERENCE_VALUE_A2DP)
}
}
if (shouldResume) {
Timber.i("Connected to Bluetooth device $name; Resuming playback.")
context.sendBroadcast(
Intent(Constants.CMD_RESUME_OR_PLAY)
.setPackage(context.packageName)
)
}
if (shouldPause) {
Timber.i("Disconnected from Bluetooth device $name; Requesting pause.")
context.sendBroadcast(
Intent(Constants.CMD_PAUSE)
.setPackage(context.packageName)
)
}
}
}
private fun BluetoothDevice?.getNameSafely(): String? {
val logBluetoothName = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&
(
ActivityCompat.checkSelfPermission(
UApp.applicationContext(), Manifest.permission.BLUETOOTH_CONNECT
) != PackageManager.PERMISSION_GRANTED
)
return if (logBluetoothName) this?.name else "Unknown"
}
private fun Intent.getBluetoothDevice(): BluetoothDevice? {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
getParcelableExtra(BluetoothDevice.EXTRA_DEVICE, BluetoothDevice::class.java)
} else {
@Suppress("DEPRECATION")
getParcelableExtra(BluetoothDevice.EXTRA_DEVICE)
}
}

View File

@ -1,39 +0,0 @@
/*
This file is part of Subsonic.
Subsonic is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Subsonic is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
Copyright 2009 (C) Sindre Mehus
*/
package org.moire.ultrasonic.util;
import android.os.Binder;
/**
* @author Sindre Mehus
*/
public class SimpleServiceBinder<S> extends Binder
{
private final S service;
public SimpleServiceBinder(S service)
{
this.service = service;
}
public S getService()
{
return service;
}
}

View File

@ -46,7 +46,7 @@ public class GenreAdapter extends ArrayAdapter<Genre> implements SectionIndexer
private final Object[] sections;
private final Integer[] positions;
public GenreAdapter(Context context, List<Genre> genres)
public GenreAdapter(@NonNull Context context, List<Genre> genres)
{
super(context, R.layout.list_item_generic, genres);

View File

@ -20,7 +20,6 @@ import android.os.Build
import android.os.Bundle
import android.provider.MediaStore
import android.provider.SearchRecentSuggestions
import android.view.KeyEvent
import android.view.Menu
import android.view.MenuItem
import android.view.View
@ -59,9 +58,9 @@ import org.moire.ultrasonic.data.ServerSettingDao
import org.moire.ultrasonic.fragment.OnBackPressedHandler
import org.moire.ultrasonic.model.ServerSettingsModel
import org.moire.ultrasonic.provider.SearchSuggestionProvider
import org.moire.ultrasonic.service.MediaPlayerController
import org.moire.ultrasonic.service.MediaPlayerLifecycleSupport
import org.moire.ultrasonic.service.MusicServiceFactory
import org.moire.ultrasonic.service.MediaPlayerManager
import org.moire.ultrasonic.service.RxBus
import org.moire.ultrasonic.service.plusAssign
import org.moire.ultrasonic.subsonic.DownloadHandler
@ -104,7 +103,7 @@ class NavigationActivity : AppCompatActivity() {
private val serverSettingsModel: ServerSettingsModel by viewModel()
private val lifecycleSupport: MediaPlayerLifecycleSupport by inject()
private val mediaPlayerController: MediaPlayerController by inject()
private val mediaPlayerManager: MediaPlayerManager by inject()
private val activeServerProvider: ActiveServerProvider by inject()
private val serverRepository: ServerSettingDao by inject()
@ -284,18 +283,6 @@ class NavigationActivity : AppCompatActivity() {
}
}
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
val isVolumeDown = keyCode == KeyEvent.KEYCODE_VOLUME_DOWN
val isVolumeUp = keyCode == KeyEvent.KEYCODE_VOLUME_UP
val isVolumeAdjust = isVolumeDown || isVolumeUp
val isJukebox = mediaPlayerController.isJukeboxEnabled
if (isVolumeAdjust && isJukebox) {
mediaPlayerController.adjustVolume(isVolumeUp)
return true
}
return super.onKeyDown(keyCode, event)
}
@RequiresApi(Build.VERSION_CODES.N_MR1)
private fun setupAppShortcut() {
@ -337,7 +324,7 @@ class NavigationActivity : AppCompatActivity() {
}
R.id.menu_exit -> {
setResult(Constants.RESULT_CLOSE_ALL)
mediaPlayerController.onDestroy()
mediaPlayerManager.onDestroy()
finish()
exit()
}
@ -415,9 +402,14 @@ class NavigationActivity : AppCompatActivity() {
val service = MusicServiceFactory.getMusicService()
val musicDirectory = service.getRandomSongs(Settings.maxSongs)
val downloadHandler: DownloadHandler by inject()
downloadHandler.download(
currentFragment, append = false, save = false, autoPlay = true, playNext = false,
shuffle = false, songs = musicDirectory.getTracks(), playlistName = null
downloadHandler.addTracksToMediaController(
songs = musicDirectory.getTracks(),
append = false,
playNext = false,
autoPlay = true,
shuffle = false,
fragment = currentFragment,
playlistName = null
)
return
}
@ -516,9 +508,9 @@ class NavigationActivity : AppCompatActivity() {
}
if (nowPlayingView != null) {
val playerState: Int = mediaPlayerController.playbackState
val playerState: Int = mediaPlayerManager.playbackState
if (playerState == STATE_BUFFERING || playerState == STATE_READY) {
val item: MediaItem? = mediaPlayerController.currentMediaItem
val item: MediaItem? = mediaPlayerManager.currentMediaItem
if (item != null) {
nowPlayingView?.visibility = View.VISIBLE
}

View File

@ -15,17 +15,17 @@ import android.view.ViewGroup
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import androidx.media3.common.HeartRating
import androidx.recyclerview.widget.RecyclerView
import com.drakeet.multitype.ItemViewDelegate
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.moire.ultrasonic.R
import org.moire.ultrasonic.data.RatingUpdate
import org.moire.ultrasonic.domain.Album
import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService
import org.moire.ultrasonic.service.RxBus
import org.moire.ultrasonic.subsonic.ImageLoaderProvider
import org.moire.ultrasonic.util.LayoutType
import org.moire.ultrasonic.util.Settings.shouldUseId3Tags
import timber.log.Timber
/**
* Creates a Row in a RecyclerView which contains the details of an Album
@ -112,28 +112,14 @@ open class AlbumRowDelegate(
private fun onStarClick(entry: Album, star: ImageView) {
entry.starred = !entry.starred
star.setImageResource(if (entry.starred) starDrawable else starHollowDrawable)
val musicService = getMusicService()
Thread {
val useId3 = shouldUseId3Tags
try {
if (entry.starred) {
musicService.star(
if (!useId3) entry.id else null,
if (useId3) entry.id else null,
null
RxBus.ratingSubmitter.onNext(
RatingUpdate(
entry.id,
HeartRating(entry.starred)
)
} else {
musicService.unstar(
if (!useId3) entry.id else null,
if (useId3) entry.id else null,
null
)
}
} catch (all: Exception) {
Timber.e(all)
}
}.start()
}
override fun onCreateViewHolder(context: Context, parent: ViewGroup): ListViewHolder {
return when (layoutType) {

View File

@ -115,7 +115,7 @@ class ArtistRowBinder(
}
private fun showArtistPicture(): Boolean {
return ActiveServerProvider.isID3Enabled() && Settings.shouldShowArtistPicture
return ActiveServerProvider.shouldUseId3Tags() && Settings.shouldShowArtistPicture
}
/**

View File

@ -51,11 +51,14 @@ class HeaderViewBinder(
val resources = context.resources
val artworkSelection = random.nextInt(item.childCount)
val size = Util.getAlbumImageSize(context)
imageLoaderProvider.executeOn {
it.loadImage(
holder.coverArtView, item.entries[artworkSelection], false,
Util.getAlbumImageSize(context)
holder.coverArtView,
item.entries[artworkSelection],
false,
size
)
}

View File

@ -62,8 +62,6 @@ class TrackViewBinder(
diffAdapter.isSelected(item.longId)
)
// Timber.v("Setting listeners")
holder.itemView.setOnLongClickListener {
if (onContextMenuClick != null) {
val popup = createContextMenu(holder.itemView, track)
@ -116,8 +114,6 @@ class TrackViewBinder(
if (newStatus != holder.check.isChecked) holder.check.isChecked = newStatus
}
// Timber.v("Setting listeners done")
}
override fun onViewRecycled(holder: TrackViewHolder) {

View File

@ -10,6 +10,8 @@ import androidx.core.content.ContextCompat
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.lifecycle.MutableLiveData
import androidx.media3.common.HeartRating
import androidx.media3.common.StarRating
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.progressindicator.CircularProgressIndicator
import io.reactivex.rxjava3.disposables.CompositeDisposable
@ -19,10 +21,10 @@ import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import org.moire.ultrasonic.R
import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.data.RatingUpdate
import org.moire.ultrasonic.domain.Track
import org.moire.ultrasonic.service.DownloadService
import org.moire.ultrasonic.service.DownloadState
import org.moire.ultrasonic.service.MusicServiceFactory
import org.moire.ultrasonic.service.RxBus
import org.moire.ultrasonic.service.plusAssign
import org.moire.ultrasonic.util.Settings
@ -81,7 +83,6 @@ class TrackViewHolder(val view: View) :
draggable: Boolean,
isSelected: Boolean = false
) {
// Timber.v("Setting song")
val useFiveStarRating = Settings.useFiveStarRating
entry = song
@ -118,9 +119,9 @@ class TrackViewHolder(val view: View) :
}
if (useFiveStarRating) {
setFiveStars(entry?.userRating ?: 0)
updateFiveStars(entry?.userRating ?: 0)
} else {
setSingleStar(entry!!.starred)
updateSingleStar(entry!!.starred)
}
if (song.isVideo) {
@ -131,7 +132,7 @@ class TrackViewHolder(val view: View) :
// Create new Disposable for the new Subscriptions
rxBusSubscription = CompositeDisposable()
rxBusSubscription!! += RxBus.playerStateObservable.subscribe {
setPlayIcon(it.index == bindingAdapterPosition && it.track?.id == song.id)
setPlayIcon(it.track?.id == song.id && it.index == bindingAdapterPosition)
}
rxBusSubscription!! += RxBus.trackDownloadStateObservable.subscribe {
@ -139,7 +140,17 @@ class TrackViewHolder(val view: View) :
updateStatus(it.state, it.progress)
}
// Timber.v("Setting song done")
// Listen for rating updates
rxBusSubscription!! += RxBus.ratingPublishedObservable.subscribe {
// Ignore updates which are not for the current song
if (it.id != song.id) return@subscribe
if (it.rating is HeartRating) {
updateSingleStar(it.rating.isHeart)
} else if (it.rating is StarRating) {
updateFiveStars(it.rating.starRating.toInt())
}
}
}
// This is called when the Holder is recycled and receives a new Song
@ -165,48 +176,32 @@ class TrackViewHolder(val view: View) :
}
}
private fun setupStarButtons(song: Track, useFiveStarRating: Boolean) {
private fun setupStarButtons(track: Track, useFiveStarRating: Boolean) {
if (useFiveStarRating) {
// Hide single star
star.isGone = true
rating.isVisible = true
val rating = if (song.userRating == null) 0 else song.userRating!!
setFiveStars(rating)
val rating = if (track.userRating == null) 0 else track.userRating!!
updateFiveStars(rating)
// Five star rating has no click handler because in the
// track view theres not enough space
} else {
star.isVisible = true
rating.isGone = true
setSingleStar(song.starred)
updateSingleStar(track.starred)
star.setOnClickListener {
val isStarred = song.starred
val id = song.id
if (!isStarred) {
star.setImageResource(R.drawable.ic_star_full)
song.starred = true
} else {
star.setImageResource(R.drawable.ic_star_hollow)
song.starred = false
}
// Should this be done here ?
Thread {
val musicService = MusicServiceFactory.getMusicService()
try {
if (!isStarred) {
musicService.star(id, null, null)
} else {
musicService.unstar(id, null, null)
}
} catch (all: Exception) {
Timber.e(all)
}
}.start()
track.starred = !track.starred
updateSingleStar(track.starred)
RxBus.ratingSubmitter.onNext(
RatingUpdate(track.id, HeartRating(track.starred))
)
}
}
}
@Suppress("MagicNumber")
private fun setFiveStars(rating: Int) {
private fun updateFiveStars(rating: Int) {
fiveStar1.setImageResource(
if (rating > 0) R.drawable.ic_star_full else R.drawable.ic_star_hollow
)
@ -224,7 +219,7 @@ class TrackViewHolder(val view: View) :
)
}
private fun setSingleStar(starred: Boolean) {
private fun updateSingleStar(starred: Boolean) {
if (starred) {
star.setImageResource(R.drawable.ic_star_full)
} else {

View File

@ -270,8 +270,8 @@ class ActiveServerProvider(
/**
* Queries if ID3 tags should be used
*/
fun isID3Enabled(): Boolean {
return Settings.shouldUseId3Tags && (!isOffline() || Settings.useId3TagsOffline)
fun shouldUseId3Tags(): Boolean {
return Settings.id3TagsEnabledOnline && (!isOffline() || Settings.id3TagsEnabledOffline)
}
/**

View File

@ -0,0 +1,16 @@
/*
* RatingUpdate.kt
* Copyright (C) 2009-2023 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
package org.moire.ultrasonic.data
import androidx.media3.common.Rating
data class RatingUpdate(
val id: String,
val rating: Rating,
val success: Boolean? = null
)

View File

@ -2,8 +2,8 @@ package org.moire.ultrasonic.di
import org.koin.dsl.module
import org.moire.ultrasonic.service.ExternalStorageMonitor
import org.moire.ultrasonic.service.MediaPlayerController
import org.moire.ultrasonic.service.MediaPlayerLifecycleSupport
import org.moire.ultrasonic.service.MediaPlayerManager
import org.moire.ultrasonic.service.PlaybackStateSerializer
/**
@ -15,5 +15,5 @@ val mediaPlayerModule = module {
single { ExternalStorageMonitor() }
// TODO Ideally this can be cleaned up when all circular references are removed.
single { MediaPlayerController(get(), get(), get()) }
single { MediaPlayerManager(get(), get(), get()) }
}

View File

@ -138,8 +138,8 @@ class AlbumListFragment(
)
private fun getListOfSortOrders(): List<SortOrder> {
val useId3 = Settings.shouldUseId3Tags
val useId3Offline = Settings.useId3TagsOffline
val useId3 = Settings.id3TagsEnabledOnline
val useId3Offline = Settings.id3TagsEnabledOffline
val isOnline = !ActiveServerProvider.isOffline()
val supported = mutableListOf<SortOrder>()

View File

@ -72,7 +72,7 @@ class BookmarksFragment : TrackCollectionFragment() {
currentPlayingPosition = songs[0].bookmarkPosition
)
mediaPlayerController.restore(
mediaPlayerManager.restore(
state = state,
autoPlay = true,
newPlaylist = true

View File

@ -16,13 +16,14 @@ import androidx.navigation.fragment.findNavController
import io.reactivex.rxjava3.disposables.CompositeDisposable
import org.moire.ultrasonic.R
import org.moire.ultrasonic.adapters.FolderSelectorBinder
import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.domain.Artist
import org.moire.ultrasonic.domain.GenericEntry
import org.moire.ultrasonic.domain.Identifiable
import org.moire.ultrasonic.service.RxBus
import org.moire.ultrasonic.service.plusAssign
import org.moire.ultrasonic.subsonic.DownloadAction
import org.moire.ultrasonic.subsonic.DownloadHandler
import org.moire.ultrasonic.util.Settings
/**
* An extension of the MultiListFragment, with a few helper functions geared
@ -38,7 +39,7 @@ abstract class EntryListFragment<T : GenericEntry> : MultiListFragment<T>() {
*/
private fun showFolderHeader(): Boolean {
return listModel.showSelectFolderHeader() && !listModel.isOffline() &&
!Settings.shouldUseId3Tags
!ActiveServerProvider.shouldUseId3Tags()
}
override fun onContextMenuItemSelected(menuItem: MenuItem, item: T): Boolean {
@ -129,81 +130,54 @@ abstract class EntryListFragment<T : GenericEntry> : MultiListFragment<T>() {
): Boolean {
when (menuItem.itemId) {
R.id.menu_play_now ->
downloadHandler.downloadRecursively(
downloadHandler.fetchTracksAndAddToController(
fragment,
item.id,
save = false,
append = false,
autoPlay = true,
shuffle = false,
background = false,
playNext = false,
unpin = false,
isArtist = isArtist
)
R.id.menu_play_next ->
downloadHandler.downloadRecursively(
downloadHandler.fetchTracksAndAddToController(
fragment,
item.id,
save = false,
append = false,
autoPlay = true,
shuffle = true,
background = false,
playNext = true,
unpin = false,
isArtist = isArtist
)
R.id.menu_play_last ->
downloadHandler.downloadRecursively(
downloadHandler.fetchTracksAndAddToController(
fragment,
item.id,
save = false,
append = true,
autoPlay = false,
shuffle = false,
background = false,
playNext = false,
unpin = false,
isArtist = isArtist
)
R.id.menu_pin ->
downloadHandler.downloadRecursively(
downloadHandler.justDownload(
action = DownloadAction.PIN,
fragment,
item.id,
save = true,
append = true,
autoPlay = false,
shuffle = false,
background = false,
playNext = false,
unpin = false,
isArtist = isArtist
)
R.id.menu_unpin ->
downloadHandler.downloadRecursively(
downloadHandler.justDownload(
action = DownloadAction.UNPIN,
fragment,
item.id,
save = false,
append = false,
autoPlay = false,
shuffle = false,
background = false,
playNext = false,
unpin = true,
isArtist = isArtist
)
R.id.menu_download ->
downloadHandler.downloadRecursively(
downloadHandler.justDownload(
action = DownloadAction.DOWNLOAD,
fragment,
item.id,
save = false,
append = false,
autoPlay = false,
shuffle = false,
background = true,
playNext = false,
unpin = false,
isArtist = isArtist
)
else -> return false

View File

@ -25,7 +25,7 @@ import kotlin.math.abs
import org.koin.android.ext.android.inject
import org.moire.ultrasonic.NavigationGraphDirections
import org.moire.ultrasonic.R
import org.moire.ultrasonic.service.MediaPlayerController
import org.moire.ultrasonic.service.MediaPlayerManager
import org.moire.ultrasonic.service.RxBus
import org.moire.ultrasonic.subsonic.ImageLoaderProvider
import org.moire.ultrasonic.util.Settings
@ -48,7 +48,7 @@ class NowPlayingFragment : Fragment() {
private var nowPlayingArtist: TextView? = null
private var rxBusSubscription: Disposable? = null
private val mediaPlayerController: MediaPlayerController by inject()
private val mediaPlayerManager: MediaPlayerManager by inject()
private val imageLoaderProvider: ImageLoaderProvider by inject()
override fun onCreate(savedInstanceState: Bundle?) {
@ -85,24 +85,25 @@ class NowPlayingFragment : Fragment() {
@SuppressLint("ClickableViewAccessibility")
private fun update() {
try {
if (mediaPlayerController.isPlaying) {
if (mediaPlayerManager.isPlaying) {
playButton!!.setIconResource(R.drawable.media_pause)
} else {
playButton!!.setIconResource(R.drawable.media_start)
}
val file = mediaPlayerController.currentMediaItem?.toTrack()
val file = mediaPlayerManager.currentMediaItem?.toTrack()
if (file != null) {
val title = file.title
val artist = file.artist
val size = getNotificationImageSize(requireContext())
imageLoaderProvider.executeOn {
it.loadImage(
nowPlayingAlbumArtImage,
file,
false,
getNotificationImageSize(requireContext())
size
)
}
@ -110,7 +111,7 @@ class NowPlayingFragment : Fragment() {
nowPlayingArtist!!.text = artist
nowPlayingAlbumArtImage!!.setOnClickListener {
val id3 = Settings.shouldUseId3Tags
val id3 = Settings.id3TagsEnabledOnline
val action = NavigationGraphDirections.toTrackCollection(
isAlbum = id3,
id = if (id3) file.albumId else file.parent,
@ -126,7 +127,7 @@ class NowPlayingFragment : Fragment() {
// This empty onClickListener is necessary for the onTouchListener to work
requireView().setOnClickListener { }
playButton!!.setOnClickListener { mediaPlayerController.togglePlayPause() }
playButton!!.setOnClickListener { mediaPlayerManager.togglePlayPause() }
} catch (all: Exception) {
Timber.w(all, "Failed to get notification cover art")
}
@ -148,10 +149,10 @@ class NowPlayingFragment : Fragment() {
if (abs(deltaX) > MIN_DISTANCE) {
// left or right
if (deltaX < 0) {
mediaPlayerController.previous()
mediaPlayerManager.seekToPrevious()
}
if (deltaX > 0) {
mediaPlayerController.next()
mediaPlayerManager.seekToNext()
}
} else if (abs(deltaY) > MIN_DISTANCE) {
if (deltaY < 0) {

View File

@ -34,12 +34,17 @@ import android.widget.SeekBar.OnSeekBarChangeListener
import android.widget.TextView
import android.widget.Toast
import android.widget.ViewFlipper
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.content.res.ResourcesCompat
import androidx.core.view.MenuHost
import androidx.core.view.MenuProvider
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.lifecycle.Lifecycle
import androidx.media3.common.HeartRating
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.session.SessionResult
import androidx.media3.common.StarRating
import androidx.navigation.Navigation
import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.ItemTouchHelper
@ -49,8 +54,7 @@ import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.LinearSmoothScroller
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.button.MaterialButton
import com.google.common.util.concurrent.FutureCallback
import com.google.common.util.concurrent.Futures
import com.google.android.material.progressindicator.CircularProgressIndicator
import io.reactivex.rxjava3.disposables.CompositeDisposable
import java.text.DateFormat
import java.text.SimpleDateFormat
@ -76,11 +80,14 @@ import org.moire.ultrasonic.adapters.TrackViewBinder
import org.moire.ultrasonic.api.subsonic.models.AlbumListType
import org.moire.ultrasonic.audiofx.EqualizerController
import org.moire.ultrasonic.data.ActiveServerProvider.Companion.isOffline
import org.moire.ultrasonic.data.ActiveServerProvider.Companion.shouldUseId3Tags
import org.moire.ultrasonic.data.RatingUpdate
import org.moire.ultrasonic.databinding.CurrentPlayingBinding
import org.moire.ultrasonic.domain.Identifiable
import org.moire.ultrasonic.domain.MusicDirectory
import org.moire.ultrasonic.domain.Track
import org.moire.ultrasonic.fragment.FragmentTitle.Companion.setTitle
import org.moire.ultrasonic.service.MediaPlayerController
import org.moire.ultrasonic.service.MediaPlayerManager
import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService
import org.moire.ultrasonic.service.RxBus
import org.moire.ultrasonic.service.plusAssign
@ -98,7 +105,7 @@ import timber.log.Timber
/**
* Contains the Music Player screen of Ultrasonic with playback controls and the playlist
* TODO: Add timeline lister -> updateProgressBar().
*
*/
@Suppress("LargeClass", "TooManyFunctions", "MagicNumber")
class PlayerFragment :
@ -121,7 +128,7 @@ class PlayerFragment :
// Data & Services
private val networkAndStorageChecker: NetworkAndStorageChecker by inject()
private val mediaPlayerController: MediaPlayerController by inject()
private val mediaPlayerManager: MediaPlayerManager by inject()
private val shareHandler: ShareHandler by inject()
private val imageLoaderProvider: ImageLoaderProvider by inject()
private var currentSong: Track? = null
@ -132,7 +139,6 @@ class PlayerFragment :
// Views and UI Elements
private lateinit var playlistNameView: EditText
private lateinit var starMenuItem: MenuItem
private lateinit var fiveStar1ImageView: ImageView
private lateinit var fiveStar2ImageView: ImageView
private lateinit var fiveStar3ImageView: ImageView
@ -140,6 +146,7 @@ class PlayerFragment :
private lateinit var fiveStar5ImageView: ImageView
private lateinit var playlistFlipper: ViewFlipper
private lateinit var emptyTextView: TextView
private lateinit var emptyView: ConstraintLayout
private lateinit var songTitleTextView: TextView
private lateinit var artistTextView: TextView
private lateinit var albumTextView: TextView
@ -154,12 +161,20 @@ class PlayerFragment :
private lateinit var pauseButton: View
private lateinit var stopButton: View
private lateinit var playButton: View
private lateinit var previousButton: MaterialButton
private lateinit var nextButton: MaterialButton
private lateinit var shuffleButton: View
private lateinit var repeatButton: MaterialButton
private lateinit var progressBar: SeekBar
private lateinit var progressIndicator: CircularProgressIndicator
private val hollowStar = R.drawable.ic_star_hollow
private val fullStar = R.drawable.ic_star_full
private var _binding: CurrentPlayingBinding? = null
// This property is only valid between onCreateView and
// onDestroyView.
private val binding get() = _binding!!
private val viewAdapter: BaseAdapter<Identifiable> by lazy {
BaseAdapter()
}
@ -173,13 +188,17 @@ class PlayerFragment :
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return inflater.inflate(R.layout.current_playing, container, false)
): View {
_binding = CurrentPlayingBinding.inflate(layoutInflater, container, false)
return binding.root
}
// TODO: Switch them all over to use the view binding
private fun findViews(view: View) {
playlistFlipper = view.findViewById(R.id.current_playing_playlist_flipper)
emptyTextView = view.findViewById(R.id.playlist_empty)
emptyTextView = view.findViewById(R.id.empty_list_text)
emptyView = view.findViewById(R.id.emptyListView)
progressIndicator = view.findViewById(R.id.progress_indicator)
songTitleTextView = view.findViewById(R.id.current_playing_song)
artistTextView = view.findViewById(R.id.current_playing_artist)
albumTextView = view.findViewById(R.id.current_playing_album)
@ -196,6 +215,8 @@ class PlayerFragment :
pauseButton = view.findViewById(R.id.button_pause)
stopButton = view.findViewById(R.id.button_stop)
playButton = view.findViewById(R.id.button_start)
nextButton = view.findViewById(R.id.button_next)
previousButton = view.findViewById(R.id.button_previous)
repeatButton = view.findViewById(R.id.button_repeat)
fiveStar1ImageView = view.findViewById(R.id.song_five_star_1)
fiveStar2ImageView = view.findViewById(R.id.song_five_star_2)
@ -204,7 +225,7 @@ class PlayerFragment :
fiveStar5ImageView = view.findViewById(R.id.song_five_star_5)
}
@Suppress("LongMethod", "DEPRECATION")
@Suppress("LongMethod")
@SuppressLint("ClickableViewAccessibility")
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
cancellationToken = CancellationToken()
@ -214,6 +235,7 @@ class PlayerFragment :
val width: Int
val height: Int
@Suppress("DEPRECATION")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val bounds = windowManager.currentWindowMetrics.bounds
width = bounds.width()
@ -226,7 +248,13 @@ class PlayerFragment :
height = size.y
}
setHasOptionsMenu(true)
// Register our options menu
(requireActivity() as MenuHost).addMenuProvider(
menuProvider,
viewLifecycleOwner,
Lifecycle.State.RESUMED
)
useFiveStarRating = Settings.useFiveStarRating
swipeDistance = (width + height) * PERCENTAGE_OF_SCREEN_FOR_SWIPE / 100
swipeVelocity = swipeDistance
@ -236,8 +264,8 @@ class PlayerFragment :
val previousButton: AutoRepeatButton = view.findViewById(R.id.button_previous)
val nextButton: AutoRepeatButton = view.findViewById(R.id.button_next)
shuffleButton = view.findViewById(R.id.button_shuffle)
updateShuffleButtonState(mediaPlayerController.isShufflePlayEnabled)
updateRepeatButtonState(mediaPlayerController.repeatMode)
updateShuffleButtonState(mediaPlayerManager.isShufflePlayEnabled)
updateRepeatButtonState(mediaPlayerManager.repeatMode)
val ratingLinearLayout = view.findViewById<LinearLayout>(R.id.song_rating)
if (!useFiveStarRating) ratingLinearLayout.isVisible = false
@ -259,9 +287,7 @@ class PlayerFragment :
previousButton.setOnClickListener {
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
launch(CommunicationError.getHandler(context)) {
mediaPlayerController.previous()
onCurrentChanged()
onSliderProgressChanged()
mediaPlayerManager.seekToPrevious()
}
}
@ -272,9 +298,7 @@ class PlayerFragment :
nextButton.setOnClickListener {
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
launch(CommunicationError.getHandler(context)) {
mediaPlayerController.next()
onCurrentChanged()
onSliderProgressChanged()
mediaPlayerManager.seekToNext()
}
}
@ -284,28 +308,22 @@ class PlayerFragment :
pauseButton.setOnClickListener {
launch(CommunicationError.getHandler(context)) {
mediaPlayerController.pause()
onCurrentChanged()
onSliderProgressChanged()
mediaPlayerManager.pause()
}
}
stopButton.setOnClickListener {
launch(CommunicationError.getHandler(context)) {
mediaPlayerController.reset()
onCurrentChanged()
onSliderProgressChanged()
mediaPlayerManager.reset()
}
}
playButton.setOnClickListener {
if (!mediaPlayerController.isJukeboxEnabled)
if (!mediaPlayerManager.isJukeboxEnabled)
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
launch(CommunicationError.getHandler(context)) {
mediaPlayerController.play()
onCurrentChanged()
onSliderProgressChanged()
mediaPlayerManager.play()
}
}
@ -314,12 +332,12 @@ class PlayerFragment :
}
repeatButton.setOnClickListener {
var newRepeat = mediaPlayerController.repeatMode + 1
var newRepeat = mediaPlayerManager.repeatMode + 1
if (newRepeat == 3) {
newRepeat = 0
}
mediaPlayerController.repeatMode = newRepeat
mediaPlayerManager.repeatMode = newRepeat
onPlaylistChanged()
@ -341,8 +359,7 @@ class PlayerFragment :
progressBar.setOnSeekBarChangeListener(object : OnSeekBarChangeListener {
override fun onStopTrackingTouch(seekBar: SeekBar) {
launch(CommunicationError.getHandler(context)) {
mediaPlayerController.seekTo(progressBar.progress)
onSliderProgressChanged()
mediaPlayerManager.seekTo(progressBar.progress)
}
}
@ -367,22 +384,31 @@ class PlayerFragment :
// Observe playlist changes and update the UI
rxBusSubscription += RxBus.playlistObservable.subscribe {
onPlaylistChanged()
onSliderProgressChanged()
updateSeekBar()
}
rxBusSubscription += RxBus.playerStateObservable.subscribe {
update()
updateTitle(it.state)
updateButtonStates(it.state)
}
// Query the Jukebox state in an IO Context
ioScope.launch(CommunicationError.getHandler(context)) {
try {
jukeboxAvailable = mediaPlayerController.isJukeboxAvailable
jukeboxAvailable = mediaPlayerManager.isJukeboxAvailable
} catch (all: Exception) {
Timber.e(all)
}
}
// Subscribe to change in command availability
mediaPlayerManager.addListener(object : Player.Listener {
override fun onAvailableCommandsChanged(availableCommands: Player.Commands) {
updateMediaButtonActivationState()
}
})
view.setOnTouchListener { _, event -> gestureScanner.onTouchEvent(event) }
}
@ -414,7 +440,7 @@ class PlayerFragment :
}
private fun toggleShuffle() {
val isEnabled = mediaPlayerController.toggleShuffle()
val isEnabled = mediaPlayerManager.toggleShuffle()
if (isEnabled) {
Util.toast(activity, R.string.download_menu_shuffle_on)
@ -427,12 +453,12 @@ class PlayerFragment :
override fun onResume() {
super.onResume()
if (mediaPlayerController.currentMediaItem == null) {
if (mediaPlayerManager.currentMediaItem == null) {
playlistFlipper.displayedChild = 1
} else {
// Download list and Album art must be updated when resumed
onPlaylistChanged()
onCurrentChanged()
onTrackChanged()
}
val handler = Handler(Looper.getMainLooper())
@ -440,7 +466,7 @@ class PlayerFragment :
executorService = Executors.newSingleThreadScheduledExecutor()
executorService.scheduleWithFixedDelay(runnable, 0L, 500L, TimeUnit.MILLISECONDS)
if (mediaPlayerController.keepScreenOn) {
if (mediaPlayerManager.keepScreenOn) {
requireActivity().window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
} else {
requireActivity().window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
@ -451,7 +477,7 @@ class PlayerFragment :
// Scroll to current playing.
private fun scrollToCurrent() {
val index = mediaPlayerController.currentMediaItemIndex
val index = mediaPlayerManager.currentMediaItemIndex
if (index != -1) {
val smoothScroller = LinearSmoothScroller(context)
@ -469,26 +495,59 @@ class PlayerFragment :
rxBusSubscription.dispose()
cancel("CoroutineScope cancelled because the view was destroyed")
cancellationToken.cancel()
_binding = null
super.onDestroyView()
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.nowplaying, menu)
super.onCreateOptionsMenu(menu, inflater)
private val menuProvider: MenuProvider = object : MenuProvider {
override fun onPrepareMenu(menu: Menu) {
setupOptionsMenu(menu)
}
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.nowplaying, menu)
}
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
return menuItemSelected(menuItem.itemId, currentSong)
}
}
@Suppress("ComplexMethod", "LongMethod", "NestedBlockDepth")
override fun onPrepareOptionsMenu(menu: Menu) {
super.onPrepareOptionsMenu(menu)
fun setupOptionsMenu(menu: Menu) {
// Seems there is nothing like ViewBinding for Menus
val screenOption = menu.findItem(R.id.menu_item_screen_on_off)
val goToAlbum = menu.findItem(R.id.menu_show_album)
val goToArtist = menu.findItem(R.id.menu_show_artist)
val jukeboxOption = menu.findItem(R.id.menu_item_jukebox)
val equalizerMenuItem = menu.findItem(R.id.menu_item_equalizer)
val shareMenuItem = menu.findItem(R.id.menu_item_share)
val shareSongMenuItem = menu.findItem(R.id.menu_item_share_song)
starMenuItem = menu.findItem(R.id.menu_item_star)
val starMenuItem = menu.findItem(R.id.menu_item_star)
val bookmarkMenuItem = menu.findItem(R.id.menu_item_bookmark_set)
val bookmarkRemoveMenuItem = menu.findItem(R.id.menu_item_bookmark_delete)
// Listen to rating changes and update the UI
rxBusSubscription += RxBus.ratingPublishedObservable.subscribe { update ->
// Ignore updates which are not for the current song
if (update.id != currentSong?.id) return@subscribe
// Ensure UI thread
launch {
if (update.success == true && update.rating is HeartRating) {
if (update.rating.isHeart) {
starMenuItem.setIcon(fullStar)
} else {
starMenuItem.setIcon(hollowStar)
}
} else if (update.success == false) {
Toast.makeText(context, "Setting rating failed", Toast.LENGTH_SHORT)
.show()
}
}
}
if (isOffline()) {
if (shareMenuItem != null) {
shareMenuItem.isVisible = false
@ -505,7 +564,8 @@ class PlayerFragment :
equalizerMenuItem.isEnabled = isEqualizerAvailable
equalizerMenuItem.isVisible = isEqualizerAvailable
}
val mediaPlayerController = mediaPlayerController
val mediaPlayerController = mediaPlayerManager
val track = mediaPlayerController.currentMediaItem?.toTrack()
if (track != null) {
@ -517,9 +577,13 @@ class PlayerFragment :
if (currentSong != null) {
starMenuItem.setIcon(if (currentSong!!.starred) fullStar else hollowStar)
shareSongMenuItem.isVisible = true
goToAlbum.isVisible = true
goToArtist.isVisible = true
} else {
starMenuItem.setIcon(hollowStar)
shareSongMenuItem.isVisible = false
goToAlbum.isVisible = false
goToArtist.isVisible = false
}
if (mediaPlayerController.keepScreenOn) {
@ -551,19 +615,15 @@ class PlayerFragment :
}
}
if (isOffline() || !Settings.shouldUseId3Tags) {
popup.menu.findItem(R.id.menu_show_artist)?.isVisible = false
}
// Only show the menu if the ID3 tags are available
popup.menu.findItem(R.id.menu_show_artist)?.isVisible = shouldUseId3Tags()
// Only show the lyrics when the user is online
popup.menu.findItem(R.id.menu_lyrics)?.isVisible = !isOffline()
popup.show()
return popup
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return menuItemSelected(item.itemId, currentSong) || super.onOptionsItemSelected(item)
}
private fun onContextMenuItemSelected(
menuItem: MenuItem,
item: MusicDirectory.Child
@ -578,7 +638,7 @@ class PlayerFragment :
R.id.menu_show_artist -> {
if (track == null) return false
if (Settings.shouldUseId3Tags) {
if (Settings.id3TagsEnabledOnline) {
val action = PlayerFragmentDirections.playerToAlbumsList(
type = AlbumListType.SORTED_BY_NAME,
byArtist = true,
@ -594,7 +654,7 @@ class PlayerFragment :
R.id.menu_show_album -> {
if (track == null) return false
val albumId = if (Settings.shouldUseId3Tags) track.albumId else track.parent
val albumId = if (shouldUseId3Tags()) track.albumId else track.parent
val action = PlayerFragmentDirections.playerToSelectAlbum(
id = albumId,
@ -614,12 +674,12 @@ class PlayerFragment :
}
R.id.menu_item_screen_on_off -> {
val window = requireActivity().window
if (mediaPlayerController.keepScreenOn) {
if (mediaPlayerManager.keepScreenOn) {
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
mediaPlayerController.keepScreenOn = false
mediaPlayerManager.keepScreenOn = false
} else {
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
mediaPlayerController.keepScreenOn = true
mediaPlayerManager.keepScreenOn = true
}
return true
}
@ -632,8 +692,8 @@ class PlayerFragment :
return true
}
R.id.menu_item_jukebox -> {
val jukeboxEnabled = !mediaPlayerController.isJukeboxEnabled
mediaPlayerController.isJukeboxEnabled = jukeboxEnabled
val jukeboxEnabled = !mediaPlayerManager.isJukeboxEnabled
mediaPlayerManager.isJukeboxEnabled = jukeboxEnabled
Util.toast(
context,
if (jukeboxEnabled) R.string.download_jukebox_on
@ -647,44 +707,24 @@ class PlayerFragment :
return true
}
R.id.menu_item_clear_playlist -> {
mediaPlayerController.isShufflePlayEnabled = false
mediaPlayerController.clear()
mediaPlayerManager.isShufflePlayEnabled = false
mediaPlayerManager.clear()
onPlaylistChanged()
return true
}
R.id.menu_item_save_playlist -> {
if (mediaPlayerController.playlistSize > 0) {
if (mediaPlayerManager.playlistSize > 0) {
showSavePlaylistDialog()
}
return true
}
R.id.menu_item_star -> {
if (track == null) return true
track.starred = !track.starred
val isStarred = track.starred
mediaPlayerController.toggleSongStarred()?.let {
Futures.addCallback(
it,
object : FutureCallback<SessionResult> {
override fun onSuccess(result: SessionResult?) {
if (isStarred) {
starMenuItem.setIcon(hollowStar)
track.starred = false
} else {
starMenuItem.setIcon(fullStar)
track.starred = true
}
}
override fun onFailure(t: Throwable) {
Toast.makeText(context, "SetRating failed", Toast.LENGTH_SHORT)
.show()
}
},
this.executorService
RxBus.ratingSubmitter.onNext(
RatingUpdate(track.id, HeartRating(track.starred))
)
}
return true
}
@ -692,7 +732,7 @@ class PlayerFragment :
if (track == null) return true
val songId = track.id
val playerPosition = mediaPlayerController.playerPosition
val playerPosition = mediaPlayerManager.playerPosition
track.bookmarkPosition = playerPosition
val bookmarkTime = Util.formatTotalDuration(playerPosition.toLong(), true)
Thread {
@ -727,7 +767,7 @@ class PlayerFragment :
return true
}
R.id.menu_item_share -> {
val mediaPlayerController = mediaPlayerController
val mediaPlayerController = mediaPlayerManager
val tracks: MutableList<Track?> = ArrayList()
val playlist = mediaPlayerController.playlist
for (item in playlist) {
@ -762,20 +802,18 @@ class PlayerFragment :
private fun update(cancel: CancellationToken? = null) {
if (cancel?.isCancellationRequested == true) return
val mediaPlayerController = mediaPlayerController
if (currentSong?.id != mediaPlayerController.currentMediaItem?.mediaId) {
onCurrentChanged()
if (currentSong?.id != mediaPlayerManager.currentMediaItem?.mediaId) {
onTrackChanged()
}
onSliderProgressChanged()
requireActivity().invalidateOptionsMenu()
updateSeekBar()
}
private fun savePlaylistInBackground(playlistName: String) {
Util.toast(context, resources.getString(R.string.download_playlist_saving, playlistName))
mediaPlayerController.suggestedPlaylistName = playlistName
mediaPlayerManager.suggestedPlaylistName = playlistName
// The playlist can be acquired only from the main thread
val entries = mediaPlayerController.playlist.map {
val entries = mediaPlayerManager.playlist.map {
it.toTrack()
}
@ -827,12 +865,9 @@ class PlayerFragment :
}
// Create listener
val clickHandler: ((Track, Int) -> Unit) = { _, pos ->
mediaPlayerController.seekTo(pos, 0)
mediaPlayerController.prepare()
mediaPlayerController.play()
onCurrentChanged()
onSliderProgressChanged()
val clickHandler: ((Track, Int) -> Unit) = { _, listPos ->
val mediaIndex = mediaPlayerManager.getUnshuffledIndexOf(listPos)
mediaPlayerManager.play(mediaIndex)
}
viewAdapter.register(
@ -896,7 +931,7 @@ class PlayerFragment :
@SuppressLint("NotifyDataSetChanged")
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
val pos = viewHolder.bindingAdapterPosition
val item = mediaPlayerController.getMediaItemAt(pos)
val item = mediaPlayerManager.getMediaItemAt(pos)
// Remove the item from the list quickly
val items = viewAdapter.getCurrentList().toMutableList()
@ -912,7 +947,7 @@ class PlayerFragment :
Util.toast(context, songRemoved)
// Remove the item from the playlist
mediaPlayerController.removeFromPlaylist(pos)
mediaPlayerManager.removeFromPlaylist(pos)
}
override fun onSelectedChanged(
@ -931,7 +966,8 @@ class PlayerFragment :
if (actionState == ACTION_STATE_IDLE && dragging) {
dragging = false
// Move the item in the playlist separately
mediaPlayerController.moveItemInPlaylist(startPosition, endPosition)
Timber.i("Moving item %s to %s", startPosition, endPosition)
mediaPlayerManager.moveItemInPlaylist(startPosition, endPosition)
}
}
@ -1009,24 +1045,24 @@ class PlayerFragment :
}
private fun onPlaylistChanged() {
val mediaPlayerController = mediaPlayerController
val list = mediaPlayerController.playlist
val mediaPlayerController = mediaPlayerManager
// Try to display playlist in play order
val list = mediaPlayerController.playlistInPlayOrder
emptyTextView.setText(R.string.playlist_empty)
viewAdapter.submitList(list.map(MediaItem::toTrack))
emptyTextView.isVisible = list.isEmpty()
progressIndicator.isVisible = false
emptyView.isVisible = list.isEmpty()
updateRepeatButtonState(mediaPlayerController.repeatMode)
}
private fun onCurrentChanged() {
currentSong = mediaPlayerController.currentMediaItem?.toTrack()
private fun onTrackChanged() {
currentSong = mediaPlayerManager.currentMediaItem?.toTrack()
scrollToCurrent()
val totalDuration = mediaPlayerController.playListDuration
val totalSongs = mediaPlayerController.playlistSize
val currentSongIndex = mediaPlayerController.currentMediaItemIndex + 1
val totalDuration = mediaPlayerManager.playListDuration
val totalSongs = mediaPlayerManager.playlistSize
val currentSongIndex = mediaPlayerManager.currentMediaItemIndex + 1
val duration = Util.formatTotalDuration(totalDuration)
val trackFormat =
String.format(Locale.getDefault(), "%d / %d", currentSongIndex, totalSongs)
@ -1064,7 +1100,7 @@ class PlayerFragment :
it.loadImage(albumArtImageView, currentSong, true, 0)
}
displaySongRating()
updateSongRating()
} else {
currentSong = null
songTitleTextView.text = null
@ -1078,26 +1114,30 @@ class PlayerFragment :
it.loadImage(albumArtImageView, null, true, 0)
}
}
updateSongRating()
updateMediaButtonActivationState()
}
private fun updateMediaButtonActivationState() {
nextButton.isEnabled = mediaPlayerManager.canSeekToNext()
previousButton.isEnabled = mediaPlayerManager.canSeekToPrevious()
}
@Suppress("LongMethod")
@Synchronized
private fun onSliderProgressChanged() {
private fun updateSeekBar() {
val isJukeboxEnabled: Boolean = mediaPlayerManager.isJukeboxEnabled
val millisPlayed: Int = max(0, mediaPlayerManager.playerPosition)
val duration: Int = mediaPlayerManager.playerDuration
val playbackState: Int = mediaPlayerManager.playbackState
val isJukeboxEnabled: Boolean = mediaPlayerController.isJukeboxEnabled
val millisPlayed: Int = max(0, mediaPlayerController.playerPosition)
val duration: Int = mediaPlayerController.playerDuration
val playbackState: Int = mediaPlayerController.playbackState
val isPlaying = mediaPlayerController.isPlaying
if (cancellationToken.isCancellationRequested) return
if (currentSong != null) {
positionTextView.text = Util.formatTotalDuration(millisPlayed.toLong(), true)
durationTextView.text = Util.formatTotalDuration(duration.toLong(), true)
progressBar.max =
if (duration == 0) 100 else duration // Work-around for apparent bug.
progressBar.max = if (duration == 0) 100 else duration // Work-around for apparent bug.
progressBar.progress = millisPlayed
progressBar.isEnabled = mediaPlayerController.isPlaying || isJukeboxEnabled
progressBar.isEnabled = mediaPlayerManager.isPlaying || isJukeboxEnabled
} else {
positionTextView.setText(R.string.util_zero_time)
durationTextView.setText(R.string.util_no_time)
@ -1106,20 +1146,20 @@ class PlayerFragment :
progressBar.isEnabled = false
}
val progress = mediaPlayerController.bufferedPercentage
val progress = mediaPlayerManager.bufferedPercentage
updateBufferProgress(playbackState, progress)
}
private fun updateTitle(playbackState: Int) {
when (playbackState) {
Player.STATE_BUFFERING -> {
val downloadStatus = resources.getString(
R.string.download_playerstate_loading
)
progressBar.secondaryProgress = progress
setTitle(this@PlayerFragment, downloadStatus)
}
Player.STATE_READY -> {
progressBar.secondaryProgress = progress
if (mediaPlayerController.isShufflePlayEnabled) {
if (mediaPlayerManager.isShufflePlayEnabled) {
setTitle(
this@PlayerFragment,
R.string.download_playerstate_playing_shuffle
@ -1128,13 +1168,22 @@ class PlayerFragment :
setTitle(this@PlayerFragment, R.string.common_appname)
}
}
Player.STATE_IDLE,
Player.STATE_ENDED,
-> {
}
Player.STATE_IDLE, Player.STATE_ENDED -> {}
else -> setTitle(this@PlayerFragment, R.string.common_appname)
}
}
private fun updateBufferProgress(playbackState: Int, progress: Int) {
when (playbackState) {
Player.STATE_BUFFERING, Player.STATE_READY -> {
progressBar.secondaryProgress = progress
}
else -> { }
}
}
private fun updateButtonStates(playbackState: Int) {
val isPlaying = mediaPlayerManager.isPlaying
when (playbackState) {
Player.STATE_READY -> {
pauseButton.isVisible = isPlaying
@ -1152,18 +1201,14 @@ class PlayerFragment :
playButton.isVisible = true
}
}
// TODO: It would be a lot nicer if MediaPlayerController would send an event
// when this is necessary instead of updating every time
displaySongRating()
}
private fun seek(forward: Boolean) {
launch(CommunicationError.getHandler(context)) {
if (forward) {
mediaPlayerController.seekForward()
mediaPlayerManager.seekForward()
} else {
mediaPlayerController.seekBack()
mediaPlayerManager.seekBack()
}
}
}
@ -1189,34 +1234,28 @@ class PlayerFragment :
// Right to Left swipe
if (e1X - e2X > swipeDistance && absX > swipeVelocity) {
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
mediaPlayerController.next()
onCurrentChanged()
onSliderProgressChanged()
mediaPlayerManager.seekToNext()
return true
}
// Left to Right swipe
if (e2X - e1X > swipeDistance && absX > swipeVelocity) {
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
mediaPlayerController.previous()
onCurrentChanged()
onSliderProgressChanged()
mediaPlayerManager.seekToPrevious()
return true
}
// Top to Bottom swipe
if (e2Y - e1Y > swipeDistance && absY > swipeVelocity) {
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
mediaPlayerController.seekTo(mediaPlayerController.playerPosition + 30000)
onSliderProgressChanged()
mediaPlayerManager.seekTo(mediaPlayerManager.playerPosition + 30000)
return true
}
// Bottom to Top swipe
if (e1Y - e2Y > swipeDistance && absY > swipeVelocity) {
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
mediaPlayerController.seekTo(mediaPlayerController.playerPosition - 8000)
onSliderProgressChanged()
mediaPlayerManager.seekTo(mediaPlayerManager.playerPosition - 8000)
return true
}
return false
@ -1237,12 +1276,8 @@ class PlayerFragment :
return false
}
private fun displaySongRating() {
var rating = 0
if (currentSong?.userRating != null) {
rating = currentSong!!.userRating!!
}
private fun updateSongRating() {
val rating = currentSong?.userRating ?: 0
fiveStar1ImageView.setImageResource(if (rating > 0) fullStar else hollowStar)
fiveStar2ImageView.setImageResource(if (rating > 1) fullStar else hollowStar)
@ -1253,8 +1288,15 @@ class PlayerFragment :
private fun setSongRating(rating: Int) {
if (currentSong == null) return
displaySongRating()
mediaPlayerController.setSongRating(rating)
currentSong?.userRating = rating
updateSongRating()
RxBus.ratingSubmitter.onNext(
RatingUpdate(
currentSong!!.id,
StarRating(5, rating.toFloat())
)
)
}
@SuppressLint("InflateParams")
@ -1278,7 +1320,7 @@ class PlayerFragment :
builder.setView(layout)
builder.setCancelable(true)
val dialog = builder.create()
val playlistName = mediaPlayerController.suggestedPlaylistName
val playlistName = mediaPlayerManager.suggestedPlaylistName
if (playlistName != null) {
playlistNameView.setText(playlistName)
} else {

View File

@ -15,8 +15,11 @@ import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import androidx.appcompat.widget.SearchView
import androidx.core.view.MenuHost
import androidx.core.view.MenuProvider
import androidx.core.view.isVisible
import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.viewModelScope
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
@ -41,7 +44,8 @@ import org.moire.ultrasonic.domain.SearchResult
import org.moire.ultrasonic.domain.Track
import org.moire.ultrasonic.fragment.FragmentTitle.Companion.setTitle
import org.moire.ultrasonic.model.SearchListModel
import org.moire.ultrasonic.service.MediaPlayerController
import org.moire.ultrasonic.service.DownloadService
import org.moire.ultrasonic.service.MediaPlayerManager
import org.moire.ultrasonic.subsonic.NetworkAndStorageChecker
import org.moire.ultrasonic.subsonic.ShareHandler
import org.moire.ultrasonic.subsonic.VideoPlayer.Companion.playVideo
@ -54,15 +58,14 @@ import timber.log.Timber
/**
* Initiates a search on the media library and displays the results
*
* TODO: Implement the search field without using the deprecated OptionsMenu calls
* TODO: Switch to material3 class
*/
class SearchFragment : MultiListFragment<Identifiable>(), KoinComponent {
private var searchResult: SearchResult? = null
private var searchRefresh: SwipeRefreshLayout? = null
private var searchView: SearchView? = null
private val mediaPlayerController: MediaPlayerController by inject()
private val mediaPlayerManager: MediaPlayerManager by inject()
private val shareHandler: ShareHandler by inject()
private val networkAndStorageChecker: NetworkAndStorageChecker by inject()
@ -79,7 +82,13 @@ class SearchFragment : MultiListFragment<Identifiable>(), KoinComponent {
super.onViewCreated(view, savedInstanceState)
cancellationToken = CancellationToken()
setTitle(this, R.string.search_title)
setHasOptionsMenu(true)
// Register our options menu
(requireActivity() as MenuHost).addMenuProvider(
menuProvider,
viewLifecycleOwner,
Lifecycle.State.RESUMED
)
listModel.searchResult.observe(
viewLifecycleOwner
@ -140,12 +149,24 @@ class SearchFragment : MultiListFragment<Identifiable>(), KoinComponent {
}
/**
* This method creates the search bar above the recycler view
* This provide creates the search bar above the recycler view
*/
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
private val menuProvider: MenuProvider = object : MenuProvider {
override fun onPrepareMenu(menu: Menu) {
setupOptionsMenu(menu)
}
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.search, menu)
}
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
return true
}
}
fun setupOptionsMenu(menu: Menu) {
val activity = activity ?: return
val searchManager = activity.getSystemService(Context.SEARCH_SERVICE) as SearchManager
inflater.inflate(R.menu.search, menu)
val searchItem = menu.findItem(R.id.search_item)
searchView = searchItem.actionView as SearchView
val searchableInfo = searchManager.getSearchableInfo(requireActivity().componentName)
@ -203,7 +224,7 @@ class SearchFragment : MultiListFragment<Identifiable>(), KoinComponent {
private fun downloadBackground(save: Boolean, songs: List<Track?>) {
val onValid = Runnable {
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
mediaPlayerController.downloadBackground(songs, save)
DownloadService.download(songs.filterNotNull(), save)
}
onValid.run()
}
@ -274,7 +295,7 @@ class SearchFragment : MultiListFragment<Identifiable>(), KoinComponent {
id = item.id,
name = item.name,
parentId = item.id,
isArtist = (item is Artist)
isArtist = false
)
} else {
SearchFragmentDirections.searchToAlbumsList(
@ -304,16 +325,15 @@ class SearchFragment : MultiListFragment<Identifiable>(), KoinComponent {
private fun onSongSelected(song: Track, append: Boolean) {
if (!append) {
mediaPlayerController.clear()
mediaPlayerManager.clear()
}
mediaPlayerController.addToPlaylist(
mediaPlayerManager.addToPlaylist(
listOf(song),
cachePermanently = false,
autoPlay = false,
shuffle = false,
insertionMode = MediaPlayerController.InsertionMode.APPEND
insertionMode = MediaPlayerManager.InsertionMode.APPEND
)
mediaPlayerController.play(mediaPlayerController.mediaItemCount - 1)
mediaPlayerManager.play(mediaPlayerManager.mediaItemCount - 1)
toast(context, resources.getQuantityString(R.plurals.select_album_n_songs_added, 1, 1))
}
@ -366,40 +386,37 @@ class SearchFragment : MultiListFragment<Identifiable>(), KoinComponent {
when (menuItem.itemId) {
R.id.song_menu_play_now -> {
songs.add(item)
downloadHandler.download(
fragment = this,
append = false,
save = false,
autoPlay = true,
playNext = false,
shuffle = false,
downloadHandler.addTracksToMediaController(
songs = songs,
append = false,
playNext = false,
autoPlay = true,
shuffle = false,
fragment = this,
playlistName = null
)
}
R.id.song_menu_play_next -> {
songs.add(item)
downloadHandler.download(
fragment = this,
append = true,
save = false,
autoPlay = false,
playNext = true,
shuffle = false,
downloadHandler.addTracksToMediaController(
songs = songs,
append = true,
playNext = true,
autoPlay = false,
shuffle = false,
fragment = this,
playlistName = null
)
}
R.id.song_menu_play_last -> {
songs.add(item)
downloadHandler.download(
fragment = this,
append = true,
save = false,
autoPlay = false,
playNext = false,
shuffle = false,
downloadHandler.addTracksToMediaController(
songs = songs,
append = true,
playNext = false,
autoPlay = false,
shuffle = false,
fragment = this,
playlistName = null
)
}
@ -437,7 +454,7 @@ class SearchFragment : MultiListFragment<Identifiable>(), KoinComponent {
songs.size
)
)
mediaPlayerController.unpin(songs)
DownloadService.unpin(songs)
}
R.id.song_menu_share -> {
songs.add(item)

View File

@ -1,14 +1,11 @@
package org.moire.ultrasonic.fragment
import android.app.Activity
import android.content.DialogInterface
import android.content.Intent
import android.content.SharedPreferences
import android.content.SharedPreferences.OnSharedPreferenceChangeListener
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.provider.DocumentsContract
import android.provider.SearchRecentSuggestions
import android.view.View
import androidx.annotation.StringRes
@ -31,16 +28,17 @@ import org.moire.ultrasonic.log.FileLoggerTree.Companion.getLogFileSizes
import org.moire.ultrasonic.log.FileLoggerTree.Companion.plantToTimberForest
import org.moire.ultrasonic.log.FileLoggerTree.Companion.uprootFromTimberForest
import org.moire.ultrasonic.provider.SearchSuggestionProvider
import org.moire.ultrasonic.service.MediaPlayerController
import org.moire.ultrasonic.service.MediaPlayerManager
import org.moire.ultrasonic.service.RxBus
import org.moire.ultrasonic.util.ConfirmationDialog
import org.moire.ultrasonic.util.Constants
import org.moire.ultrasonic.util.ErrorDialog
import org.moire.ultrasonic.util.FileUtil.ultrasonicDirectory
import org.moire.ultrasonic.util.InfoDialog
import org.moire.ultrasonic.util.SelectCacheActivityContract
import org.moire.ultrasonic.util.Settings
import org.moire.ultrasonic.util.Settings.id3TagsEnabledOnline
import org.moire.ultrasonic.util.Settings.preferences
import org.moire.ultrasonic.util.Settings.shouldUseId3Tags
import org.moire.ultrasonic.util.Storage
import org.moire.ultrasonic.util.TimeSpanPreference
import org.moire.ultrasonic.util.TimeSpanPreferenceDialogFragmentCompat
@ -64,7 +62,7 @@ class SettingsFragment :
private var debugLogToFile: CheckBoxPreference? = null
private var customCacheLocation: CheckBoxPreference? = null
private val mediaPlayerController: MediaPlayerController by inject()
private val mediaPlayerManager: MediaPlayerManager by inject()
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
setPreferencesFromResource(R.xml.settings, rootKey)
@ -100,64 +98,14 @@ class SettingsFragment :
updateCustomPreferences()
}
/**
* This function will be called when we return from the file picker
* with a new custom cache location
*
* TODO: This method has been deprecated in favor of using the Activity Result API
* which brings increased type safety via an ActivityResultContract and the prebuilt
* contracts for common intents available in
* androidx.activity.result.contract.ActivityResultContracts,
* provides hooks for testing, and allow receiving results in separate,
* testable classes independent from your fragment.
* Use registerForActivityResult(ActivityResultContract, ActivityResultCallback) with the
* appropriate ActivityResultContract and handling the result in the callback.
*/
override fun onActivityResult(requestCode: Int, resultCode: Int, resultData: Intent?) {
if (
requestCode == SELECT_CACHE_ACTIVITY &&
resultCode == Activity.RESULT_OK &&
resultData != null
) {
val read = (resultData.flags and Intent.FLAG_GRANT_READ_URI_PERMISSION) != 0
val write = (resultData.flags and Intent.FLAG_GRANT_WRITE_URI_PERMISSION) != 0
val persist = (resultData.flags and Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION) != 0
if (read && write && persist) {
if (resultData.data != null) {
// The result data contains a URI for the document or directory that
// the user selected.
val uri = resultData.data!!
val contentResolver = UApp.applicationContext().contentResolver
contentResolver.takePersistableUriPermission(uri, RW_FLAG)
setCacheLocation(uri.toString())
setupCacheLocationPreference()
return
}
}
ErrorDialog.Builder(requireContext())
.setMessage(R.string.settings_cache_location_error)
.show()
}
if (Settings.cacheLocationUri == "") {
Settings.customCacheLocation = false
customCacheLocation?.isChecked = false
setupCacheLocationPreference()
}
}
override fun onResume() {
super.onResume()
val preferences = preferences
preferences.registerOnSharedPreferenceChangeListener(this)
}
override fun onPause() {
super.onPause()
val prefs = preferences
prefs.unregisterOnSharedPreferenceChangeListener(this)
preferences.unregisterOnSharedPreferenceChangeListener(this)
}
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
@ -249,17 +197,29 @@ class SettingsFragment :
}
private fun selectCacheLocation() {
// Choose a directory using the system's file picker.
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
if (Settings.cacheLocationUri != "" && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, Settings.cacheLocationUri)
// Start the activity to pick a directory using the system's file picker.
selectCacheActivityContract.launch(Settings.cacheLocationUri)
}
intent.addFlags(RW_FLAG)
intent.addFlags(PERSISTABLE_FLAG)
startActivityForResult(intent, SELECT_CACHE_ACTIVITY)
// Custom activity result contract
private val selectCacheActivityContract =
registerForActivityResult(SelectCacheActivityContract()) { uri ->
// parseResult will return the chosen path as an Uri
if (uri != null) {
val contentResolver = UApp.applicationContext().contentResolver
contentResolver.takePersistableUriPermission(uri, RW_FLAG)
setCacheLocation(uri.toString())
setupCacheLocationPreference()
} else {
ErrorDialog.Builder(requireContext())
.setMessage(R.string.settings_cache_location_error)
.show()
if (Settings.cacheLocationUri == "") {
Settings.customCacheLocation = false
customCacheLocation?.isChecked = false
setupCacheLocationPreference()
}
}
}
private fun setupBluetoothDevicePreferences() {
@ -354,8 +314,8 @@ class SettingsFragment :
debugLogToFile?.summary = ""
}
showArtistPicture?.isEnabled = shouldUseId3Tags
useId3TagsOffline?.isEnabled = shouldUseId3Tags
showArtistPicture?.isEnabled = id3TagsEnabledOnline
useId3TagsOffline?.isEnabled = id3TagsEnabledOnline
}
private fun setHideMedia(hide: Boolean) {
@ -382,7 +342,7 @@ class SettingsFragment :
Settings.cacheLocationUri = path
// Clear download queue.
mediaPlayerController.clear()
mediaPlayerManager.clear()
Storage.reset()
Storage.ensureRootIsAvailable()
}
@ -425,7 +385,6 @@ class SettingsFragment :
}
companion object {
const val SELECT_CACHE_ACTIVITY = 161161
const val RW_FLAG = Intent.FLAG_GRANT_READ_URI_PERMISSION or
Intent.FLAG_GRANT_WRITE_URI_PERMISSION
const val PERSISTABLE_FLAG = Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION

View File

@ -12,8 +12,11 @@ import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import androidx.core.view.MenuHost
import androidx.core.view.MenuProvider
import androidx.core.view.isVisible
import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LiveData
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.viewModelScope
@ -40,10 +43,10 @@ import org.moire.ultrasonic.domain.MusicDirectory
import org.moire.ultrasonic.domain.Track
import org.moire.ultrasonic.fragment.FragmentTitle.Companion.setTitle
import org.moire.ultrasonic.model.TrackCollectionModel
import org.moire.ultrasonic.service.MediaPlayerController
import org.moire.ultrasonic.service.MediaPlayerManager
import org.moire.ultrasonic.service.RxBus
import org.moire.ultrasonic.service.plusAssign
import org.moire.ultrasonic.subsonic.NetworkAndStorageChecker
import org.moire.ultrasonic.subsonic.DownloadAction
import org.moire.ultrasonic.subsonic.ShareHandler
import org.moire.ultrasonic.subsonic.VideoPlayer
import org.moire.ultrasonic.util.CancellationToken
@ -82,8 +85,7 @@ open class TrackCollectionFragment(
private var playAllButton: MenuItem? = null
private var shareButton: MenuItem? = null
internal val mediaPlayerController: MediaPlayerController by inject()
private val networkAndStorageChecker: NetworkAndStorageChecker by inject()
internal val mediaPlayerManager: MediaPlayerManager by inject()
private val shareHandler: ShareHandler by inject()
internal var cancellationToken: CancellationToken? = null
@ -115,7 +117,13 @@ open class TrackCollectionFragment(
setupButtons(view)
registerForContextMenu(listView!!)
setHasOptionsMenu(true)
// Register our options menu
(requireActivity() as MenuHost).addMenuProvider(
menuProvider,
viewLifecycleOwner,
Lifecycle.State.RESUMED
)
// Create a View Manager
viewManager = LinearLayoutManager(this.context)
@ -210,11 +218,14 @@ open class TrackCollectionFragment(
}
playNextButton?.setOnClickListener {
downloadHandler.download(
this@TrackCollectionFragment, append = true,
save = false, autoPlay = false, playNext = true, shuffle = false,
downloadHandler.addTracksToMediaController(
songs = getSelectedSongs(),
playlistName = navArgs.playlistName
append = true,
playNext = true,
autoPlay = false,
shuffle = false,
playlistName = navArgs.playlistName,
this@TrackCollectionFragment
)
}
@ -255,8 +266,8 @@ open class TrackCollectionFragment(
}
}
override fun onPrepareOptionsMenu(menu: Menu) {
super.onPrepareOptionsMenu(menu)
private val menuProvider: MenuProvider = object : MenuProvider {
override fun onPrepareMenu(menu: Menu) {
playAllButton = menu.findItem(R.id.select_album_play_all)
if (playAllButton != null) {
@ -270,27 +281,25 @@ open class TrackCollectionFragment(
}
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
override fun onCreateMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.select_album, menu)
super.onCreateOptionsMenu(menu, inflater)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
val itemId = item.itemId
if (itemId == R.id.select_album_play_all) {
override fun onMenuItemSelected(item: MenuItem): Boolean {
if (item.itemId == R.id.select_album_play_all) {
playAll()
return true
} else if (itemId == R.id.menu_item_share) {
} else if (item.itemId == R.id.menu_item_share) {
shareHandler.createShare(
this, getSelectedSongs(),
this@TrackCollectionFragment, getSelectedSongs(),
refreshListView, cancellationToken!!,
navArgs.id
)
return true
}
return false
}
}
override fun onDestroyView() {
cancellationToken!!.cancel()
@ -303,9 +312,14 @@ open class TrackCollectionFragment(
selectedSongs: List<Track> = getSelectedSongs()
) {
if (selectedSongs.isNotEmpty()) {
downloadHandler.download(
this, append, false, !append, playNext = false,
shuffle = false, songs = selectedSongs, null
downloadHandler.addTracksToMediaController(
songs = selectedSongs,
append = append,
playNext = false,
autoPlay = !append,
shuffle = false,
playlistName = null,
fragment = this
)
} else {
playAll(false, append)
@ -336,31 +350,29 @@ open class TrackCollectionFragment(
}
val isArtist = navArgs.isArtist
val id = navArgs.id
// Need a valid id to download stuff
val id = navArgs.id ?: return
if (hasSubFolders) {
downloadHandler.downloadRecursively(
downloadHandler.fetchTracksAndAddToController(
fragment = this,
id = id,
save = false,
append = append,
autoPlay = !append,
shuffle = shuffle,
background = false,
playNext = false,
unpin = false,
isArtist = isArtist
)
} else {
downloadHandler.download(
fragment = this,
append = append,
save = false,
autoPlay = !append,
playNext = false,
shuffle = shuffle,
downloadHandler.addTracksToMediaController(
songs = getAllSongs(),
playlistName = navArgs.playlistName
append = append,
playNext = false,
autoPlay = !append,
shuffle = shuffle,
playlistName = navArgs.playlistName,
fragment = this
)
}
}
@ -374,21 +386,18 @@ open class TrackCollectionFragment(
private fun selectAllOrNone() {
val someUnselected = viewAdapter.selectedSet.size < childCount
selectAll(someUnselected, true)
selectAll(someUnselected)
}
private fun selectAll(selected: Boolean, toast: Boolean) {
private fun selectAll(selected: Boolean) {
var selectedCount = viewAdapter.selectedSet.size * -1
selectedCount += viewAdapter.setSelectionStatusOfAll(selected)
// Display toast: N tracks selected
if (toast) {
val toastResId = R.string.select_album_n_selected
Util.toast(activity, getString(toastResId, selectedCount.coerceAtLeast(0)))
}
}
@Synchronized
fun triggerButtonUpdate(selection: List<Track> = getSelectedSongs()) {
@ -400,6 +409,8 @@ open class TrackCollectionFragment(
) {
// We are coming back from unknown context
// and need to ensure Main Thread in order to manipulate the UI
// If view is null, our view was disposed in the meantime
if (view == null) return
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.Main) {
val multipleSelection = viewAdapter.hasMultipleSelection()
@ -413,62 +424,35 @@ open class TrackCollectionFragment(
}
}
private fun downloadBackground(save: Boolean) {
var songs = getSelectedSongs()
private fun downloadBackground(save: Boolean, tracks: List<Track> = getSelectedSongs()) {
var songs = tracks
if (songs.isEmpty()) {
songs = getAllSongs()
}
downloadBackground(save, songs)
}
private fun downloadBackground(
save: Boolean,
songs: List<Track?>
) {
val onValid = Runnable {
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
mediaPlayerController.downloadBackground(songs, save)
if (save) {
Util.toast(
context,
resources.getQuantityString(
R.plurals.select_album_n_songs_pinned, songs.size, songs.size
val action = if (save) DownloadAction.PIN else DownloadAction.DOWNLOAD
downloadHandler.justDownload(
action = action,
fragment = this,
tracks = songs
)
)
} else {
Util.toast(
context,
resources.getQuantityString(
R.plurals.select_album_n_songs_downloaded, songs.size, songs.size
)
)
}
}
onValid.run()
}
internal fun delete(songs: List<Track> = getSelectedSongs()) {
Util.toast(
context,
resources.getQuantityString(
R.plurals.select_album_n_songs_deleted, songs.size, songs.size
downloadHandler.justDownload(
action = DownloadAction.DELETE,
fragment = this,
tracks = songs
)
)
mediaPlayerController.delete(songs)
}
internal fun unpin(songs: List<Track> = getSelectedSongs()) {
Util.toast(
context,
resources.getQuantityString(
R.plurals.select_album_n_songs_unpinned, songs.size, songs.size
downloadHandler.justDownload(
action = DownloadAction.UNPIN,
fragment = this,
tracks = songs
)
)
mediaPlayerController.unpin(songs)
}
override val defaultObserver: (List<MusicDirectory.Child>) -> Unit = {
@ -594,15 +578,15 @@ open class TrackCollectionFragment(
} else if (getVideos) {
setTitle(R.string.main_videos)
listModel.getVideos(refresh2)
} else if (getRandomTracks) {
} else if (id == null || getRandomTracks) {
// There seems to be a bug in ViewPager when resuming the Activity that sub-fragments
// arguments are empty. If we have no id, just show some random tracks
setTitle(R.string.main_songs_random)
listModel.getRandom(size, append)
} else {
setTitle(name)
requireNotNull(id) {
"ID must be set. NavArgs: ${navArgs.toBundle()}"
}
if (ActiveServerProvider.isID3Enabled()) {
if (ActiveServerProvider.shouldUseId3Tags()) {
if (isAlbum) {
listModel.getAlbum(refresh2, id, name)
} else {
@ -634,15 +618,14 @@ open class TrackCollectionFragment(
playNow(false, songs)
}
R.id.song_menu_play_next -> {
downloadHandler.download(
fragment = this@TrackCollectionFragment,
append = true,
save = false,
autoPlay = false,
playNext = true,
shuffle = false,
downloadHandler.addTracksToMediaController(
songs = songs,
playlistName = navArgs.playlistName
append = true,
playNext = true,
autoPlay = false,
shuffle = false,
playlistName = navArgs.playlistName,
fragment = this@TrackCollectionFragment
)
}
R.id.song_menu_play_last -> {
@ -657,10 +640,6 @@ open class TrackCollectionFragment(
R.id.song_menu_download -> {
downloadBackground(false, songs)
}
R.id.select_album_play_all -> {
// TODO: Why is this being handled here?!
playAll()
}
R.id.song_menu_share -> {
if (item is Track) {
shareHandler.createShare(

View File

@ -29,7 +29,8 @@ import androidx.fragment.app.Fragment
import androidx.navigation.fragment.findNavController
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import java.util.Locale
import org.koin.java.KoinJavaComponent.inject
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.moire.ultrasonic.NavigationGraphDirections
import org.moire.ultrasonic.R
import org.moire.ultrasonic.api.subsonic.ApiNotSupportedException
@ -38,6 +39,7 @@ import org.moire.ultrasonic.domain.Playlist
import org.moire.ultrasonic.fragment.FragmentTitle.Companion.setTitle
import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService
import org.moire.ultrasonic.service.OfflineException
import org.moire.ultrasonic.subsonic.DownloadAction
import org.moire.ultrasonic.subsonic.DownloadHandler
import org.moire.ultrasonic.util.BackgroundTask
import org.moire.ultrasonic.util.CacheCleaner
@ -54,15 +56,13 @@ import org.moire.ultrasonic.util.Util.toast
*
* TODO: This file has been converted from Java, but not modernized yet.
*/
class PlaylistsFragment : Fragment() {
class PlaylistsFragment : Fragment(), KoinComponent {
private var refreshPlaylistsListView: SwipeRefreshLayout? = null
private var playlistsListView: ListView? = null
private var emptyTextView: View? = null
private var playlistAdapter: ArrayAdapter<Playlist>? = null
private val downloadHandler = inject<DownloadHandler>(
DownloadHandler::class.java
)
private val downloadHandler by inject<DownloadHandler>()
private var cancellationToken: CancellationToken? = null
@ -147,45 +147,33 @@ class PlaylistsFragment : Fragment() {
val playlist = playlistsListView!!.getItemAtPosition(info.position) as Playlist
when (menuItem.itemId) {
R.id.playlist_menu_pin -> {
downloadHandler.value.downloadPlaylist(
this,
downloadHandler.justDownload(
DownloadAction.PIN,
fragment = this,
id = playlist.id,
name = playlist.name,
save = true,
append = true,
autoplay = false,
shuffle = false,
background = true,
playNext = false,
unpin = false
isShare = false,
isDirectory = false
)
}
R.id.playlist_menu_unpin -> {
downloadHandler.value.downloadPlaylist(
this,
downloadHandler.justDownload(
DownloadAction.UNPIN,
fragment = this,
id = playlist.id,
name = playlist.name,
save = false,
append = false,
autoplay = false,
shuffle = false,
background = true,
playNext = false,
unpin = true
isShare = false,
isDirectory = false
)
}
R.id.playlist_menu_download -> {
downloadHandler.value.downloadPlaylist(
this,
downloadHandler.justDownload(
DownloadAction.DOWNLOAD,
fragment = this,
id = playlist.id,
name = playlist.name,
save = false,
append = false,
autoplay = false,
shuffle = false,
background = true,
playNext = false,
unpin = false
isShare = false,
isDirectory = false
)
}
R.id.playlist_menu_play_now -> {

View File

@ -102,7 +102,9 @@ class SelectGenreFragment : Fragment() {
override fun done(result: List<Genre>) {
emptyView!!.isVisible = result.isEmpty()
genreListView!!.adapter = GenreAdapter(context, result)
if (context != null) {
genreListView!!.adapter = GenreAdapter(context!!, result)
}
}
}
task.execute()

View File

@ -28,7 +28,8 @@ import androidx.fragment.app.Fragment
import androidx.navigation.fragment.findNavController
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import java.util.Locale
import org.koin.java.KoinJavaComponent
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.moire.ultrasonic.NavigationGraphDirections
import org.moire.ultrasonic.R
import org.moire.ultrasonic.api.subsonic.ApiNotSupportedException
@ -36,6 +37,7 @@ import org.moire.ultrasonic.domain.Share
import org.moire.ultrasonic.fragment.FragmentTitle
import org.moire.ultrasonic.service.MusicServiceFactory
import org.moire.ultrasonic.service.OfflineException
import org.moire.ultrasonic.subsonic.DownloadAction
import org.moire.ultrasonic.subsonic.DownloadHandler
import org.moire.ultrasonic.util.BackgroundTask
import org.moire.ultrasonic.util.CancellationToken
@ -50,14 +52,12 @@ import org.moire.ultrasonic.view.ShareAdapter
*
* TODO: This file has been converted from Java, but not modernized yet.
*/
class SharesFragment : Fragment() {
class SharesFragment : Fragment(), KoinComponent {
private var refreshSharesListView: SwipeRefreshLayout? = null
private var sharesListView: ListView? = null
private var emptyTextView: View? = null
private var shareAdapter: ShareAdapter? = null
private val downloadHandler = KoinJavaComponent.inject<DownloadHandler>(
DownloadHandler::class.java
)
private val downloadHandler = inject<DownloadHandler>()
private var cancellationToken: CancellationToken? = null
override fun onCreate(savedInstanceState: Bundle?) {
Util.applyTheme(this.context)
@ -72,7 +72,6 @@ class SharesFragment : Fragment() {
return inflater.inflate(R.layout.select_share, container, false)
}
@Suppress("NAME_SHADOWING")
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
cancellationToken = CancellationToken()
refreshSharesListView = view.findViewById(R.id.select_share_refresh)
@ -132,73 +131,55 @@ class SharesFragment : Fragment() {
val share = sharesListView!!.getItemAtPosition(info.position) as Share
when (menuItem.itemId) {
R.id.share_menu_pin -> {
downloadHandler.value.downloadShare(
this,
share.id,
share.name,
save = true,
append = true,
autoplay = false,
shuffle = false,
background = true,
playNext = false,
unpin = false
downloadHandler.value.justDownload(
DownloadAction.PIN,
fragment = this,
id = share.id,
name = share.name,
isShare = true,
isDirectory = false
)
}
R.id.share_menu_unpin -> {
downloadHandler.value.downloadShare(
this,
share.id,
share.name,
save = false,
append = false,
autoplay = false,
shuffle = false,
background = true,
playNext = false,
unpin = true
downloadHandler.value.justDownload(
DownloadAction.UNPIN,
fragment = this,
id = share.id,
name = share.name,
isShare = true,
isDirectory = false
)
}
R.id.share_menu_download -> {
downloadHandler.value.downloadShare(
this,
share.id,
share.name,
save = false,
append = false,
autoplay = false,
shuffle = false,
background = true,
playNext = false,
unpin = false
downloadHandler.value.justDownload(
DownloadAction.DOWNLOAD,
fragment = this,
id = share.id,
name = share.name,
isShare = true,
isDirectory = false
)
}
R.id.share_menu_play_now -> {
downloadHandler.value.downloadShare(
downloadHandler.value.fetchTracksAndAddToController(
this,
share.id,
share.name,
save = false,
append = false,
autoplay = true,
autoPlay = true,
shuffle = false,
background = false,
playNext = false,
unpin = false
)
}
R.id.share_menu_play_shuffled -> {
downloadHandler.value.downloadShare(
downloadHandler.value.fetchTracksAndAddToController(
this,
share.id,
share.name,
save = false,
append = false,
autoplay = true,
autoPlay = true,
shuffle = true,
background = false,
playNext = false,
unpin = false
)
}
R.id.share_menu_delete -> {

View File

@ -19,11 +19,12 @@ import java.io.IOException
import java.util.concurrent.Executors
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.moire.ultrasonic.subsonic.ImageLoaderProvider
@SuppressLint("UnsafeOptInUsageError")
class ArtworkBitmapLoader : BitmapLoader, KoinComponent {
private val imageLoader: ImageLoader by inject()
private val imageLoaderProvider: ImageLoaderProvider by inject()
private val executorService: ListeningExecutorService by lazy {
MoreExecutors.listeningDecorator(
@ -55,6 +56,6 @@ class ArtworkBitmapLoader : BitmapLoader, KoinComponent {
val parts = uri.path?.trim('/')?.split('|')
require(parts!!.count() == 2) { "Invalid bitmap Uri" }
return imageLoader.getImage(parts[0], parts[1], false, 0)
return imageLoaderProvider.getImageLoader().getImage(parts[0], parts[1], false, 0)
}
}

View File

@ -18,6 +18,9 @@ import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CountDownLatch
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.moire.ultrasonic.R
import org.moire.ultrasonic.api.subsonic.SubsonicAPIClient
import org.moire.ultrasonic.api.subsonic.throwOnFailure
@ -36,7 +39,7 @@ class ImageLoader(
context: Context,
apiClient: SubsonicAPIClient,
private val config: ImageLoaderConfig,
) : CoroutineScope by CoroutineScope(Dispatchers.IO) {
) : CoroutineScope by CoroutineScope(Dispatchers.Main + SupervisorJob()) {
private val cacheInProgress: ConcurrentHashMap<String, CountDownLatch> = ConcurrentHashMap()
// Shortcut
@ -126,10 +129,13 @@ class ImageLoader(
large: Boolean,
size: Int,
defaultResourceId: Int = R.drawable.unknown_album
) {
) = launch {
val id = entry?.coverArt
// TODO getAlbumArtKey() accesses the disk from the UI thread..
val key = FileUtil.getAlbumArtKey(entry, large)
val key: String?
withContext(Dispatchers.IO) {
key = FileUtil.getAlbumArtKey(entry, large)
}
loadImage(view, id, key, large, size, defaultResourceId)
}
@ -194,30 +200,30 @@ class ImageLoader(
cacheCoverArt(track.coverArt!!, FileUtil.getAlbumArtFile(track))
}
fun cacheCoverArt(id: String, file: String) {
if (id.isBlank()) return
fun cacheCoverArt(id: String, file: String) = launch {
if (id.isBlank()) return@launch
withContext(Dispatchers.IO) {
// Return if have a cache hit
if (File(file).exists()) return
if (File(file).exists()) return@withContext
// If another thread has started caching, wait until it finishes
val latch = cacheInProgress.putIfAbsent(file, CountDownLatch(1))
if (latch != null) {
latch.await()
return
}
// If another coroutine has started caching, abort
if (cacheInProgress[file] != null) return@withContext
try {
// Always download the large size..
val size = config.largeSize
File(file).createNewFile()
// Query the API
Timber.d("Loading cover art for: %s", id)
try {
val response = API.getCoverArt(id, size.toLong()).execute().toStreamResponse()
response.throwOnFailure()
// Check for failure
if (response.stream == null) return
if (response.stream == null) return@withContext
// Write Response stream to file
var inputStream: InputStream? = null
@ -234,10 +240,13 @@ class ImageLoader(
} finally {
inputStream.safeClose()
}
} catch (all: Exception) {
Timber.w(all)
} finally {
cacheInProgress.remove(file)?.countDown()
}
}
}
private fun resolveSize(requested: Int, large: Boolean): Int {
return if (requested <= 0) {

View File

@ -12,9 +12,9 @@ import androidx.lifecycle.MutableLiveData
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.moire.ultrasonic.api.subsonic.models.AlbumListType
import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.domain.Album
import org.moire.ultrasonic.service.MusicServiceFactory
import org.moire.ultrasonic.util.Settings
class AlbumListModel(application: Application) : GenericListModel(application) {
@ -69,7 +69,7 @@ class AlbumListModel(application: Application) : GenericListModel(application) {
// If appending the existing list, set the offset from where to load
if (append) offset += (size + loadedUntil)
musicDirectory = if (Settings.shouldUseId3Tags) {
musicDirectory = if (ActiveServerProvider.shouldUseId3Tags()) {
service.getAlbumList2(
albumListType, size,
offset, musicFolderId
@ -119,7 +119,7 @@ class AlbumListModel(application: Application) : GenericListModel(application) {
val isAlphabetical = (lastType == AlbumListType.SORTED_BY_NAME) ||
(lastType == AlbumListType.SORTED_BY_ARTIST)
return !isOffline() && !Settings.shouldUseId3Tags && isAlphabetical
return !isOffline() && !ActiveServerProvider.shouldUseId3Tags() && isAlphabetical
}
private fun isCollectionSortable(albumListType: AlbumListType): Boolean {

View File

@ -43,7 +43,7 @@ class ArtistListModel(application: Application) : GenericListModel(application)
val musicFolderId = activeServer.musicFolderId
val result = if (ActiveServerProvider.isID3Enabled()) {
val result = if (ActiveServerProvider.shouldUseId3Tags()) {
musicService.getArtists(refresh)
} else {
musicService.getIndexes(musicFolderId, refresh)

View File

@ -10,7 +10,7 @@ package org.moire.ultrasonic.model
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import java.io.IOException
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.flatMapMerge
@ -90,7 +90,7 @@ class EditServerModel(val app: Application) : AndroidViewModel(app), KoinCompone
}
}
@OptIn(FlowPreview::class)
@OptIn(ExperimentalCoroutinesApi::class)
suspend fun queryFeatureSupport(currentServerSetting: ServerSetting): Flow<FeatureSupport> {
val client = buildTestClient(currentServerSetting)
// One line of magic:

View File

@ -26,7 +26,6 @@ import org.moire.ultrasonic.domain.MusicFolder
import org.moire.ultrasonic.service.MusicService
import org.moire.ultrasonic.service.MusicServiceFactory
import org.moire.ultrasonic.util.CommunicationError
import org.moire.ultrasonic.util.Settings
/**
* An abstract Model, which can be extended to retrieve a list of items from the API
@ -89,7 +88,7 @@ open class GenericListModel(application: Application) :
withContext(Dispatchers.IO) {
val musicService = MusicServiceFactory.getMusicService()
val isOffline = ActiveServerProvider.isOffline()
val useId3Tags = Settings.shouldUseId3Tags
val useId3Tags = ActiveServerProvider.shouldUseId3Tags()
try {
load(isOffline, useId3Tags, musicService, refresh)

View File

@ -13,12 +13,12 @@ import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.domain.MusicDirectory
import org.moire.ultrasonic.domain.Track
import org.moire.ultrasonic.service.DownloadService
import org.moire.ultrasonic.service.DownloadState
import org.moire.ultrasonic.service.MusicServiceFactory
import org.moire.ultrasonic.util.Settings
import org.moire.ultrasonic.util.Util
/*
@ -40,7 +40,7 @@ class TrackCollectionModel(application: Application) : GenericListModel(applicat
val service = MusicServiceFactory.getMusicService()
val musicDirectory = service.getMusicDirectory(id, name, refresh)
currentListIsSortable = true
updateList(musicDirectory)
}
}
@ -51,7 +51,7 @@ class TrackCollectionModel(application: Application) : GenericListModel(applicat
val service = MusicServiceFactory.getMusicService()
val musicDirectory: MusicDirectory = service.getAlbumAsDir(id, name, refresh)
currentListIsSortable = true
updateList(musicDirectory)
}
}
@ -60,6 +60,7 @@ class TrackCollectionModel(application: Application) : GenericListModel(applicat
withContext(Dispatchers.IO) {
val service = MusicServiceFactory.getMusicService()
val musicDirectory = service.getSongsByGenre(genre, count, offset)
currentListIsSortable = false
updateList(musicDirectory, append)
}
}
@ -71,12 +72,12 @@ class TrackCollectionModel(application: Application) : GenericListModel(applicat
val service = MusicServiceFactory.getMusicService()
val musicDirectory: MusicDirectory
musicDirectory = if (Settings.shouldUseId3Tags) {
musicDirectory = if (ActiveServerProvider.shouldUseId3Tags()) {
Util.getSongsFromSearchResult(service.getStarred2())
} else {
Util.getSongsFromSearchResult(service.getStarred())
}
currentListIsSortable = false
updateList(musicDirectory)
}
}
@ -87,8 +88,8 @@ class TrackCollectionModel(application: Application) : GenericListModel(applicat
withContext(Dispatchers.IO) {
val service = MusicServiceFactory.getMusicService()
val videos = service.getVideos(refresh)
if (videos != null) {
currentListIsSortable = false
updateList(videos)
}
}
@ -99,19 +100,16 @@ class TrackCollectionModel(application: Application) : GenericListModel(applicat
withContext(Dispatchers.IO) {
val service = MusicServiceFactory.getMusicService()
val musicDirectory = service.getRandomSongs(size)
currentListIsSortable = false
updateList(musicDirectory, append)
}
}
suspend fun getPlaylist(playlistId: String, playlistName: String) {
withContext(Dispatchers.IO) {
val service = MusicServiceFactory.getMusicService()
val musicDirectory = service.getPlaylist(playlistId, playlistName)
currentListIsSortable = false
updateList(musicDirectory)
}
}
@ -121,8 +119,8 @@ class TrackCollectionModel(application: Application) : GenericListModel(applicat
withContext(Dispatchers.IO) {
val service = MusicServiceFactory.getMusicService()
val musicDirectory = service.getPodcastEpisodes(podcastChannelId)
if (musicDirectory != null) {
currentListIsSortable = false
updateList(musicDirectory)
}
}
@ -144,7 +142,7 @@ class TrackCollectionModel(application: Application) : GenericListModel(applicat
break
}
}
currentListIsSortable = false
updateList(musicDirectory)
}
}
@ -153,7 +151,7 @@ class TrackCollectionModel(application: Application) : GenericListModel(applicat
withContext(Dispatchers.IO) {
val service = MusicServiceFactory.getMusicService()
val musicDirectory = Util.getSongsFromBookmarks(service.getBookmarks())
currentListIsSortable = false
updateList(musicDirectory)
}
}

View File

@ -9,8 +9,6 @@ package org.moire.ultrasonic.playback
import android.annotation.SuppressLint
import android.os.Bundle
import android.widget.Toast
import android.widget.Toast.LENGTH_SHORT
import androidx.media3.common.HeartRating
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
@ -19,18 +17,16 @@ import androidx.media3.common.MediaMetadata.FOLDER_TYPE_ARTISTS
import androidx.media3.common.MediaMetadata.FOLDER_TYPE_MIXED
import androidx.media3.common.MediaMetadata.FOLDER_TYPE_PLAYLISTS
import androidx.media3.common.MediaMetadata.FOLDER_TYPE_TITLES
import androidx.media3.common.Player
import androidx.media3.common.Rating
import androidx.media3.common.StarRating
import androidx.media3.session.CommandButton
import androidx.media3.session.LibraryResult
import androidx.media3.session.MediaLibraryService
import androidx.media3.session.MediaSession
import androidx.media3.session.SessionCommand
import androidx.media3.session.SessionResult
import androidx.media3.session.SessionResult.RESULT_ERROR_BAD_VALUE
import androidx.media3.session.SessionResult.RESULT_ERROR_UNKNOWN
import androidx.media3.session.SessionResult.RESULT_SUCCESS
import com.google.common.collect.ImmutableList
import com.google.common.util.concurrent.FutureCallback
import com.google.common.util.concurrent.Futures
import com.google.common.util.concurrent.ListenableFuture
import kotlinx.coroutines.CoroutineScope
@ -44,14 +40,14 @@ import org.moire.ultrasonic.R
import org.moire.ultrasonic.api.subsonic.models.AlbumListType
import org.moire.ultrasonic.app.UApp
import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.data.RatingUpdate
import org.moire.ultrasonic.domain.MusicDirectory
import org.moire.ultrasonic.domain.SearchCriteria
import org.moire.ultrasonic.domain.SearchResult
import org.moire.ultrasonic.domain.Track
import org.moire.ultrasonic.service.MediaPlayerController
import org.moire.ultrasonic.service.MediaPlayerManager
import org.moire.ultrasonic.service.MusicServiceFactory
import org.moire.ultrasonic.util.MainThreadExecutor
import org.moire.ultrasonic.util.Settings
import org.moire.ultrasonic.service.RatingManager
import org.moire.ultrasonic.util.Util
import org.moire.ultrasonic.util.buildMediaItem
import org.moire.ultrasonic.util.toMediaItem
@ -94,7 +90,6 @@ private const val DISPLAY_LIMIT = 100
private const val SEARCH_LIMIT = 10
// List of available custom SessionCommands
const val SESSION_CUSTOM_SET_RATING = "SESSION_CUSTOM_SET_RATING"
const val PLAY_COMMAND = "play "
/**
@ -102,10 +97,10 @@ const val PLAY_COMMAND = "play "
*/
@Suppress("TooManyFunctions", "LargeClass", "UnusedPrivateMember")
@SuppressLint("UnsafeOptInUsageError")
class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibraryService) :
class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) :
MediaLibraryService.MediaLibrarySession.Callback, KoinComponent {
private val mediaPlayerController by inject<MediaPlayerController>()
private val mediaPlayerManager by inject<MediaPlayerManager>()
private val activeServerProvider: ActiveServerProvider by inject()
private val serviceJob = SupervisorJob()
@ -119,9 +114,26 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
private val musicService get() = MusicServiceFactory.getMusicService()
private val isOffline get() = ActiveServerProvider.isOffline()
private val useId3Tags get() = Settings.shouldUseId3Tags
private val musicFolderId get() = activeServerProvider.getActiveServer().musicFolderId
private var customCommands: List<CommandButton>
internal var customLayout = ImmutableList.of<CommandButton>()
init {
customCommands =
listOf(
// This button is used for an unstarred track, and its action will star the track
getHeartCommandButton(
SessionCommand(PlaybackService.CUSTOM_COMMAND_TOGGLE_HEART_ON, Bundle.EMPTY)
),
// This button is used for an starred track, and its action will unstar the track
getHeartCommandButton(
SessionCommand(PlaybackService.CUSTOM_COMMAND_TOGGLE_HEART_OFF, Bundle.EMPTY)
)
)
customLayout = ImmutableList.of(customCommands[0])
}
/**
* Called when a {@link MediaBrowser} requests the root {@link MediaItem} by {@link
* MediaBrowser#getLibraryRoot(LibraryParams)}.
@ -179,11 +191,10 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
val connectionResult = super.onConnect(session, controller)
val availableSessionCommands = connectionResult.availableSessionCommands.buildUpon()
/*
* TODO: Currently we need to create a custom session command, see https://github.com/androidx/media/issues/107
* When this issue is fixed we should be able to remove this method again
*/
availableSessionCommands.add(SessionCommand(SESSION_CUSTOM_SET_RATING, Bundle()))
for (commandButton in customCommands) {
// Add custom command to available session commands.
commandButton.sessionCommand?.let { availableSessionCommands.add(it) }
}
return MediaSession.ConnectionResult.accept(
availableSessionCommands.build(),
@ -191,6 +202,28 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
)
}
override fun onPostConnect(session: MediaSession, controller: MediaSession.ControllerInfo) {
if (!customLayout.isEmpty() && controller.controllerVersion != 0) {
// Let Media3 controller (for instance the MediaNotificationProvider)
// know about the custom layout right after it connected.
session.setCustomLayout(customLayout)
}
}
private fun getHeartCommandButton(sessionCommand: SessionCommand): CommandButton {
val willHeart =
(sessionCommand.customAction == PlaybackService.CUSTOM_COMMAND_TOGGLE_HEART_ON)
return CommandButton.Builder()
.setDisplayName("Love")
.setIconResId(
if (willHeart) R.drawable.ic_star_hollow
else R.drawable.ic_star_full
)
.setSessionCommand(sessionCommand)
.setEnabled(true)
.build()
}
override fun onGetItem(
session: MediaLibraryService.MediaLibrarySession,
browser: MediaSession.ControllerInfo,
@ -204,12 +237,12 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
// Create LRU Cache of MediaItems, fill it in the other calls
// and retrieve it here.
if (mediaItem != null) {
return Futures.immediateFuture(
return if (mediaItem != null) {
Futures.immediateFuture(
LibraryResult.ofItem(mediaItem, null)
)
} else {
return Futures.immediateFuture(
Futures.immediateFuture(
LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE)
)
}
@ -237,39 +270,13 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
var customCommandFuture: ListenableFuture<SessionResult>? = null
when (customCommand.customAction) {
SESSION_CUSTOM_SET_RATING -> {
/*
* It is currently not possible to edit a MediaItem after creation so the isRated value
* is stored in the track.starred value
* See https://github.com/androidx/media/issues/33
*/
val track = mediaPlayerController.currentMediaItem?.toTrack()
if (track != null) {
customCommandFuture = onSetRating(
session,
controller,
HeartRating(!track.starred)
)
Futures.addCallback(
customCommandFuture,
object : FutureCallback<SessionResult> {
override fun onSuccess(result: SessionResult) {
track.starred = !track.starred
// This needs to be called on the main Thread
libraryService.onUpdateNotification(session)
}
override fun onFailure(t: Throwable) {
Toast.makeText(
mediaPlayerController.context,
"There was an error updating the rating",
LENGTH_SHORT
).show()
}
},
MainThreadExecutor()
)
PlaybackService.CUSTOM_COMMAND_TOGGLE_HEART_ON -> {
customCommandFuture = onSetRating(session, controller, HeartRating(true))
updateCustomHeartButton(session, true)
}
PlaybackService.CUSTOM_COMMAND_TOGGLE_HEART_OFF -> {
customCommandFuture = onSetRating(session, controller, HeartRating(false))
updateCustomHeartButton(session, false)
}
else -> {
Timber.d(
@ -283,19 +290,25 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
return customCommandFuture
return super.onCustomCommand(session, controller, customCommand, args)
}
override fun onSetRating(
session: MediaSession,
controller: MediaSession.ControllerInfo,
rating: Rating
): ListenableFuture<SessionResult> {
if (session.player.currentMediaItem != null)
val mediaItem = session.player.currentMediaItem
if (mediaItem != null) {
if (rating is HeartRating) {
mediaItem.toTrack().starred = rating.isHeart
} else if (rating is StarRating) {
mediaItem.toTrack().userRating = rating.starRating.toInt()
}
return onSetRating(
session,
controller,
session.player.currentMediaItem!!.mediaId,
mediaItem.mediaId,
rating
)
}
return super.onSetRating(session, controller, rating)
}
@ -305,23 +318,23 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
mediaId: String,
rating: Rating
): ListenableFuture<SessionResult> {
// TODO: Through this methods it is possible to set a rating on an arbitrary MediaItem.
// Right now the ratings are submitted, yet the underlying track is only updated when
// coming from the other onSetRating(session, controller, rating)
return serviceScope.future {
if (rating is HeartRating) {
try {
if (rating.isHeart) {
musicService.star(mediaId, null, null)
} else {
musicService.unstar(mediaId, null, null)
}
} catch (all: Exception) {
Timber.e(all)
// TODO: Better handle exception
return@future SessionResult(RESULT_ERROR_UNKNOWN)
}
Timber.i(controller.packageName)
// This function even though its declared in AutoMediaBrowserCallback.kt is
// actually called every time we set the rating on an MediaItem.
// To avoid an event loop it does not emit a RatingUpdate event,
// but calls the Manager directly
RatingManager.instance.submitRating(
RatingUpdate(
id = mediaId,
rating = rating
)
)
return@future SessionResult(RESULT_SUCCESS)
}
return@future SessionResult(RESULT_ERROR_BAD_VALUE)
}
}
/*
@ -329,7 +342,6 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
* and thereby customarily it is required to rebuild it..
* See also: https://stackoverflow.com/questions/70096715/adding-mediaitem-when-using-the-media3-library-caused-an-error
*/
override fun onAddMediaItems(
mediaSession: MediaSession,
controller: MediaSession.ControllerInfo,
@ -445,6 +457,9 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
private fun playFromSearch(
query: String,
): ListenableFuture<List<MediaItem>> {
Timber.w("App state: %s", UApp.instance != null)
Timber.i("AutoMediaBrowserService onSearch query: %s", query)
val mediaItems: MutableList<MediaItem> = ArrayList()
@ -661,7 +676,7 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
var childMediaId: String = MEDIA_ARTIST_ITEM
var artists = serviceScope.future {
if (!isOffline && useId3Tags) {
if (ActiveServerProvider.shouldUseId3Tags()) {
// TODO this list can be big so we're not refreshing.
// Maybe a refresh menu item can be added
callWithErrorHandling { musicService.getArtists(false) }
@ -716,7 +731,7 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
return mainScope.future {
val albums = serviceScope.future {
if (!isOffline && useId3Tags) {
if (ActiveServerProvider.shouldUseId3Tags()) {
callWithErrorHandling { musicService.getAlbumsOfArtist(id, name, false) }
} else {
callWithErrorHandling {
@ -788,7 +803,7 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
val offset = (page ?: 0) * DISPLAY_LIMIT
val albums = serviceScope.future {
if (useId3Tags) {
if (ActiveServerProvider.shouldUseId3Tags()) {
callWithErrorHandling {
musicService.getAlbumList2(
type, DISPLAY_LIMIT, offset, null
@ -1190,7 +1205,7 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
private fun listSongsInMusicService(id: String, name: String?): MusicDirectory? {
return serviceScope.future {
if (!ActiveServerProvider.isOffline() && Settings.shouldUseId3Tags) {
if (ActiveServerProvider.shouldUseId3Tags()) {
callWithErrorHandling { musicService.getAlbumAsDir(id, name, false) }
} else {
callWithErrorHandling { musicService.getMusicDirectory(id, name, false) }
@ -1200,7 +1215,7 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
private fun listStarredSongsInMusicService(): SearchResult? {
return serviceScope.future {
if (Settings.shouldUseId3Tags) {
if (ActiveServerProvider.shouldUseId3Tags()) {
callWithErrorHandling { musicService.getStarred2() }
} else {
callWithErrorHandling { musicService.getStarred() }
@ -1278,4 +1293,15 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
null
}
}
fun updateCustomHeartButton(
session: MediaSession,
isHeart: Boolean
) {
val command = if (isHeart) customCommands[1] else customCommands[0]
// Change the custom layout to contain the right heart button
customLayout = ImmutableList.of(command)
// Send the updated custom layout to controllers.
session.setCustomLayout(customLayout)
}
}

View File

@ -7,79 +7,22 @@
package org.moire.ultrasonic.playback
import android.content.Context
import androidx.core.app.NotificationCompat
import androidx.media3.common.HeartRating
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.session.CommandButton
import androidx.media3.session.DefaultMediaNotificationProvider
import androidx.media3.session.MediaNotification
import androidx.media3.session.MediaSession
import androidx.media3.session.SessionCommand
import com.google.common.collect.ImmutableList
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.moire.ultrasonic.R
import org.moire.ultrasonic.service.MediaPlayerController
import org.moire.ultrasonic.util.toTrack
@UnstableApi
class CustomNotificationProvider(ctx: Context) :
DefaultMediaNotificationProvider(ctx),
KoinComponent {
/*
* It is currently not possible to edit a MediaItem after creation so the isRated value
* is stored in the track.starred value. See https://github.com/androidx/media/issues/33
* TODO: Once the bug is fixed remove this circular reference!
*/
private val mediaPlayerController by inject<MediaPlayerController>()
override fun addNotificationActions(
mediaSession: MediaSession,
mediaButtons: ImmutableList<CommandButton>,
builder: NotificationCompat.Builder,
actionFactory: MediaNotification.ActionFactory
): IntArray {
val tmp: MutableList<CommandButton> = mutableListOf()
/*
* TODO:
* It is currently not possible to edit a MediaItem after creation so the isRated value
* is stored in the track.starred value
* See https://github.com/androidx/media/issues/33
*/
val rating = mediaPlayerController.currentMediaItem?.toTrack()?.starred?.let {
HeartRating(
it
)
}
if (rating is HeartRating) {
tmp.add(
CommandButton.Builder()
.setDisplayName("Love")
.setIconResId(
if (rating.isHeart) R.drawable.ic_star_full
else R.drawable.ic_star_hollow
)
.setSessionCommand(
SessionCommand(
SESSION_CUSTOM_SET_RATING,
HeartRating(rating.isHeart).toBundle()
)
)
.setExtras(HeartRating(rating.isHeart).toBundle())
.setEnabled(true)
.build()
)
}
return super.addNotificationActions(
mediaSession,
ImmutableList.copyOf((mediaButtons + tmp)),
builder,
actionFactory
)
}
// By default the skip buttons are not shown in compact view.
// We add the COMMAND_KEY_COMPACT_VIEW_INDEX to show them
// See also: https://github.com/androidx/media/issues/410
override fun getMediaButtons(
session: MediaSession,
playerCommands: Player.Commands,

View File

@ -26,14 +26,17 @@ import androidx.media3.datasource.okhttp.OkHttpDataSource
import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.exoplayer.source.ShuffleOrder
import androidx.media3.session.MediaLibraryService
import androidx.media3.session.MediaSession
import io.reactivex.rxjava3.disposables.CompositeDisposable
import java.util.Random
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import okhttp3.OkHttpClient
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.moire.ultrasonic.R
import org.moire.ultrasonic.activity.NavigationActivity
import org.moire.ultrasonic.app.UApp
@ -43,6 +46,8 @@ import org.moire.ultrasonic.domain.Track
import org.moire.ultrasonic.imageloader.ArtworkBitmapLoader
import org.moire.ultrasonic.provider.UltrasonicAppWidgetProvider
import org.moire.ultrasonic.service.DownloadService
import org.moire.ultrasonic.service.JukeboxMediaPlayer
import org.moire.ultrasonic.service.MediaPlayerManager
import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService
import org.moire.ultrasonic.service.RxBus
import org.moire.ultrasonic.service.plusAssign
@ -58,11 +63,12 @@ class PlaybackService :
MediaLibraryService(),
KoinComponent,
CoroutineScope by CoroutineScope(Dispatchers.IO) {
private lateinit var player: ExoPlayer
private lateinit var player: Player
private lateinit var mediaLibrarySession: MediaLibrarySession
private var equalizer: EqualizerController? = null
private val activeServerProvider: ActiveServerProvider by inject()
private lateinit var librarySessionCallback: MediaLibrarySession.Callback
private lateinit var librarySessionCallback: AutoMediaBrowserCallback
private var rxBusSubscription = CompositeDisposable()
@ -73,6 +79,7 @@ class PlaybackService :
super.onCreate()
initializeSessionAndPlayer()
setListener(MediaSessionServiceListener())
instance = this
}
private fun getWakeModeFlag(): Int {
@ -96,6 +103,7 @@ class PlaybackService :
}
private fun releasePlayerAndSession() {
Timber.i("Releasing player and session")
// Broadcast that the service is being shutdown
RxBus.stopServiceCommandPublisher.onNext(Unit)
@ -124,6 +132,106 @@ class PlaybackService :
setMediaNotificationProvider(CustomNotificationProvider(UApp.applicationContext()))
// TODO: Remove minor code duplication with updateBackend()
val desiredBackend = if (activeServerProvider.getActiveServer().jukeboxByDefault) {
MediaPlayerManager.PlayerBackend.JUKEBOX
} else {
MediaPlayerManager.PlayerBackend.LOCAL
}
player = if (activeServerProvider.getActiveServer().jukeboxByDefault) {
Timber.i("Jukebox enabled by default")
getJukeboxPlayer()
} else {
getLocalPlayer()
}
actualBackend = desiredBackend
// Create browser interface
librarySessionCallback = AutoMediaBrowserCallback(this)
// This will need to use the AutoCalls
mediaLibrarySession = MediaLibrarySession.Builder(this, player, librarySessionCallback)
.setSessionActivity(getPendingIntentForContent())
.setBitmapLoader(ArtworkBitmapLoader())
.build()
if (!librarySessionCallback.customLayout.isEmpty()) {
// Send custom layout to legacy session.
mediaLibrarySession.setCustomLayout(librarySessionCallback.customLayout)
}
// Set a listener to update the API client when the active server has changed
rxBusSubscription += RxBus.activeServerChangedObservable.subscribe {
// Set the player wake mode
(player as? ExoPlayer)?.setWakeMode(getWakeModeFlag())
}
// Set a listener to reset the ShuffleOrder
rxBusSubscription += RxBus.shufflePlayObservable.subscribe { shuffle ->
// This only applies for local playback
val exo = if (player is ExoPlayer) {
player as ExoPlayer
} else {
return@subscribe
}
val len = player.currentTimeline.windowCount
Timber.i("Resetting shuffle order, isShuffled: %s", shuffle)
// If disabling Shuffle return early
if (!shuffle) {
return@subscribe exo.setShuffleOrder(
ShuffleOrder.UnshuffledShuffleOrder(len)
)
}
// Get the position of the current track in the unshuffled order
val cur = player.currentMediaItemIndex
val seed = System.currentTimeMillis()
val random = Random(seed)
val list = createShuffleListFromCurrentIndex(cur, len, random)
Timber.i("New Shuffle order: %s", list.joinToString { it.toString() })
exo.setShuffleOrder(ShuffleOrder.DefaultShuffleOrder(list, seed))
}
// Listen to the shutdown command
rxBusSubscription += RxBus.shutdownCommandObservable.subscribe {
Timber.i("Received destroy command via Rx")
onDestroy()
}
player.addListener(listener)
isStarted = true
}
private fun updateBackend(newBackend: MediaPlayerManager.PlayerBackend) {
Timber.i("Switching player backends")
// Remove old listeners
player.removeListener(listener)
player.release()
player = if (newBackend == MediaPlayerManager.PlayerBackend.JUKEBOX) {
getJukeboxPlayer()
} else {
getLocalPlayer()
}
// Add fresh listeners
player.addListener(listener)
mediaLibrarySession.player = player
actualBackend = newBackend
}
private fun getJukeboxPlayer(): Player {
return JukeboxMediaPlayer()
}
private fun getLocalPlayer(): Player {
// Create a new plain OkHttpClient
val builder = OkHttpClient.Builder()
val client = builder.build()
@ -144,7 +252,7 @@ class PlaybackService :
renderer.setEnableAudioOffload(true)
// Create the player
player = ExoPlayer.Builder(this)
val player = ExoPlayer.Builder(this)
.setAudioAttributes(getAudioAttributes(), true)
.setWakeMode(getWakeModeFlag())
.setHandleAudioBecomingNoisy(true)
@ -154,35 +262,32 @@ class PlaybackService :
.setSeekForwardIncrementMs(Settings.seekInterval.toLong())
.build()
// Setup Equalizer
equalizer = EqualizerController.create(player.audioSessionId)
// Enable audio offload
if (Settings.useHwOffload)
player.experimentalSetOffloadSchedulingEnabled(true)
// Create browser interface
librarySessionCallback = AutoMediaBrowserCallback(player, this)
// This will need to use the AutoCalls
mediaLibrarySession = MediaLibrarySession.Builder(this, player, librarySessionCallback)
.setSessionActivity(getPendingIntentForContent())
.setBitmapLoader(ArtworkBitmapLoader())
.build()
// Set a listener to update the API client when the active server has changed
rxBusSubscription += RxBus.activeServerChangedObservable.subscribe {
// Set the player wake mode
player.setWakeMode(getWakeModeFlag())
return player
}
// Listen to the shutdown command
rxBusSubscription += RxBus.shutdownCommandObservable.subscribe {
Timber.i("Received destroy command via Rx")
onDestroy()
private fun createShuffleListFromCurrentIndex(
currentIndex: Int,
length: Int,
random: Random
): IntArray {
val list = IntArray(length) { it }
// Shuffle the remaining items using a swapping algorithm
for (i in currentIndex + 1 until length) {
val swapIndex = (currentIndex + 1) + random.nextInt(i - currentIndex)
val swapItem = list[i]
list[i] = list[swapIndex]
list[swapIndex] = swapItem
}
player.addListener(listener)
isStarted = true
return list
}
private val listener: Player.Listener = object : Player.Listener {
@ -191,7 +296,14 @@ class PlaybackService :
}
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
updateWidgetTrack(mediaItem?.toTrack())
// Since we cannot update the metadata of the media item after creation,
// we cannot set change the rating on it
// Therefore the track must be our source of truth
val track = mediaItem?.toTrack()
if (track != null) {
updateCustomHeartButton(track.starred)
}
updateWidgetTrack(track)
cacheNextSongs()
}
@ -201,7 +313,12 @@ class PlaybackService :
}
}
private fun updateCustomHeartButton(isHeart: Boolean) {
librarySessionCallback.updateCustomHeartButton(mediaLibrarySession, isHeart)
}
private fun cacheNextSongs() {
if (actualBackend == MediaPlayerManager.PlayerBackend.JUKEBOX) return
Timber.d("PlaybackService caching the next songs")
val nextSongs = Util.getPlayListFromTimeline(
player.currentTimeline,
@ -291,8 +408,22 @@ class PlaybackService :
}
companion object {
var actualBackend: MediaPlayerManager.PlayerBackend? = null
private var desiredBackend: MediaPlayerManager.PlayerBackend? = null
fun setBackend(playerBackend: MediaPlayerManager.PlayerBackend) {
desiredBackend = playerBackend
instance?.updateBackend(playerBackend)
}
var instance: PlaybackService? = null
private const val NOTIFICATION_CHANNEL_ID = "org.moire.ultrasonic.error"
private const val NOTIFICATION_CHANNEL_NAME = "Ultrasonic error messages"
const val CUSTOM_COMMAND_TOGGLE_HEART_ON =
"org.moire.ultrasonic.HEART_ON"
const val CUSTOM_COMMAND_TOGGLE_HEART_OFF =
"org.moire.ultrasonic.HEART_OFF"
private const val NOTIFICATION_ID = 3009
}
}

View File

@ -11,6 +11,7 @@ import android.app.Notification
import android.app.Service
import android.content.Intent
import android.net.wifi.WifiManager
import android.os.Binder
import android.os.Build
import android.os.Handler
import android.os.IBinder
@ -27,6 +28,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.moire.ultrasonic.R
@ -34,12 +36,10 @@ import org.moire.ultrasonic.app.UApp
import org.moire.ultrasonic.domain.Track
import org.moire.ultrasonic.service.DownloadState.Companion.isFinalState
import org.moire.ultrasonic.util.CacheCleaner
import org.moire.ultrasonic.util.FileUtil
import org.moire.ultrasonic.util.FileUtil.getCompleteFile
import org.moire.ultrasonic.util.FileUtil.getPartialFile
import org.moire.ultrasonic.util.FileUtil.getPinnedFile
import org.moire.ultrasonic.util.Settings
import org.moire.ultrasonic.util.SimpleServiceBinder
import org.moire.ultrasonic.util.Storage
import org.moire.ultrasonic.util.Util
import org.moire.ultrasonic.util.Util.stopForegroundRemoveNotification
@ -77,8 +77,7 @@ class DownloadService : Service(), KoinComponent {
// Create Coroutine lifecycle scope. We use a SupervisorJob(), otherwise the failure of one
// would mean the failure of all jobs!
val supervisor = SupervisorJob()
scope = CoroutineScope(Dispatchers.IO + supervisor)
scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
val notificationManagerCompat = NotificationManagerCompat.from(this)
@ -147,7 +146,7 @@ class DownloadService : Service(), KoinComponent {
val downloadTask = DownloadTask(track, scope!!, ::downloadStateChangedCallback)
activeDownloads[track.id] = downloadTask
FileUtil.createDirectoryForParent(track.pinnedFile)
downloadTask.start()
listChanged = true
}
@ -200,7 +199,7 @@ class DownloadService : Service(), KoinComponent {
private fun updateLiveData() {
val temp: MutableList<Track> = ArrayList()
temp.addAll(activeDownloads.values.map { it.track.track })
temp.addAll(activeDownloads.values.map { it.downloadTrack.track })
temp.addAll(downloadQueue.map { x -> x.track })
observableDownloads.postValue(temp.distinct().sorted())
}
@ -257,7 +256,7 @@ class DownloadService : Service(), KoinComponent {
return notificationBuilder.build()
}
@Suppress("MagicNumber", "NestedBlockDepth")
@Suppress("MagicNumber", "NestedBlockDepth", "TooManyFunctions")
companion object {
private var startFuture: SettableFuture<DownloadService>? = null
@ -278,6 +277,8 @@ class DownloadService : Service(), KoinComponent {
save: Boolean,
isHighPriority: Boolean = false
) {
CoroutineScope(Dispatchers.IO).launch {
// First handle and filter out those tracks that are already completed
var filteredTracks: List<Track>
if (save) {
@ -302,7 +303,7 @@ class DownloadService : Service(), KoinComponent {
downloadQueue.filter { item -> tracks.any { it.id == item.id } }
.forEach { it.pinned = save }
tracks.forEach {
activeDownloads[it.id]?.track?.pinned = save
activeDownloads[it.id]?.downloadTrack?.pinned = save
}
tracks.forEach {
failedList[it.id]?.pinned = save
@ -331,6 +332,7 @@ class DownloadService : Service(), KoinComponent {
processNextTracksOnService()
}
}
}
fun requestStop() {
val context = UApp.applicationContext()
@ -340,7 +342,7 @@ class DownloadService : Service(), KoinComponent {
}
fun delete(track: Track) {
CoroutineScope(Dispatchers.IO).launch {
downloadQueue.get(track.id)?.let { downloadQueue.remove(it) }
failedList[track.id]?.let { downloadQueue.remove(it) }
cancelDownload(track)
@ -352,11 +354,22 @@ class DownloadService : Service(), KoinComponent {
CacheCleaner().cleanDatabaseSelective(track)
Util.scanMedia(track.getPinnedFile())
}
}
@Synchronized
fun unpin(tracks: List<Track>) {
tracks.forEach(::unpin)
}
@Synchronized
fun delete(tracks: List<Track>) {
tracks.forEach(::delete)
}
fun unpin(track: Track) {
// Update Pinned flag of items in progress
downloadQueue.get(track.id)?.pinned = false
activeDownloads[track.id]?.track?.pinned = false
activeDownloads[track.id]?.downloadTrack?.pinned = false
failedList[track.id]?.pinned = false
val pinnedFile = track.getPinnedFile()
@ -376,7 +389,7 @@ class DownloadService : Service(), KoinComponent {
if (activeDownloads.contains(track.id)) return DownloadState.QUEUED
if (downloadQueue.contains(track.id)) return DownloadState.QUEUED
val downloadableTrack = activeDownloads[track.id]?.track
val downloadableTrack = activeDownloads[track.id]?.downloadTrack
if (downloadableTrack != null) {
if (downloadableTrack.tryCount > 0) return DownloadState.RETRYING
return DownloadState.DOWNLOADING
@ -439,3 +452,5 @@ class DownloadService : Service(), KoinComponent {
}
}
}
class SimpleServiceBinder<S>(val service: S) : Binder()

View File

@ -36,7 +36,7 @@ private const val MAX_RETRIES = 5
private const val REFRESH_INTERVAL = 50
class DownloadTask(
private val item: DownloadableTrack,
val downloadTrack: DownloadableTrack,
private val scope: CoroutineScope,
private val stateChangedCallback: (DownloadableTrack, DownloadState, progress: Int?) -> Unit
) : KoinComponent {
@ -49,38 +49,35 @@ class DownloadTask(
private var outputStream: OutputStream? = null
private var lastPostTime: Long = 0
val track: DownloadableTrack
get() = item
private fun checkIfExists(): Boolean {
if (Storage.isPathExists(item.pinnedFile)) {
Timber.i("%s already exists. Skipping.", item.pinnedFile)
stateChangedCallback(item, DownloadState.PINNED, null)
if (Storage.isPathExists(downloadTrack.pinnedFile)) {
Timber.i("%s already exists. Skipping.", downloadTrack.pinnedFile)
stateChangedCallback(downloadTrack, DownloadState.PINNED, null)
return true
}
if (Storage.isPathExists(item.completeFile)) {
if (Storage.isPathExists(downloadTrack.completeFile)) {
var newStatus: DownloadState = DownloadState.DONE
if (item.pinned) {
if (downloadTrack.pinned) {
Storage.rename(
item.completeFile,
item.pinnedFile
downloadTrack.completeFile,
downloadTrack.pinnedFile
)
newStatus = DownloadState.PINNED
} else {
Timber.i(
"%s already exists. Skipping.",
item.completeFile
downloadTrack.completeFile
)
}
// Hidden feature: If track is toggled between pinned/saved, refresh the metadata..
try {
item.track.cacheMetadataAndArtwork()
downloadTrack.track.cacheMetadataAndArtwork()
} catch (ignore: Exception) {
Timber.w(ignore)
}
stateChangedCallback(item, newStatus, null)
stateChangedCallback(downloadTrack, newStatus, null)
return true
}
@ -88,15 +85,15 @@ class DownloadTask(
}
fun download() {
stateChangedCallback(item, DownloadState.DOWNLOADING, null)
stateChangedCallback(downloadTrack, DownloadState.DOWNLOADING, null)
val fileLength = Storage.getFromPath(item.partialFile)?.length ?: 0
val fileLength = Storage.getFromPath(downloadTrack.partialFile)?.length ?: 0
// Attempt partial HTTP GET, appending to the file if it exists.
val (inStream, isPartial) = musicService.getDownloadInputStream(
item.track, fileLength,
Settings.maxBitRate,
item.pinned
downloadTrack.track, fileLength,
if (downloadTrack.pinned) Settings.maxBitRatePinning else Settings.maxBitRate,
downloadTrack.pinned && Settings.pinWithHighestQuality
)
inputStream = inStream
@ -105,7 +102,7 @@ class DownloadTask(
Timber.i("Executed partial HTTP GET, skipping %d bytes", fileLength)
}
outputStream = Storage.getOrCreateFileFromPath(item.partialFile)
outputStream = Storage.getOrCreateFileFromPath(downloadTrack.partialFile)
.getFileOutputStream(isPartial)
val len = inputStream!!.copyWithProgress(outputStream!!) { totalBytesCopied ->
@ -113,7 +110,7 @@ class DownloadTask(
publishProgressUpdate(fileLength + totalBytesCopied)
}
Timber.i("Downloaded %d bytes to %s", len, item.partialFile)
Timber.i("Downloaded %d bytes to %s", len, downloadTrack.partialFile)
inputStream?.close()
outputStream?.flush()
@ -131,7 +128,7 @@ class DownloadTask(
lastPostTime = SystemClock.elapsedRealtime()
// If the file size is unknown we can only provide null as the progress
val size = item.track.size ?: 0
val size = downloadTrack.track.size ?: 0
val progress = if (size <= 0) {
null
} else {
@ -139,7 +136,7 @@ class DownloadTask(
}
stateChangedCallback(
item,
downloadTrack,
DownloadState.DOWNLOADING,
progress
)
@ -148,39 +145,39 @@ class DownloadTask(
private fun afterDownload() {
try {
item.track.cacheMetadataAndArtwork()
downloadTrack.track.cacheMetadataAndArtwork()
} catch (ignore: Exception) {
Timber.w(ignore)
}
if (item.pinned) {
if (downloadTrack.pinned) {
Storage.rename(
item.partialFile,
item.pinnedFile
downloadTrack.partialFile,
downloadTrack.pinnedFile
)
Timber.i("Renamed file to ${item.pinnedFile}")
stateChangedCallback(item, DownloadState.PINNED, null)
Util.scanMedia(item.pinnedFile)
Timber.i("Renamed file to ${downloadTrack.pinnedFile}")
stateChangedCallback(downloadTrack, DownloadState.PINNED, null)
Util.scanMedia(downloadTrack.pinnedFile)
} else {
Storage.rename(
item.partialFile,
item.completeFile
downloadTrack.partialFile,
downloadTrack.completeFile
)
Timber.i("Renamed file to ${item.completeFile}")
stateChangedCallback(item, DownloadState.DONE, null)
Timber.i("Renamed file to ${downloadTrack.completeFile}")
stateChangedCallback(downloadTrack, DownloadState.DONE, null)
}
}
private fun onCompletion(e: Throwable?) {
if (e is CancellationException) {
Timber.w(e, "CompletionHandler ${item.pinnedFile}")
stateChangedCallback(item, DownloadState.CANCELLED, null)
Timber.w(e, "CompletionHandler ${downloadTrack.pinnedFile}")
stateChangedCallback(downloadTrack, DownloadState.CANCELLED, null)
} else if (e != null) {
Timber.w(e, "CompletionHandler ${item.pinnedFile}")
if (item.tryCount < MAX_RETRIES) {
stateChangedCallback(item, DownloadState.RETRYING, null)
Timber.w(e, "CompletionHandler ${downloadTrack.pinnedFile}")
if (downloadTrack.tryCount < MAX_RETRIES) {
stateChangedCallback(downloadTrack, DownloadState.RETRYING, null)
} else {
stateChangedCallback(item, DownloadState.FAILED, null)
stateChangedCallback(downloadTrack, DownloadState.FAILED, null)
}
}
inputStream.safeClose()
@ -189,15 +186,16 @@ class DownloadTask(
private fun exceptionHandler(): CoroutineExceptionHandler {
return CoroutineExceptionHandler { _, exception ->
Timber.w(exception, "Exception in DownloadTask ${item.pinnedFile}")
Storage.delete(item.completeFile)
Storage.delete(item.pinnedFile)
Timber.w(exception, "Exception in DownloadTask ${downloadTrack.pinnedFile}")
Storage.delete(downloadTrack.completeFile)
Storage.delete(downloadTrack.pinnedFile)
}
}
fun start() {
Timber.i("Launching new Job ${item.pinnedFile}")
Timber.i("Launching new Job ${downloadTrack.pinnedFile}")
job = scope.launch(exceptionHandler()) {
FileUtil.createDirectoryForParent(downloadTrack.pinnedFile)
if (!checkIfExists() && isActive) {
download()
afterDownload()

View File

@ -7,29 +7,12 @@
package org.moire.ultrasonic.service
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.view.Gravity
import android.view.KeyEvent
import android.view.KeyEvent.KEYCODE_MEDIA_NEXT
import android.view.KeyEvent.KEYCODE_MEDIA_PAUSE
import android.view.KeyEvent.KEYCODE_MEDIA_PLAY
import android.view.KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE
import android.view.KeyEvent.KEYCODE_MEDIA_PREVIOUS
import android.view.KeyEvent.KEYCODE_MEDIA_STOP
import android.view.LayoutInflater
import android.view.View
import android.widget.ProgressBar
import android.widget.Toast
import androidx.core.app.NotificationManagerCompat
import androidx.media3.common.AudioAttributes
import androidx.media3.common.C
import androidx.media3.common.DeviceInfo
import androidx.media3.common.FlagSet
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.common.PlaybackException
@ -39,34 +22,27 @@ import androidx.media3.common.Timeline
import androidx.media3.common.TrackSelectionParameters
import androidx.media3.common.VideoSize
import androidx.media3.common.text.CueGroup
import androidx.media3.common.util.Clock
import androidx.media3.common.util.ListenerSet
import androidx.media3.common.util.Size
import androidx.media3.session.MediaSession
import com.google.common.collect.ImmutableList
import com.google.common.util.concurrent.ListenableFuture
import com.google.common.util.concurrent.SettableFuture
import java.util.concurrent.Executors
import java.util.concurrent.LinkedBlockingQueue
import java.util.concurrent.ScheduledFuture
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicLong
import kotlin.math.roundToInt
import org.moire.ultrasonic.R
import org.moire.ultrasonic.api.subsonic.ApiNotSupportedException
import org.moire.ultrasonic.api.subsonic.SubsonicRESTException
import org.moire.ultrasonic.app.UApp.Companion.applicationContext
import org.moire.ultrasonic.data.ActiveServerProvider.Companion.isOffline
import org.moire.ultrasonic.domain.JukeboxStatus
import org.moire.ultrasonic.playback.CustomNotificationProvider
import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService
import org.moire.ultrasonic.util.Util.getPendingIntentToShowPlayer
import org.moire.ultrasonic.util.Settings
import org.moire.ultrasonic.util.Util.sleepQuietly
import org.moire.ultrasonic.util.Util.stopForegroundRemoveNotification
import timber.log.Timber
private const val STATUS_UPDATE_INTERVAL_SECONDS = 5L
private const val SEEK_INCREMENT_SECONDS = 5L
private const val SEEK_START_AFTER_SECONDS = 5
private const val QUEUE_POLL_INTERVAL_SECONDS = 1L
/**
@ -86,135 +62,64 @@ class JukeboxMediaPlayer : JukeboxUnimplementedFunctions(), Player {
private val timeOfLastUpdate = AtomicLong()
private var jukeboxStatus: JukeboxStatus? = null
private var previousJukeboxStatus: JukeboxStatus? = null
private var gain = 0.5f
private var volumeToast: VolumeToast? = null
private var gain = (MAX_GAIN / 3)
private val floatGain: Float
get() = gain.toFloat() / MAX_GAIN
private var serviceThread: Thread? = null
private var listeners: MutableList<Player.Listener> = mutableListOf()
private var listeners: ListenerSet<Player.Listener>
private val playlist: MutableList<MediaItem> = mutableListOf()
private var currentIndex: Int = 0
private val notificationProvider = CustomNotificationProvider(applicationContext())
private lateinit var mediaSession: MediaSession
private lateinit var notificationManagerCompat: NotificationManagerCompat
@Suppress("MagicNumber")
override fun onCreate() {
super.onCreate()
if (running.get()) return
private var _currentIndex: Int = 0
private var currentIndex: Int
get() = _currentIndex
set(value) {
// This must never be smaller 0
_currentIndex = if (value >= 0) value else 0
}
companion object {
// This is quite important, by setting the DeviceInfo the player is recognized by
// Android as being a remote playback surface
val DEVICE_INFO = DeviceInfo(DeviceInfo.PLAYBACK_TYPE_REMOTE, 0, 10)
val running = AtomicBoolean()
const val MAX_GAIN = 10
}
init {
running.set(true)
listeners = ListenerSet(
applicationLooper,
Clock.DEFAULT
) { listener: Player.Listener, flags: FlagSet? ->
listener.onEvents(
this,
Player.Events(
flags!!
)
)
}
tasks.clear()
updatePlaylist()
stop()
startFuture?.set(this)
startProcessTasks()
notificationManagerCompat = NotificationManagerCompat.from(this)
mediaSession = MediaSession.Builder(applicationContext(), this)
.setId("jukebox")
.setSessionActivity(getPendingIntentToShowPlayer(this))
.build()
val notification = notificationProvider.createNotification(
mediaSession,
ImmutableList.of(),
JukeboxNotificationActionFactory()
) {}
if (Build.VERSION.SDK_INT >= 29) {
startForeground(
notification.notificationId,
notification.notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK
)
} else {
startForeground(
notification.notificationId, notification.notification
)
}
@Suppress("MagicNumber")
Timber.d("Started Jukebox Service")
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
super.onStartCommand(intent, flags, startId)
if (Intent.ACTION_MEDIA_BUTTON != intent?.action) return START_STICKY
val extras = intent.extras
if ((extras != null) && extras.containsKey(Intent.EXTRA_KEY_EVENT)) {
val event = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
extras.getParcelable(Intent.EXTRA_KEY_EVENT, KeyEvent::class.java)
} else {
@Suppress("DEPRECATION")
extras.getParcelable<KeyEvent>(Intent.EXTRA_KEY_EVENT)
}
when (event?.keyCode) {
KEYCODE_MEDIA_PLAY -> play()
KEYCODE_MEDIA_PAUSE -> stop()
KEYCODE_MEDIA_STOP -> stop()
KEYCODE_MEDIA_PLAY_PAUSE -> if (isPlaying) stop() else play()
KEYCODE_MEDIA_PREVIOUS -> seekToPrevious()
KEYCODE_MEDIA_NEXT -> seekToNext()
}
}
return START_STICKY
}
override fun onDestroy() {
override fun release() {
tasks.clear()
stop()
if (!running.get()) return
running.set(false)
serviceThread!!.join()
serviceThread?.join()
stopForegroundRemoveNotification()
mediaSession.release()
super.onDestroy()
Timber.d("Stopped Jukebox Service")
}
override fun onBind(p0: Intent?): IBinder? {
return null
}
fun requestStop() {
stopSelf()
}
private fun updateNotification() {
val notification = notificationProvider.createNotification(
mediaSession,
ImmutableList.of(),
JukeboxNotificationActionFactory()
) {}
notificationManagerCompat.notify(notification.notificationId, notification.notification)
}
companion object {
val running = AtomicBoolean()
private var startFuture: SettableFuture<JukeboxMediaPlayer>? = null
@JvmStatic
fun requestStart(): ListenableFuture<JukeboxMediaPlayer>? {
if (running.get()) return null
startFuture = SettableFuture.create()
val context = applicationContext()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(
Intent(context, JukeboxMediaPlayer::class.java)
)
} else {
context.startService(Intent(context, JukeboxMediaPlayer::class.java))
}
Timber.i("JukeboxMediaPlayer starting...")
return startFuture
}
}
override fun addListener(listener: Player.Listener) {
listeners.add(listener)
}
@ -263,14 +168,20 @@ class JukeboxMediaPlayer : JukeboxUnimplementedFunctions(), Player {
}
tasks.add(Skip(mediaItemIndex, positionSeconds))
currentIndex = mediaItemIndex
updateAvailableCommands()
}
override fun seekBack() {
seekTo(0L.coerceAtMost((jukeboxStatus?.positionSeconds ?: 0) - SEEK_INCREMENT_SECONDS))
seekTo(
0L.coerceAtMost(
(jukeboxStatus?.positionSeconds ?: 0) -
Settings.seekIntervalMillis
)
)
}
override fun seekForward() {
seekTo((jukeboxStatus?.positionSeconds ?: 0) + SEEK_INCREMENT_SECONDS)
seekTo((jukeboxStatus?.positionSeconds ?: 0) + Settings.seekIntervalMillis)
}
override fun isCurrentMediaItemSeekable() = true
@ -292,8 +203,11 @@ class JukeboxMediaPlayer : JukeboxUnimplementedFunctions(), Player {
override fun getAvailableCommands(): Player.Commands {
val commandsBuilder = Player.Commands.Builder().addAll(
Player.COMMAND_SET_VOLUME,
Player.COMMAND_GET_VOLUME
Player.COMMAND_CHANGE_MEDIA_ITEMS,
Player.COMMAND_GET_TIMELINE,
Player.COMMAND_GET_DEVICE_VOLUME,
Player.COMMAND_ADJUST_DEVICE_VOLUME,
Player.COMMAND_SET_DEVICE_VOLUME
)
if (isPlaying) commandsBuilder.add(Player.COMMAND_STOP)
if (playlist.isNotEmpty()) {
@ -306,8 +220,7 @@ class JukeboxMediaPlayer : JukeboxUnimplementedFunctions(), Player {
Player.COMMAND_SEEK_FORWARD,
Player.COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM,
Player.COMMAND_SEEK_TO_MEDIA_ITEM,
)
if (currentIndex > 0) commandsBuilder.addAll(
// Seeking back is always available
Player.COMMAND_SEEK_TO_PREVIOUS,
Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM
)
@ -323,6 +236,18 @@ class JukeboxMediaPlayer : JukeboxUnimplementedFunctions(), Player {
return availableCommands.contains(command)
}
private fun updateAvailableCommands() {
Handler(Looper.getMainLooper()).post {
listeners.sendEvent(
Player.EVENT_AVAILABLE_COMMANDS_CHANGED
) { listener: Player.Listener ->
listener.onAvailableCommandsChanged(
availableCommands
)
}
}
}
override fun getPlayWhenReady(): Boolean {
return isPlaying
}
@ -358,21 +283,43 @@ class JukeboxMediaPlayer : JukeboxUnimplementedFunctions(), Player {
override fun setShuffleModeEnabled(shuffleModeEnabled: Boolean) {}
override fun setVolume(volume: Float) {
override fun setDeviceVolume(volume: Int) {
gain = volume
tasks.remove(SetGain::class.java)
tasks.add(SetGain(volume))
val context = applicationContext()
if (volumeToast == null) volumeToast = VolumeToast(context)
volumeToast!!.setVolume(volume)
tasks.add(SetGain(floatGain))
// We must trigger an event so that the Controller knows the new volume
Handler(Looper.getMainLooper()).post {
listeners.queueEvent(Player.EVENT_DEVICE_VOLUME_CHANGED) {
it.onDeviceVolumeChanged(
gain,
false
)
}
}
}
override fun increaseDeviceVolume() {
gain = (gain + 1).coerceAtMost(MAX_GAIN)
deviceVolume = gain
}
override fun decreaseDeviceVolume() {
gain = (gain - 1).coerceAtLeast(0)
deviceVolume = gain
}
override fun setDeviceMuted(muted: Boolean) {
gain = 0
deviceVolume = gain
}
override fun getVolume(): Float {
return gain
return floatGain
}
override fun getDeviceVolume(): Int {
return (gain * 100).toInt()
return gain
}
override fun addMediaItems(index: Int, mediaItems: MutableList<MediaItem>) {
@ -444,7 +391,7 @@ class JukeboxMediaPlayer : JukeboxUnimplementedFunctions(), Player {
}
override fun seekToPrevious() {
if ((jukeboxStatus?.positionSeconds ?: 0) > SEEK_START_AFTER_SECONDS) {
if ((jukeboxStatus?.positionSeconds ?: 0) > (Settings.seekIntervalMillis)) {
seekTo(currentIndex, 0)
return
}
@ -499,51 +446,63 @@ class JukeboxMediaPlayer : JukeboxUnimplementedFunctions(), Player {
@Suppress("LoopWithTooManyJumpStatements")
private fun processTasks() {
Timber.d("JukeboxMediaPlayer processTasks starting")
while (true) {
while (running.get()) {
// Sleep a bit to spare processor time if we loop a lot
sleepQuietly(10)
// This is only necessary if Ultrasonic goes offline sooner than the thread stops
if (isOffline()) continue
var task: JukeboxTask? = null
try {
task = tasks.poll()
// If running is false, exit when the queue is empty
if (task == null && !running.get()) break
if (task == null) continue
task = tasks.poll() ?: continue
Timber.v("JukeBoxMediaPlayer processTasks processes Task %s", task::class)
val status = task.execute()
onStatusUpdate(status)
} catch (x: Throwable) {
onError(task, x)
} catch (all: Throwable) {
onError(task, all)
}
}
Timber.d("JukeboxMediaPlayer processTasks stopped")
}
// Jukebox status contains data received from the server, we need to validate it!
private fun onStatusUpdate(jukeboxStatus: JukeboxStatus) {
timeOfLastUpdate.set(System.currentTimeMillis())
previousJukeboxStatus = this.jukeboxStatus
this.jukeboxStatus = jukeboxStatus
var shouldUpdateCommands = false
// Ensure that the index is never smaller than 0
// If -1 assume that this means we are not playing
if (jukeboxStatus.currentPlayingIndex != null && jukeboxStatus.currentPlayingIndex!! < 0) {
jukeboxStatus.currentPlayingIndex = 0
jukeboxStatus.isPlaying = false
}
currentIndex = jukeboxStatus.currentPlayingIndex ?: currentIndex
if (jukeboxStatus.isPlaying != previousJukeboxStatus?.isPlaying) {
shouldUpdateCommands = true
Handler(Looper.getMainLooper()).post {
listeners.forEach {
listeners.queueEvent(Player.EVENT_PLAYBACK_STATE_CHANGED) {
it.onPlaybackStateChanged(
if (jukeboxStatus.isPlaying) Player.STATE_READY else Player.STATE_IDLE
)
}
listeners.queueEvent(Player.EVENT_IS_PLAYING_CHANGED) {
it.onIsPlayingChanged(jukeboxStatus.isPlaying)
}
}
}
if (jukeboxStatus.currentPlayingIndex != previousJukeboxStatus?.currentPlayingIndex) {
shouldUpdateCommands = true
currentIndex = jukeboxStatus.currentPlayingIndex ?: 0
val currentMedia =
if (currentIndex > 0 && currentIndex < playlist.size) playlist[currentIndex]
else MediaItem.EMPTY
Handler(Looper.getMainLooper()).post {
listeners.forEach {
listeners.queueEvent(Player.EVENT_MEDIA_ITEM_TRANSITION) {
it.onMediaItemTransition(
currentMedia,
Player.MEDIA_ITEM_TRANSITION_REASON_SEEK
@ -552,44 +511,39 @@ class JukeboxMediaPlayer : JukeboxUnimplementedFunctions(), Player {
}
}
updateNotification()
if (shouldUpdateCommands) updateAvailableCommands()
Handler(Looper.getMainLooper()).post {
listeners.flushEvents()
}
}
private fun onError(task: JukeboxTask?, x: Throwable) {
var exception: PlaybackException? = null
if (x is ApiNotSupportedException && task !is Stop) {
Handler(Looper.getMainLooper()).post {
listeners.forEach {
it.onPlayerError(
PlaybackException(
exception = PlaybackException(
"Jukebox server too old",
null,
R.string.download_jukebox_server_too_old
)
)
}
}
} else if (x is OfflineException && task !is Stop) {
Handler(Looper.getMainLooper()).post {
listeners.forEach {
it.onPlayerError(
PlaybackException(
exception = PlaybackException(
"Jukebox offline",
null,
R.string.download_jukebox_offline
)
)
}
}
} else if (x is SubsonicRESTException && x.code == 50 && task !is Stop) {
Handler(Looper.getMainLooper()).post {
listeners.forEach {
it.onPlayerError(
PlaybackException(
exception = PlaybackException(
"Jukebox not authorized",
null,
R.string.download_jukebox_not_authorized
)
)
}
if (exception != null) {
Handler(Looper.getMainLooper()).post {
listeners.sendEvent(Player.EVENT_PLAYER_ERROR) {
it.onPlayerError(exception)
}
}
} else {
@ -608,8 +562,10 @@ class JukeboxMediaPlayer : JukeboxUnimplementedFunctions(), Player {
}
tasks.add(SetPlaylist(ids))
Handler(Looper.getMainLooper()).post {
listeners.forEach {
it.onTimelineChanged(
listeners.sendEvent(
Player.EVENT_TIMELINE_CHANGED
) { listener: Player.Listener ->
listener.onTimelineChanged(
PlaylistTimeline(playlist),
Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED
)
@ -719,25 +675,6 @@ class JukeboxMediaPlayer : JukeboxUnimplementedFunctions(), Player {
}
}
@SuppressLint("InflateParams")
private class VolumeToast(context: Context) : Toast(context) {
private val progressBar: ProgressBar
fun setVolume(volume: Float) {
progressBar.progress = (100 * volume).roundToInt()
show()
}
init {
duration = LENGTH_SHORT
val inflater =
context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater
val view = inflater.inflate(R.layout.jukebox_volume, null)
progressBar = view.findViewById<View>(R.id.jukebox_volume_progress_bar) as ProgressBar
setView(view)
setGravity(Gravity.TOP, 0, 0)
}
}
// The constants below are necessary so a MediaSession can be built from the Jukebox Service
override fun isCurrentMediaItemDynamic(): Boolean {
return false
@ -748,15 +685,15 @@ class JukeboxMediaPlayer : JukeboxUnimplementedFunctions(), Player {
}
override fun getMaxSeekToPreviousPosition(): Long {
return SEEK_START_AFTER_SECONDS * 1000L
return Settings.seekInterval.toLong()
}
override fun getSeekBackIncrement(): Long {
return SEEK_INCREMENT_SECONDS * 1000L
return Settings.seekInterval.toLong()
}
override fun getSeekForwardIncrement(): Long {
return SEEK_INCREMENT_SECONDS * 1000L
return Settings.seekInterval.toLong()
}
override fun isLoading(): Boolean {
@ -779,6 +716,8 @@ class JukeboxMediaPlayer : JukeboxUnimplementedFunctions(), Player {
return AudioAttributes.DEFAULT
}
override fun setVolume(volume: Float) {}
override fun getVideoSize(): VideoSize {
return VideoSize(0, 0)
}
@ -824,7 +763,7 @@ class JukeboxMediaPlayer : JukeboxUnimplementedFunctions(), Player {
}
override fun getDeviceInfo(): DeviceInfo {
return DeviceInfo(DeviceInfo.PLAYBACK_TYPE_REMOTE, 0, 1)
return DEVICE_INFO
}
override fun getPlayerError(): PlaybackException? {

View File

@ -1,97 +0,0 @@
/*
* JukeboxNotificationActionFactory.kt
* Copyright (C) 2009-2022 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
package org.moire.ultrasonic.service
import android.annotation.SuppressLint
import android.app.PendingIntent
import android.content.ComponentName
import android.content.Intent
import android.os.Bundle
import android.view.KeyEvent
import androidx.core.app.NotificationCompat
import androidx.core.graphics.drawable.IconCompat
import androidx.media3.common.Player
import androidx.media3.common.util.Util
import androidx.media3.session.CommandButton
import androidx.media3.session.MediaNotification
import androidx.media3.session.MediaSession
import org.moire.ultrasonic.app.UApp
/**
* This class creates Intents and Actions to be used with the Media Notification
* of the Jukebox Service
*/
@SuppressLint("UnsafeOptInUsageError")
class JukeboxNotificationActionFactory : MediaNotification.ActionFactory {
override fun createMediaAction(
mediaSession: MediaSession,
icon: IconCompat,
title: CharSequence,
command: Int
): NotificationCompat.Action {
return NotificationCompat.Action(
icon, title, createMediaActionPendingIntent(mediaSession, command.toLong())
)
}
override fun createCustomAction(
mediaSession: MediaSession,
icon: IconCompat,
title: CharSequence,
customAction: String,
extras: Bundle
): NotificationCompat.Action {
return NotificationCompat.Action(
icon, title, null
)
}
override fun createCustomActionFromCustomCommandButton(
mediaSession: MediaSession,
customCommandButton: CommandButton
): NotificationCompat.Action {
return NotificationCompat.Action(null, null, null)
}
@Suppress("MagicNumber")
override fun createMediaActionPendingIntent(
mediaSession: MediaSession,
command: Long
): PendingIntent {
val keyCode: Int = toKeyCode(command)
val intent = Intent(Intent.ACTION_MEDIA_BUTTON)
intent.component = ComponentName(UApp.applicationContext(), JukeboxMediaPlayer::class.java)
intent.putExtra(Intent.EXTRA_KEY_EVENT, KeyEvent(KeyEvent.ACTION_DOWN, keyCode))
return if (Util.SDK_INT >= 26 && command == Player.COMMAND_PLAY_PAUSE.toLong()) {
return PendingIntent.getForegroundService(
UApp.applicationContext(), keyCode, intent, PendingIntent.FLAG_IMMUTABLE
)
} else {
PendingIntent.getService(
UApp.applicationContext(),
keyCode,
intent,
if (Util.SDK_INT >= 23) PendingIntent.FLAG_IMMUTABLE else 0
)
}
}
private fun toKeyCode(action: @Player.Command Long): Int {
return when (action.toInt()) {
Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM,
Player.COMMAND_SEEK_TO_NEXT -> KeyEvent.KEYCODE_MEDIA_NEXT
Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM,
Player.COMMAND_SEEK_TO_PREVIOUS -> KeyEvent.KEYCODE_MEDIA_PREVIOUS
Player.COMMAND_STOP -> KeyEvent.KEYCODE_MEDIA_STOP
Player.COMMAND_SEEK_FORWARD -> KeyEvent.KEYCODE_MEDIA_FAST_FORWARD
Player.COMMAND_SEEK_BACK -> KeyEvent.KEYCODE_MEDIA_REWIND
Player.COMMAND_PLAY_PAUSE -> KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE
else -> KeyEvent.KEYCODE_UNKNOWN
}
}
}

View File

@ -8,7 +8,6 @@
package org.moire.ultrasonic.service
import android.annotation.SuppressLint
import android.app.Service
import android.view.Surface
import android.view.SurfaceHolder
import android.view.SurfaceView
@ -26,7 +25,7 @@ import androidx.media3.common.Tracks
*/
@Suppress("TooManyFunctions")
@SuppressLint("UnsafeOptInUsageError")
abstract class JukeboxUnimplementedFunctions : Service(), Player {
abstract class JukeboxUnimplementedFunctions : Player {
override fun setMediaItems(mediaItems: MutableList<MediaItem>) {
TODO("Not yet implemented")
@ -140,10 +139,6 @@ abstract class JukeboxUnimplementedFunctions : Service(), Player {
TODO("Not yet implemented")
}
override fun release() {
TODO("Not yet implemented")
}
override fun getCurrentTracks(): Tracks {
// TODO Dummy information is returned for now, this seems to work
return Tracks.EMPTY
@ -228,20 +223,4 @@ abstract class JukeboxUnimplementedFunctions : Service(), Player {
override fun clearVideoTextureView(textureView: TextureView?) {
TODO("Not yet implemented")
}
override fun setDeviceVolume(volume: Int) {
TODO("Not yet implemented")
}
override fun increaseDeviceVolume() {
TODO("Not yet implemented")
}
override fun decreaseDeviceVolume() {
TODO("Not yet implemented")
}
override fun setDeviceMuted(muted: Boolean) {
TODO("Not yet implemented")
}
}

View File

@ -30,8 +30,9 @@ import timber.log.Timber
* This class is responsible for handling received events for the Media Player implementation
*/
class MediaPlayerLifecycleSupport : KoinComponent {
private lateinit var ratingManager: RatingManager
private val playbackStateSerializer by inject<PlaybackStateSerializer>()
private val mediaPlayerController by inject<MediaPlayerController>()
private val mediaPlayerManager by inject<MediaPlayerManager>()
private val imageLoaderProvider: ImageLoaderProvider by inject()
private var created = false
@ -63,7 +64,7 @@ class MediaPlayerLifecycleSupport : KoinComponent {
return
}
mediaPlayerController.onCreate {
mediaPlayerManager.onCreate {
restoreLastSession(autoPlay, afterRestore)
}
@ -71,6 +72,7 @@ class MediaPlayerLifecycleSupport : KoinComponent {
CacheCleaner().clean()
created = true
ratingManager = RatingManager.instance
Timber.i("LifecycleSupport created")
}
@ -79,7 +81,7 @@ class MediaPlayerLifecycleSupport : KoinComponent {
Timber.i("Restoring %s songs", it!!.songs.size)
mediaPlayerController.restore(
mediaPlayerManager.restore(
it,
autoPlay,
false
@ -108,7 +110,7 @@ class MediaPlayerLifecycleSupport : KoinComponent {
if (intent == null) return
val intentAction = intent.action
if (intentAction == null || intentAction.isEmpty()) return
if (intentAction.isNullOrEmpty()) return
Timber.i("Received intent: %s", intentAction)
@ -144,15 +146,15 @@ class MediaPlayerLifecycleSupport : KoinComponent {
val state = extras.getInt("state")
if (state == 0) {
if (!mediaPlayerController.isJukeboxEnabled) {
mediaPlayerController.pause()
if (!mediaPlayerManager.isJukeboxEnabled) {
mediaPlayerManager.pause()
}
} else if (state == 1) {
if (!mediaPlayerController.isJukeboxEnabled &&
Settings.resumePlayOnHeadphonePlug && !mediaPlayerController.isPlaying
if (!mediaPlayerManager.isJukeboxEnabled &&
Settings.resumePlayOnHeadphonePlug && !mediaPlayerManager.isPlaying
) {
mediaPlayerController.prepare()
mediaPlayerController.play()
mediaPlayerManager.prepare()
mediaPlayerManager.play()
}
}
}
@ -181,18 +183,18 @@ class MediaPlayerLifecycleSupport : KoinComponent {
onCreate(autoStart) {
when (keyCode) {
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE,
KeyEvent.KEYCODE_HEADSETHOOK -> mediaPlayerController.togglePlayPause()
KeyEvent.KEYCODE_MEDIA_PREVIOUS -> mediaPlayerController.previous()
KeyEvent.KEYCODE_MEDIA_NEXT -> mediaPlayerController.next()
KeyEvent.KEYCODE_MEDIA_STOP -> mediaPlayerController.stop()
KeyEvent.KEYCODE_MEDIA_PLAY -> mediaPlayerController.play()
KeyEvent.KEYCODE_MEDIA_PAUSE -> mediaPlayerController.pause()
KeyEvent.KEYCODE_1 -> mediaPlayerController.setSongRating(1)
KeyEvent.KEYCODE_2 -> mediaPlayerController.setSongRating(2)
KeyEvent.KEYCODE_3 -> mediaPlayerController.setSongRating(3)
KeyEvent.KEYCODE_4 -> mediaPlayerController.setSongRating(4)
KeyEvent.KEYCODE_5 -> mediaPlayerController.setSongRating(5)
KeyEvent.KEYCODE_STAR -> mediaPlayerController.toggleSongStarred()
KeyEvent.KEYCODE_HEADSETHOOK -> mediaPlayerManager.togglePlayPause()
KeyEvent.KEYCODE_MEDIA_PREVIOUS -> mediaPlayerManager.seekToPrevious()
KeyEvent.KEYCODE_MEDIA_NEXT -> mediaPlayerManager.seekToNext()
KeyEvent.KEYCODE_MEDIA_STOP -> mediaPlayerManager.stop()
KeyEvent.KEYCODE_MEDIA_PLAY -> mediaPlayerManager.play()
KeyEvent.KEYCODE_MEDIA_PAUSE -> mediaPlayerManager.pause()
KeyEvent.KEYCODE_1 -> mediaPlayerManager.legacySetRating(1)
KeyEvent.KEYCODE_2 -> mediaPlayerManager.legacySetRating(2)
KeyEvent.KEYCODE_3 -> mediaPlayerManager.legacySetRating(3)
KeyEvent.KEYCODE_4 -> mediaPlayerManager.legacySetRating(4)
KeyEvent.KEYCODE_5 -> mediaPlayerManager.legacySetRating(5)
KeyEvent.KEYCODE_STAR -> mediaPlayerManager.legacyToggleStar()
else -> {
}
}
@ -220,17 +222,17 @@ class MediaPlayerLifecycleSupport : KoinComponent {
// We can receive intents when everything is stopped, so we need to start
onCreate(autoStart) {
when (action) {
Constants.CMD_PLAY -> mediaPlayerController.play()
Constants.CMD_PLAY -> mediaPlayerManager.play()
Constants.CMD_RESUME_OR_PLAY ->
// If Ultrasonic wasn't running, the autoStart is enough to resume,
// no need to call anything
if (isRunning) mediaPlayerController.resumeOrPlay()
if (isRunning) mediaPlayerManager.resumeOrPlay()
Constants.CMD_NEXT -> mediaPlayerController.next()
Constants.CMD_PREVIOUS -> mediaPlayerController.previous()
Constants.CMD_TOGGLEPAUSE -> mediaPlayerController.togglePlayPause()
Constants.CMD_STOP -> mediaPlayerController.stop()
Constants.CMD_PAUSE -> mediaPlayerController.pause()
Constants.CMD_NEXT -> mediaPlayerManager.seekToNext()
Constants.CMD_PREVIOUS -> mediaPlayerManager.seekToPrevious()
Constants.CMD_TOGGLEPAUSE -> mediaPlayerManager.togglePlayPause()
Constants.CMD_STOP -> mediaPlayerManager.stop()
Constants.CMD_PAUSE -> mediaPlayerManager.pause()
}
}
}

View File

@ -10,7 +10,7 @@ import android.content.ComponentName
import android.content.Context
import android.os.Handler
import android.os.Looper
import android.widget.Toast
import androidx.annotation.IntRange
import androidx.media3.common.C
import androidx.media3.common.HeartRating
import androidx.media3.common.MediaItem
@ -18,12 +18,11 @@ import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.common.Player.MEDIA_ITEM_TRANSITION_REASON_AUTO
import androidx.media3.common.Player.REPEAT_MODE_OFF
import androidx.media3.common.Rating
import androidx.media3.common.StarRating
import androidx.media3.common.Timeline
import androidx.media3.session.MediaController
import androidx.media3.session.SessionResult
import androidx.media3.session.SessionToken
import com.google.common.util.concurrent.FutureCallback
import com.google.common.util.concurrent.Futures
import com.google.common.util.concurrent.ListenableFuture
import com.google.common.util.concurrent.MoreExecutors
import io.reactivex.rxjava3.disposables.CompositeDisposable
@ -35,13 +34,12 @@ import org.koin.core.component.inject
import org.moire.ultrasonic.app.UApp
import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.data.ActiveServerProvider.Companion.OFFLINE_DB_ID
import org.moire.ultrasonic.data.RatingUpdate
import org.moire.ultrasonic.domain.Track
import org.moire.ultrasonic.playback.PlaybackService
import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService
import org.moire.ultrasonic.util.MainThreadExecutor
import org.moire.ultrasonic.util.Settings
import org.moire.ultrasonic.util.Util
import org.moire.ultrasonic.util.setPin
import org.moire.ultrasonic.util.toMediaItem
import org.moire.ultrasonic.util.toTrack
import timber.log.Timber
@ -50,16 +48,18 @@ private const val CONTROLLER_SWITCH_DELAY = 500L
private const val VOLUME_DELTA = 0.05f
/**
* The implementation of the Media Player Controller.
* The Media Player Manager can forward commands to the Media3 controller as
* well as switch between different player interfaces (local, remote, cast etc).
* This class contains everything that is necessary for the Application UI
* to control the Media Player implementation.
*/
@Suppress("TooManyFunctions")
class MediaPlayerController(
class MediaPlayerManager(
private val playbackStateSerializer: PlaybackStateSerializer,
private val externalStorageMonitor: ExternalStorageMonitor,
val context: Context
) : KoinComponent {
private val activeServerProvider: ActiveServerProvider by inject()
private var created = false
@ -96,7 +96,15 @@ class MediaPlayerController(
* We run the event through RxBus in order to throttle them
*/
override fun onTimelineChanged(timeline: Timeline, reason: Int) {
RxBus.playlistPublisher.onNext(playlist.map(MediaItem::toTrack))
val start = timeline.getFirstWindowIndex(isShufflePlayEnabled)
Timber.w("On timeline changed. First shuffle play at index: %s", start)
deferredPlay?.let {
Timber.w("Executing deferred shuffle play")
it()
deferredPlay = null
}
val playlist = Util.getPlayListFromTimeline(timeline, false).map(MediaItem::toTrack)
RxBus.playlistPublisher.onNext(playlist)
}
override fun onPlaybackStateChanged(playbackState: Int) {
@ -150,29 +158,28 @@ class MediaPlayerController(
override fun onShuffleModeEnabledChanged(shuffleModeEnabled: Boolean) {
val timeline: Timeline = controller!!.currentTimeline
var windowIndex = timeline.getFirstWindowIndex( /* shuffleModeEnabled= */true)
var windowIndex = timeline.getFirstWindowIndex(true)
var count = 0
Timber.d("Shuffle: windowIndex: $windowIndex, at: $count")
while (windowIndex != C.INDEX_UNSET) {
count++
windowIndex = timeline.getNextWindowIndex(
windowIndex, REPEAT_MODE_OFF, /* shuffleModeEnabled= */true
windowIndex, REPEAT_MODE_OFF, true
)
Timber.d("Shuffle: windowIndex: $windowIndex, at: $count")
}
}
}
private var deferredPlay: (() -> Unit)? = null
private var cachedMediaItem: MediaItem? = null
fun onCreate(onCreated: () -> Unit) {
if (created) return
externalStorageMonitor.onCreate { reset() }
if (activeServerProvider.getActiveServer().jukeboxByDefault) {
switchToJukebox(onCreated)
} else {
switchToLocalPlayer(onCreated)
}
createMediaController(onCreated)
rxBusSubscription += RxBus.activeServerChangingObservable.subscribe { oldServer ->
if (oldServer != OFFLINE_DB_ID) {
@ -184,8 +191,7 @@ class MediaPlayerController(
if (controller is JukeboxMediaPlayer) {
// When the server changes, the Jukebox should be released.
// The new server won't understand the jukebox requests of the old one.
releaseJukebox(controller)
controller = null
switchToLocalPlayer()
}
}
@ -216,15 +222,41 @@ class MediaPlayerController(
clear(false)
onDestroy()
}
rxBusSubscription += RxBus.stopServiceCommandObservable.subscribe {
clear(false)
onDestroy()
}
rxBusSubscription += RxBus.ratingSubmitterObservable.subscribe {
// Ensure correct thread
mainScope.launch {
// This deals only with the current track!
if (it.id != currentMediaItem?.toTrack()?.id) return@launch
setRating(it.rating)
}
}
created = true
Timber.i("MediaPlayerController started")
}
private fun createMediaController(onCreated: () -> Unit) {
mediaControllerFuture = MediaController.Builder(
context,
sessionToken
).buildAsync()
mediaControllerFuture?.addListener({
controller = mediaControllerFuture?.get()
Timber.i("MediaController Instance received")
controller?.addListener(listeners)
onCreated()
Timber.i("MediaPlayerController creation complete")
}, MoreExecutors.directExecutor())
}
private fun playerStateChangedHandler() {
val currentPlaying = controller?.currentMediaItem?.toTrack() ?: return
@ -241,6 +273,10 @@ class MediaPlayerController(
}
}
fun addListener(listener: Player.Listener) {
controller?.addListener(listener)
}
private fun clearBookmark() {
// This method is called just before we update the cachedMediaItem,
// so in fact cachedMediaItem will refer to the track that has just finished.
@ -259,7 +295,7 @@ class MediaPlayerController(
private fun publishPlaybackState() {
val newState = RxBus.StateWithTrack(
track = currentMediaItem?.toTrack(),
index = currentMediaItemIndex,
index = if (isShufflePlayEnabled) getCurrentShuffleIndex() else currentMediaItemIndex,
isPlaying = isPlaying,
state = playbackState
)
@ -292,7 +328,6 @@ class MediaPlayerController(
addToPlaylist(
state.songs,
cachePermanently = false,
autoPlay = false,
shuffle = false,
insertionMode = insertionMode
@ -316,6 +351,7 @@ class MediaPlayerController(
@Synchronized
fun play(index: Int) {
controller?.seekTo(index, 0L)
controller?.prepare()
controller?.play()
}
@ -384,7 +420,6 @@ class MediaPlayerController(
@Synchronized
fun addToPlaylist(
songs: List<Track>,
cachePermanently: Boolean,
autoPlay: Boolean,
shuffle: Boolean,
insertionMode: InsertionMode
@ -399,11 +434,11 @@ class MediaPlayerController(
val mediaItems: List<MediaItem> = songs.map {
val result = it.toMediaItem()
if (cachePermanently) result.setPin(true)
result
}
if (shuffle) isShufflePlayEnabled = true
Timber.w("Adding ${mediaItems.size} media items")
controller?.addMediaItems(insertAt, mediaItems)
prepare()
@ -411,24 +446,28 @@ class MediaPlayerController(
// Playback doesn't start correctly when the player is in STATE_ENDED.
// So we need to call seek before (this is what play(0,0)) does.
// We can't just use play(0,0) then all random playlists will start with the first track.
// This means that we need to generate the random first track ourselves.
// Additionally the shuffle order becomes clear on after some time, so we need to wait for
// the right event, and can start playback only then.
if (autoPlay) {
val start = controller?.currentTimeline?.getFirstWindowIndex(isShufflePlayEnabled) ?: 0
if (isShufflePlayEnabled) {
deferredPlay = {
val start = controller?.currentTimeline
?.getFirstWindowIndex(isShufflePlayEnabled) ?: 0
Timber.i("Deferred shuffle play starting now at index: %s", start)
play(start)
}
} else {
play(0)
}
}
@Synchronized
fun downloadBackground(songs: List<Track?>?, save: Boolean) {
if (songs == null) return
val filteredSongs = songs.filterNotNull()
DownloadService.download(filteredSongs, save)
}
@set:Synchronized
var isShufflePlayEnabled: Boolean
get() = controller?.shuffleModeEnabled == true
set(enabled) {
Timber.i("Shuffle is now enabled: %s", enabled)
RxBus.shufflePlayPublisher.onNext(enabled)
controller?.shuffleModeEnabled = enabled
}
@ -438,11 +477,17 @@ class MediaPlayerController(
return isShufflePlayEnabled
}
/**
* Returns an estimate of the percentage in the current content up to which data is
* buffered, or 0 if no estimate is available.
*/
@get:IntRange(from = 0, to = 100)
val bufferedPercentage: Int
get() = controller?.bufferedPercentage ?: 0
@Synchronized
fun moveItemInPlaylist(oldPos: Int, newPos: Int) {
// TODO: This currently does not care about shuffle position.
controller?.moveMediaItem(oldPos, newPos)
}
@ -501,31 +546,25 @@ class MediaPlayerController(
}
@Synchronized
// TODO: Make it require not null
fun delete(tracks: List<Track?>) {
for (track in tracks.filterNotNull()) {
DownloadService.delete(track)
}
}
@Synchronized
// TODO: Make it require not null
fun unpin(tracks: List<Track?>) {
for (track in tracks.filterNotNull()) {
DownloadService.unpin(track)
}
}
@Synchronized
fun previous() {
fun seekToPrevious() {
controller?.seekToPrevious()
}
@Synchronized
operator fun next() {
fun canSeekToPrevious(): Boolean {
return controller?.isCommandAvailable(Player.COMMAND_SEEK_TO_PREVIOUS) == true
}
@Synchronized
fun seekToNext() {
controller?.seekToNext()
}
@Synchronized
fun canSeekToNext(): Boolean {
return controller?.isCommandAvailable(Player.COMMAND_SEEK_TO_NEXT) == true
}
@Synchronized
fun reset() {
controller?.clearMediaItems()
@ -555,102 +594,49 @@ class MediaPlayerController(
@set:Synchronized
var isJukeboxEnabled: Boolean
get() = controller is JukeboxMediaPlayer
set(jukeboxEnabled) {
if (jukeboxEnabled) {
switchToJukebox {}
get() = PlaybackService.actualBackend == PlayerBackend.JUKEBOX
set(shouldEnable) {
if (shouldEnable) {
switchToJukebox()
} else {
switchToLocalPlayer {}
switchToLocalPlayer()
}
}
private fun switchToJukebox(onCreated: () -> Unit) {
if (controller is JukeboxMediaPlayer) return
val currentPlaylist = playlist
val currentIndex = controller?.currentMediaItemIndex ?: 0
val currentPosition = controller?.currentPosition ?: 0
private fun switchToJukebox() {
if (isJukeboxEnabled) return
scheduleSwitchTo(PlayerBackend.JUKEBOX)
DownloadService.requestStop()
controller?.pause()
controller?.stop()
val oldController = controller
controller = null // While we switch, the controller shouldn't be available
// Stop() won't work if we don't give it time to be processed
Handler(Looper.getMainLooper()).postDelayed({
if (oldController != null) releaseLocalPlayer(oldController)
setupJukebox {
controller?.setMediaItems(currentPlaylist, currentIndex, currentPosition)
onCreated()
}
}, CONTROLLER_SWITCH_DELAY)
}
private fun switchToLocalPlayer(onCreated: () -> Unit) {
if (controller is MediaController) return
val currentPlaylist = playlist
private fun switchToLocalPlayer() {
if (!isJukeboxEnabled) return
scheduleSwitchTo(PlayerBackend.LOCAL)
controller?.stop()
}
private fun scheduleSwitchTo(newBackend: PlayerBackend) {
val currentPlaylist = playlist.toList()
val currentIndex = controller?.currentMediaItemIndex ?: 0
val currentPosition = controller?.currentPosition ?: 0
controller?.stop()
val oldController = controller
controller = null // While we switch, the controller shouldn't be available
Handler(Looper.getMainLooper()).postDelayed({
if (oldController != null) releaseJukebox(oldController)
setupLocalPlayer {
// Change the backend
PlaybackService.setBackend(newBackend)
// Restore the media items
controller?.setMediaItems(currentPlaylist, currentIndex, currentPosition)
onCreated()
}
}, CONTROLLER_SWITCH_DELAY)
}
private fun releaseController() {
when (controller) {
null -> return
is JukeboxMediaPlayer -> releaseJukebox(controller)
is MediaController -> releaseLocalPlayer(controller)
}
}
private fun setupLocalPlayer(onCreated: () -> Unit) {
mediaControllerFuture = MediaController.Builder(
context,
sessionToken
).buildAsync()
mediaControllerFuture?.addListener({
controller = mediaControllerFuture?.get()
Timber.i("MediaController Instance received")
controller?.addListener(listeners)
onCreated()
Timber.i("MediaPlayerController creation complete")
}, MoreExecutors.directExecutor())
}
private fun releaseLocalPlayer(player: Player?) {
player?.removeListener(listeners)
player?.release()
controller?.removeListener(listeners)
controller?.release()
if (mediaControllerFuture != null) MediaController.releaseFuture(mediaControllerFuture!!)
Timber.i("MediaPlayerController released")
}
private fun setupJukebox(onCreated: () -> Unit) {
val jukeboxFuture = JukeboxMediaPlayer.requestStart()
jukeboxFuture?.addListener({
controller = jukeboxFuture.get()
onCreated()
controller?.addListener(listeners)
Timber.i("JukeboxService creation complete")
}, MoreExecutors.directExecutor())
}
private fun releaseJukebox(player: Player?) {
val jukebox = player as JukeboxMediaPlayer?
jukebox?.removeListener(listeners)
jukebox?.requestStop()
Timber.i("JukeboxService released")
}
/**
* This function calls the music service directly and
* therefore can't be called from the main thread
@ -675,56 +661,49 @@ class MediaPlayerController(
controller?.volume = gain
}
fun setVolume(volume: Float) {
controller?.volume = volume
}
fun toggleSongStarred(): ListenableFuture<SessionResult>? {
if (currentMediaItem == null) return null
val song = currentMediaItem!!.toTrack()
return (controller as? MediaController)?.setRating(
HeartRating(!song.starred)
)?.let {
Futures.addCallback(
it,
object : FutureCallback<SessionResult> {
override fun onSuccess(result: SessionResult?) {
// Trigger an update
// TODO Update Metadata of MediaItem...
// localMediaPlayer.setCurrentPlaying(localMediaPlayer.currentPlaying)
song.starred = !song.starred
}
override fun onFailure(t: Throwable) {
Toast.makeText(
context,
"There was an error updating the rating",
Toast.LENGTH_SHORT
).show()
}
},
MainThreadExecutor()
)
it
/*
* Sets the rating of the current track
*/
fun setRating(rating: Rating) {
if (controller is MediaController) {
(controller as MediaController).setRating(rating)
}
}
@Suppress("TooGenericExceptionCaught") // The interface throws only generic exceptions
fun setSongRating(rating: Int) {
if (!Settings.useFiveStarRating) return
/*
* This legacy function simply emits a rating update,
* which will then be processed by both the RatingManager as well as the controller
*/
fun legacyToggleStar() {
if (currentMediaItem == null) return
val song = currentMediaItem!!.toTrack()
song.userRating = rating
Thread {
try {
getMusicService().setRating(song.id, rating)
} catch (e: Exception) {
Timber.e(e)
val track = currentMediaItem!!.toTrack()
track.starred = !track.starred
val rating = HeartRating(track.starred)
RxBus.ratingSubmitter.onNext(
RatingUpdate(
track.id,
rating
)
)
}
}.start()
// TODO this would be better handled with a Rx command
// updateNotification()
/*
* This legacy function simply emits a rating update,
* which will then be processed by both the RatingManager as well as the controller
*/
fun legacySetRating(num: Int) {
if (currentMediaItem == null) return
val track = currentMediaItem!!.toTrack()
track.userRating = num
val rating = StarRating(5, num.toFloat())
RxBus.ratingSubmitter.onNext(
RatingUpdate(
track.id,
rating
)
)
}
val currentMediaItem: MediaItem?
@ -733,9 +712,64 @@ class MediaPlayerController(
val currentMediaItemIndex: Int
get() = controller?.currentMediaItemIndex ?: -1
fun getCurrentShuffleIndex(): Int {
val currentMediaItemIndex = controller?.currentMediaItemIndex ?: return -1
return getShuffledIndexOf(currentMediaItemIndex)
}
/**
* Loops over the timeline windows to find the entry which matches the given closure.
*
* @param searchClosure Determines the condition which the searched for window needs to match.
* @return the index of the window that satisfies the search condition,
* or [C.INDEX_UNSET] if not found.
*/
private fun getWindowIndexWhere(searchClosure: (Int, Int) -> Boolean): Int {
val timeline = controller?.currentTimeline!!
var windowIndex = timeline.getFirstWindowIndex(true)
var count = 0
while (windowIndex != C.INDEX_UNSET) {
if (searchClosure(count, windowIndex)) return count
count++
windowIndex = timeline.getNextWindowIndex(
windowIndex, REPEAT_MODE_OFF, true
)
}
return C.INDEX_UNSET
}
/**
* Returns the index of the shuffled position of the current playback item given its original
* position in the unshuffled timeline.
*
* @param searchPosition The index of the item in the unshuffled timeline to search for
* in the shuffled timeline.
* @return The index of the item in the shuffled timeline, or [C.INDEX_UNSET] if not found.
*/
fun getShuffledIndexOf(searchPosition: Int): Int {
return getWindowIndexWhere { _, windowIndex -> windowIndex == searchPosition }
}
/**
* Returns the index of the unshuffled position of the current playback item given its shuffled
* position in the shuffled timeline.
*
* @param shufflePosition the index of the item in the shuffled timeline to search for in the
* unshuffled timeline.
* @return the index of the item in the unshuffled timeline, or [C.INDEX_UNSET] if not found.
*/
fun getUnshuffledIndexOf(shufflePosition: Int): Int {
return getWindowIndexWhere { count, _ -> count == shufflePosition }
}
val mediaItemCount: Int
get() = controller?.mediaItemCount ?: 0
fun getMediaItemAt(index: Int): MediaItem? {
return controller?.getMediaItemAt(index)
}
val playlistSize: Int
get() = controller?.currentTimeline?.windowCount ?: 0
@ -744,10 +778,6 @@ class MediaPlayerController(
return Util.getPlayListFromTimeline(controller?.currentTimeline, false)
}
fun getMediaItemAt(index: Int): MediaItem? {
return controller?.getMediaItemAt(index)
}
val playlistInPlayOrder: List<MediaItem>
get() {
return Util.getPlayListFromTimeline(
@ -768,4 +798,6 @@ class MediaPlayerController(
enum class InsertionMode {
CLEAR, APPEND, AFTER_CURRENT
}
enum class PlayerBackend { JUKEBOX, LOCAL }
}

View File

@ -39,10 +39,10 @@ interface MusicService {
fun getGenres(refresh: Boolean): List<Genre>
@Throws(Exception::class)
fun star(id: String?, albumId: String?, artistId: String?)
fun star(id: String?, albumId: String? = null, artistId: String? = null)
@Throws(Exception::class)
fun unstar(id: String?, albumId: String?, artistId: String?)
fun unstar(id: String?, albumId: String? = null, artistId: String? = null)
@Throws(Exception::class)
fun setRating(id: String, rating: Int)

View File

@ -18,7 +18,7 @@ import org.moire.ultrasonic.api.subsonic.models.JukeboxAction
import org.moire.ultrasonic.api.subsonic.throwOnFailure
import org.moire.ultrasonic.api.subsonic.toStreamResponse
import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.data.ActiveServerProvider.Companion.isOffline
import org.moire.ultrasonic.data.ActiveServerProvider.Companion.shouldUseId3Tags
import org.moire.ultrasonic.domain.Album
import org.moire.ultrasonic.domain.Artist
import org.moire.ultrasonic.domain.Bookmark
@ -44,7 +44,6 @@ import org.moire.ultrasonic.domain.toIndexList
import org.moire.ultrasonic.domain.toMusicDirectoryDomainEntity
import org.moire.ultrasonic.domain.toTrackEntity
import org.moire.ultrasonic.util.FileUtil
import org.moire.ultrasonic.util.Settings
import timber.log.Timber
/**
@ -181,7 +180,7 @@ open class RESTMusicService(
criteria: SearchCriteria
): SearchResult {
return try {
if (!isOffline() && Settings.shouldUseId3Tags) {
if (shouldUseId3Tags()) {
search3(criteria)
} else {
search2(criteria)

View File

@ -0,0 +1,87 @@
/*
* RatingManager.kt
* Copyright (C) 2009-2023 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
package org.moire.ultrasonic.service
import androidx.media3.common.HeartRating
import androidx.media3.common.StarRating
import io.reactivex.rxjava3.disposables.CompositeDisposable
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.moire.ultrasonic.data.RatingUpdate
import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService
import timber.log.Timber
/*
* This class subscribes to RatingEvents and submits them to the server.
* In the future it could be extended to store the ratings when offline
* and submit them when back online.
* Only the manager should publish RatingSubmitted events
*/
class RatingManager : CoroutineScope by CoroutineScope(Dispatchers.Default) {
private val rxBusSubscription: CompositeDisposable = CompositeDisposable()
var lastUpdate: RatingUpdate? = null
init {
rxBusSubscription += RxBus.ratingSubmitterObservable.subscribe {
submitRating(it)
}
}
internal fun submitRating(update: RatingUpdate) {
// Don't submit the same rating twice
if (update.id == lastUpdate?.id && update.rating == lastUpdate?.rating) return
val service = getMusicService()
val id = update.id
Timber.i("Submitting rating to server: ${update.rating} for $id")
if (update.rating is HeartRating) {
launch {
var success = false
withContext(Dispatchers.IO) {
try {
if (update.rating.isHeart) service.star(id)
else service.unstar(id)
success = true
} catch (all: Exception) {
Timber.e(all)
}
}
RxBus.ratingPublished.onNext(
update.copy(success = success)
)
}
} else if (update.rating is StarRating) {
launch {
var success = false
withContext(Dispatchers.IO) {
try {
getMusicService().setRating(id, update.rating.starRating.toInt())
success = true
} catch (all: Exception) {
Timber.e(all)
}
}
RxBus.ratingPublished.onNext(
update.copy(success = success)
)
}
}
lastUpdate = update
}
companion object {
val instance: RatingManager by lazy {
RatingManager()
}
}
}

View File

@ -7,6 +7,7 @@ import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.disposables.Disposable
import io.reactivex.rxjava3.subjects.PublishSubject
import java.util.concurrent.TimeUnit
import org.moire.ultrasonic.data.RatingUpdate
import org.moire.ultrasonic.domain.Track
class RxBus {
@ -20,9 +21,13 @@ class RxBus {
private fun mainThread() = AndroidSchedulers.from(Looper.getMainLooper())
val shufflePlayPublisher: PublishSubject<Boolean> =
PublishSubject.create()
val shufflePlayObservable: Observable<Boolean> =
shufflePlayPublisher
var activeServerChangingPublisher: PublishSubject<Int> =
PublishSubject.create()
// Subscribers should be called synchronously, not on another thread
var activeServerChangingObservable: Observable<Int> =
activeServerChangingPublisher
@ -71,6 +76,18 @@ class RxBus {
val trackDownloadStateObservable: Observable<TrackDownloadState> =
trackDownloadStatePublisher.observeOn(mainThread())
// Sends a RatingUpdate which was just triggered by the user
val ratingSubmitter: PublishSubject<RatingUpdate> =
PublishSubject.create()
val ratingSubmitterObservable: Observable<RatingUpdate> =
ratingSubmitter
// Sends a RatingUpdate which was successfully submitted to the server or database
val ratingPublished: PublishSubject<RatingUpdate> =
PublishSubject.create()
val ratingPublishedObservable: Observable<RatingUpdate> =
ratingPublished
// Commands
val dismissNowPlayingCommandPublisher: PublishSubject<Unit> =
PublishSubject.create()

View File

@ -9,296 +9,167 @@ package org.moire.ultrasonic.subsonic
import androidx.fragment.app.Fragment
import androidx.navigation.fragment.findNavController
import java.util.Collections
import java.util.LinkedList
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.moire.ultrasonic.R
import org.moire.ultrasonic.data.ActiveServerProvider.Companion.isOffline
import org.moire.ultrasonic.data.ActiveServerProvider.Companion.shouldUseId3Tags
import org.moire.ultrasonic.domain.MusicDirectory
import org.moire.ultrasonic.domain.Track
import org.moire.ultrasonic.service.MediaPlayerController
import org.moire.ultrasonic.service.DownloadService
import org.moire.ultrasonic.service.MediaPlayerManager
import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService
import org.moire.ultrasonic.util.CommunicationError
import org.moire.ultrasonic.util.EntryByDiscAndTrackComparator
import org.moire.ultrasonic.util.InfoDialog
import org.moire.ultrasonic.util.Settings
import org.moire.ultrasonic.util.Util
import org.moire.ultrasonic.util.executeTaskWithToast
/**
* Retrieves a list of songs and adds them to the now playing list
*/
@Suppress("LongParameterList")
class DownloadHandler(
val mediaPlayerController: MediaPlayerController,
val networkAndStorageChecker: NetworkAndStorageChecker
val mediaPlayerManager: MediaPlayerManager,
private val networkAndStorageChecker: NetworkAndStorageChecker
) : CoroutineScope by CoroutineScope(Dispatchers.IO) {
private val maxSongs = 500
fun download(
fun justDownload(
action: DownloadAction,
fragment: Fragment,
append: Boolean,
save: Boolean,
autoPlay: Boolean,
playNext: Boolean,
shuffle: Boolean,
songs: List<Track>,
playlistName: String?,
id: String? = null,
name: String? = "",
isShare: Boolean = false,
isDirectory: Boolean = true,
isArtist: Boolean = false,
tracks: List<Track>? = null
) {
val onValid = Runnable {
// TODO: The logic here is different than in the controller...
val insertionMode = when {
playNext -> MediaPlayerController.InsertionMode.AFTER_CURRENT
append -> MediaPlayerController.InsertionMode.APPEND
else -> MediaPlayerController.InsertionMode.CLEAR
}
var successString: String? = null
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
mediaPlayerController.addToPlaylist(
songs,
save,
autoPlay,
shuffle,
insertionMode
// Launch the Job
executeTaskWithToast(fragment, {
val tracksToDownload: List<Track> = tracks
?: getTracksFromServer(isArtist, id!!, isDirectory, name, isShare)
withContext(Dispatchers.Main) {
// If we are just downloading tracks we don't need to add them to the controller
when (action) {
DownloadAction.DOWNLOAD -> DownloadService.download(tracksToDownload, false)
DownloadAction.PIN -> DownloadService.download(tracksToDownload, true)
DownloadAction.UNPIN -> DownloadService.unpin(tracksToDownload)
DownloadAction.DELETE -> DownloadService.delete(tracksToDownload)
}
successString = when (action) {
DownloadAction.DOWNLOAD -> fragment.resources.getQuantityString(
R.plurals.select_album_n_songs_downloaded,
tracksToDownload.size,
tracksToDownload.size
)
DownloadAction.UNPIN -> {
fragment.resources.getQuantityString(
R.plurals.select_album_n_songs_unpinned,
tracksToDownload.size,
tracksToDownload.size
)
if (playlistName != null) {
mediaPlayerController.suggestedPlaylistName = playlistName
}
if (autoPlay) {
if (Settings.shouldTransitionOnPlayback) {
fragment.findNavController().popBackStack(R.id.playerFragment, true)
fragment.findNavController().navigate(R.id.playerFragment)
}
} else if (save) {
Util.toast(
fragment.context,
DownloadAction.PIN -> {
fragment.resources.getQuantityString(
R.plurals.select_album_n_songs_pinned,
songs.size,
songs.size
tracksToDownload.size,
tracksToDownload.size
)
)
} else if (playNext) {
Util.toast(
fragment.context,
}
DownloadAction.DELETE -> {
fragment.resources.getQuantityString(
R.plurals.select_album_n_songs_play_next,
songs.size,
songs.size
)
)
} else if (append) {
Util.toast(
fragment.context,
fragment.resources.getQuantityString(
R.plurals.select_album_n_songs_added,
songs.size,
songs.size
)
R.plurals.select_album_n_songs_deleted,
tracksToDownload.size,
tracksToDownload.size
)
}
}
onValid.run()
}
}) { successString }
}
fun downloadPlaylist(
fun fetchTracksAndAddToController(
fragment: Fragment,
id: String,
name: String?,
save: Boolean,
append: Boolean,
autoplay: Boolean,
shuffle: Boolean,
background: Boolean,
playNext: Boolean,
unpin: Boolean
) {
downloadRecursively(
fragment,
id,
name,
isShare = false,
isDirectory = false,
save = save,
append = append,
autoPlay = autoplay,
shuffle = shuffle,
background = background,
playNext = playNext,
unpin = unpin,
isArtist = false
)
}
fun downloadShare(
fragment: Fragment,
id: String,
name: String?,
save: Boolean,
append: Boolean,
autoplay: Boolean,
shuffle: Boolean,
background: Boolean,
playNext: Boolean,
unpin: Boolean
) {
downloadRecursively(
fragment,
id,
name,
isShare = true,
isDirectory = false,
save = save,
append = append,
autoPlay = autoplay,
shuffle = shuffle,
background = background,
playNext = playNext,
unpin = unpin,
isArtist = false
)
}
fun downloadRecursively(
fragment: Fragment,
id: String?,
save: Boolean,
name: String? = "",
isShare: Boolean = false,
isDirectory: Boolean = true,
append: Boolean,
autoPlay: Boolean,
shuffle: Boolean,
background: Boolean,
playNext: Boolean,
unpin: Boolean,
isArtist: Boolean
) {
if (id.isNullOrEmpty()) return
downloadRecursively(
fragment,
id,
"",
isShare = false,
isDirectory = true,
save = save,
append = append,
autoPlay = autoPlay,
shuffle = shuffle,
background = background,
playNext = playNext,
unpin = unpin,
isArtist = isArtist
)
}
private fun downloadRecursively(
fragment: Fragment,
id: String,
name: String?,
isShare: Boolean,
isDirectory: Boolean,
save: Boolean,
append: Boolean,
autoPlay: Boolean,
shuffle: Boolean,
background: Boolean,
playNext: Boolean,
unpin: Boolean,
isArtist: Boolean
isArtist: Boolean = false
) {
var successString: String? = null
// Launch the Job
val job = launch {
executeTaskWithToast(fragment, {
val songs: MutableList<Track> =
getTracksFromServer(isArtist, id, isDirectory, name, isShare)
withContext(Dispatchers.Main) {
addTracksToMediaController(
songs,
background,
unpin,
append,
playNext,
save,
autoPlay,
shuffle,
fragment
)
}
}
// Create the dialog
val builder = InfoDialog.Builder(fragment.requireContext())
builder.setTitle(R.string.background_task_wait)
builder.setMessage(R.string.background_task_loading)
builder.setOnCancelListener { job.cancel() }
builder.setPositiveButton(R.string.common_cancel) { _, i -> job.cancel() }
val dialog = builder.create()
dialog.show()
job.invokeOnCompletion {
dialog.dismiss()
if (it != null && it !is CancellationException) {
Util.toast(
fragment.requireContext(),
CommunicationError.getErrorMessage(it, fragment.requireContext())
songs = songs,
append = append,
playNext = playNext,
autoPlay = autoPlay,
shuffle = shuffle,
playlistName = null,
fragment = fragment
)
// Play Now doesn't get a Toast :)
if (playNext) {
successString = fragment.resources.getQuantityString(
R.plurals.select_album_n_songs_play_next,
songs.size,
songs.size
)
} else if (append) {
successString = fragment.resources.getQuantityString(
R.plurals.select_album_n_songs_added,
songs.size,
songs.size
)
}
}
}) { successString }
}
private fun addTracksToMediaController(
songs: MutableList<Track>,
background: Boolean,
unpin: Boolean,
fun addTracksToMediaController(
songs: List<Track>,
append: Boolean,
playNext: Boolean,
save: Boolean,
autoPlay: Boolean,
shuffle: Boolean,
playlistName: String? = null,
fragment: Fragment
) {
if (songs.isEmpty()) return
if (Settings.shouldSortByDisc) {
Collections.sort(songs, EntryByDiscAndTrackComparator())
}
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
if (!background) {
if (unpin) {
mediaPlayerController.unpin(songs)
} else {
val insertionMode = when {
append -> MediaPlayerController.InsertionMode.APPEND
playNext -> MediaPlayerController.InsertionMode.AFTER_CURRENT
else -> MediaPlayerController.InsertionMode.CLEAR
append -> MediaPlayerManager.InsertionMode.APPEND
playNext -> MediaPlayerManager.InsertionMode.AFTER_CURRENT
else -> MediaPlayerManager.InsertionMode.CLEAR
}
mediaPlayerController.addToPlaylist(
if (playlistName != null) {
mediaPlayerManager.suggestedPlaylistName = playlistName
}
mediaPlayerManager.addToPlaylist(
songs,
save,
autoPlay,
shuffle,
insertionMode
)
if (
!append &&
Settings.shouldTransitionOnPlayback
) {
fragment.findNavController().popBackStack(
R.id.playerFragment,
true
)
if (Settings.shouldTransitionOnPlayback && (!append || autoPlay)) {
fragment.findNavController().popBackStack(R.id.playerFragment, true)
fragment.findNavController().navigate(R.id.playerFragment)
}
}
} else {
if (unpin) {
mediaPlayerController.unpin(songs)
} else {
mediaPlayerController.downloadBackground(songs, save)
}
}
}
private fun getTracksFromServer(
isArtist: Boolean,
@ -310,11 +181,11 @@ class DownloadHandler(
val musicService = getMusicService()
val songs: MutableList<Track> = LinkedList()
val root: MusicDirectory
if (!isOffline() && isArtist && Settings.shouldUseId3Tags) {
getSongsForArtist(id, songs)
if (shouldUseId3Tags() && isArtist) {
return getSongsForArtist(id)
} else {
if (isDirectory) {
root = if (!isOffline() && Settings.shouldUseId3Tags)
root = if (shouldUseId3Tags())
musicService.getAlbumAsDir(id, name, false)
else
musicService.getMusicDirectory(id, name, false)
@ -348,23 +219,19 @@ class DownloadHandler(
}
val musicService = getMusicService()
for ((id1, _, _, title) in parent.getAlbums()) {
val root: MusicDirectory = if (
!isOffline() &&
Settings.shouldUseId3Tags
) musicService.getAlbumAsDir(id1, title, false)
else musicService.getMusicDirectory(id1, title, false)
val root: MusicDirectory = if (shouldUseId3Tags())
musicService.getAlbumAsDir(id1, title, false)
else
musicService.getMusicDirectory(id1, title, false)
getSongsRecursively(root, songs)
}
}
@Throws(Exception::class)
private fun getSongsForArtist(
id: String,
songs: MutableCollection<Track>
) {
if (songs.size > maxSongs) {
return
}
id: String
): MutableList<Track> {
val songs: MutableList<Track> = LinkedList()
val musicService = getMusicService()
val artist = musicService.getAlbumsOfArtist(id, "", false)
for ((id1) in artist) {
@ -379,5 +246,10 @@ class DownloadHandler(
}
}
}
return songs
}
}
enum class DownloadAction {
DOWNLOAD, PIN, UNPIN, DELETE
}

View File

@ -33,7 +33,7 @@ ImageLoaderProvider(val context: Context) :
}
init {
Timber.e("Prepping Loader")
Timber.d("Prepping Loader")
// Populate the ImageLoader async & early
launch {
getImageLoader()

View File

@ -19,7 +19,7 @@ import org.koin.java.KoinJavaComponent.inject
import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.domain.Playlist
import org.moire.ultrasonic.domain.Track
import org.moire.ultrasonic.service.MediaPlayerController
import org.moire.ultrasonic.service.MediaPlayerManager
import org.moire.ultrasonic.util.FileUtil.getAlbumArtFile
import org.moire.ultrasonic.util.FileUtil.getCompleteFile
import org.moire.ultrasonic.util.FileUtil.getPartialFile
@ -235,8 +235,8 @@ class CacheCleaner : CoroutineScope by CoroutineScope(Dispatchers.IO), KoinCompo
private fun findFilesToNotDelete(): Set<String> {
val filesToNotDelete: MutableSet<String> = HashSet(5)
val mediaController = inject<MediaPlayerController>(
MediaPlayerController::class.java
val mediaController = inject<MediaPlayerManager>(
MediaPlayerManager::class.java
)
val playlist = mainScope.future { mediaController.value.playlist }.get()

View File

@ -0,0 +1,82 @@
/*
* CoroutinePatterns.kt
* Copyright (C) 2009-2023 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
package org.moire.ultrasonic.util
import android.os.Handler
import android.os.Looper
import androidx.fragment.app.Fragment
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import org.moire.ultrasonic.R
import timber.log.Timber
object CoroutinePatterns {
val loggingExceptionHandler by lazy {
CoroutineExceptionHandler { _, exception ->
Handler(Looper.getMainLooper()).post {
Timber.w(exception)
}
}
}
}
fun CoroutineScope.executeTaskWithToast(
fragment: Fragment,
task: suspend CoroutineScope.() -> Unit,
successString: () -> String?
): Job {
// Launch the Job
val job = launch(CoroutinePatterns.loggingExceptionHandler, block = task)
// Setup a handler when the job is done
job.invokeOnCompletion {
val toastString = if (it != null && it !is CancellationException) {
CommunicationError.getErrorMessage(it, fragment.context)
} else {
successString()
}
// Return early if nothing to post
if (toastString == null) return@invokeOnCompletion
launch(Dispatchers.Main) {
Util.toast(fragment.context, toastString)
}
}
return job
}
fun CoroutineScope.executeTaskWithModalDialog(
fragment: Fragment,
task: suspend CoroutineScope.() -> Unit,
successString: () -> String
) {
// Create the job
val job = executeTaskWithToast(fragment, task, successString)
// Create the dialog
val builder = InfoDialog.Builder(fragment.requireContext())
builder.setTitle(R.string.background_task_wait)
builder.setMessage(R.string.background_task_loading)
builder.setOnCancelListener { job.cancel() }
builder.setPositiveButton(R.string.common_cancel) { _, _ -> job.cancel() }
val dialog = builder.create()
dialog.show()
// Add additional handler to close the dialog
job.invokeOnCompletion {
launch(Dispatchers.Main) {
dialog.dismiss()
}
}
}

View File

@ -10,7 +10,9 @@ package org.moire.ultrasonic.util
import android.app.Activity
import android.content.Context
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import java.lang.ref.WeakReference
import org.moire.ultrasonic.R
import timber.log.Timber
/*
* InfoDialog can be used to show some information to the user. Typically it cannot be cancelled,
@ -19,24 +21,30 @@ import org.moire.ultrasonic.R
open class InfoDialog(
context: Context,
message: CharSequence?,
private val activity: Activity? = null,
activity: Activity? = null,
private val finishActivityOnClose: Boolean = false
) {
open var builder: MaterialAlertDialogBuilder = Builder(activity ?: context, message)
private val activityRef: WeakReference<Activity?> = WeakReference(activity)
open var builder: MaterialAlertDialogBuilder = Builder(activityRef.get() ?: context, message)
fun show() {
builder.setOnCancelListener {
if (finishActivityOnClose) {
activity!!.finish()
activityRef.get()?.finish()
}
}
builder.setPositiveButton(R.string.common_ok) { _, _ ->
if (finishActivityOnClose) {
activity!!.finish()
activityRef.get()?.finish()
}
}
// If the app was put into the background in the meantime this would fail
try {
builder.create().show()
} catch (all: Exception) {
Timber.w(all, "Failed to create dialog")
}
}
class Builder(context: Context) : MaterialAlertDialogBuilder(context) {
@ -93,7 +101,6 @@ class ConfirmationDialog(
activity: Activity? = null,
finishActivityOnClose: Boolean = false
) : InfoDialog(context, message, activity, finishActivityOnClose) {
override var builder: MaterialAlertDialogBuilder = Builder(activity ?: context, message)
class Builder(context: Context) : MaterialAlertDialogBuilder(context) {

View File

@ -0,0 +1,49 @@
/*
* SelectCacheActivityContract.kt
* Copyright (C) 2009-2023 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
package org.moire.ultrasonic.util
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.provider.DocumentsContract
import androidx.activity.result.contract.ActivityResultContract
import org.moire.ultrasonic.fragment.SettingsFragment
class SelectCacheActivityContract : ActivityResultContract<String?, Uri?>() {
override fun createIntent(context: Context, input: String?): Intent {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
if (Settings.cacheLocationUri != "" && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, input)
}
intent.addFlags(SettingsFragment.RW_FLAG)
intent.addFlags(SettingsFragment.PERSISTABLE_FLAG)
return intent
}
override fun parseResult(resultCode: Int, intent: Intent?): Uri? {
if (
resultCode == Activity.RESULT_OK &&
intent != null
) {
val read = (intent.flags and Intent.FLAG_GRANT_READ_URI_PERMISSION) != 0
val write = (intent.flags and Intent.FLAG_GRANT_WRITE_URI_PERMISSION) != 0
val persist = (intent.flags and Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION) != 0
if (read && write && persist) {
if (intent.data != null) {
// The result data contains a URI for the document or directory that
// the user selected.
return intent.data!!
}
}
}
return null
}
}

View File

@ -33,22 +33,26 @@ object Settings {
val maxBitRate: Int
get() {
return if (Util.isNetworkRestricted()) {
maxMobileBitRate
maxBitRateMobile
} else {
maxWifiBitRate
maxBitRateWifi
}
}
private var maxWifiBitRate
private var maxBitRateWifi
by StringIntSetting(getKey(R.string.setting_key_max_bitrate_wifi))
private var maxMobileBitRate
private var maxBitRateMobile
by StringIntSetting(getKey(R.string.setting_key_max_bitrate_mobile))
var maxBitRatePinning
by StringIntSetting(getKey(R.string.setting_key_max_bitrate_pinning))
val pinWithHighestQuality: Boolean
get() = (maxBitRatePinning == 0)
@JvmStatic
val preloadCount: Int
get() {
val preferences = preferences
val preloadCount =
preferences.getString(getKey(R.string.setting_key_preload_count), "-1")!!
.toInt()
@ -60,7 +64,6 @@ object Settings {
@JvmStatic
val cacheSizeMB: Int
get() {
val preferences = preferences
val cacheSize = preferences.getString(
getKey(R.string.setting_key_cache_size),
"-1"
@ -130,6 +133,9 @@ object Settings {
var seekInterval
by StringIntSetting(getKey(R.string.setting_key_increment_time), 5000)
val seekIntervalMillis: Long
get() = (seekInterval / 1000).toLong()
@JvmStatic
var mediaButtonsEnabled
by BooleanSetting(getKey(R.string.setting_key_media_buttons), true)
@ -168,11 +174,11 @@ object Settings {
// Normally you don't need to use these Settings directly,
// use ActiveServerProvider.isID3Enabled() instead
@JvmStatic
var shouldUseId3Tags by BooleanSetting(getKey(R.string.setting_key_id3_tags), true)
var id3TagsEnabledOnline by BooleanSetting(getKey(R.string.setting_key_id3_tags), true)
// See comment above.
@JvmStatic
var useId3TagsOffline by BooleanSetting(getKey(R.string.setting_key_id3_tags_offline), true)
var id3TagsEnabledOffline by BooleanSetting(getKey(R.string.setting_key_id3_tags_offline), true)
var activeServer by IntSetting(getKey(R.string.setting_key_server_instance), -1)
@ -181,7 +187,7 @@ object Settings {
var firstRunExecuted by BooleanSetting(getKey(R.string.setting_key_first_run_executed), false)
val shouldShowArtistPicture
by BooleanSetting(getKey(R.string.setting_key_show_artist_picture), false)
by BooleanSetting(getKey(R.string.setting_key_show_artist_picture), true)
@JvmStatic
var chatRefreshInterval by StringIntSetting(
@ -209,7 +215,6 @@ object Settings {
@JvmStatic
val shareGreeting: String?
get() {
val preferences = preferences
val context = Util.appContext()
val defaultVal = String.format(
context.resources.getString(R.string.share_default_greeting),
@ -278,8 +283,7 @@ object Settings {
}
fun getAllKeys(): List<String> {
val prefs = PreferenceManager.getDefaultSharedPreferences(UApp.applicationContext())
return prefs.all.keys.toList()
return preferences.all.keys.toList()
}
private val appContext: Context

View File

@ -16,7 +16,8 @@ import timber.log.Timber
/**
* Provides filesystem access abstraction which works
* both on File based paths and Storage Access Framework Uris
* both on File based paths (when using the internal directory for storing media files)
* and Storage Access Framework Uris (when using a custom directory)
*/
object Storage {

View File

@ -273,7 +273,7 @@ class StorageFile(
}
private fun getStorageFileForParentDirectory(path: String): StorageFile? {
val parentPath = FileUtil.getParentPath(path)!!
val parentPath = FileUtil.getParentPath(path) ?: return null
if (storageFilePathDictionary.containsKey(parentPath))
return storageFilePathDictionary[parentPath]!!
if (notExistingPathDictionary.contains(parentPath)) return null

View File

@ -133,6 +133,8 @@ object Util {
@JvmStatic
@SuppressLint("ShowToast") // Invalid warning
fun toast(context: Context?, message: CharSequence?, shortDuration: Boolean) {
// If called after doing some background processing, our context might have expired!
try {
if (toast == null) {
toast = Toast.makeText(
context,
@ -146,6 +148,9 @@ object Util {
if (shortDuration) Toast.LENGTH_SHORT else Toast.LENGTH_LONG
}
toast!!.show()
} catch (all: Exception) {
Timber.w(all)
}
}
/**
@ -757,7 +762,7 @@ object Util {
fun getPlayListFromTimeline(
timeline: Timeline?,
shuffle: Boolean,
isShuffled: Boolean,
firstIndex: Int? = null,
count: Int? = null
): List<MediaItem> {
@ -765,13 +770,13 @@ object Util {
if (timeline.windowCount < 1) return emptyList()
val playlist: MutableList<MediaItem> = mutableListOf()
var i = firstIndex ?: timeline.getFirstWindowIndex(false)
var i = firstIndex ?: timeline.getFirstWindowIndex(isShuffled)
if (i == C.INDEX_UNSET) return emptyList()
while (i != C.INDEX_UNSET && (count != playlist.count())) {
val window = timeline.getWindow(i, Timeline.Window())
playlist.add(window.mediaItem)
i = timeline.getNextWindowIndex(i, Player.REPEAT_MODE_OFF, shuffle)
i = timeline.getNextWindowIndex(i, Player.REPEAT_MODE_OFF, isShuffled)
}
return playlist
}
@ -828,6 +833,7 @@ object Util {
Timber.d("Current user preferences")
Timber.d("========================")
val keys = Settings.preferences.all
keys.forEach {
Timber.d("${it.key}: ${it.value}")
}

View File

@ -119,7 +119,7 @@
</LinearLayout>
</FrameLayout>
<include layout="@layout/current_playlist" />
<include layout="@layout/current_playlist" a:id="@+id/playlist"/>
</ViewFlipper>
</LinearLayout>

View File

@ -112,7 +112,7 @@
</LinearLayout>
</RelativeLayout>
<include layout="@layout/current_playlist" />
<include layout="@layout/current_playlist" a:id="@+id/playlist"/>
</ViewFlipper>
<include layout="@layout/player_media_info" />

View File

@ -5,13 +5,17 @@
a:layout_height="fill_parent"
a:orientation="vertical">
<TextView
a:id="@+id/playlist_empty"
a:layout_width="fill_parent"
a:layout_height="wrap_content"
a:padding="10dip"
a:text="@string/playlist.empty" />
<com.google.android.material.progressindicator.CircularProgressIndicator
a:id="@+id/progress_indicator"
a:layout_width="wrap_content"
a:layout_height="0dip"
a:indeterminate="true"
a:layout_weight="1"
a:layout_gravity="center|center_horizontal|center_vertical" />
<include
a:id="@+id/emptyListView"
layout="@layout/list_parts_empty_view" />
<com.simplecityapps.recyclerview_fastscroll.views.FastScrollRecyclerView
a:id="@+id/playlist_view"

View File

@ -1,28 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:a="http://schemas.android.com/apk/res/android"
a:id="@+id/toast_layout_root"
a:orientation="vertical"
a:layout_width="fill_parent"
a:layout_height="fill_parent"
a:background="@android:drawable/toast_frame">
<TextView
a:layout_width="wrap_content"
a:layout_height="wrap_content"
a:text="@string/download.jukebox_volume"
a:textAppearance="?android:attr/textAppearanceMedium"
a:textColor="#ffffffff"
a:shadowColor="#bb000000"
a:shadowRadius="2.75"
a:paddingStart="32dp"
a:paddingEnd="32dp"
a:paddingBottom="12dp"
/>
<ProgressBar a:id="@+id/jukebox_volume_progress_bar"
style="@android:style/Widget.Holo.Light.ProgressBar.Horizontal"
a:layout_width="fill_parent"
a:layout_height="wrap_content"
a:paddingBottom="3dp" />
</LinearLayout>

View File

@ -15,6 +15,13 @@
app:showAsAction="ifRoom|withText"
a:title="@string/download.menu_star"/>
<item
a:id="@+id/menu_show_artist"
a:title="@string/download.menu_show_artist"/>
<item
a:id="@+id/menu_show_album"
a:title="@string/download.menu_show_album"/>
<item
a:id="@+id/menu_item_share_song"
a:icon="@drawable/ic_menu_share"

View File

@ -48,7 +48,6 @@
<string name="download.jukebox_offline">Vzdálené ovládání není dostupné v offline módu.</string>
<string name="download.jukebox_on">Vzdálené ovládání zapnuto. Hudba přehrávána na serveru.</string>
<string name="download.jukebox_server_too_old">Vzdálené ovládání není podporováno. Aktualizujte svůj Subsonic server.</string>
<string name="download.jukebox_volume">Hlasitost vzdáleného přístroje</string>
<string name="download.menu_equalizer">Ekvalizér</string>
<string name="download.menu_jukebox_off">Jukebox vypnut</string>
<string name="download.menu_jukebox_on">Jukebox zapnut</string>

View File

@ -61,7 +61,6 @@
<string name="download.jukebox_offline">Fernbedienungs-Modus is Offline nicht verfügbar.</string>
<string name="download.jukebox_on">Fernbedienung ausgeschaltet. Musik wird auf dem Server wiedergegeben.</string>
<string name="download.jukebox_server_too_old">Fernbedienungs Modus wird nicht unterstützt. Bitte den Subsonic Server aktualisieren.</string>
<string name="download.jukebox_volume">Entfernte Lautstärke</string>
<string name="download.menu_equalizer">Equalizer</string>
<string name="download.menu_jukebox_off">Jukebox Aus</string>
<string name="download.menu_jukebox_on">Jukebox An</string>

View File

@ -62,7 +62,6 @@
<string name="download.jukebox_offline">Control remoto no disponible en modo fuera de línea.</string>
<string name="download.jukebox_on">Control remoto encendido. La música se reproduce en el servidor.</string>
<string name="download.jukebox_server_too_old">Control remoto no soportado. Por favor actualiza tu servidor de Subsonic.</string>
<string name="download.jukebox_volume">Volumen remoto</string>
<string name="download.menu_equalizer">Ecualizador</string>
<string name="download.menu_jukebox_off">Apagar Jukebox</string>
<string name="download.menu_jukebox_on">Encender Jukebox</string>
@ -70,7 +69,7 @@
<string name="download.menu_save">Guardar lista de reproducción</string>
<string name="download.menu_screen_off">Pantalla apagada</string>
<string name="download.menu_screen_on">Pantalla encendida</string>
<string name="download.menu_show_album">Mostrar Álbum</string>
<string name="download.menu_show_album">Ir al álbum</string>
<string name="download.menu_shuffle">Aleatorio</string>
<string name="download.menu_shuffle_on">Modo aleatorio activado</string>
<string name="download.menu_shuffle_off">Modo aleatorio desactivado</string>
@ -361,7 +360,7 @@
<string name="share_default_greeting">Echa un vistazo a esta música que te comparto desde %s</string>
<string name="share_via">Compartir canciones vía</string>
<string name="menu.share">Compartir</string>
<string name="download.menu_show_artist">Mostrar artista</string>
<string name="download.menu_show_artist">Ir al artista</string>
<string name="albumArt">Portadas de álbumes</string>
<string name="common_multiple_years">Múltiples años</string>
<string name="settings.show_confirmation_dialog">Mostrar diálogo de confirmación</string>
@ -442,7 +441,7 @@
<string name="settings.five_star_rating_title">Use cinco estrellas para las canciones</string>
<string name="settings.five_star_rating_description">Utilice el sistema de calificación de cinco estrellas para canciones en lugar de simplemente marcar / desmarcar elementos.</string>
<string name="settings.use_hw_offload_title">Utilizar la reproducción por hardware (experimental)</string>
<string name="settings.use_hw_offload_description">Intenta reproducir los medios utilizando el chip decodificador de medios de tu teléfono. Esto puede mejorar el uso de la batería.</string>
<string name="settings.use_hw_offload_description">Intenta reproducir los medios usando el procesador decodificador de los medios en tu teléfono. Esto puede mejorar el uso de la batería. ¡Algunos usuarios informan de fallos en la reproducción cuando activan esta opción!</string>
<string name="list_view">Lista</string>
<string name="grid_view">Portada</string>
<string name="settings.preload_100">100 canciones</string>

View File

@ -61,7 +61,6 @@
<string name="download.jukebox_offline">Le mode jukebox n\'est pas disponible en mode déconnecté.</string>
<string name="download.jukebox_on">Mode jukebox activé. La musique est jouée sur le serveur</string>
<string name="download.jukebox_server_too_old">Le mode jukebox n\'est pas pris en charge. Mise à jour du serveur Subsonic requise.</string>
<string name="download.jukebox_volume">Volume sur serveur distant</string>
<string name="download.menu_equalizer">Égaliseur</string>
<string name="download.menu_jukebox_off">Désactiver le mode jukebox</string>
<string name="download.menu_jukebox_on">Activer le mode jukebox</string>

View File

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="background_task.loading">Cargando…</string>
<string name="background_task.network_error">Ocorreu un erro de rede. Por favor comproba a dirección do servidor ou téntao de novo mais tarde.</string>
<string name="background_task.unsupported_api">A API do servidor v%1$s non admite esta función.</string>
<string name="background_task.no_network">Este programa require acceso á rede. Por favor acende a Wi-Fi ou a rede móbil.</string>
<string name="background_task.not_found">Recurso non atopado. Por favor comproba a dirección do servidor.</string>
<string name="background_task.parse_error">Non se entende a resposta. Por favor comproba a dirección do servidor.</string>
<string name="background_task.ssl_cert_error">Erro do certificado HTTPS: %1$s.</string>
<string name="background_task.ssl_error">Excepción de conexión SSL. Comprobe o certificado do servidor.</string>
<string name="background_task.wait">Por favor agarde…</string>
<string name="button_bar.browse">Biblioteca</string>
<string name="button_bar.chat">Chat</string>
<string name="button_bar.now_playing">Reproducindo agora</string>
<string name="buttons.play">Reproducir</string>
<string name="buttons.pause">Pausar</string>
<string name="buttons.repeat">Repetir</string>
<string name="buttons.shuffle">Mesturar</string>
<string name="buttons.stop">Parar</string>
<string name="buttons.next">Seguinte</string>
<string name="buttons.previous">Anterior</string>
<string name="podcasts.label">Podcast</string>
<string name="podcasts_channels.empty">Non hai canles de Podcasts rexistrados</string>
<string name="button_bar.podcasts">Podcast</string>
<string name="button_bar.search">Buscar</string>
<string name="chat.send_a_message">Enviar unha mensaxe</string>
</resources>

View File

@ -54,7 +54,6 @@
<string name="download.jukebox_offline">A távvezérlés nem lehetséges kapcsolat nélküli módban!</string>
<string name="download.jukebox_on">Távvezérlés bekapcsolása. A zenelejátszás a kiszolgálón történik.</string>
<string name="download.jukebox_server_too_old">A távvezérlés nem támogatott. Kérjük, frissítse a Subsonic kiszolgálót!</string>
<string name="download.jukebox_volume">Hangerő távvezérlése</string>
<string name="download.menu_equalizer">Equalizer</string>
<string name="download.menu_jukebox_off">Jukebox ki</string>
<string name="download.menu_jukebox_on">Jukebox be</string>

View File

@ -45,7 +45,6 @@
<string name="download.jukebox_offline">Il controllo remoto non è disponibile nella modalità offline. </string>
<string name="download.jukebox_on">Controllo remoto abilitato. La musica verrà riprodotta sul server.</string>
<string name="download.jukebox_server_too_old">Il controllo remoto non è supportato. Per favore aggiorna la versione del server Airsonic.</string>
<string name="download.jukebox_volume">Volume remoto</string>
<string name="download.menu_equalizer">Equalizzatore</string>
<string name="download.menu_jukebox_off">Jukebox spento</string>
<string name="download.menu_jukebox_on">Jukebox acceso</string>

View File

@ -0,0 +1,449 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools">
<string name="background_task.loading">読み込み中…</string>
<string name="background_task.network_error">ネットワークエラーが発生しました。サーバーのアドレスを確認してやり直してください。</string>
<string name="background_task.parse_error">応答が確認できません。サーバーのアドレスを確認してください。</string>
<string name="background_task.ssl_cert_error">HTTPS証明書エラー: %1$s.</string>
<string name="background_task.ssl_error">SSL接続が異常です。サーバーの証明書を確認してください。</string>
<string name="background_task.wait">お待ち下さい…</string>
<string name="button_bar.bookmarks">ブックマーク</string>
<string name="button_bar.browse">メディアライブラリ</string>
<string name="button_bar.chat">チャット</string>
<string name="button_bar.now_playing">再生中</string>
<string name="buttons.pause">一時停止</string>
<string name="buttons.repeat">リピート</string>
<string name="buttons.shuffle">シャッフル</string>
<string name="buttons.stop">停止</string>
<string name="chat.send_a_message">メッセージ送信</string>
<string name="common.appname">Ultrasonic</string>
<string name="common.artist">アーティスト</string>
<string name="common.cancel">キャンセル</string>
<string name="common.comment">コメント</string>
<string name="common.confirm">確認</string>
<string name="common.delete">削除</string>
<string name="common.download">ダウンロード</string>
<string name="common.info">詳細</string>
<string name="common.multiple_genres">複数ジャンル</string>
<string name="common.name">名前</string>
<string name="common.ok">OK</string>
<string name="common.pin">固定</string>
<string name="common.play_last">最後に再生</string>
<string name="common.play_next">次に再生</string>
<string name="common.play_now">今すぐ再生</string>
<string name="common.play_shuffled">シャッフル再生</string>
<string name="common.public">公開</string>
<string name="common.save">保存</string>
<string name="common.select_all">すべて選択</string>
<string name="common.title">タイトル</string>
<string name="common.delete_selection_confirmation">選択した項目を削除してよろしいですか\?</string>
<string name="common.unpin_selection_confirmation">選択した項目を固定解除してよろしいですか\?</string>
<string name="download.jukebox_not_authorized">リモートコントロールが許可されていません。Subsonicサーバー上で <b> ユーザ &gt; 設定</b> からジュークボックスモードを有効化してください。</string>
<string name="download.jukebox_offline">リモートコントロールはオフラインモードでは利用できません。</string>
<string name="download.jukebox_on">リモートコントロールがオンになりました。サーバーで音楽が再生されます。</string>
<string name="download.jukebox_off">リモートコントロールがオフになりました。モバイル端末で音楽が再生されます。</string>
<string name="download.jukebox_server_too_old">リモートコントロールがサポートされていません。Subsonicサーバーをアップグレードしてください。</string>
<string name="download.menu_jukebox_on">ジュークボックス ON</string>
<string name="download.menu_lyrics">歌詞</string>
<string name="download.menu_show_album">アルバムを表示</string>
<string name="download.menu_shuffle">シャッフル</string>
<string name="download.menu_shuffle_on">シャッフルモードは有効です</string>
<string name="download.playerstate_loading">バッファ中…</string>
<string name="download.playerstate_playing_shuffle">シャッフル再生</string>
<string name="download.playlist_done">プレイリストが保存されました。</string>
<string name="download.playlist_error">プレイリストの保存ができません、やり直してください。</string>
<string name="download.playlist_name">プレイリストの名前を入力:</string>
<string name="download.playlist_saving">プレイリスト \"%s\" を保存中…</string>
<string name="download.playlist_title">プレイリストを保存</string>
<string name="download.repeat_all">全曲リピート</string>
<string name="download.repeat_off">リピートしない</string>
<string name="jukebox.is_default">ジュークボックスをデフォルト化</string>
<string name="lyrics.nomatch">歌詞が見つかりません</string>
<string name="language.default">システム既定</string>
<string name="language.zh_CN">中国語 (中国)</string>
<string name="language.zh_TW">中国語 (台湾)</string>
<string name="language.cs">チェコ語</string>
<string name="language.nl">オランダ語</string>
<string name="language.en">英語</string>
<string name="language.fr">フランス語</string>
<string name="language.de">ドイツ語</string>
<string name="language.hu">ハンガリー語</string>
<string name="language.it">イタリア語</string>
<string name="language.es">スペイン語</string>
<string name="language.pl">ポーランド語</string>
<string name="language.pt">ポルトガル語</string>
<string name="language.pt_BR">ポルトガル語 (ブラジル)</string>
<string name="language.ru">ロシア語</string>
<string name="main.albums_alphaByArtist">アーティスト別</string>
<string name="main.albums_frequent">最多再生回数</string>
<string name="main.albums_highest">高評価</string>
<string name="main.albums_newest">最近の追加</string>
<string name="main.albums_random">ランダム</string>
<string name="main.albums_recent">最近の再生</string>
<string name="main.albums_starred">スター付き</string>
<string name="main.albums_by_year">年代順</string>
<string name="main.albums_title">アルバム</string>
<string name="main.artists_title">アーティスト</string>
<string name="main.genres_title">ジャンル</string>
<string name="main.offline">オフライン</string>
<string name="main.setup_server">%s - サーバの設定</string>
<string name="main.songs_random">ランダム</string>
<string name="main.songs_starred">スター付き</string>
<string name="main.videos">動画</string>
<string name="main.welcome_text_demo">自分の曲をUltrasonicで再生するには <b>自身のサーバー</b> が必要です。
\n
\n➤ アプリを試したい場合、デモサーバーを追加できます。
\n
\n➤ それ以外の場合、 <b>設定</b> でサーバーを設定できます。</string>
<string name="main.welcome_title">ようこそ、Ultrasonicへ!</string>
<string name="menu.about">アプリについて</string>
<string name="menu.common">一般</string>
<string name="menu.deleted_playlist">プレイリスト %s を削除しました</string>
<string name="menu.deleted_playlist_error">プレイリスト %s を削除できません</string>
<string name="menu.downloads">ダウンロード</string>
<string name="menu.exit">終了</string>
<string name="menu.settings">設定</string>
<string name="playlist.updated_info">%s のプレイリスト情報をアップデートしました</string>
<string name="playlist.updated_info_error">%s のプレイリスト情報をアップデートできません</string>
<string name="search.albums">アルバム</string>
<string name="search.artists">アーティスト</string>
<string name="search.label">検索</string>
<string name="search.more">もっと表示</string>
<string name="search.no_match">一致するものはありません、やり直してください</string>
<string name="search.songs"></string>
<string name="search.title">検索</string>
<string name="select_album.empty">メディアが見つかりません</string>
<string name="select_album.n_selected">%dトラックが選択されています</string>
<string name="select_album.no_network">警告: 使用可能なネットワークがありません。
\n モバイルデータを使用している場合、設定で従量制接続でのダウンロードを許可する必要がある場合があります。</string>
<string name="select_album.no_sdcard">エラー: SDカードが利用できません。</string>
<string name="select_album.play_all">すべて再生</string>
<string name="select_artist.all_folders">すべてのフォルダ</string>
<string name="select_artist.folder">フォルダを選択</string>
<string name="settings.appearance_title">外観</string>
<string name="settings.increment_time_10">10秒</string>
<string name="settings.increment_time_12">12秒</string>
<string name="settings.increment_time_120">2分</string>
<string name="settings.increment_time_15">15秒</string>
<string name="settings.increment_time_2">2秒</string>
<string name="settings.increment_time_20">20秒</string>
<string name="settings.increment_time_30">30秒</string>
<string name="settings.increment_time_5">5秒</string>
<string name="settings.increment_time_60">1分</string>
<string name="settings.increment_time_8">8秒</string>
<string name="settings.custom_cache_location">キャッシュの場所をカスタムする</string>
<string name="settings.cache_location">キャッシュの場所</string>
<string name="settings.cache_location_error">キャッシュの場所が無効です。デフォルトを使用します。</string>
<string name="settings.cache_size">キャッシュサイズ</string>
<string name="settings.cache_size_100">100 MB</string>
<string name="settings.cache_size_1000">1 GB</string>
<string name="settings.cache_size_10000">10 GB</string>
<string name="settings.cache_size_15000">15 GB</string>
<string name="settings.cache_size_200">200 MB</string>
<string name="settings.cache_size_30000">30 GB</string>
<string name="settings.cache_size_4000">4 GB</string>
<string name="settings.cache_size_500">500 MB</string>
<string name="settings.cache_size_5000">5 GB</string>
<string name="settings.cache_size_6000">6 GB</string>
<string name="settings.cache_size_7000">7 GB</string>
<string name="settings.cache_size_8000">8 GB</string>
<string name="settings.cache_size_9000">9 GB</string>
<string name="settings.cache_size_unlimited">無制限</string>
<string name="settings.cache_title">音楽キャッシュ</string>
<string name="settings.default_artists">デフォルトアーティスト数</string>
<string name="settings.default_songs">デフォルト曲数</string>
<string name="settings.directory_cache_time">ディレクトリキャッシュ時間</string>
<string name="settings.directory_cache_time_0">無効</string>
<string name="settings.directory_cache_time_1">1分</string>
<string name="settings.directory_cache_time_10">10分</string>
<string name="settings.directory_cache_time_2">2分</string>
<string name="settings.directory_cache_time_30">30分</string>
<string name="settings.directory_cache_time_5">5分</string>
<string name="settings.directory_cache_time_60">1時間</string>
<string name="settings.disc_sort">ディスクで曲を並べ替え</string>
<string name="settings.disc_sort_summary">ディスク番号とトラック番号で曲リストを並び替え</string>
<string name="settings.display_bitrate">ビットレートと拡張子の表示</string>
<string name="settings.display_bitrate_summary">アーティスト名に加え、ビットレートと拡張子も表示します</string>
<string name="settings.download_transition">再生時に、再生中画面を表示</string>
<string name="settings.download_transition_summary">メディア一覧表示から再生を開始した場合、自動で再生中画面に切り替えます</string>
<string name="settings.hide_media_title">他アプリから隠す</string>
<string name="settings.hide_media_toast">Androidが、端末内の音楽を次回スキャンするときに有効になります。</string>
<string name="settings.hide_media_summary">他のアプリから音楽ファイルを見えないようにします。</string>
<string name="settings.increment_time">シーク間隔</string>
<string name="settings.invalid_url">有効なURLを指定してください。</string>
<string name="settings.max_albums">最大アルバム数</string>
<string name="settings.max_artists">最大アーティスト数</string>
<string name="settings.max_bitrate_112">112 Kbps</string>
<string name="settings.max_bitrate_128">128 Kbps</string>
<string name="settings.max_bitrate_160">160 Kbps</string>
<string name="settings.max_bitrate_192">192 Kbps</string>
<string name="settings.max_bitrate_256">256 Kbps</string>
<string name="settings.max_bitrate_32">32 Kbps</string>
<string name="settings.max_bitrate_320">320 Kbps</string>
<string name="settings.max_bitrate_64">64 Kbps</string>
<string name="settings.max_bitrate_80">80 Kbps</string>
<string name="settings.max_bitrate_96">96 Kbps</string>
<string name="settings.media_button_summary">端末本体、ヘッドセットやBluetoothの再生コントロールボタンに対応します</string>
<string name="settings.media_button_title">メディアボタン</string>
<string name="settings.network_timeout_15000">15秒</string>
<string name="settings.network_timeout_75000">75秒</string>
<string name="settings.network_timeout_90000">90秒</string>
<string name="settings.notifications_title">通知</string>
<string name="settings.network_title">ネットワーク</string>
<string name="settings.other_title">その他の設定</string>
<string name="settings.override_language">言語を指定</string>
<string name="settings.override_language_summary">言語の変更には、アプリ再起動が必要です</string>
<string name="settings.playback_control_title">再生コントロール設定</string>
<string name="settings.playback.resume_on_bluetooth_device">Bluetoothデバイスの接続時に再生を再開</string>
<string name="settings.playback.pause_on_bluetooth_device">Bluetoothデバイスの切断時に再生を一時停止</string>
<string name="settings.playback.bluetooth_all">すべてのBluetoothデバイス</string>
<string name="settings.playback.bluetooth_a2dp">オーディオデバイス (A2DP) のみ</string>
<string name="settings.playback.bluetooth_disabled">無効</string>
<string name="settings.playback.resume_play_on_headphones_plug.title">ヘッドホン装着時に再生を再開</string>
<string name="settings.playback.resume_play_on_headphones_plug.summary">デバイスにヘッドホンが差し込まれたとき、アプリが自動的に再生を再開します。</string>
<string name="settings.preload">プリロードする曲数</string>
<string name="settings.parallel_downloads">並行ダウンロードする曲数</string>
<string name="settings.preload_1">1曲</string>
<string name="settings.preload_10">10曲</string>
<string name="settings.preload_2">2曲</string>
<string name="settings.preload_3">3曲</string>
<string name="settings.preload_500">500曲</string>
<string name="settings.preload_1000">1000曲</string>
<string name="settings.preload_unlimited">無制限</string>
<string name="settings.scrobble_summary">サーバー上のScrobbleサービスで、ユーザー名とパスワードを忘れずに設定してください</string>
<string name="settings.scrobble_title">再生した曲をScrobble</string>
<string name="settings.search_1">1</string>
<string name="settings.search_10">10</string>
<string name="settings.search_100">100</string>
<string name="settings.search_15">15</string>
<string name="settings.search_20">20</string>
<string name="settings.search_25">25</string>
<string name="settings.search_250">250</string>
<string name="settings.search_3">3</string>
<string name="settings.server_name">サーバー名</string>
<string name="settings.server_password">パスワード</string>
<string name="settings.server_scaling_summary">サーバーから、フルサイズではなく圧縮された画像をダウンロードする (帯域幅を節約できます)</string>
<string name="settings.server_scaling_title">サーバー側でアルバムアートの圧縮</string>
<string name="settings.server_username">ユーザー名</string>
<string name="settings.server_color">サーバーの色</string>
<string name="settings.show_now_playing">再生中を表示</string>
<string name="settings.show_now_playing_summary">すべてのアクティビティで、現在再生中のトラックを表示します</string>
<string name="settings.show_track_number">トラック番号を表示</string>
<string name="settings.show_track_number_summary">曲を表示するとき、トラック番号も表示します</string>
<string name="settings.test_connection_title">接続テスト</string>
<string name="settings.theme_day_night">デイ &amp; ナイト</string>
<string name="settings.theme_light">ライト</string>
<string name="settings.theme_dark">ダーク</string>
<string name="settings.title.allow_self_signed_certificate">自己署名のHTTPS証明書を許可</string>
<string name="settings.title.force_plain_text_password">平文パスワード認証を強制する</string>
<string name="settings.summary.force_plain_text_password">この機能により、アプリは常にパスワードを暗号化せずに送信するようになります。 Subsonic サーバーがユーザーの新しい認証 API をサポートしていない場合に有用です。</string>
<string name="settings.use_folder_for_album_artist">アーティスト名としてフォルダを使用</string>
<string name="settings.use_folder_for_album_artist_summary">最上位のフォルダ名を、アルバムアーティスト名として利用します</string>
<string name="settings.show_now_playing_details_summary">再生中画面で曲の詳細を表示 (ジャンル、年、ビットレート)</string>
<string name="settings.use_id3">ID3タグを利用してブラウズ</string>
<string name="settings.use_id3_summary">ファイルシステムベースの方式ではなく、ID3タグ方式を利用します</string>
<string name="settings.use_id3_offline">オフライン時もID3方式を利用</string>
<string name="settings.show_artist_picture">アーティストリストでアーティスト画像を表示</string>
<string name="settings.show_artist_picture_summary">利用可能であれば、アーティストリストでアーティスト画像を表示します</string>
<string name="main.video" tools:ignore="UnusedResources">動画</string>
<string name="settings.wifi_required_summary">定額制接続でのみメディアをダウンロードします</string>
<string name="settings.wifi_required_title">Wi-Fi接続時のみダウンロード</string>
<string name="song_details.kbps">%d kbps</string>
<string name="util.bytes_format.byte">0 B</string>
<string name="util.bytes_format.gigabyte">0.00 GB</string>
<string name="download.menu_clear_playlist">プレイリストを消去</string>
<string name="util.bytes_format.megabyte">0.00 MB</string>
<string name="util.no_time" tools:ignore="TypographyDashes">-:--</string>
<string name="util.zero_time">0:00</string>
<string name="widget.initial_text">タップして音楽を選択</string>
<string name="widget.sdcard_missing">SDカードなし</string>
<string name="settings.share_description_default">デフォルトの共有説明文</string>
<string name="settings.sharing_title">共有</string>
<string name="settings.sharing_always_ask_for_details_summary">サーバー上に共有を作成するとき、常に説明と有効期限を確認します</string>
<string name="share_set_share_options">共有オプションの設定</string>
<string name="share_on_server">サーバー上に共有を作成</string>
<string name="settings.share_on_server_summary">有効の場合、サーバー上に共有が作成され、そのURLが共有されます。無効の場合、曲の詳細のみが共有されます</string>
<string name="no_expiration">有効期限なし</string>
<string name="download.toggle_playlist">プレイリストの切り替え</string>
<string name="download.menu_star">スター</string>
<string name="button_bar.shares">共有</string>
<string name="select_share.empty">サーバー上で利用可能な共有がありません</string>
<string name="menu_deleted_share">共有 %s を削除しました</string>
<string name="menu_deleted_share_error">共有 %s を削除できません</string>
<string name="settings.share_minutes"></string>
<string name="settings.share_hours">時間</string>
<string name="settings.share_days"></string>
<string name="time_span_disable">無効</string>
<string name="save_as_defaults">デフォルトとして保存</string>
<string name="share_comment">コメント</string>
<string name="settings.share_expiration">有効期限までの時間</string>
<string name="download_song_removed">\"%s\" をプレイリストから削除しました</string>
<string name="download.share_playlist">プレイリストを共有</string>
<string name="download.share_song">現在の曲を共有</string>
<string name="settings.share_greeting_default">デフォルトの共有時あいさつ文</string>
<string name="share_default_greeting">%s から共有した音楽をチェックしてみましょう</string>
<string name="share_via">経由で曲を共有</string>
<string name="menu.share">共有</string>
<string name="download.menu_show_artist">アーティストを表示</string>
<string name="settings.show_confirmation_dialog">確認ダイアログを表示</string>
<string name="settings.show_confirmation_dialog_summary">曲を削除または固定解除する前、確認ダイアログを表示します</string>
<string name="settings.debug.log_path">ログファイルは %1$s/%2$s に保存されています</string>
<string name="settings.debug.log_keep">ファイルを保持</string>
<string name="settings.debug.log_deleted">ログファイルを削除しました。</string>
<string name="notification.permission_required">メディア再生には通知が必要です。Androidの設定からいつでも権限を許可することができます。</string>
<string name="server_selector.label">設定済みサーバー</string>
<string name="server_editor.new_label">サーバーを追加</string>
<string name="server_editor.leave_confirmation">変更を保存せずに離れてよろしいですか\?</string>
<string name="server_editor.required">この項目は必須です</string>
<string name="server_menu.edit">編集</string>
<string name="server_menu.delete">削除</string>
<string name="server_menu.move_up">上に移動</string>
<string name="server_editor.advanced">詳細設定</string>
<string name="server_editor.disabled_feature">サーバーがサポート外のため、いくつかの機能が無効になりました。
\nこのテストはいつでも再実行することができます。</string>
<string name="server_menu.demo">デモサーバー</string>
<string name="about.webpage">Webページにアクセス</string>
<string name="about.report">バグを報告</string>
<plurals name="select_album_n_songs">
<item quantity="other">%d 曲</item>
</plurals>
<plurals name="select_album_n_songs_downloaded">
<item quantity="other">%d 曲がダウンロード選択されました</item>
</plurals>
<plurals name="select_album_n_songs_unpinned">
<item quantity="other">%d 曲が固定解除されました</item>
</plurals>
<plurals name="select_album_n_songs_pinned">
<item quantity="other">%d 曲が固定されるよう選択されました</item>
</plurals>
<plurals name="select_album_n_songs_deleted">
<item quantity="other">%d 曲が削除されました</item>
</plurals>
<plurals name="select_album_n_songs_added">
<item quantity="other">%d 曲が再生キューの末尾に追加されました</item>
</plurals>
<plurals name="select_album_n_songs_play_next">
<item quantity="other">%d 曲が現在再生中の曲の次に追加されました</item>
</plurals>
<string name="api.subsonic.generic">一般APIエラー: %1$s</string>
<string name="api.subsonic.generic.no.message">サーバーからメッセージが応答されていません</string>
<string name="api.subsonic.token_auth_not_supported_for_ldap">LDAPユーザーに対してのトークン認証はサポートされていません。</string>
<string name="api.subsonic.not_authenticated">ユーザー名またはパスワードが間違っています。</string>
<string name="api.subsonic.not_authorized">許可されていません。 Subsonic サーバーのユーザー権限を確認してください。</string>
<string name="api.subsonic.param_missing">必要なパラメータが不足しています。</string>
<string name="api.subsonic.requested_data_was_not_found">要求されたデータが見つかりませんでした。</string>
<string name="api.subsonic.upgrade_client">互換性のないバージョンです。UltrasonicのAndroidアプリをバージョンアップしてください。</string>
<string name="api.subsonic.upgrade_server">互換性のないバージョンです。Subsonicサーバーをバージョンアップしてください。</string>
<string name="settings.five_star_rating_title">曲に五つ星評価を利用</string>
<string name="settings.five_star_rating_description">楽曲の評価を、スターあり/なし ではなく、5つの星を付ける方式にします。</string>
<string name="list_view">リスト</string>
<string name="grid_view">カバー</string>
<string name="supported_server_features">サポートされている機能</string>
<string name="jukebox">ジュークボックス</string>
<string name="foreground_exception_title">再生を再開できません</string>
<string name="background_task.unsupported_api">サーバーAPI v%1$s ではこの機能がサポートされていません。</string>
<string name="chat.send_button">送信</string>
<string name="background_task.no_network">アプリを利用するにはネットワークアクセスが必要です。Wi-Fiかモバイル回線に接続してください。</string>
<string name="buttons.play">再生</string>
<string name="background_task.not_found">リソースが見つかりません。サーバーのアドレスを確認してください。</string>
<string name="buttons.next">次へ</string>
<string name="buttons.previous">前へ</string>
<string name="podcasts.label">ポッドキャスト</string>
<string name="podcasts_channels.empty">ポッドキャストが何も登録されていません</string>
<string name="button_bar.podcasts">ポッドキャスト</string>
<string name="chat.user_avatar">アバター画像</string>
<string name="common.album">アルバム</string>
<string name="button_bar.search">検索</string>
<string name="common.unpin">固定解除</string>
<string name="common.various_artists">様々なアーティスト</string>
<string name="download.bookmark_removed" formatted="false">ブックマークが削除されました。</string>
<string name="download.empty">何もダウンロードしていません</string>
<string name="delete_playlist">%1$s を削除してよろしいですか</string>
<string name="download.bookmark_set_at_position" formatted="false">%s にブックマークされました。</string>
<string name="playlist.empty">プレイリストは空です</string>
<string name="download.menu_equalizer">イコライザー</string>
<string name="download.menu_save">プレイリストを保存</string>
<string name="download.menu_screen_off">画面オフ</string>
<string name="download.menu_jukebox_off">ジュークボックス OFF</string>
<string name="download.menu_screen_on">画面オン</string>
<string name="download.menu_shuffle_off">シャッフルモードは無効です</string>
<string name="equalizer.preset">プリセットを選択</string>
<string name="download.repeat_single">一曲リピート</string>
<string name="equalizer.enabled">有効です</string>
<string name="equalizer.label">イコライザー</string>
<string name="error.label">エラー</string>
<string name="main.albums_alphaByName">名前別</string>
<string name="main.songs_title"></string>
<string name="main.welcome_cancel">設定に移動</string>
<string name="menu.refresh">再読み込み</string>
<string name="music_library.label">メディアライブラリ</string>
<string name="music_library.label_offline">オフラインメディア</string>
<string name="playlist.label">プレイリスト</string>
<string name="playlist.update_info">情報をアップデート</string>
<string name="select_genre.empty">ジャンルが見つかりません</string>
<string name="select_playlist.empty">サーバーに保存されたプレイリストがありません</string>
<string name="settings.increment_time_0">無効</string>
<string name="settings.increment_time_1">1秒</string>
<string name="settings.cache_size_20000">20 GB</string>
<string name="settings.cache_size_25000">25 GB</string>
<string name="settings.cache_size_2000">2 GB</string>
<string name="settings.cache_size_3000">3 GB</string>
<string name="settings.chat_refresh">チャットの更新間隔</string>
<string name="settings.clear_bookmark">ブックマークを消去</string>
<string name="settings.clear_search_history">検索履歴の消去</string>
<string name="settings.clear_bookmark_summary">曲の再生完了時にブックマークを消去</string>
<string name="settings.search_history_cleared">検索履歴が消去されました</string>
<string name="settings.default_albums">デフォルトアルバム数</string>
<string name="settings.server_address">サーバーアドレス</string>
<string name="settings.network_timeout_30000">30秒</string>
<string name="settings.network_timeout_45000">45秒</string>
<string name="settings.max_bitrate_mobile">最高ビットレート - モバイル回線</string>
<string name="settings.max_songs">最大曲数</string>
<string name="settings.max_bitrate_unlimited">無制限</string>
<string name="settings.network_timeout_120000">120秒</string>
<string name="settings.max_bitrate_wifi">最高ビットレート - Wi-Fi</string>
<string name="settings.network_timeout">ネットワークタイムアウト</string>
<string name="settings.network_timeout_105000">105秒</string>
<string name="settings.network_timeout_60000">60秒</string>
<string name="settings.preload_50">50曲</string>
<string name="settings.preload_5">5曲</string>
<string name="settings.preload_100">100曲</string>
<string name="settings.search_30">30</string>
<string name="settings.search_40">40</string>
<string name="settings.search_5">5</string>
<string name="settings.search_title">検索設定</string>
<string name="settings.search_50">50</string>
<string name="settings.search_75">75</string>
<string name="settings.search_500">500</string>
<string name="settings.theme_black">ブラック</string>
<string name="settings.theme_title">テーマ</string>
<string name="settings.use_id3_offline_summary">この設定を有効にすると、Ultrasonic 4.0以降でダウンロードした音楽のみが表示されます。それ以前のバージョンでダウンロードしたファイルには、必要なメタデータが含まれていません。固定モードと保存モードを切り替えることで、不足メタデータのダウンロードができます。</string>
<string name="settings.show_now_playing_details">再生中に詳細を表示</string>
<string name="util.bytes_format.kilobyte">0 KB</string>
<string name="widget.sdcard_busy">SDカード利用不可</string>
<string name="settings.sharing_always_ask_for_details">常に詳細を確認</string>
<string name="settings.share_expiration_default">デフォルトの有効期限</string>
<string name="do_not_show_dialog_again">ダイアログを再度表示しない</string>
<string name="download.bookmark_set">ブックマーク設定</string>
<string name="download.bookmark_delete">ブックマーク削除</string>
<string name="time_span_disabled">無効</string>
<string name="common_multiple_years">複数の年</string>
<string name="server_selector.delete_confirmation">サーバーを削除してよろしいですか\?</string>
<string name="settings.debug.title">デバッグ用オプション</string>
<string name="albumArt">アルバムアートワーク</string>
<string name="settings.debug.log_to_file">デバッグログをファイルに書き込み</string>
<string name="settings.debug.log_delete">ファイルを削除</string>
<string name="settings.debug.log_summary">%1$s 個のログファイルが ディレクトリ %3$s で ~%2$s MBの容量を使用しています。これらを保持しますか\?</string>
<string name="notification.downloading_title">バックグラウンドでメディアをダウンロード中…</string>
<string name="server_editor.label">サーバーの編集</string>
<string name="server_menu.move_down">下に移動</string>
<string name="server_editor.authentication">認証</string>
<string name="about.text"><b>Ultrasonic</b> はフリーでオープンソースであり、Subsonic API (バージョン 1.7.0 以上) 互換サーバーに対応した音楽ストリーミングAndroidクライアントです。
\n
\n<b>Ultrasonic</b> はSubsonic互換サーバーに接続することで、自宅コンピューターからAndroid端末へ簡単に音楽をストリーミングしたりダウンロードできます。Subsonicサーバーソフトは、Ultrasonicと別に設定が必要です。
\n
\nデフォルトでは、Ultrasonicは何も設定されていません。自分用サーバーを構築した後に、そのサーバーに接続するように設定を変更してください。</string>
<string name="api.subsonic.trial_period_is_over">試用期間は終了しました。</string>
<string name="settings.use_hw_offload_title">ハードウェア再生を使用する (実験的)</string>
<string name="settings.use_hw_offload_description">端末のメディアデコーダーチップを使用してメディアを再生するよう試行します。これにより、バッテリー使用量を改善できます。このオプションを有効化することで、再生の不具合が起こる場合も報告されています!</string>
<string name="foreground_exception_text">メディア通知の再生ボタンがある場合はそれをタップします。ない場合はアプリを開いて再生を開始し、セッションをコントローラーに再接続します</string>
</resources>

View File

@ -339,7 +339,6 @@
<string name="download.jukebox_not_authorized">Fjernkontroll er avskrudd. Skru på jukebox-modus i <b>Brukere &gt; Innstillinger</b> på din Subsonic-tjener.</string>
<string name="download.jukebox_off">Fjernkontroll avskrudd. Musikk spilles på enheten.</string>
<string name="download.jukebox_server_too_old">Fjernkontroll støttes ikke. Oppgrader din Subsonic-tjener.</string>
<string name="download.jukebox_volume">Fjernkontroll</string>
<string name="download.menu_jukebox_off">Jukebox avslått</string>
<string name="download.menu_jukebox_on">Jukebox påslått</string>
<string name="download.menu_shuffle">Omstokking</string>

View File

@ -63,7 +63,6 @@
<string name="download.jukebox_offline">Afstandsbediening is niet beschikbaar in offline-modus.</string>
<string name="download.jukebox_on">Afstandsbediening ingeschakeld; muziek wordt afgespeeld op de server.</string>
<string name="download.jukebox_server_too_old">Afstandsbediening wordt niet ondersteund. Werk je Subsonic-server bij.</string>
<string name="download.jukebox_volume">Afstandsbedieningvolume</string>
<string name="download.menu_equalizer">Equalizer</string>
<string name="download.menu_jukebox_off">Jukebox uitgeschakeld</string>
<string name="download.menu_jukebox_on">Jukebox ingeschakeld</string>

View File

@ -1,7 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools">
<string name="background_task.loading">Ładowanie&#8230;</string>
<string name="background_task.loading">Ładowanie…</string>
<string name="background_task.network_error">Wystąpił błąd sieci. Proszę sprawdzić adres serwera i spróbować później.</string>
<string name="background_task.unsupported_api">Server api v%1$s does not support this function.</string>
<string name="background_task.no_network">Ta aplikacja wymaga dostępu do sieci. Proszę włączyć wi-fi lub dane komórkowe.</string>
@ -9,7 +8,7 @@
<string name="background_task.parse_error">Brak prawidłowej odpowiedzi. Proszę sprawdzić adres serwera.</string>
<string name="background_task.ssl_cert_error">Błąd certyfikatu HTTPS: %1$s.</string>
<string name="background_task.ssl_error">Błąd połączenia SSL. Proszę sprawdzić certyfikat serwera.</string>
<string name="background_task.wait">Proszę czekać&#8230;</string>
<string name="background_task.wait">Proszę czekać</string>
<string name="button_bar.bookmarks">Zakładki</string>
<string name="button_bar.browse">Biblioteka</string>
<string name="button_bar.chat">Czat</string>
@ -39,7 +38,7 @@
<string name="common.save">Zapisz</string>
<string name="common.unpin">Odepnij</string>
<string name="common.various_artists">Różni artyści</string>
<string name="delete_playlist">Czy chcesz usunąć %1$s?</string>
<string name="delete_playlist">Czy chcesz usunąć %1$s</string>
<string name="download.bookmark_removed" formatted="false">Zakładka usunięta.</string>
<string name="download.bookmark_set_at_position" formatted="false">Zakładka ustawiona na %s.</string>
<string name="playlist.empty">Playlista jest pusta</string>
@ -48,7 +47,6 @@
<string name="download.jukebox_offline">Pilot jest niedostępny w trybie offline.</string>
<string name="download.jukebox_on">Tryb pilota jest włączony. Muzyka jest odtwarzana na serwerze.</string>
<string name="download.jukebox_server_too_old">Tryb pilota jest niedostępny. Proszę uaktualnić serwer Subsonic.</string>
<string name="download.jukebox_volume">Zdalna głośność</string>
<string name="download.menu_equalizer">Korektor dźwięku</string>
<string name="download.menu_jukebox_off">Jukebox wyłączony</string>
<string name="download.menu_jukebox_on">Jukebox włączony</string>
@ -62,7 +60,7 @@
<string name="download.playlist_done">Playlista została zapisana.</string>
<string name="download.playlist_error">Błąd zapisu playlisty. Proszę spróbować później.</string>
<string name="download.playlist_name">Wprowadź nazwę playlisty:</string>
<string name="download.playlist_saving">Trwa zapis playlisty \"%s\"&#8230;</string>
<string name="download.playlist_saving">Trwa zapis playlisty \"%s\"</string>
<string name="download.playlist_title">Zapisz playlistę</string>
<string name="download.repeat_all">Powtarzaj wszystko</string>
<string name="download.repeat_off">Powtarzanie wyłączone</string>
@ -96,7 +94,7 @@
<string name="menu.deleted_playlist_error">Usunięcie playlisty %s nie powiodło się</string>
<string name="menu.exit">Zakończ</string>
<string name="menu.settings">Ustawienia</string>
<string name="menu.refresh">Refresh</string>
<string name="menu.refresh">Odśwież</string>
<string name="music_library.label">Biblioteka mediów</string>
<string name="music_library.label_offline">Media offline</string>
<string name="playlist.label">Playlisty</string>
@ -172,8 +170,8 @@
<string name="settings.display_bitrate_summary">Dołącza bitrate i typ pliku do nazwy artysty</string>
<string name="settings.hide_media_summary">Ukrywa pliki muzyczne przed innymi aplikacjami.</string>
<string name="settings.hide_media_title">Ukryj pliki</string>
<string name="settings.hide_media_toast">Efekt widoczny będzie po następnym skanowaniu muzyki przez system Android</string>
<string name="settings.invalid_url">Proszę wprowadzić prawidłowy URL</string>
<string name="settings.hide_media_toast">Efekt widoczny będzie po następnym skanowaniu muzyki przez system Android.</string>
<string name="settings.invalid_url">Proszę wprowadzić prawidłowy URL.</string>
<string name="settings.max_albums">Maksymalna ilość wyników - albumy</string>
<string name="settings.max_artists">Maksymalna ilość wyników - artyści</string>
<string name="settings.max_bitrate_112">112 Kbps</string>
@ -205,10 +203,10 @@
<string name="settings.network_title">Sieć</string>
<string name="settings.other_title">Inne ustawienia</string>
<string name="settings.playback_control_title">Ustawienia sterowania odtwarzaniem</string>
<string name="settings.playback.resume_on_bluetooth_device">Resume when a Bluetooth device is connected</string>
<string name="settings.playback.pause_on_bluetooth_device">Pause when a Bluetooth device is disconnected</string>
<string name="settings.playback.bluetooth_all">All Bluetooth devices</string>
<string name="settings.playback.bluetooth_a2dp">Only audio (A2DP) devices</string>
<string name="settings.playback.resume_on_bluetooth_device">Wznów po podłączeniu urządzenia Bluetooth</string>
<string name="settings.playback.pause_on_bluetooth_device">Wstrzymaj, gdy urządzenie Bluetooth jest odłączone</string>
<string name="settings.playback.bluetooth_all">Wszystkie urządzenia Bluetooth</string>
<string name="settings.playback.bluetooth_a2dp">Tylko urządzenia audio (A2DP)</string>
<string name="settings.playback.bluetooth_disabled">Wyłączone</string>
<string name="settings.playback.resume_play_on_headphones_plug.title">Wznawiaj po podłączeniu słuchawek</string>
<string name="settings.playback.resume_play_on_headphones_plug.summary">Aplikacja wznowi zatrzymane odtwarzanie po podpięciu słuchawek.</string>
@ -298,18 +296,18 @@
<string name="menu.share">Udostępnianie</string>
<string name="download.menu_show_artist">Wyświetlaj artystę</string>
<string name="common_multiple_years">Z różnych lat</string>
<string name="server_selector.label">Configured servers</string>
<string name="server_selector.delete_confirmation">Are you sure you want to delete the server?</string>
<string name="server_editor.label">Editing server</string>
<string name="server_selector.label">Skonfigurowane serwery</string>
<string name="server_selector.delete_confirmation">Czy na pewno chcesz usunąć ten serwer\?</string>
<string name="server_editor.label">Edycja serwera</string>
<string name="server_editor.new_label">Dodaj serwer</string>
<string name="server_editor.leave_confirmation">Are you sure you want to leave and lose your changes?</string>
<string name="server_editor.required">This field is required</string>
<string name="server_menu.edit">Edit</string>
<string name="server_editor.leave_confirmation">Czy na pewno chcesz wyjść i utracić dokonane zmiany\?</string>
<string name="server_editor.required">To pole jest wymagane</string>
<string name="server_menu.edit">Edytuj</string>
<string name="server_menu.delete">Usuń</string>
<string name="server_menu.move_up">Move up</string>
<string name="server_menu.move_down">Move down</string>
<string name="server_menu.move_up">Przesuń się w górę</string>
<string name="server_menu.move_down">Przesuń się w dół</string>
<string name="server_editor.authentication">Authentication</string>
<string name="server_editor.advanced">Advanced settings</string>
<string name="server_editor.advanced">Ustawienia zaawansowane</string>
<plurals name="select_album_n_songs">
<item quantity="one">%d utwór</item>
<item quantity="few">%d utwory</item>
@ -327,7 +325,148 @@
<string name="api.subsonic.trial_period_is_over">Okres próbny się zakończył.</string>
<string name="api.subsonic.upgrade_client">Brak zgodności wersji. Uaktualnij aplikację Ultrasonic na Androida.</string>
<string name="api.subsonic.upgrade_server">Brak zgodności wersji. Uaktualnij serwer Subsonic.</string>
<!-- Subsonic features -->
<string name="settings.five_star_rating_title">Użyj pięciu gwiazdek dla utworów</string>
</resources>
<string name="settings.show_confirmation_dialog_summary">Pokaż okno potwierdzające usunięcie lub odpięcie utworów</string>
<string name="language.en">Angielski</string>
<string name="settings.scrobble_summary">Pamiętaj o ustawieniu nazwy użytkownika i hasła do usługi Scrobble na serwerze</string>
<string name="settings.use_id3_offline">Użyj metody ID3 także kiedy nie masz połączenia</string>
<string name="settings.debug.log_keep">Zatrzymaj pliki</string>
<string name="download.menu_shuffle_off">Wyłączony tryb losowy</string>
<string name="buttons.stop">Zatrzymaj</string>
<string name="language.fr">Francuski</string>
<string name="common.unpin_selection_confirmation">Czy na pewno chcesz odpiąć zaznaczone pozycje\?</string>
<string name="settings.custom_cache_location">Użyj niestandardowej lokacji pamięci podręcznej</string>
<string name="common.select_all">Wybierz wszystko</string>
<string name="download.menu_shuffle_on">Włączony tryb losowy</string>
<string name="buttons.next">Następne</string>
<string name="main.albums_by_year">Chronologicznie</string>
<string name="main.welcome_cancel">Otwórz ustawienia</string>
<string name="language.cs">Czeski</string>
<string name="chat.send_button">Wyślij</string>
<string name="select_album.n_selected">Zaznaczono %d utworów</string>
<string name="share_on_server">Stwórz udostępnienie na serwerze</string>
<string name="language.de">Niemiecki</string>
<string name="about.report">Zgłoś błąd</string>
<string name="notification.downloading_title">Pobieranie w tle…</string>
<string name="settings.preload_1000">1000 piosenek</string>
<string name="supported_server_features">Wspierane funkcje</string>
<string name="language.pl">Polski</string>
<string name="common.artist">Artysta</string>
<string name="language.nl">Holenderski</string>
<string name="language.hu">Węgierski</string>
<string name="settings.debug.log_summary">Zapisanych jest %1$s plików z logami, które zajmują ~%2$s MB miejsca w katalogu %3$s. Czy chcesz je zachować\?</string>
<string name="buttons.previous">Poprzednie</string>
<plurals name="select_album_n_songs_deleted">
<item quantity="one">Usunięto %d utwór</item>
<item quantity="few">Usunięto %d utwory</item>
<item quantity="many">Usunięto %d utworów</item>
<item quantity="other">Usunięto %d utworów</item>
</plurals>
<string name="buttons.repeat">Powtarzaj</string>
<string name="download.empty">Nic nie jest pobierane</string>
<string name="language.ru">Rosyjski</string>
<string name="download.playerstate_loading">Byforowanie…</string>
<string name="main.setup_server">%s - Ustaw serwer</string>
<string name="settings.preload_50">50 piosenek</string>
<string name="language.zh_CN">Chiński (Chiny)</string>
<string name="settings.override_language">Nadpisz język</string>
<string name="buttons.play">Odtwórz</string>
<string name="language.default">Domyślne systemowe</string>
<string name="menu.downloads">Pobrane</string>
<string name="settings.display_bitrate">Wyświetlaj bitrate i typ pliku</string>
<string name="language.pt_BR">Portugalski (Brazylia)</string>
<string name="settings.debug.log_path">Plik z logami jest dostępny w %1$s/%2$s</string>
<string name="settings.debug.log_deleted">Usunięte pliki z logami.</string>
<string name="foreground_exception_text">Naciśnij przycisk odtwarzania na powiadomieniu o mediach, jeśli jest ono nadal obecne, w przeciwnym razie otwórz aplikację, aby rozpocząć odtwarzanie i ponownie podłącz sesję do kontrolera</string>
<string name="language.it">Włoski</string>
<string name="language.pt">Portugalski</string>
<string name="settings.server_color">Kolor serwera</string>
<string name="buttons.pause">Pauza</string>
<string name="settings.show_artist_picture">Pokaż obraz wykonawcy na liście</string>
<string name="common.title">Tytuł</string>
<string name="common.delete_selection_confirmation">Czy na pewno chcesz usunąć zaznaczone pozycje\?</string>
<string name="albumArt">Okładka albumu</string>
<string name="common.album">Album</string>
<string name="settings.preload_500">500 piosenek</string>
<string name="settings.share_on_server_summary">Udostępnianie spowoduje utworzenie go na serwerze i udostępnienie jego adresu URL. Jeśli ta opcja jest wyłączona, udostępniane są tylko szczegóły utworu</string>
<string name="settings.download_transition">Pokaż Obecnie odtwarzane po kliknięciu przycisku Odtwarzaj</string>
<string name="language.es">Hiszpański</string>
<string name="settings.override_language_summary">Wymagane jest ponowne uruchomienie aplikacji po zmianie języka</string>
<string name="foreground_exception_title">Nie można wznowić odtwarzania</string>
<string name="chat.user_avatar">Awatar</string>
<string name="language.zh_TW">Chiński (Tajwan)</string>
<string name="settings.theme_day_night">Dzień i noc</string>
<string name="settings.theme_black">Czarny</string>
<string name="settings.summary.force_plain_text_password">Zmusza to aplikację do wysyłania hasła w postaci niezaszyfrowanej. Przydatne, jeśli serwer Subsonic nie obsługuje nowego interfejsu API uwierzytelniania dla użytkowników.</string>
<string name="settings.show_now_playing_details_summary">Pokaż więcej informacji o utworze w sekcji Obecnie odtwarzane (gatunek, rok, przepustowość)</string>
<string name="settings.show_now_playing_details">Pokaż szczegóły w sekcji Obecnie odtwarzane</string>
<string name="settings.wifi_required_title">Pobieraj tylko przez Wi-Fi</string>
<string name="settings.sharing_always_ask_for_details_summary">Zawsze pytaj o opis i czas wygaśnięcia podczas tworzenia udostępnienia na serwerze</string>
<string name="settings.debug.log_delete">Usuń pliki</string>
<plurals name="select_album_n_songs_pinned">
<item quantity="one">%d utwór zaznaczony do przypięcia</item>
<item quantity="few">%d utwory zaznaczone do przypięcia</item>
<item quantity="many">%d utworów zaznaczonych do przypięcia</item>
<item quantity="other">%d utworów zaznaczonych do przypięcia</item>
</plurals>
<plurals name="select_album_n_songs_downloaded">
<item quantity="one">%d utworów zaznaczonych do pobrania</item>
<item quantity="few">%d utwory zaznaczone do pobrania</item>
<item quantity="many">%d utworów zaznaczonych do pobrania</item>
<item quantity="other">%d utworów zaznaczonych do pobrania</item>
</plurals>
<string name="about.webpage">Odwiedź stronę internetową</string>
<plurals name="select_album_n_songs_unpinned">
<item quantity="one">Odpięto %d utwór</item>
<item quantity="few">Odpięto %d utwory</item>
<item quantity="many">Odpięto %d utworów</item>
<item quantity="other">Odpięto %d utworów</item>
</plurals>
<string name="settings.use_hw_offload_title">Użyj odtwarzania sprzętowwego (eksperymentalne)</string>
<string name="jukebox">Jukebox</string>
<string name="select_album.no_network">Uwaga: Brak dostępnych sieci do użycia.
\n Jeżeli używasz danych mobilnych, potrzebne może być włączenie płatnych połączeń w ustawieniach.</string>
<string name="settings.download_transition_summary">Przełącz na Obecnie odtwarzane po rozpoczęciu odtwarzania w widoku multimediów</string>
<string name="settings.increment_time">Odstępy między wyszukaniami</string>
<string name="settings.parallel_downloads">Ilość równocześnie pobieranych piosenek</string>
<string name="settings.preload_100">100 piosenek</string>
<string name="settings.scrobble_title">Scrobbluj moje odtworzenia</string>
<string name="settings.use_id3_offline_summary">Jeśli włączysz to ustawienie, będzie ono wyświetlać tylko muzykę pobraną za pomocą Ultrasonic w wersji 4.0 lub nowszej. Wcześniejsze pobrane pliki nie zawierają wymaganych metadanych. Możesz przełączać się między trybami Przypinania i Zapisywania, aby wyzwolić pobieranie brakujących metadanych.</string>
<string name="settings.show_artist_picture_summary">Wyświetla obraz wykonawcy na liście wykonawców, jeśli jest dostępny</string>
<string name="settings.wifi_required_summary">Pobieraj tylko podczas połączeń niepłatnuch</string>
<string name="download.share_song">Udostępnij obecnie odtwarzaną piosenkę</string>
<string name="settings.show_confirmation_dialog">Pokaż okno potwierdzające</string>
<string name="settings.debug.title">Opcje debugowania</string>
<string name="settings.debug.log_to_file">Zapisz logi debugowania do pliku</string>
<string name="notification.permission_required">Powiadomienia są wymagane do odtwarzania multimediów. Możesz przyznać uprawnienie do nich w dowolnym momencie w ustawieniach Androida.</string>
<string name="server_editor.disabled_feature">Jedna lub więcej funkcji zostało wyłączonych ponieważ serwer ich nie obsługiwał.
\nMożesz uruchomić ten test ponownie kiedykolwiek.</string>
<string name="server_menu.demo">Serwer demonstracyjny</string>
<string name="settings.five_star_rating_description">Użyj systemu pięciu gwiazdek do oceniania utworów zamiast po prostu dodawać lub usuwać utwory z ulubionych.</string>
<string name="list_view">Lista</string>
<string name="grid_view">Okładka</string>
<string name="settings.use_hw_offload_description">Spróbuj odtworzyć pliki multimedialne za pomocą układu dekodującego w telefonie. Może to zmniejszyć zużycie baterii!</string>
<plurals name="select_album_n_songs_added">
<item quantity="one">Dodano %d utwór na koniec kolejki odtwarzania</item>
<item quantity="few">Dodano %d utwory na koniec kolejki odtwarzania</item>
<item quantity="many">Dodano %d utworów na koniec kolejki odtwarzania</item>
<item quantity="other">Dodano %d utworów na koniec kolejki odtwarzania</item>
</plurals>
<plurals name="select_album_n_songs_play_next">
<item quantity="one">Wstawiono %d utwór po bieżącym utworze</item>
<item quantity="few">Wstawiono %d utwory po bieżącym utworze</item>
<item quantity="many">Wstawiono %d utworów po bieżącym utworze</item>
<item quantity="other">Wstawiono %d utworów po bieżącym utworze</item>
</plurals>
<string name="about.text"><b>Ultrasonic</b> to darmowy i otwarty klient strumieniowego przesyłania muzyki dla serwerów kompatybilnych z API Subsonic (wersja 1.7.0 lub nowsza).
\n
\nDzięki <b>Ultrasonic</b> możesz łatwo przesyłać strumieniowo lub pobierać muzykę z komputera domowego na telefon za pomocą serwera multimediów kompatybilnego z Subsonic. Oprogramowanie serwera Subsonic wymaga oddzielnej konfiguracji od Ultrasonic.
\n
\nDomyślnie Ultrasonic nie jest skonfigurowane. Po skonfigurowaniu własnego serwera zmień ustawienia serwera, aby połączyć się z komputerem.</string>
<string name="main.welcome_text_demo">Aby używać Ultrasonic z własną muzyką, potrzebujesz <b>własnego serwera</b>.
\n
\n➤ Jeśli chcesz najpierw wypróbować aplikację, możesz teraz dodać serwer demonstracyjny.
\n
\n➤ W przeciwnym razie możesz skonfigurować serwer w <b>ustawieniach</b>.</string>
</resources>

Some files were not shown because too many files have changed in this diff Show More