mirror of
https://gitlab.com/ultrasonic/ultrasonic.git
synced 2025-07-21 10:51:55 +03:00
Compare commits
130 Commits
94f29d270c
...
365067f1a0
Author | SHA1 | Date | |
---|---|---|---|
|
365067f1a0 | ||
|
eb3aa0d202 | ||
|
c5c0497716 | ||
|
79ac73020b | ||
|
d9dfef4016 | ||
|
3bd3607220 | ||
|
e35a33edde | ||
|
c1013f6b80 | ||
|
21a27c691d | ||
|
25f3ff0bd3 | ||
|
4feb84bd83 | ||
|
4c049671db | ||
|
3a1251dd2a | ||
|
8dd7758bc6 | ||
|
296308cebf | ||
|
45ca0966fd | ||
|
77d3f8c11b | ||
|
7a453dbd30 | ||
|
0a6a12c70a | ||
|
448fdb70b0 | ||
|
5e4ec56ae7 | ||
|
8c42700676 | ||
|
22fda501f4 | ||
|
0f18b20fa3 | ||
|
a8961e8e96 | ||
|
291528a309 | ||
|
2a7cdbeded | ||
|
c09739cea4 | ||
|
315271390f | ||
|
2df8d049d0 | ||
|
0643b1bd1c | ||
|
2b1291ae51 | ||
|
5ec0d8a96b | ||
|
71168983b6 | ||
|
bdcb1a505b | ||
|
238d91c167 | ||
|
376748b298 | ||
|
13091948ea | ||
|
0cfd8e8240 | ||
|
7a17936855 | ||
|
1d7328c03e | ||
|
76da209c6d | ||
|
ddd9c29d7a | ||
|
fe696943a4 | ||
|
a5bfc08264 | ||
|
4c2c7252c3 | ||
|
b5dd0fdca2 | ||
|
a7ee33c7c0 | ||
|
7b56017844 | ||
|
0fb345dd24 | ||
|
4faf2db11f | ||
|
c118bd70f9 | ||
|
b0e850d17e | ||
|
a97c6e15e9 | ||
|
d084a35316 | ||
|
e8bfa5dc04 | ||
|
e729e3b063 | ||
|
8337f4a7e4 | ||
|
0ae32c3cfc | ||
|
49f3bd27ed | ||
|
1acfa917c9 | ||
|
5ab2ec08f0 | ||
|
e21477a5ee | ||
|
5daeddcc63 | ||
|
70d02f4493 | ||
|
58de991d64 | ||
|
90ffa32246 | ||
|
3d94de9e46 | ||
|
50aa2d0a2d | ||
|
0e2171b872 | ||
|
2c3f43f139 | ||
|
fd8afe0231 | ||
|
cd982814cf | ||
|
338fb618b9 | ||
|
842cb36ecb | ||
|
e06b8bc22e | ||
|
82fb45bd55 | ||
|
751b946092 | ||
|
39085f68b1 | ||
|
1beb67c497 | ||
|
2ba001894a | ||
|
0650ce0bba | ||
|
218f144848 | ||
|
83c9c188e9 | ||
|
a4e8a7f94d | ||
|
4f5d503ceb | ||
|
381e2e4b86 | ||
|
2a90fe4aab | ||
|
f37301e738 | ||
|
fca5ffaa0c | ||
|
a0314a865c | ||
|
5da9a2819c | ||
|
2a02c94c8f | ||
|
96073125ca | ||
|
58bd663ac0 | ||
|
e689193df1 | ||
|
1aa388d48f | ||
|
8f84020cfa | ||
|
db88ff8431 | ||
|
2d1642170a | ||
|
0cb7952943 | ||
|
d750c84606 | ||
|
7abca537c9 | ||
|
ca2c5483c0 | ||
|
7b414a3a23 | ||
|
10767d2d5b | ||
|
138db03667 | ||
|
f59e039c49 | ||
|
e0679f99cf | ||
|
ffb78b166b | ||
|
5fcbf59e0e | ||
|
e62b8972e7 | ||
|
322457910c | ||
|
e9b602890a | ||
|
08d3618eb3 | ||
|
4f59c4d3ad | ||
|
9ca5a9257d | ||
|
6694d6f60b | ||
|
df7ff21cc9 | ||
|
732d44cb73 | ||
|
76eb89f5eb | ||
|
185762e164 | ||
|
aede9be97c | ||
|
97556a36e5 | ||
|
fb970ffb80 | ||
|
6de6cda7a4 | ||
|
5eed5c70b5 | ||
|
6e1078a256 | ||
|
dbef8307ea | ||
|
ee52070925 |
@ -1,5 +1,5 @@
|
|||||||
default:
|
default:
|
||||||
image: registry.gitlab.com/ultrasonic/ci-android:latest
|
image: registry.gitlab.com/ultrasonic/ci-android:1.1.0
|
||||||
cache: &global_cache
|
cache: &global_cache
|
||||||
key:
|
key:
|
||||||
files:
|
files:
|
||||||
@ -74,7 +74,9 @@ Unit Tests:
|
|||||||
|
|
||||||
Assemble Release:
|
Assemble Release:
|
||||||
stage: Build
|
stage: Build
|
||||||
script: ./gradlew assembleRelease
|
script:
|
||||||
|
- sed -i 's/applicationId \"org.moire.ultrasonic\"/applicationId "org.moire.ultrasonic.gitlab"/' ultrasonic/build.gradle
|
||||||
|
- ./gradlew assembleRelease
|
||||||
artifacts:
|
artifacts:
|
||||||
name: ultrasonic-release-unsigned-${CI_COMMIT_SHA}
|
name: ultrasonic-release-unsigned-${CI_COMMIT_SHA}
|
||||||
paths:
|
paths:
|
||||||
|
@ -31,12 +31,6 @@ If you want to use the version downloaded from F-Droid or from GitLab with
|
|||||||
First, see if your issue haven’t been yet reported [here][issues], otherwise
|
First, see if your issue haven’t been yet reported [here][issues], otherwise
|
||||||
open [a new issue][newissue].
|
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
|
## Contributing
|
||||||
|
|
||||||
See [CONTRIBUTING](CONTRIBUTING.md).
|
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
|
[wikiaa]: https://gitlab.com/ultrasonic/ultrasonic/-/wikis/Using-Ultrasonic-with-Android-Auto
|
||||||
[issues]: https://gitlab.com/ultrasonic/ultrasonic/-/issues
|
[issues]: https://gitlab.com/ultrasonic/ultrasonic/-/issues
|
||||||
[newissue]: https://gitlab.com/ultrasonic/ultrasonic/-/issues/new
|
[newissue]: https://gitlab.com/ultrasonic/ultrasonic/-/issues/new
|
||||||
[madbug]: https://gitlab.com/ultrasonic/ultrasonic/-/issues/129
|
|
||||||
[subsonic]: http://www.subsonic.org/
|
[subsonic]: http://www.subsonic.org/
|
||||||
[subapi]: http://www.subsonic.org/pages/api.jsp
|
[subapi]: http://www.subsonic.org/pages/api.jsp
|
||||||
[airsonic]: https://github.com/airsonic-advanced/airsonic-advanced
|
[airsonic]: https://github.com/airsonic-advanced/airsonic-advanced
|
||||||
|
@ -52,7 +52,11 @@ style:
|
|||||||
active: true
|
active: true
|
||||||
ForbiddenComment:
|
ForbiddenComment:
|
||||||
active: true
|
active: true
|
||||||
values: ['FIXME:', 'STOPSHIP:']
|
comments:
|
||||||
|
- reason: 'Forbidden FIXME todo marker in comment, please fix the problem.'
|
||||||
|
value: 'FIXME:'
|
||||||
|
- reason: 'Forbidden STOPSHIP todo marker in comment, please address the problem before shipping the code.'
|
||||||
|
value: 'STOPSHIP:'
|
||||||
WildcardImport:
|
WildcardImport:
|
||||||
active: true
|
active: true
|
||||||
MaxLineLength:
|
MaxLineLength:
|
@ -2,6 +2,7 @@ apply from: bootstrap.androidModule
|
|||||||
apply plugin: 'kotlin-kapt'
|
apply plugin: 'kotlin-kapt'
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
|
implementation libs.core
|
||||||
implementation libs.roomRuntime
|
implementation libs.roomRuntime
|
||||||
implementation libs.roomKtx
|
implementation libs.roomKtx
|
||||||
kapt libs.room
|
kapt libs.room
|
||||||
|
10
fastlane/metadata/android/en-US/changelogs/122.txt
Normal file
10
fastlane/metadata/android/en-US/changelogs/122.txt
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
Features:
|
||||||
|
- Revamp management of ratings. Tracks can be starred from the notification in Android 13, and the changes will show up everywhere immediately.
|
||||||
|
- Add a setting to control the maximum bitrate when pinning music (can be used to avoid downloading lossless files like flac).
|
||||||
|
- Modernize the Jukebox player.
|
||||||
|
- The hardware keys can be used to set the Jukebox volume.
|
||||||
|
- The current playlist shows a spinner when loading takes some time
|
||||||
|
|
||||||
|
Bug fixes:
|
||||||
|
- Request correct bluetooth permission on Android 13 (needed to pause/play on connect)
|
||||||
|
- Update dependencies (OkHttp, Material)
|
@ -1,17 +1,17 @@
|
|||||||
Ultrasonic is a Subsonic (and compatible servers) client to Android. You can use Ultrasonic to connect with your server and listen music.
|
Ultrasonic is a Subsonic (and compatible servers) client to Android. You can use Ultrasonic to connect with your server and listen music.
|
||||||
|
|
||||||
Main features:
|
Main features:
|
||||||
* Thin
|
* Small size & fast
|
||||||
* Fast
|
* Material You theme with dark and light variants
|
||||||
* Material theme with dark and light variants
|
|
||||||
* Multiple server support
|
* Multiple server support
|
||||||
* Offline Mode
|
* Download tracks for offline playback
|
||||||
* Bookmarks
|
* Bookmarks
|
||||||
* Playlists on server
|
* Playlists on server
|
||||||
* Random play
|
* Shuffled playback
|
||||||
* Jukebox mode
|
* Jukebox mode
|
||||||
* Server chat
|
* And much more!!
|
||||||
* And much more!!!
|
|
||||||
|
Note: Ultrasonic uses semantic release versions. Releases with a zero in the last digit introduce new features or significant changes, all other releases focus on fixing bugs.
|
||||||
|
|
||||||
The source code is available with GPL license in GitLab: https://gitlab.com/ultrasonic/ultrasonic
|
The source code is available with GPL license in GitLab: https://gitlab.com/ultrasonic/ultrasonic
|
||||||
If you have any issue, please post in: https://gitlab.com/ultrasonic/ultrasonic/issues
|
If you have any issue, please post in: https://gitlab.com/ultrasonic/ultrasonic/issues
|
||||||
|
@ -3,13 +3,13 @@
|
|||||||
gradle = "8.1.1"
|
gradle = "8.1.1"
|
||||||
|
|
||||||
navigation = "2.5.3"
|
navigation = "2.5.3"
|
||||||
gradlePlugin = "8.0.1"
|
gradlePlugin = "8.0.2"
|
||||||
androidxcore = "1.10.0"
|
androidxcore = "1.10.1"
|
||||||
ktlint = "0.43.2"
|
ktlint = "0.43.2"
|
||||||
ktlintGradle = "11.3.2"
|
ktlintGradle = "11.3.2"
|
||||||
detekt = "1.22.0"
|
detekt = "1.23.0"
|
||||||
preferences = "1.2.0"
|
preferences = "1.2.0"
|
||||||
media3 = "1.0.1"
|
media3 = "1.0.2"
|
||||||
|
|
||||||
androidSupport = "1.6.0"
|
androidSupport = "1.6.0"
|
||||||
materialDesign = "1.8.0"
|
materialDesign = "1.8.0"
|
||||||
@ -17,25 +17,24 @@ constraintLayout = "2.1.4"
|
|||||||
multidex = "2.0.1"
|
multidex = "2.0.1"
|
||||||
room = "2.5.1"
|
room = "2.5.1"
|
||||||
kotlin = "1.8.21"
|
kotlin = "1.8.21"
|
||||||
kotlinxCoroutines = "1.7.0"
|
kotlinxCoroutines = "1.7.1"
|
||||||
kotlinxGuava = "1.7.0"
|
|
||||||
viewModelKtx = "2.6.1"
|
viewModelKtx = "2.6.1"
|
||||||
swipeRefresh = "1.1.0"
|
swipeRefresh = "1.1.0"
|
||||||
|
|
||||||
retrofit = "2.9.0"
|
retrofit = "2.9.0"
|
||||||
## KEEP ON 2.13 branch (https://github.com/FasterXML/jackson-databind/issues/3658#issuecomment-1312633064) for compatibility with API 24
|
## KEEP ON 2.13 branch (https://github.com/FasterXML/jackson-databind/issues/3658#issuecomment-1312633064) for compatibility with API 24
|
||||||
jackson = "2.13.5"
|
jackson = "2.13.5"
|
||||||
okhttp = "4.10.0"
|
okhttp = "4.11.0"
|
||||||
koin = "3.3.2"
|
koin = "3.3.2"
|
||||||
picasso = "2.8"
|
picasso = "2.8"
|
||||||
|
|
||||||
junit4 = "4.13.2"
|
junit4 = "4.13.2"
|
||||||
junit5 = "5.9.3"
|
junit5 = "5.9.3"
|
||||||
mockito = "5.3.1"
|
mockito = "5.3.1"
|
||||||
mockitoKotlin = "4.1.0"
|
mockitoKotlin = "5.0.0"
|
||||||
kluent = "1.73"
|
kluent = "1.73"
|
||||||
apacheCodecs = "1.15"
|
apacheCodecs = "1.15"
|
||||||
robolectric = "4.10.2"
|
robolectric = "4.10.3"
|
||||||
timber = "5.0.1"
|
timber = "5.0.1"
|
||||||
fastScroll = "2.0.1"
|
fastScroll = "2.0.1"
|
||||||
colorPicker = "2.2.4"
|
colorPicker = "2.2.4"
|
||||||
@ -74,7 +73,7 @@ swipeRefresh = { module = "androidx.swiperefreshlayout:swiperefreshla
|
|||||||
kotlinStdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" }
|
kotlinStdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" }
|
||||||
kotlinReflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" }
|
kotlinReflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" }
|
||||||
kotlinxCoroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" }
|
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" }
|
retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" }
|
||||||
gsonConverter = { module = "com.squareup.retrofit2:converter-gson", version.ref = "retrofit" }
|
gsonConverter = { module = "com.squareup.retrofit2:converter-gson", version.ref = "retrofit" }
|
||||||
jacksonConverter = { module = "com.squareup.retrofit2:converter-jackson", version.ref = "retrofit" }
|
jacksonConverter = { module = "com.squareup.retrofit2:converter-jackson", version.ref = "retrofit" }
|
||||||
|
@ -2,9 +2,9 @@
|
|||||||
* This module provides a base for for submodules which depend on the Android runtime
|
* This module provides a base for for submodules which depend on the Android runtime
|
||||||
*/
|
*/
|
||||||
apply plugin: 'com.android.library'
|
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 from: "${project.rootDir}/gradle_scripts/code_quality.gradle"
|
||||||
apply plugin: 'kotlin-kapt'
|
apply plugin: 'org.jetbrains.kotlin.kapt'
|
||||||
|
|
||||||
android {
|
android {
|
||||||
compileSdkVersion versions.compileSdk
|
compileSdkVersion versions.compileSdk
|
||||||
|
@ -25,8 +25,6 @@ if (isCodeQualityEnabled) {
|
|||||||
// Builds the AST in parallel. Rules are always executed in parallel.
|
// Builds the AST in parallel. Rules are always executed in parallel.
|
||||||
// Can lead to speedups in larger projects.
|
// Can lead to speedups in larger projects.
|
||||||
parallel = true
|
parallel = true
|
||||||
baseline = file("${rootProject.projectDir}/detekt-baseline.xml")
|
|
||||||
config = files("${rootProject.projectDir}/detekt-config.yml")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
tasks.detekt.jvmTarget = "17"
|
tasks.detekt.jvmTarget = "17"
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
* This module provides a base for for pure kotlin modules
|
* This module provides a base for for pure kotlin modules
|
||||||
*/
|
*/
|
||||||
apply plugin: 'kotlin'
|
apply plugin: 'kotlin'
|
||||||
apply plugin: 'kotlin-kapt'
|
apply plugin: 'org.jetbrains.kotlin.kapt'
|
||||||
apply from: "${project.rootDir}/gradle_scripts/code_quality.gradle"
|
apply from: "${project.rootDir}/gradle_scripts/code_quality.gradle"
|
||||||
|
|
||||||
sourceSets {
|
sourceSets {
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
apply plugin: 'com.android.application'
|
apply plugin: 'com.android.application'
|
||||||
apply plugin: 'kotlin-android'
|
apply plugin: 'org.jetbrains.kotlin.android'
|
||||||
apply plugin: 'kotlin-kapt'
|
apply plugin: 'org.jetbrains.kotlin.kapt'
|
||||||
apply plugin: "androidx.navigation.safeargs.kotlin"
|
apply plugin: "androidx.navigation.safeargs.kotlin"
|
||||||
apply from: "../gradle_scripts/code_quality.gradle"
|
apply from: "../gradle_scripts/code_quality.gradle"
|
||||||
|
|
||||||
@ -9,12 +9,12 @@ android {
|
|||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId "org.moire.ultrasonic"
|
applicationId "org.moire.ultrasonic"
|
||||||
versionCode 120
|
versionCode 122
|
||||||
versionName "4.4.1"
|
versionName "4.5.0"
|
||||||
|
|
||||||
minSdkVersion versions.minSdk
|
minSdkVersion versions.minSdk
|
||||||
targetSdkVersion versions.targetSdk
|
targetSdkVersion versions.targetSdk
|
||||||
resConfigs 'cs', 'de', 'en', 'es', 'fr', 'gl', 'hu', 'it', 'ja', 'nb-rNO', 'nl', 'pl', 'pt', 'pt-rBR', 'ru', 'zh-rCN', 'zh-rTW'
|
resourceConfigurations += ['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
|
bundle.language.enableSplit = false
|
||||||
|
@ -1,9 +1,9 @@
|
|||||||
<?xml version='1.0' encoding='UTF-8'?>
|
<?xml version="1.0" ?>
|
||||||
<SmellBaseline>
|
<SmellBaseline>
|
||||||
<ManuallySuppressedIssues>
|
<ManuallySuppressedIssues>
|
||||||
<ID>TooManyFunctions:PlaybackService.kt$PlaybackService : MediaLibraryServiceKoinComponentCoroutineScope</ID>
|
<ID>TooManyFunctions:PlaybackService.kt$PlaybackService : MediaLibraryServiceKoinComponentCoroutineScope</ID>
|
||||||
<ID>UnusedPrivateMember:UApp.kt$private fun VmPolicy.Builder.detectAllExceptSocket(): VmPolicy.Builder</ID>
|
<ID>UnusedPrivateMember:UApp.kt$private fun VmPolicy.Builder.detectAllExceptSocket(): VmPolicy.Builder</ID>
|
||||||
<ID>ImplicitDefaultLocale:EditServerFragment.kt$EditServerFragment.<no name provided>$String.format( "%s %s", resources.getString(R.string.settings_connection_failure), getErrorMessage(error) )</ID>
|
<ID>ImplicitDefaultLocale:EditServerFragment.kt$EditServerFragment.<no name provided>$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("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("Log file rotated, logging into file %s", file?.name)</ID>
|
||||||
<ID>ImplicitDefaultLocale:FileLoggerTree.kt$FileLoggerTree$String.format("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: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: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>LongMethod:SharesFragment.kt$SharesFragment$override fun onContextItemSelected(menuItem: MenuItem): Boolean</ID>
|
||||||
<ID>LongParameterList:ServerRowAdapter.kt$ServerRowAdapter$( private var context: Context, passedData: Array<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<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>MagicNumber:ActiveServerProvider.kt$ActiveServerProvider$8192</ID>
|
<ID>MagicNumber:ActiveServerProvider.kt$ActiveServerProvider$8192</ID>
|
||||||
<ID>MagicNumber:JukeboxMediaPlayer.kt$JukeboxMediaPlayer$0.05f</ID>
|
<ID>MagicNumber:JukeboxMediaPlayer.kt$JukeboxMediaPlayer$0.05f</ID>
|
||||||
<ID>MagicNumber:JukeboxMediaPlayer.kt$JukeboxMediaPlayer$50</ID>
|
<ID>MagicNumber:JukeboxMediaPlayer.kt$JukeboxMediaPlayer$50</ID>
|
@ -1,27 +1,5 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?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">
|
<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="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>
|
|
||||||
|
|
||||||
<issue
|
<issue
|
||||||
id="PluralsCandidate"
|
id="PluralsCandidate"
|
||||||
@ -30,7 +8,7 @@
|
|||||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||||
<location
|
<location
|
||||||
file="src/main/res/values/strings.xml"
|
file="src/main/res/values/strings.xml"
|
||||||
line="152"
|
line="151"
|
||||||
column="5"/>
|
column="5"/>
|
||||||
</issue>
|
</issue>
|
||||||
|
|
||||||
@ -48,50 +26,6 @@
|
|||||||
file="../core/subsonic-api/build/libs/subsonic-api.jar"/>
|
file="../core/subsonic-api/build/libs/subsonic-api.jar"/>
|
||||||
</issue>
|
</issue>
|
||||||
|
|
||||||
<issue
|
|
||||||
id="ExportedContentProvider"
|
|
||||||
message="Exported content providers can provide access to potentially sensitive data"
|
|
||||||
errorLine1=" <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=" <provider"
|
|
||||||
errorLine2=" ~~~~~~~~">
|
|
||||||
<location
|
|
||||||
file="src/main/AndroidManifest.xml"
|
|
||||||
line="133"
|
|
||||||
column="10"/>
|
|
||||||
</issue>
|
|
||||||
|
|
||||||
<issue
|
|
||||||
id="ExportedReceiver"
|
|
||||||
message="Exported receiver does not require permission"
|
|
||||||
errorLine1=" <receiver android:name=".receiver.UltrasonicIntentReceiver""
|
|
||||||
errorLine2=" ~~~~~~~~">
|
|
||||||
<location
|
|
||||||
file="src/main/AndroidManifest.xml"
|
|
||||||
line="88"
|
|
||||||
column="10"/>
|
|
||||||
</issue>
|
|
||||||
|
|
||||||
<issue
|
|
||||||
id="ExportedService"
|
|
||||||
message="Exported service does not require permission"
|
|
||||||
errorLine1=" <service android:name=".playback.PlaybackService""
|
|
||||||
errorLine2=" ~~~~~~~">
|
|
||||||
<location
|
|
||||||
file="src/main/AndroidManifest.xml"
|
|
||||||
line="77"
|
|
||||||
column="10"/>
|
|
||||||
</issue>
|
|
||||||
|
|
||||||
<issue
|
<issue
|
||||||
id="UnusedResources"
|
id="UnusedResources"
|
||||||
message="The resource `R.drawable.media3_notification_pause` appears to be unused"
|
message="The resource `R.drawable.media3_notification_pause` appears to be unused"
|
||||||
@ -136,17 +70,6 @@
|
|||||||
column="1"/>
|
column="1"/>
|
||||||
</issue>
|
</issue>
|
||||||
|
|
||||||
<issue
|
|
||||||
id="UnusedResources"
|
|
||||||
message="The resource `R.drawable.media3_notification_small_icon` appears to be unused"
|
|
||||||
errorLine1="<vector xmlns:android="http://schemas.android.com/apk/res/android""
|
|
||||||
errorLine2="^">
|
|
||||||
<location
|
|
||||||
file="src/main/res/drawable/media3_notification_small_icon.xml"
|
|
||||||
line="1"
|
|
||||||
column="1"/>
|
|
||||||
</issue>
|
|
||||||
|
|
||||||
<issue
|
<issue
|
||||||
id="Autofill"
|
id="Autofill"
|
||||||
message="Missing `autofillHints` attribute"
|
message="Missing `autofillHints` attribute"
|
||||||
|
@ -3,6 +3,7 @@
|
|||||||
xmlns:tools="http://schemas.android.com/tools"
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
android:installLocation="auto">
|
android:installLocation="auto">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||||
<uses-permission android:name="android.permission.INTERNET"/>
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
@ -21,6 +22,7 @@
|
|||||||
<application
|
<application
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:fullBackupContent="@xml/backup_descriptor"
|
android:fullBackupContent="@xml/backup_descriptor"
|
||||||
|
android:hasFragileUserData="true" tools:targetApi="q"
|
||||||
android:dataExtractionRules="@xml/backup_rules"
|
android:dataExtractionRules="@xml/backup_rules"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
@ -65,13 +67,6 @@
|
|||||||
android:exported="false">
|
android:exported="false">
|
||||||
</service>
|
</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 -->
|
<!-- Needs to be exported: https://android.googlesource.com/platform/developers/build/+/4de32d4/prebuilts/gradle/MediaBrowserService/README.md -->
|
||||||
<service android:name=".playback.PlaybackService"
|
<service android:name=".playback.PlaybackService"
|
||||||
android:label="@string/common.appname"
|
android:label="@string/common.appname"
|
||||||
|
@ -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()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
@ -58,7 +58,7 @@ public abstract class BackgroundTask<T> implements ProgressListener
|
|||||||
|
|
||||||
protected String getErrorMessage(Throwable error)
|
protected String getErrorMessage(Throwable error)
|
||||||
{
|
{
|
||||||
return CommunicationError.getErrorMessage(error, activity);
|
return CommunicationError.getErrorMessage(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
@ -17,7 +17,6 @@ import android.os.Build
|
|||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.provider.MediaStore
|
import android.provider.MediaStore
|
||||||
import android.provider.SearchRecentSuggestions
|
import android.provider.SearchRecentSuggestions
|
||||||
import android.view.KeyEvent
|
|
||||||
import android.view.Menu
|
import android.view.Menu
|
||||||
import android.view.MenuItem
|
import android.view.MenuItem
|
||||||
import android.view.View
|
import android.view.View
|
||||||
@ -55,8 +54,8 @@ import org.moire.ultrasonic.data.ServerSettingDao
|
|||||||
import org.moire.ultrasonic.fragment.OnBackPressedHandler
|
import org.moire.ultrasonic.fragment.OnBackPressedHandler
|
||||||
import org.moire.ultrasonic.model.ServerSettingsModel
|
import org.moire.ultrasonic.model.ServerSettingsModel
|
||||||
import org.moire.ultrasonic.provider.SearchSuggestionProvider
|
import org.moire.ultrasonic.provider.SearchSuggestionProvider
|
||||||
import org.moire.ultrasonic.service.MediaPlayerController
|
|
||||||
import org.moire.ultrasonic.service.MediaPlayerLifecycleSupport
|
import org.moire.ultrasonic.service.MediaPlayerLifecycleSupport
|
||||||
|
import org.moire.ultrasonic.service.MediaPlayerManager
|
||||||
import org.moire.ultrasonic.service.RxBus
|
import org.moire.ultrasonic.service.RxBus
|
||||||
import org.moire.ultrasonic.service.plusAssign
|
import org.moire.ultrasonic.service.plusAssign
|
||||||
import org.moire.ultrasonic.util.Constants
|
import org.moire.ultrasonic.util.Constants
|
||||||
@ -98,7 +97,7 @@ class NavigationActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
private val serverSettingsModel: ServerSettingsModel by viewModel()
|
private val serverSettingsModel: ServerSettingsModel by viewModel()
|
||||||
private val lifecycleSupport: MediaPlayerLifecycleSupport by inject()
|
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 activeServerProvider: ActiveServerProvider by inject()
|
||||||
private val serverRepository: ServerSettingDao by inject()
|
private val serverRepository: ServerSettingDao by inject()
|
||||||
|
|
||||||
@ -274,18 +273,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)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun setupNavigationMenu(navController: NavController) {
|
private fun setupNavigationMenu(navController: NavController) {
|
||||||
navigationView?.setupWithNavController(navController)
|
navigationView?.setupWithNavController(navController)
|
||||||
|
|
||||||
@ -308,7 +295,7 @@ class NavigationActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
R.id.menu_exit -> {
|
R.id.menu_exit -> {
|
||||||
setResult(Constants.RESULT_CLOSE_ALL)
|
setResult(Constants.RESULT_CLOSE_ALL)
|
||||||
mediaPlayerController.onDestroy()
|
mediaPlayerManager.onDestroy()
|
||||||
finish()
|
finish()
|
||||||
exit()
|
exit()
|
||||||
}
|
}
|
||||||
@ -475,9 +462,9 @@ class NavigationActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (nowPlayingView != null) {
|
if (nowPlayingView != null) {
|
||||||
val playerState: Int = mediaPlayerController.playbackState
|
val playerState: Int = mediaPlayerManager.playbackState
|
||||||
if (playerState == STATE_BUFFERING || playerState == STATE_READY) {
|
if (playerState == STATE_BUFFERING || playerState == STATE_READY) {
|
||||||
val item: MediaItem? = mediaPlayerController.currentMediaItem
|
val item: MediaItem? = mediaPlayerManager.currentMediaItem
|
||||||
if (item != null) {
|
if (item != null) {
|
||||||
nowPlayingView?.visibility = View.VISIBLE
|
nowPlayingView?.visibility = View.VISIBLE
|
||||||
}
|
}
|
||||||
|
@ -115,7 +115,7 @@ class ArtistRowBinder(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun showArtistPicture(): Boolean {
|
private fun showArtistPicture(): Boolean {
|
||||||
return ActiveServerProvider.isID3Enabled() && Settings.shouldShowArtistPicture
|
return ActiveServerProvider.shouldUseId3Tags() && Settings.shouldShowArtistPicture
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -11,6 +11,7 @@ import androidx.core.view.isGone
|
|||||||
import androidx.core.view.isVisible
|
import androidx.core.view.isVisible
|
||||||
import androidx.lifecycle.MutableLiveData
|
import androidx.lifecycle.MutableLiveData
|
||||||
import androidx.media3.common.HeartRating
|
import androidx.media3.common.HeartRating
|
||||||
|
import androidx.media3.common.StarRating
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import com.google.android.material.progressindicator.CircularProgressIndicator
|
import com.google.android.material.progressindicator.CircularProgressIndicator
|
||||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||||
@ -139,7 +140,19 @@ class TrackViewHolder(val view: View) :
|
|||||||
updateStatus(it.state, it.progress)
|
updateStatus(it.state, it.progress)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Timber.v("Setting song done")
|
// Listen for rating updates
|
||||||
|
rxBusSubscription!! += RxBus.ratingPublishedObservable.subscribe {
|
||||||
|
launch(Dispatchers.Main) {
|
||||||
|
// Ignore updates which are not for the current song
|
||||||
|
if (it.id != song.id) return@launch
|
||||||
|
|
||||||
|
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
|
// This is called when the Holder is recycled and receives a new Song
|
||||||
|
@ -270,8 +270,8 @@ class ActiveServerProvider(
|
|||||||
/**
|
/**
|
||||||
* Queries if ID3 tags should be used
|
* Queries if ID3 tags should be used
|
||||||
*/
|
*/
|
||||||
fun isID3Enabled(): Boolean {
|
fun shouldUseId3Tags(): Boolean {
|
||||||
return Settings.shouldUseId3Tags && (!isOffline() || Settings.useId3TagsOffline)
|
return Settings.id3TagsEnabledOnline && (!isOffline() || Settings.id3TagsEnabledOffline)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -2,8 +2,8 @@ package org.moire.ultrasonic.di
|
|||||||
|
|
||||||
import org.koin.dsl.module
|
import org.koin.dsl.module
|
||||||
import org.moire.ultrasonic.service.ExternalStorageMonitor
|
import org.moire.ultrasonic.service.ExternalStorageMonitor
|
||||||
import org.moire.ultrasonic.service.MediaPlayerController
|
|
||||||
import org.moire.ultrasonic.service.MediaPlayerLifecycleSupport
|
import org.moire.ultrasonic.service.MediaPlayerLifecycleSupport
|
||||||
|
import org.moire.ultrasonic.service.MediaPlayerManager
|
||||||
import org.moire.ultrasonic.service.PlaybackStateSerializer
|
import org.moire.ultrasonic.service.PlaybackStateSerializer
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -15,5 +15,5 @@ val mediaPlayerModule = module {
|
|||||||
single { ExternalStorageMonitor() }
|
single { ExternalStorageMonitor() }
|
||||||
|
|
||||||
// TODO Ideally this can be cleaned up when all circular references are removed.
|
// TODO Ideally this can be cleaned up when all circular references are removed.
|
||||||
single { MediaPlayerController(get(), get(), get()) }
|
single { MediaPlayerManager(get(), get(), get()) }
|
||||||
}
|
}
|
||||||
|
@ -138,8 +138,8 @@ class AlbumListFragment(
|
|||||||
)
|
)
|
||||||
|
|
||||||
private fun getListOfSortOrders(): List<SortOrder> {
|
private fun getListOfSortOrders(): List<SortOrder> {
|
||||||
val useId3 = Settings.shouldUseId3Tags
|
val useId3 = Settings.id3TagsEnabledOnline
|
||||||
val useId3Offline = Settings.useId3TagsOffline
|
val useId3Offline = Settings.id3TagsEnabledOffline
|
||||||
val isOnline = !ActiveServerProvider.isOffline()
|
val isOnline = !ActiveServerProvider.isOffline()
|
||||||
|
|
||||||
val supported = mutableListOf<SortOrder>()
|
val supported = mutableListOf<SortOrder>()
|
||||||
|
@ -72,7 +72,7 @@ class BookmarksFragment : TrackCollectionFragment() {
|
|||||||
currentPlayingPosition = songs[0].bookmarkPosition
|
currentPlayingPosition = songs[0].bookmarkPosition
|
||||||
)
|
)
|
||||||
|
|
||||||
mediaPlayerController.restore(
|
mediaPlayerManager.restore(
|
||||||
state = state,
|
state = state,
|
||||||
autoPlay = true,
|
autoPlay = true,
|
||||||
newPlaylist = true
|
newPlaylist = true
|
||||||
|
@ -401,7 +401,7 @@ class EditServerFragment : Fragment(), OnBackPressedHandler {
|
|||||||
Timber.w(exception)
|
Timber.w(exception)
|
||||||
ErrorDialog.Builder(requireContext())
|
ErrorDialog.Builder(requireContext())
|
||||||
.setTitle(R.string.error_label)
|
.setTitle(R.string.error_label)
|
||||||
.setMessage(getErrorMessage(exception, context))
|
.setMessage(getErrorMessage(exception))
|
||||||
.show()
|
.show()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -16,6 +16,7 @@ import androidx.navigation.fragment.findNavController
|
|||||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||||
import org.moire.ultrasonic.R
|
import org.moire.ultrasonic.R
|
||||||
import org.moire.ultrasonic.adapters.FolderSelectorBinder
|
import org.moire.ultrasonic.adapters.FolderSelectorBinder
|
||||||
|
import org.moire.ultrasonic.data.ActiveServerProvider
|
||||||
import org.moire.ultrasonic.domain.Artist
|
import org.moire.ultrasonic.domain.Artist
|
||||||
import org.moire.ultrasonic.domain.GenericEntry
|
import org.moire.ultrasonic.domain.GenericEntry
|
||||||
import org.moire.ultrasonic.domain.Identifiable
|
import org.moire.ultrasonic.domain.Identifiable
|
||||||
@ -23,7 +24,6 @@ import org.moire.ultrasonic.service.RxBus
|
|||||||
import org.moire.ultrasonic.service.plusAssign
|
import org.moire.ultrasonic.service.plusAssign
|
||||||
import org.moire.ultrasonic.subsonic.DownloadAction
|
import org.moire.ultrasonic.subsonic.DownloadAction
|
||||||
import org.moire.ultrasonic.subsonic.DownloadHandler
|
import org.moire.ultrasonic.subsonic.DownloadHandler
|
||||||
import org.moire.ultrasonic.util.Settings
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An extension of the MultiListFragment, with a few helper functions geared
|
* An extension of the MultiListFragment, with a few helper functions geared
|
||||||
@ -39,7 +39,7 @@ abstract class EntryListFragment<T : GenericEntry> : MultiListFragment<T>() {
|
|||||||
*/
|
*/
|
||||||
private fun showFolderHeader(): Boolean {
|
private fun showFolderHeader(): Boolean {
|
||||||
return listModel.showSelectFolderHeader() && !listModel.isOffline() &&
|
return listModel.showSelectFolderHeader() && !listModel.isOffline() &&
|
||||||
!Settings.shouldUseId3Tags
|
!ActiveServerProvider.shouldUseId3Tags()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onContextMenuItemSelected(menuItem: MenuItem, item: T): Boolean {
|
override fun onContextMenuItemSelected(menuItem: MenuItem, item: T): Boolean {
|
||||||
|
@ -25,7 +25,7 @@ import kotlin.math.abs
|
|||||||
import org.koin.android.ext.android.inject
|
import org.koin.android.ext.android.inject
|
||||||
import org.moire.ultrasonic.NavigationGraphDirections
|
import org.moire.ultrasonic.NavigationGraphDirections
|
||||||
import org.moire.ultrasonic.R
|
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.service.RxBus
|
||||||
import org.moire.ultrasonic.subsonic.ImageLoaderProvider
|
import org.moire.ultrasonic.subsonic.ImageLoaderProvider
|
||||||
import org.moire.ultrasonic.util.Settings
|
import org.moire.ultrasonic.util.Settings
|
||||||
@ -48,7 +48,7 @@ class NowPlayingFragment : Fragment() {
|
|||||||
private var nowPlayingArtist: TextView? = null
|
private var nowPlayingArtist: TextView? = null
|
||||||
|
|
||||||
private var rxBusSubscription: Disposable? = null
|
private var rxBusSubscription: Disposable? = null
|
||||||
private val mediaPlayerController: MediaPlayerController by inject()
|
private val mediaPlayerManager: MediaPlayerManager by inject()
|
||||||
private val imageLoaderProvider: ImageLoaderProvider by inject()
|
private val imageLoaderProvider: ImageLoaderProvider by inject()
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
@ -85,13 +85,13 @@ class NowPlayingFragment : Fragment() {
|
|||||||
@SuppressLint("ClickableViewAccessibility")
|
@SuppressLint("ClickableViewAccessibility")
|
||||||
private fun update() {
|
private fun update() {
|
||||||
try {
|
try {
|
||||||
if (mediaPlayerController.isPlaying) {
|
if (mediaPlayerManager.isPlaying) {
|
||||||
playButton!!.setIconResource(R.drawable.media_pause)
|
playButton!!.setIconResource(R.drawable.media_pause)
|
||||||
} else {
|
} else {
|
||||||
playButton!!.setIconResource(R.drawable.media_start)
|
playButton!!.setIconResource(R.drawable.media_start)
|
||||||
}
|
}
|
||||||
|
|
||||||
val file = mediaPlayerController.currentMediaItem?.toTrack()
|
val file = mediaPlayerManager.currentMediaItem?.toTrack()
|
||||||
|
|
||||||
if (file != null) {
|
if (file != null) {
|
||||||
val title = file.title
|
val title = file.title
|
||||||
@ -111,7 +111,7 @@ class NowPlayingFragment : Fragment() {
|
|||||||
nowPlayingArtist!!.text = artist
|
nowPlayingArtist!!.text = artist
|
||||||
|
|
||||||
nowPlayingAlbumArtImage!!.setOnClickListener {
|
nowPlayingAlbumArtImage!!.setOnClickListener {
|
||||||
val id3 = Settings.shouldUseId3Tags
|
val id3 = Settings.id3TagsEnabledOnline
|
||||||
val action = NavigationGraphDirections.toTrackCollection(
|
val action = NavigationGraphDirections.toTrackCollection(
|
||||||
isAlbum = id3,
|
isAlbum = id3,
|
||||||
id = if (id3) file.albumId else file.parent,
|
id = if (id3) file.albumId else file.parent,
|
||||||
@ -127,7 +127,7 @@ class NowPlayingFragment : Fragment() {
|
|||||||
|
|
||||||
// This empty onClickListener is necessary for the onTouchListener to work
|
// This empty onClickListener is necessary for the onTouchListener to work
|
||||||
requireView().setOnClickListener { }
|
requireView().setOnClickListener { }
|
||||||
playButton!!.setOnClickListener { mediaPlayerController.togglePlayPause() }
|
playButton!!.setOnClickListener { mediaPlayerManager.togglePlayPause() }
|
||||||
} catch (all: Exception) {
|
} catch (all: Exception) {
|
||||||
Timber.w(all, "Failed to get notification cover art")
|
Timber.w(all, "Failed to get notification cover art")
|
||||||
}
|
}
|
||||||
@ -149,10 +149,10 @@ class NowPlayingFragment : Fragment() {
|
|||||||
if (abs(deltaX) > MIN_DISTANCE) {
|
if (abs(deltaX) > MIN_DISTANCE) {
|
||||||
// left or right
|
// left or right
|
||||||
if (deltaX < 0) {
|
if (deltaX < 0) {
|
||||||
mediaPlayerController.seekToPrevious()
|
mediaPlayerManager.seekToPrevious()
|
||||||
}
|
}
|
||||||
if (deltaX > 0) {
|
if (deltaX > 0) {
|
||||||
mediaPlayerController.seekToNext()
|
mediaPlayerManager.seekToNext()
|
||||||
}
|
}
|
||||||
} else if (abs(deltaY) > MIN_DISTANCE) {
|
} else if (abs(deltaY) > MIN_DISTANCE) {
|
||||||
if (deltaY < 0) {
|
if (deltaY < 0) {
|
||||||
|
@ -34,6 +34,7 @@ import android.widget.SeekBar.OnSeekBarChangeListener
|
|||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import android.widget.ViewFlipper
|
import android.widget.ViewFlipper
|
||||||
|
import androidx.constraintlayout.widget.ConstraintLayout
|
||||||
import androidx.core.content.res.ResourcesCompat
|
import androidx.core.content.res.ResourcesCompat
|
||||||
import androidx.core.view.MenuHost
|
import androidx.core.view.MenuHost
|
||||||
import androidx.core.view.MenuProvider
|
import androidx.core.view.MenuProvider
|
||||||
@ -53,6 +54,7 @@ import androidx.recyclerview.widget.LinearLayoutManager
|
|||||||
import androidx.recyclerview.widget.LinearSmoothScroller
|
import androidx.recyclerview.widget.LinearSmoothScroller
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import com.google.android.material.button.MaterialButton
|
import com.google.android.material.button.MaterialButton
|
||||||
|
import com.google.android.material.progressindicator.CircularProgressIndicator
|
||||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||||
import java.text.DateFormat
|
import java.text.DateFormat
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
@ -76,14 +78,17 @@ import org.moire.ultrasonic.R
|
|||||||
import org.moire.ultrasonic.adapters.BaseAdapter
|
import org.moire.ultrasonic.adapters.BaseAdapter
|
||||||
import org.moire.ultrasonic.adapters.TrackViewBinder
|
import org.moire.ultrasonic.adapters.TrackViewBinder
|
||||||
import org.moire.ultrasonic.api.subsonic.models.AlbumListType
|
import org.moire.ultrasonic.api.subsonic.models.AlbumListType
|
||||||
|
import org.moire.ultrasonic.app.UApp
|
||||||
import org.moire.ultrasonic.audiofx.EqualizerController
|
import org.moire.ultrasonic.audiofx.EqualizerController
|
||||||
import org.moire.ultrasonic.data.ActiveServerProvider.Companion.isOffline
|
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.data.RatingUpdate
|
||||||
|
import org.moire.ultrasonic.databinding.CurrentPlayingBinding
|
||||||
import org.moire.ultrasonic.domain.Identifiable
|
import org.moire.ultrasonic.domain.Identifiable
|
||||||
import org.moire.ultrasonic.domain.MusicDirectory
|
import org.moire.ultrasonic.domain.MusicDirectory
|
||||||
import org.moire.ultrasonic.domain.Track
|
import org.moire.ultrasonic.domain.Track
|
||||||
import org.moire.ultrasonic.fragment.FragmentTitle.Companion.setTitle
|
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.MusicServiceFactory.getMusicService
|
||||||
import org.moire.ultrasonic.service.RxBus
|
import org.moire.ultrasonic.service.RxBus
|
||||||
import org.moire.ultrasonic.service.plusAssign
|
import org.moire.ultrasonic.service.plusAssign
|
||||||
@ -124,7 +129,7 @@ class PlayerFragment :
|
|||||||
|
|
||||||
// Data & Services
|
// Data & Services
|
||||||
private val networkAndStorageChecker: NetworkAndStorageChecker by inject()
|
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 shareHandler: ShareHandler by inject()
|
||||||
private val imageLoaderProvider: ImageLoaderProvider by inject()
|
private val imageLoaderProvider: ImageLoaderProvider by inject()
|
||||||
private var currentSong: Track? = null
|
private var currentSong: Track? = null
|
||||||
@ -142,6 +147,7 @@ class PlayerFragment :
|
|||||||
private lateinit var fiveStar5ImageView: ImageView
|
private lateinit var fiveStar5ImageView: ImageView
|
||||||
private lateinit var playlistFlipper: ViewFlipper
|
private lateinit var playlistFlipper: ViewFlipper
|
||||||
private lateinit var emptyTextView: TextView
|
private lateinit var emptyTextView: TextView
|
||||||
|
private lateinit var emptyView: ConstraintLayout
|
||||||
private lateinit var songTitleTextView: TextView
|
private lateinit var songTitleTextView: TextView
|
||||||
private lateinit var artistTextView: TextView
|
private lateinit var artistTextView: TextView
|
||||||
private lateinit var albumTextView: TextView
|
private lateinit var albumTextView: TextView
|
||||||
@ -161,9 +167,15 @@ class PlayerFragment :
|
|||||||
private lateinit var shuffleButton: View
|
private lateinit var shuffleButton: View
|
||||||
private lateinit var repeatButton: MaterialButton
|
private lateinit var repeatButton: MaterialButton
|
||||||
private lateinit var progressBar: SeekBar
|
private lateinit var progressBar: SeekBar
|
||||||
|
private lateinit var progressIndicator: CircularProgressIndicator
|
||||||
private val hollowStar = R.drawable.ic_star_hollow
|
private val hollowStar = R.drawable.ic_star_hollow
|
||||||
private val fullStar = R.drawable.ic_star_full
|
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 {
|
private val viewAdapter: BaseAdapter<Identifiable> by lazy {
|
||||||
BaseAdapter()
|
BaseAdapter()
|
||||||
}
|
}
|
||||||
@ -177,13 +189,17 @@ class PlayerFragment :
|
|||||||
inflater: LayoutInflater,
|
inflater: LayoutInflater,
|
||||||
container: ViewGroup?,
|
container: ViewGroup?,
|
||||||
savedInstanceState: Bundle?
|
savedInstanceState: Bundle?
|
||||||
): View? {
|
): View {
|
||||||
return inflater.inflate(R.layout.current_playing, container, false)
|
_binding = CurrentPlayingBinding.inflate(layoutInflater, container, false)
|
||||||
|
return binding.root
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: Switch them all over to use the view binding
|
||||||
private fun findViews(view: View) {
|
private fun findViews(view: View) {
|
||||||
playlistFlipper = view.findViewById(R.id.current_playing_playlist_flipper)
|
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)
|
songTitleTextView = view.findViewById(R.id.current_playing_song)
|
||||||
artistTextView = view.findViewById(R.id.current_playing_artist)
|
artistTextView = view.findViewById(R.id.current_playing_artist)
|
||||||
albumTextView = view.findViewById(R.id.current_playing_album)
|
albumTextView = view.findViewById(R.id.current_playing_album)
|
||||||
@ -210,7 +226,7 @@ class PlayerFragment :
|
|||||||
fiveStar5ImageView = view.findViewById(R.id.song_five_star_5)
|
fiveStar5ImageView = view.findViewById(R.id.song_five_star_5)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suppress("LongMethod", "DEPRECATION")
|
@Suppress("LongMethod")
|
||||||
@SuppressLint("ClickableViewAccessibility")
|
@SuppressLint("ClickableViewAccessibility")
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
cancellationToken = CancellationToken()
|
cancellationToken = CancellationToken()
|
||||||
@ -220,6 +236,7 @@ class PlayerFragment :
|
|||||||
val width: Int
|
val width: Int
|
||||||
val height: Int
|
val height: Int
|
||||||
|
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
val bounds = windowManager.currentWindowMetrics.bounds
|
val bounds = windowManager.currentWindowMetrics.bounds
|
||||||
width = bounds.width()
|
width = bounds.width()
|
||||||
@ -248,8 +265,8 @@ class PlayerFragment :
|
|||||||
val previousButton: AutoRepeatButton = view.findViewById(R.id.button_previous)
|
val previousButton: AutoRepeatButton = view.findViewById(R.id.button_previous)
|
||||||
val nextButton: AutoRepeatButton = view.findViewById(R.id.button_next)
|
val nextButton: AutoRepeatButton = view.findViewById(R.id.button_next)
|
||||||
shuffleButton = view.findViewById(R.id.button_shuffle)
|
shuffleButton = view.findViewById(R.id.button_shuffle)
|
||||||
updateShuffleButtonState(mediaPlayerController.isShufflePlayEnabled)
|
updateShuffleButtonState(mediaPlayerManager.isShufflePlayEnabled)
|
||||||
updateRepeatButtonState(mediaPlayerController.repeatMode)
|
updateRepeatButtonState(mediaPlayerManager.repeatMode)
|
||||||
|
|
||||||
val ratingLinearLayout = view.findViewById<LinearLayout>(R.id.song_rating)
|
val ratingLinearLayout = view.findViewById<LinearLayout>(R.id.song_rating)
|
||||||
if (!useFiveStarRating) ratingLinearLayout.isVisible = false
|
if (!useFiveStarRating) ratingLinearLayout.isVisible = false
|
||||||
@ -271,7 +288,7 @@ class PlayerFragment :
|
|||||||
previousButton.setOnClickListener {
|
previousButton.setOnClickListener {
|
||||||
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
|
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
|
||||||
launch(CommunicationError.getHandler(context)) {
|
launch(CommunicationError.getHandler(context)) {
|
||||||
mediaPlayerController.seekToPrevious()
|
mediaPlayerManager.seekToPrevious()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -282,7 +299,7 @@ class PlayerFragment :
|
|||||||
nextButton.setOnClickListener {
|
nextButton.setOnClickListener {
|
||||||
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
|
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
|
||||||
launch(CommunicationError.getHandler(context)) {
|
launch(CommunicationError.getHandler(context)) {
|
||||||
mediaPlayerController.seekToNext()
|
mediaPlayerManager.seekToNext()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -292,22 +309,22 @@ class PlayerFragment :
|
|||||||
|
|
||||||
pauseButton.setOnClickListener {
|
pauseButton.setOnClickListener {
|
||||||
launch(CommunicationError.getHandler(context)) {
|
launch(CommunicationError.getHandler(context)) {
|
||||||
mediaPlayerController.pause()
|
mediaPlayerManager.pause()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
stopButton.setOnClickListener {
|
stopButton.setOnClickListener {
|
||||||
launch(CommunicationError.getHandler(context)) {
|
launch(CommunicationError.getHandler(context)) {
|
||||||
mediaPlayerController.reset()
|
mediaPlayerManager.reset()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
playButton.setOnClickListener {
|
playButton.setOnClickListener {
|
||||||
if (!mediaPlayerController.isJukeboxEnabled)
|
if (!mediaPlayerManager.isJukeboxEnabled)
|
||||||
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
|
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
|
||||||
|
|
||||||
launch(CommunicationError.getHandler(context)) {
|
launch(CommunicationError.getHandler(context)) {
|
||||||
mediaPlayerController.play()
|
mediaPlayerManager.play()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -316,12 +333,12 @@ class PlayerFragment :
|
|||||||
}
|
}
|
||||||
|
|
||||||
repeatButton.setOnClickListener {
|
repeatButton.setOnClickListener {
|
||||||
var newRepeat = mediaPlayerController.repeatMode + 1
|
var newRepeat = mediaPlayerManager.repeatMode + 1
|
||||||
if (newRepeat == 3) {
|
if (newRepeat == 3) {
|
||||||
newRepeat = 0
|
newRepeat = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
mediaPlayerController.repeatMode = newRepeat
|
mediaPlayerManager.repeatMode = newRepeat
|
||||||
|
|
||||||
onPlaylistChanged()
|
onPlaylistChanged()
|
||||||
|
|
||||||
@ -343,7 +360,7 @@ class PlayerFragment :
|
|||||||
progressBar.setOnSeekBarChangeListener(object : OnSeekBarChangeListener {
|
progressBar.setOnSeekBarChangeListener(object : OnSeekBarChangeListener {
|
||||||
override fun onStopTrackingTouch(seekBar: SeekBar) {
|
override fun onStopTrackingTouch(seekBar: SeekBar) {
|
||||||
launch(CommunicationError.getHandler(context)) {
|
launch(CommunicationError.getHandler(context)) {
|
||||||
mediaPlayerController.seekTo(progressBar.progress)
|
mediaPlayerManager.seekTo(progressBar.progress)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -380,12 +397,19 @@ class PlayerFragment :
|
|||||||
// Query the Jukebox state in an IO Context
|
// Query the Jukebox state in an IO Context
|
||||||
ioScope.launch(CommunicationError.getHandler(context)) {
|
ioScope.launch(CommunicationError.getHandler(context)) {
|
||||||
try {
|
try {
|
||||||
jukeboxAvailable = mediaPlayerController.isJukeboxAvailable
|
jukeboxAvailable = mediaPlayerManager.isJukeboxAvailable
|
||||||
} catch (all: Exception) {
|
} catch (all: Exception) {
|
||||||
Timber.e(all)
|
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) }
|
view.setOnTouchListener { _, event -> gestureScanner.onTouchEvent(event) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -417,7 +441,7 @@ class PlayerFragment :
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun toggleShuffle() {
|
private fun toggleShuffle() {
|
||||||
val isEnabled = mediaPlayerController.toggleShuffle()
|
val isEnabled = mediaPlayerManager.toggleShuffle()
|
||||||
|
|
||||||
if (isEnabled) {
|
if (isEnabled) {
|
||||||
Util.toast(activity, R.string.download_menu_shuffle_on)
|
Util.toast(activity, R.string.download_menu_shuffle_on)
|
||||||
@ -430,7 +454,7 @@ class PlayerFragment :
|
|||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
if (mediaPlayerController.currentMediaItem == null) {
|
if (mediaPlayerManager.currentMediaItem == null) {
|
||||||
playlistFlipper.displayedChild = 1
|
playlistFlipper.displayedChild = 1
|
||||||
} else {
|
} else {
|
||||||
// Download list and Album art must be updated when resumed
|
// Download list and Album art must be updated when resumed
|
||||||
@ -443,7 +467,7 @@ class PlayerFragment :
|
|||||||
executorService = Executors.newSingleThreadScheduledExecutor()
|
executorService = Executors.newSingleThreadScheduledExecutor()
|
||||||
executorService.scheduleWithFixedDelay(runnable, 0L, 500L, TimeUnit.MILLISECONDS)
|
executorService.scheduleWithFixedDelay(runnable, 0L, 500L, TimeUnit.MILLISECONDS)
|
||||||
|
|
||||||
if (mediaPlayerController.keepScreenOn) {
|
if (mediaPlayerManager.keepScreenOn) {
|
||||||
requireActivity().window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
requireActivity().window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||||
} else {
|
} else {
|
||||||
requireActivity().window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
requireActivity().window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||||
@ -454,7 +478,7 @@ class PlayerFragment :
|
|||||||
|
|
||||||
// Scroll to current playing.
|
// Scroll to current playing.
|
||||||
private fun scrollToCurrent() {
|
private fun scrollToCurrent() {
|
||||||
val index = mediaPlayerController.currentMediaItemIndex
|
val index = mediaPlayerManager.currentMediaItemIndex
|
||||||
|
|
||||||
if (index != -1) {
|
if (index != -1) {
|
||||||
val smoothScroller = LinearSmoothScroller(context)
|
val smoothScroller = LinearSmoothScroller(context)
|
||||||
@ -472,6 +496,7 @@ class PlayerFragment :
|
|||||||
rxBusSubscription.dispose()
|
rxBusSubscription.dispose()
|
||||||
cancel("CoroutineScope cancelled because the view was destroyed")
|
cancel("CoroutineScope cancelled because the view was destroyed")
|
||||||
cancellationToken.cancel()
|
cancellationToken.cancel()
|
||||||
|
_binding = null
|
||||||
super.onDestroyView()
|
super.onDestroyView()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -541,7 +566,7 @@ class PlayerFragment :
|
|||||||
equalizerMenuItem.isVisible = isEqualizerAvailable
|
equalizerMenuItem.isVisible = isEqualizerAvailable
|
||||||
}
|
}
|
||||||
|
|
||||||
val mediaPlayerController = mediaPlayerController
|
val mediaPlayerController = mediaPlayerManager
|
||||||
val track = mediaPlayerController.currentMediaItem?.toTrack()
|
val track = mediaPlayerController.currentMediaItem?.toTrack()
|
||||||
|
|
||||||
if (track != null) {
|
if (track != null) {
|
||||||
@ -591,10 +616,10 @@ class PlayerFragment :
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isOffline() || !Settings.shouldUseId3Tags) {
|
// Only show the menu if the ID3 tags are available
|
||||||
popup.menu.findItem(R.id.menu_show_artist)?.isVisible = false
|
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.menu.findItem(R.id.menu_lyrics)?.isVisible = !isOffline()
|
||||||
popup.show()
|
popup.show()
|
||||||
return popup
|
return popup
|
||||||
@ -614,7 +639,7 @@ class PlayerFragment :
|
|||||||
R.id.menu_show_artist -> {
|
R.id.menu_show_artist -> {
|
||||||
if (track == null) return false
|
if (track == null) return false
|
||||||
|
|
||||||
if (Settings.shouldUseId3Tags) {
|
if (Settings.id3TagsEnabledOnline) {
|
||||||
val action = PlayerFragmentDirections.playerToAlbumsList(
|
val action = PlayerFragmentDirections.playerToAlbumsList(
|
||||||
type = AlbumListType.SORTED_BY_NAME,
|
type = AlbumListType.SORTED_BY_NAME,
|
||||||
byArtist = true,
|
byArtist = true,
|
||||||
@ -630,7 +655,7 @@ class PlayerFragment :
|
|||||||
R.id.menu_show_album -> {
|
R.id.menu_show_album -> {
|
||||||
if (track == null) return false
|
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(
|
val action = PlayerFragmentDirections.playerToSelectAlbum(
|
||||||
id = albumId,
|
id = albumId,
|
||||||
@ -638,7 +663,6 @@ class PlayerFragment :
|
|||||||
parentId = track.parent,
|
parentId = track.parent,
|
||||||
isAlbum = true
|
isAlbum = true
|
||||||
)
|
)
|
||||||
|
|
||||||
findNavController().navigate(action)
|
findNavController().navigate(action)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@ -650,12 +674,12 @@ class PlayerFragment :
|
|||||||
}
|
}
|
||||||
R.id.menu_item_screen_on_off -> {
|
R.id.menu_item_screen_on_off -> {
|
||||||
val window = requireActivity().window
|
val window = requireActivity().window
|
||||||
if (mediaPlayerController.keepScreenOn) {
|
if (mediaPlayerManager.keepScreenOn) {
|
||||||
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||||
mediaPlayerController.keepScreenOn = false
|
mediaPlayerManager.keepScreenOn = false
|
||||||
} else {
|
} else {
|
||||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||||
mediaPlayerController.keepScreenOn = true
|
mediaPlayerManager.keepScreenOn = true
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@ -668,8 +692,8 @@ class PlayerFragment :
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
R.id.menu_item_jukebox -> {
|
R.id.menu_item_jukebox -> {
|
||||||
val jukeboxEnabled = !mediaPlayerController.isJukeboxEnabled
|
val jukeboxEnabled = !mediaPlayerManager.isJukeboxEnabled
|
||||||
mediaPlayerController.isJukeboxEnabled = jukeboxEnabled
|
mediaPlayerManager.isJukeboxEnabled = jukeboxEnabled
|
||||||
Util.toast(
|
Util.toast(
|
||||||
context,
|
context,
|
||||||
if (jukeboxEnabled) R.string.download_jukebox_on
|
if (jukeboxEnabled) R.string.download_jukebox_on
|
||||||
@ -683,13 +707,13 @@ class PlayerFragment :
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
R.id.menu_item_clear_playlist -> {
|
R.id.menu_item_clear_playlist -> {
|
||||||
mediaPlayerController.isShufflePlayEnabled = false
|
mediaPlayerManager.isShufflePlayEnabled = false
|
||||||
mediaPlayerController.clear()
|
mediaPlayerManager.clear()
|
||||||
onPlaylistChanged()
|
onPlaylistChanged()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
R.id.menu_item_save_playlist -> {
|
R.id.menu_item_save_playlist -> {
|
||||||
if (mediaPlayerController.playlistSize > 0) {
|
if (mediaPlayerManager.playlistSize > 0) {
|
||||||
showSavePlaylistDialog()
|
showSavePlaylistDialog()
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
@ -708,7 +732,7 @@ class PlayerFragment :
|
|||||||
if (track == null) return true
|
if (track == null) return true
|
||||||
|
|
||||||
val songId = track.id
|
val songId = track.id
|
||||||
val playerPosition = mediaPlayerController.playerPosition
|
val playerPosition = mediaPlayerManager.playerPosition
|
||||||
track.bookmarkPosition = playerPosition
|
track.bookmarkPosition = playerPosition
|
||||||
val bookmarkTime = Util.formatTotalDuration(playerPosition.toLong(), true)
|
val bookmarkTime = Util.formatTotalDuration(playerPosition.toLong(), true)
|
||||||
Thread {
|
Thread {
|
||||||
@ -743,7 +767,7 @@ class PlayerFragment :
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
R.id.menu_item_share -> {
|
R.id.menu_item_share -> {
|
||||||
val mediaPlayerController = mediaPlayerController
|
val mediaPlayerController = mediaPlayerManager
|
||||||
val tracks: MutableList<Track?> = ArrayList()
|
val tracks: MutableList<Track?> = ArrayList()
|
||||||
val playlist = mediaPlayerController.playlist
|
val playlist = mediaPlayerController.playlist
|
||||||
for (item in playlist) {
|
for (item in playlist) {
|
||||||
@ -778,8 +802,7 @@ class PlayerFragment :
|
|||||||
|
|
||||||
private fun update(cancel: CancellationToken? = null) {
|
private fun update(cancel: CancellationToken? = null) {
|
||||||
if (cancel?.isCancellationRequested == true) return
|
if (cancel?.isCancellationRequested == true) return
|
||||||
val mediaPlayerController = mediaPlayerController
|
if (currentSong?.id != mediaPlayerManager.currentMediaItem?.mediaId) {
|
||||||
if (currentSong?.id != mediaPlayerController.currentMediaItem?.mediaId) {
|
|
||||||
onTrackChanged()
|
onTrackChanged()
|
||||||
}
|
}
|
||||||
updateSeekBar()
|
updateSeekBar()
|
||||||
@ -787,10 +810,10 @@ class PlayerFragment :
|
|||||||
|
|
||||||
private fun savePlaylistInBackground(playlistName: String) {
|
private fun savePlaylistInBackground(playlistName: String) {
|
||||||
Util.toast(context, resources.getString(R.string.download_playlist_saving, playlistName))
|
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
|
// The playlist can be acquired only from the main thread
|
||||||
val entries = mediaPlayerController.playlist.map {
|
val entries = mediaPlayerManager.playlist.map {
|
||||||
it.toTrack()
|
it.toTrack()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -799,16 +822,16 @@ class PlayerFragment :
|
|||||||
musicService.createPlaylist(null, playlistName, entries)
|
musicService.createPlaylist(null, playlistName, entries)
|
||||||
}.invokeOnCompletion {
|
}.invokeOnCompletion {
|
||||||
if (it == null || it is CancellationException) {
|
if (it == null || it is CancellationException) {
|
||||||
Util.toast(context, R.string.download_playlist_done)
|
Util.toast(UApp.applicationContext(), R.string.download_playlist_done)
|
||||||
} else {
|
} else {
|
||||||
Timber.e(it, "Exception has occurred in savePlaylistInBackground")
|
Timber.e(it, "Exception has occurred in savePlaylistInBackground")
|
||||||
val msg = String.format(
|
val msg = String.format(
|
||||||
Locale.ROOT,
|
Locale.ROOT,
|
||||||
"%s %s",
|
"%s %s",
|
||||||
resources.getString(R.string.download_playlist_error),
|
resources.getString(R.string.download_playlist_error),
|
||||||
CommunicationError.getErrorMessage(it, context)
|
CommunicationError.getErrorMessage(it)
|
||||||
)
|
)
|
||||||
Util.toast(context, msg)
|
Util.toast(UApp.applicationContext(), msg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -843,8 +866,8 @@ class PlayerFragment :
|
|||||||
|
|
||||||
// Create listener
|
// Create listener
|
||||||
val clickHandler: ((Track, Int) -> Unit) = { _, listPos ->
|
val clickHandler: ((Track, Int) -> Unit) = { _, listPos ->
|
||||||
val mediaIndex = mediaPlayerController.getUnshuffledIndexOf(listPos)
|
val mediaIndex = mediaPlayerManager.getUnshuffledIndexOf(listPos)
|
||||||
mediaPlayerController.play(mediaIndex)
|
mediaPlayerManager.play(mediaIndex)
|
||||||
}
|
}
|
||||||
|
|
||||||
viewAdapter.register(
|
viewAdapter.register(
|
||||||
@ -908,7 +931,7 @@ class PlayerFragment :
|
|||||||
@SuppressLint("NotifyDataSetChanged")
|
@SuppressLint("NotifyDataSetChanged")
|
||||||
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
|
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
|
||||||
val pos = viewHolder.bindingAdapterPosition
|
val pos = viewHolder.bindingAdapterPosition
|
||||||
val item = mediaPlayerController.getMediaItemAt(pos)
|
val item = mediaPlayerManager.getMediaItemAt(pos)
|
||||||
|
|
||||||
// Remove the item from the list quickly
|
// Remove the item from the list quickly
|
||||||
val items = viewAdapter.getCurrentList().toMutableList()
|
val items = viewAdapter.getCurrentList().toMutableList()
|
||||||
@ -924,7 +947,7 @@ class PlayerFragment :
|
|||||||
Util.toast(context, songRemoved)
|
Util.toast(context, songRemoved)
|
||||||
|
|
||||||
// Remove the item from the playlist
|
// Remove the item from the playlist
|
||||||
mediaPlayerController.removeFromPlaylist(pos)
|
mediaPlayerManager.removeFromPlaylist(pos)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onSelectedChanged(
|
override fun onSelectedChanged(
|
||||||
@ -944,7 +967,7 @@ class PlayerFragment :
|
|||||||
dragging = false
|
dragging = false
|
||||||
// Move the item in the playlist separately
|
// Move the item in the playlist separately
|
||||||
Timber.i("Moving item %s to %s", startPosition, endPosition)
|
Timber.i("Moving item %s to %s", startPosition, endPosition)
|
||||||
mediaPlayerController.moveItemInPlaylist(startPosition, endPosition)
|
mediaPlayerManager.moveItemInPlaylist(startPosition, endPosition)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1022,25 +1045,24 @@ class PlayerFragment :
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun onPlaylistChanged() {
|
private fun onPlaylistChanged() {
|
||||||
val mediaPlayerController = mediaPlayerController
|
val mediaPlayerController = mediaPlayerManager
|
||||||
// Try to display playlist in play order
|
// Try to display playlist in play order
|
||||||
val list = mediaPlayerController.playlistInPlayOrder
|
val list = mediaPlayerController.playlistInPlayOrder
|
||||||
emptyTextView.setText(R.string.playlist_empty)
|
emptyTextView.setText(R.string.playlist_empty)
|
||||||
|
|
||||||
viewAdapter.submitList(list.map(MediaItem::toTrack))
|
viewAdapter.submitList(list.map(MediaItem::toTrack))
|
||||||
|
progressIndicator.isVisible = false
|
||||||
emptyTextView.isVisible = list.isEmpty()
|
emptyView.isVisible = list.isEmpty()
|
||||||
|
|
||||||
updateRepeatButtonState(mediaPlayerController.repeatMode)
|
updateRepeatButtonState(mediaPlayerController.repeatMode)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onTrackChanged() {
|
private fun onTrackChanged() {
|
||||||
currentSong = mediaPlayerController.currentMediaItem?.toTrack()
|
currentSong = mediaPlayerManager.currentMediaItem?.toTrack()
|
||||||
|
|
||||||
scrollToCurrent()
|
scrollToCurrent()
|
||||||
val totalDuration = mediaPlayerController.playListDuration
|
val totalDuration = mediaPlayerManager.playListDuration
|
||||||
val totalSongs = mediaPlayerController.playlistSize
|
val totalSongs = mediaPlayerManager.playlistSize
|
||||||
val currentSongIndex = mediaPlayerController.currentMediaItemIndex + 1
|
val currentSongIndex = mediaPlayerManager.currentMediaItemIndex + 1
|
||||||
val duration = Util.formatTotalDuration(totalDuration)
|
val duration = Util.formatTotalDuration(totalDuration)
|
||||||
val trackFormat =
|
val trackFormat =
|
||||||
String.format(Locale.getDefault(), "%d / %d", currentSongIndex, totalSongs)
|
String.format(Locale.getDefault(), "%d / %d", currentSongIndex, totalSongs)
|
||||||
@ -1095,23 +1117,27 @@ class PlayerFragment :
|
|||||||
|
|
||||||
updateSongRating()
|
updateSongRating()
|
||||||
|
|
||||||
nextButton.isEnabled = mediaPlayerController.canSeekToNext()
|
updateMediaButtonActivationState()
|
||||||
previousButton.isEnabled = mediaPlayerController.canSeekToPrevious()
|
}
|
||||||
|
|
||||||
|
private fun updateMediaButtonActivationState() {
|
||||||
|
nextButton.isEnabled = mediaPlayerManager.canSeekToNext()
|
||||||
|
previousButton.isEnabled = mediaPlayerManager.canSeekToPrevious()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
private fun updateSeekBar() {
|
private fun updateSeekBar() {
|
||||||
val isJukeboxEnabled: Boolean = mediaPlayerController.isJukeboxEnabled
|
val isJukeboxEnabled: Boolean = mediaPlayerManager.isJukeboxEnabled
|
||||||
val millisPlayed: Int = max(0, mediaPlayerController.playerPosition)
|
val millisPlayed: Int = max(0, mediaPlayerManager.playerPosition)
|
||||||
val duration: Int = mediaPlayerController.playerDuration
|
val duration: Int = mediaPlayerManager.playerDuration
|
||||||
val playbackState: Int = mediaPlayerController.playbackState
|
val playbackState: Int = mediaPlayerManager.playbackState
|
||||||
|
|
||||||
if (currentSong != null) {
|
if (currentSong != null) {
|
||||||
positionTextView.text = Util.formatTotalDuration(millisPlayed.toLong(), true)
|
positionTextView.text = Util.formatTotalDuration(millisPlayed.toLong(), true)
|
||||||
durationTextView.text = Util.formatTotalDuration(duration.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.progress = millisPlayed
|
||||||
progressBar.isEnabled = mediaPlayerController.isPlaying || isJukeboxEnabled
|
progressBar.isEnabled = mediaPlayerManager.isPlaying || isJukeboxEnabled
|
||||||
} else {
|
} else {
|
||||||
positionTextView.setText(R.string.util_zero_time)
|
positionTextView.setText(R.string.util_zero_time)
|
||||||
durationTextView.setText(R.string.util_no_time)
|
durationTextView.setText(R.string.util_no_time)
|
||||||
@ -1120,7 +1146,7 @@ class PlayerFragment :
|
|||||||
progressBar.isEnabled = false
|
progressBar.isEnabled = false
|
||||||
}
|
}
|
||||||
|
|
||||||
val progress = mediaPlayerController.bufferedPercentage
|
val progress = mediaPlayerManager.bufferedPercentage
|
||||||
updateBufferProgress(playbackState, progress)
|
updateBufferProgress(playbackState, progress)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1133,7 +1159,7 @@ class PlayerFragment :
|
|||||||
setTitle(this@PlayerFragment, downloadStatus)
|
setTitle(this@PlayerFragment, downloadStatus)
|
||||||
}
|
}
|
||||||
Player.STATE_READY -> {
|
Player.STATE_READY -> {
|
||||||
if (mediaPlayerController.isShufflePlayEnabled) {
|
if (mediaPlayerManager.isShufflePlayEnabled) {
|
||||||
setTitle(
|
setTitle(
|
||||||
this@PlayerFragment,
|
this@PlayerFragment,
|
||||||
R.string.download_playerstate_playing_shuffle
|
R.string.download_playerstate_playing_shuffle
|
||||||
@ -1157,7 +1183,7 @@ class PlayerFragment :
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun updateButtonStates(playbackState: Int) {
|
private fun updateButtonStates(playbackState: Int) {
|
||||||
val isPlaying = mediaPlayerController.isPlaying
|
val isPlaying = mediaPlayerManager.isPlaying
|
||||||
when (playbackState) {
|
when (playbackState) {
|
||||||
Player.STATE_READY -> {
|
Player.STATE_READY -> {
|
||||||
pauseButton.isVisible = isPlaying
|
pauseButton.isVisible = isPlaying
|
||||||
@ -1180,9 +1206,9 @@ class PlayerFragment :
|
|||||||
private fun seek(forward: Boolean) {
|
private fun seek(forward: Boolean) {
|
||||||
launch(CommunicationError.getHandler(context)) {
|
launch(CommunicationError.getHandler(context)) {
|
||||||
if (forward) {
|
if (forward) {
|
||||||
mediaPlayerController.seekForward()
|
mediaPlayerManager.seekForward()
|
||||||
} else {
|
} else {
|
||||||
mediaPlayerController.seekBack()
|
mediaPlayerManager.seekBack()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1208,28 +1234,28 @@ class PlayerFragment :
|
|||||||
// Right to Left swipe
|
// Right to Left swipe
|
||||||
if (e1X - e2X > swipeDistance && absX > swipeVelocity) {
|
if (e1X - e2X > swipeDistance && absX > swipeVelocity) {
|
||||||
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
|
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
|
||||||
mediaPlayerController.seekToNext()
|
mediaPlayerManager.seekToNext()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Left to Right swipe
|
// Left to Right swipe
|
||||||
if (e2X - e1X > swipeDistance && absX > swipeVelocity) {
|
if (e2X - e1X > swipeDistance && absX > swipeVelocity) {
|
||||||
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
|
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
|
||||||
mediaPlayerController.seekToPrevious()
|
mediaPlayerManager.seekToPrevious()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Top to Bottom swipe
|
// Top to Bottom swipe
|
||||||
if (e2Y - e1Y > swipeDistance && absY > swipeVelocity) {
|
if (e2Y - e1Y > swipeDistance && absY > swipeVelocity) {
|
||||||
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
|
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
|
||||||
mediaPlayerController.seekTo(mediaPlayerController.playerPosition + 30000)
|
mediaPlayerManager.seekTo(mediaPlayerManager.playerPosition + 30000)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bottom to Top swipe
|
// Bottom to Top swipe
|
||||||
if (e1Y - e2Y > swipeDistance && absY > swipeVelocity) {
|
if (e1Y - e2Y > swipeDistance && absY > swipeVelocity) {
|
||||||
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
|
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
|
||||||
mediaPlayerController.seekTo(mediaPlayerController.playerPosition - 8000)
|
mediaPlayerManager.seekTo(mediaPlayerManager.playerPosition - 8000)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
@ -1294,7 +1320,7 @@ class PlayerFragment :
|
|||||||
builder.setView(layout)
|
builder.setView(layout)
|
||||||
builder.setCancelable(true)
|
builder.setCancelable(true)
|
||||||
val dialog = builder.create()
|
val dialog = builder.create()
|
||||||
val playlistName = mediaPlayerController.suggestedPlaylistName
|
val playlistName = mediaPlayerManager.suggestedPlaylistName
|
||||||
if (playlistName != null) {
|
if (playlistName != null) {
|
||||||
playlistNameView.setText(playlistName)
|
playlistNameView.setText(playlistName)
|
||||||
} else {
|
} else {
|
||||||
|
@ -15,8 +15,11 @@ import android.view.MenuInflater
|
|||||||
import android.view.MenuItem
|
import android.view.MenuItem
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import androidx.appcompat.widget.SearchView
|
import androidx.appcompat.widget.SearchView
|
||||||
|
import androidx.core.view.MenuHost
|
||||||
|
import androidx.core.view.MenuProvider
|
||||||
import androidx.core.view.isVisible
|
import androidx.core.view.isVisible
|
||||||
import androidx.fragment.app.viewModels
|
import androidx.fragment.app.viewModels
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import androidx.navigation.fragment.findNavController
|
import androidx.navigation.fragment.findNavController
|
||||||
import androidx.navigation.fragment.navArgs
|
import androidx.navigation.fragment.navArgs
|
||||||
@ -42,7 +45,7 @@ import org.moire.ultrasonic.domain.Track
|
|||||||
import org.moire.ultrasonic.fragment.FragmentTitle.Companion.setTitle
|
import org.moire.ultrasonic.fragment.FragmentTitle.Companion.setTitle
|
||||||
import org.moire.ultrasonic.model.SearchListModel
|
import org.moire.ultrasonic.model.SearchListModel
|
||||||
import org.moire.ultrasonic.service.DownloadService
|
import org.moire.ultrasonic.service.DownloadService
|
||||||
import org.moire.ultrasonic.service.MediaPlayerController
|
import org.moire.ultrasonic.service.MediaPlayerManager
|
||||||
import org.moire.ultrasonic.subsonic.NetworkAndStorageChecker
|
import org.moire.ultrasonic.subsonic.NetworkAndStorageChecker
|
||||||
import org.moire.ultrasonic.subsonic.ShareHandler
|
import org.moire.ultrasonic.subsonic.ShareHandler
|
||||||
import org.moire.ultrasonic.subsonic.VideoPlayer.Companion.playVideo
|
import org.moire.ultrasonic.subsonic.VideoPlayer.Companion.playVideo
|
||||||
@ -55,15 +58,14 @@ import timber.log.Timber
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Initiates a search on the media library and displays the results
|
* Initiates a search on the media library and displays the results
|
||||||
*
|
* TODO: Switch to material3 class
|
||||||
* TODO: Implement the search field without using the deprecated OptionsMenu calls
|
|
||||||
*/
|
*/
|
||||||
class SearchFragment : MultiListFragment<Identifiable>(), KoinComponent {
|
class SearchFragment : MultiListFragment<Identifiable>(), KoinComponent {
|
||||||
private var searchResult: SearchResult? = null
|
private var searchResult: SearchResult? = null
|
||||||
private var searchRefresh: SwipeRefreshLayout? = null
|
private var searchRefresh: SwipeRefreshLayout? = null
|
||||||
private var searchView: SearchView? = 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 shareHandler: ShareHandler by inject()
|
||||||
private val networkAndStorageChecker: NetworkAndStorageChecker by inject()
|
private val networkAndStorageChecker: NetworkAndStorageChecker by inject()
|
||||||
@ -80,7 +82,13 @@ class SearchFragment : MultiListFragment<Identifiable>(), KoinComponent {
|
|||||||
super.onViewCreated(view, savedInstanceState)
|
super.onViewCreated(view, savedInstanceState)
|
||||||
cancellationToken = CancellationToken()
|
cancellationToken = CancellationToken()
|
||||||
setTitle(this, R.string.search_title)
|
setTitle(this, R.string.search_title)
|
||||||
setHasOptionsMenu(true)
|
|
||||||
|
// Register our options menu
|
||||||
|
(requireActivity() as MenuHost).addMenuProvider(
|
||||||
|
menuProvider,
|
||||||
|
viewLifecycleOwner,
|
||||||
|
Lifecycle.State.RESUMED
|
||||||
|
)
|
||||||
|
|
||||||
listModel.searchResult.observe(
|
listModel.searchResult.observe(
|
||||||
viewLifecycleOwner
|
viewLifecycleOwner
|
||||||
@ -141,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 activity = activity ?: return
|
||||||
val searchManager = activity.getSystemService(Context.SEARCH_SERVICE) as SearchManager
|
val searchManager = activity.getSystemService(Context.SEARCH_SERVICE) as SearchManager
|
||||||
inflater.inflate(R.menu.search, menu)
|
|
||||||
val searchItem = menu.findItem(R.id.search_item)
|
val searchItem = menu.findItem(R.id.search_item)
|
||||||
searchView = searchItem.actionView as SearchView
|
searchView = searchItem.actionView as SearchView
|
||||||
val searchableInfo = searchManager.getSearchableInfo(requireActivity().componentName)
|
val searchableInfo = searchManager.getSearchableInfo(requireActivity().componentName)
|
||||||
@ -275,7 +295,7 @@ class SearchFragment : MultiListFragment<Identifiable>(), KoinComponent {
|
|||||||
id = item.id,
|
id = item.id,
|
||||||
name = item.name,
|
name = item.name,
|
||||||
parentId = item.id,
|
parentId = item.id,
|
||||||
isArtist = (item is Artist)
|
isArtist = false
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
SearchFragmentDirections.searchToAlbumsList(
|
SearchFragmentDirections.searchToAlbumsList(
|
||||||
@ -305,15 +325,15 @@ class SearchFragment : MultiListFragment<Identifiable>(), KoinComponent {
|
|||||||
|
|
||||||
private fun onSongSelected(song: Track, append: Boolean) {
|
private fun onSongSelected(song: Track, append: Boolean) {
|
||||||
if (!append) {
|
if (!append) {
|
||||||
mediaPlayerController.clear()
|
mediaPlayerManager.clear()
|
||||||
}
|
}
|
||||||
mediaPlayerController.addToPlaylist(
|
mediaPlayerManager.addToPlaylist(
|
||||||
listOf(song),
|
listOf(song),
|
||||||
autoPlay = false,
|
autoPlay = false,
|
||||||
shuffle = 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))
|
toast(context, resources.getQuantityString(R.plurals.select_album_n_songs_added, 1, 1))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,14 +1,11 @@
|
|||||||
package org.moire.ultrasonic.fragment
|
package org.moire.ultrasonic.fragment
|
||||||
|
|
||||||
import android.app.Activity
|
|
||||||
import android.content.DialogInterface
|
import android.content.DialogInterface
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.SharedPreferences
|
import android.content.SharedPreferences
|
||||||
import android.content.SharedPreferences.OnSharedPreferenceChangeListener
|
import android.content.SharedPreferences.OnSharedPreferenceChangeListener
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.provider.DocumentsContract
|
|
||||||
import android.provider.SearchRecentSuggestions
|
import android.provider.SearchRecentSuggestions
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import androidx.annotation.StringRes
|
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.plantToTimberForest
|
||||||
import org.moire.ultrasonic.log.FileLoggerTree.Companion.uprootFromTimberForest
|
import org.moire.ultrasonic.log.FileLoggerTree.Companion.uprootFromTimberForest
|
||||||
import org.moire.ultrasonic.provider.SearchSuggestionProvider
|
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.service.RxBus
|
||||||
import org.moire.ultrasonic.util.ConfirmationDialog
|
import org.moire.ultrasonic.util.ConfirmationDialog
|
||||||
import org.moire.ultrasonic.util.Constants
|
import org.moire.ultrasonic.util.Constants
|
||||||
import org.moire.ultrasonic.util.ErrorDialog
|
import org.moire.ultrasonic.util.ErrorDialog
|
||||||
import org.moire.ultrasonic.util.FileUtil.ultrasonicDirectory
|
import org.moire.ultrasonic.util.FileUtil.ultrasonicDirectory
|
||||||
import org.moire.ultrasonic.util.InfoDialog
|
import org.moire.ultrasonic.util.InfoDialog
|
||||||
|
import org.moire.ultrasonic.util.SelectCacheActivityContract
|
||||||
import org.moire.ultrasonic.util.Settings
|
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.preferences
|
||||||
import org.moire.ultrasonic.util.Settings.shouldUseId3Tags
|
|
||||||
import org.moire.ultrasonic.util.Storage
|
import org.moire.ultrasonic.util.Storage
|
||||||
import org.moire.ultrasonic.util.TimeSpanPreference
|
import org.moire.ultrasonic.util.TimeSpanPreference
|
||||||
import org.moire.ultrasonic.util.TimeSpanPreferenceDialogFragmentCompat
|
import org.moire.ultrasonic.util.TimeSpanPreferenceDialogFragmentCompat
|
||||||
@ -64,7 +62,7 @@ class SettingsFragment :
|
|||||||
private var debugLogToFile: CheckBoxPreference? = null
|
private var debugLogToFile: CheckBoxPreference? = null
|
||||||
private var customCacheLocation: 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?) {
|
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||||
setPreferencesFromResource(R.xml.settings, rootKey)
|
setPreferencesFromResource(R.xml.settings, rootKey)
|
||||||
@ -100,64 +98,14 @@ class SettingsFragment :
|
|||||||
updateCustomPreferences()
|
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() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
val preferences = preferences
|
|
||||||
preferences.registerOnSharedPreferenceChangeListener(this)
|
preferences.registerOnSharedPreferenceChangeListener(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPause() {
|
override fun onPause() {
|
||||||
super.onPause()
|
super.onPause()
|
||||||
val prefs = preferences
|
preferences.unregisterOnSharedPreferenceChangeListener(this)
|
||||||
prefs.unregisterOnSharedPreferenceChangeListener(this)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
|
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
|
||||||
@ -249,19 +197,31 @@ class SettingsFragment :
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun selectCacheLocation() {
|
private fun selectCacheLocation() {
|
||||||
// Choose a directory using the system's file picker.
|
// Start the activity to pick a directory using the system's file picker.
|
||||||
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
|
selectCacheActivityContract.launch(Settings.cacheLocationUri)
|
||||||
|
|
||||||
if (Settings.cacheLocationUri != "" && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
||||||
intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, 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() {
|
private fun setupBluetoothDevicePreferences() {
|
||||||
val resumeSetting = Settings.resumeOnBluetoothDevice
|
val resumeSetting = Settings.resumeOnBluetoothDevice
|
||||||
val pauseSetting = Settings.pauseOnBluetoothDevice
|
val pauseSetting = Settings.pauseOnBluetoothDevice
|
||||||
@ -354,8 +314,8 @@ class SettingsFragment :
|
|||||||
debugLogToFile?.summary = ""
|
debugLogToFile?.summary = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
showArtistPicture?.isEnabled = shouldUseId3Tags
|
showArtistPicture?.isEnabled = id3TagsEnabledOnline
|
||||||
useId3TagsOffline?.isEnabled = shouldUseId3Tags
|
useId3TagsOffline?.isEnabled = id3TagsEnabledOnline
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setHideMedia(hide: Boolean) {
|
private fun setHideMedia(hide: Boolean) {
|
||||||
@ -382,7 +342,7 @@ class SettingsFragment :
|
|||||||
Settings.cacheLocationUri = path
|
Settings.cacheLocationUri = path
|
||||||
|
|
||||||
// Clear download queue.
|
// Clear download queue.
|
||||||
mediaPlayerController.clear()
|
mediaPlayerManager.clear()
|
||||||
Storage.reset()
|
Storage.reset()
|
||||||
Storage.ensureRootIsAvailable()
|
Storage.ensureRootIsAvailable()
|
||||||
}
|
}
|
||||||
@ -425,7 +385,6 @@ class SettingsFragment :
|
|||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val SELECT_CACHE_ACTIVITY = 161161
|
|
||||||
const val RW_FLAG = Intent.FLAG_GRANT_READ_URI_PERMISSION or
|
const val RW_FLAG = Intent.FLAG_GRANT_READ_URI_PERMISSION or
|
||||||
Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||||
const val PERSISTABLE_FLAG = Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION
|
const val PERSISTABLE_FLAG = Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION
|
||||||
|
@ -12,8 +12,11 @@ import android.view.Menu
|
|||||||
import android.view.MenuInflater
|
import android.view.MenuInflater
|
||||||
import android.view.MenuItem
|
import android.view.MenuItem
|
||||||
import android.view.View
|
import android.view.View
|
||||||
|
import androidx.core.view.MenuHost
|
||||||
|
import androidx.core.view.MenuProvider
|
||||||
import androidx.core.view.isVisible
|
import androidx.core.view.isVisible
|
||||||
import androidx.fragment.app.viewModels
|
import androidx.fragment.app.viewModels
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.LiveData
|
import androidx.lifecycle.LiveData
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
@ -40,7 +43,7 @@ import org.moire.ultrasonic.domain.MusicDirectory
|
|||||||
import org.moire.ultrasonic.domain.Track
|
import org.moire.ultrasonic.domain.Track
|
||||||
import org.moire.ultrasonic.fragment.FragmentTitle.Companion.setTitle
|
import org.moire.ultrasonic.fragment.FragmentTitle.Companion.setTitle
|
||||||
import org.moire.ultrasonic.model.TrackCollectionModel
|
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.RxBus
|
||||||
import org.moire.ultrasonic.service.plusAssign
|
import org.moire.ultrasonic.service.plusAssign
|
||||||
import org.moire.ultrasonic.subsonic.DownloadAction
|
import org.moire.ultrasonic.subsonic.DownloadAction
|
||||||
@ -82,7 +85,7 @@ open class TrackCollectionFragment(
|
|||||||
private var playAllButton: MenuItem? = null
|
private var playAllButton: MenuItem? = null
|
||||||
private var shareButton: MenuItem? = null
|
private var shareButton: MenuItem? = null
|
||||||
|
|
||||||
internal val mediaPlayerController: MediaPlayerController by inject()
|
internal val mediaPlayerManager: MediaPlayerManager by inject()
|
||||||
private val shareHandler: ShareHandler by inject()
|
private val shareHandler: ShareHandler by inject()
|
||||||
internal var cancellationToken: CancellationToken? = null
|
internal var cancellationToken: CancellationToken? = null
|
||||||
|
|
||||||
@ -114,7 +117,13 @@ open class TrackCollectionFragment(
|
|||||||
setupButtons(view)
|
setupButtons(view)
|
||||||
|
|
||||||
registerForContextMenu(listView!!)
|
registerForContextMenu(listView!!)
|
||||||
setHasOptionsMenu(true)
|
|
||||||
|
// Register our options menu
|
||||||
|
(requireActivity() as MenuHost).addMenuProvider(
|
||||||
|
menuProvider,
|
||||||
|
viewLifecycleOwner,
|
||||||
|
Lifecycle.State.RESUMED
|
||||||
|
)
|
||||||
|
|
||||||
// Create a View Manager
|
// Create a View Manager
|
||||||
viewManager = LinearLayoutManager(this.context)
|
viewManager = LinearLayoutManager(this.context)
|
||||||
@ -257,41 +266,39 @@ open class TrackCollectionFragment(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPrepareOptionsMenu(menu: Menu) {
|
private val menuProvider: MenuProvider = object : MenuProvider {
|
||||||
super.onPrepareOptionsMenu(menu)
|
override fun onPrepareMenu(menu: Menu) {
|
||||||
playAllButton = menu.findItem(R.id.select_album_play_all)
|
playAllButton = menu.findItem(R.id.select_album_play_all)
|
||||||
|
|
||||||
if (playAllButton != null) {
|
if (playAllButton != null) {
|
||||||
playAllButton!!.isVisible = playAllButtonVisible
|
playAllButton!!.isVisible = playAllButtonVisible
|
||||||
|
}
|
||||||
|
|
||||||
|
shareButton = menu.findItem(R.id.menu_item_share)
|
||||||
|
|
||||||
|
if (shareButton != null) {
|
||||||
|
shareButton!!.isVisible = shareButtonVisible
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
shareButton = menu.findItem(R.id.menu_item_share)
|
override fun onCreateMenu(menu: Menu, inflater: MenuInflater) {
|
||||||
|
inflater.inflate(R.menu.select_album, menu)
|
||||||
if (shareButton != null) {
|
|
||||||
shareButton!!.isVisible = shareButtonVisible
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreateOptionsMenu(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) {
|
|
||||||
playAll()
|
|
||||||
return true
|
|
||||||
} else if (itemId == R.id.menu_item_share) {
|
|
||||||
shareHandler.createShare(
|
|
||||||
this, getSelectedSongs(),
|
|
||||||
refreshListView, cancellationToken!!,
|
|
||||||
navArgs.id
|
|
||||||
)
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return false
|
override fun onMenuItemSelected(item: MenuItem): Boolean {
|
||||||
|
if (item.itemId == R.id.select_album_play_all) {
|
||||||
|
playAll()
|
||||||
|
return true
|
||||||
|
} else if (item.itemId == R.id.menu_item_share) {
|
||||||
|
shareHandler.createShare(
|
||||||
|
this@TrackCollectionFragment, getSelectedSongs(),
|
||||||
|
refreshListView, cancellationToken!!,
|
||||||
|
navArgs.id
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroyView() {
|
override fun onDestroyView() {
|
||||||
@ -344,13 +351,11 @@ open class TrackCollectionFragment(
|
|||||||
|
|
||||||
val isArtist = navArgs.isArtist
|
val isArtist = navArgs.isArtist
|
||||||
|
|
||||||
// Need a valid id to download stuff
|
// Need a valid id to recurse sub directories stuff
|
||||||
val id = navArgs.id ?: return
|
if (hasSubFolders && navArgs.id != null) {
|
||||||
|
|
||||||
if (hasSubFolders) {
|
|
||||||
downloadHandler.fetchTracksAndAddToController(
|
downloadHandler.fetchTracksAndAddToController(
|
||||||
fragment = this,
|
fragment = this,
|
||||||
id = id,
|
id = navArgs.id!!,
|
||||||
append = append,
|
append = append,
|
||||||
autoPlay = !append,
|
autoPlay = !append,
|
||||||
shuffle = shuffle,
|
shuffle = shuffle,
|
||||||
@ -379,20 +384,17 @@ open class TrackCollectionFragment(
|
|||||||
|
|
||||||
private fun selectAllOrNone() {
|
private fun selectAllOrNone() {
|
||||||
val someUnselected = viewAdapter.selectedSet.size < childCount
|
val someUnselected = viewAdapter.selectedSet.size < childCount
|
||||||
|
selectAll(someUnselected)
|
||||||
selectAll(someUnselected, true)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun selectAll(selected: Boolean, toast: Boolean) {
|
private fun selectAll(selected: Boolean) {
|
||||||
var selectedCount = viewAdapter.selectedSet.size * -1
|
var selectedCount = viewAdapter.selectedSet.size * -1
|
||||||
|
|
||||||
selectedCount += viewAdapter.setSelectionStatusOfAll(selected)
|
selectedCount += viewAdapter.setSelectionStatusOfAll(selected)
|
||||||
|
|
||||||
// Display toast: N tracks selected
|
// Display toast: N tracks selected
|
||||||
if (toast) {
|
val toastResId = R.string.select_album_n_selected
|
||||||
val toastResId = R.string.select_album_n_selected
|
Util.toast(activity, getString(toastResId, selectedCount.coerceAtLeast(0)))
|
||||||
Util.toast(activity, getString(toastResId, selectedCount.coerceAtLeast(0)))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
@ -575,14 +577,14 @@ open class TrackCollectionFragment(
|
|||||||
setTitle(R.string.main_videos)
|
setTitle(R.string.main_videos)
|
||||||
listModel.getVideos(refresh2)
|
listModel.getVideos(refresh2)
|
||||||
} else if (id == null || getRandomTracks) {
|
} else if (id == null || getRandomTracks) {
|
||||||
// There seems to be a bug in ViewPager when resuming the Actitivy that subfragments
|
// 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
|
// arguments are empty. If we have no id, just show some random tracks
|
||||||
setTitle(R.string.main_songs_random)
|
setTitle(R.string.main_songs_random)
|
||||||
listModel.getRandom(size, append)
|
listModel.getRandom(size, append)
|
||||||
} else {
|
} else {
|
||||||
setTitle(name)
|
setTitle(name)
|
||||||
|
|
||||||
if (ActiveServerProvider.isID3Enabled()) {
|
if (ActiveServerProvider.shouldUseId3Tags()) {
|
||||||
if (isAlbum) {
|
if (isAlbum) {
|
||||||
listModel.getAlbum(refresh2, id, name)
|
listModel.getAlbum(refresh2, id, name)
|
||||||
} else {
|
} else {
|
||||||
@ -636,10 +638,6 @@ open class TrackCollectionFragment(
|
|||||||
R.id.song_menu_download -> {
|
R.id.song_menu_download -> {
|
||||||
downloadBackground(false, songs)
|
downloadBackground(false, songs)
|
||||||
}
|
}
|
||||||
R.id.select_album_play_all -> {
|
|
||||||
// TODO: Why is this being handled here?!
|
|
||||||
playAll()
|
|
||||||
}
|
|
||||||
R.id.song_menu_share -> {
|
R.id.song_menu_share -> {
|
||||||
if (item is Track) {
|
if (item is Track) {
|
||||||
shareHandler.createShare(
|
shareHandler.createShare(
|
||||||
|
@ -29,7 +29,8 @@ import androidx.fragment.app.Fragment
|
|||||||
import androidx.navigation.fragment.findNavController
|
import androidx.navigation.fragment.findNavController
|
||||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||||
import java.util.Locale
|
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.NavigationGraphDirections
|
||||||
import org.moire.ultrasonic.R
|
import org.moire.ultrasonic.R
|
||||||
import org.moire.ultrasonic.api.subsonic.ApiNotSupportedException
|
import org.moire.ultrasonic.api.subsonic.ApiNotSupportedException
|
||||||
@ -55,15 +56,13 @@ import org.moire.ultrasonic.util.Util.toast
|
|||||||
*
|
*
|
||||||
* TODO: This file has been converted from Java, but not modernized yet.
|
* 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 refreshPlaylistsListView: SwipeRefreshLayout? = null
|
||||||
private var playlistsListView: ListView? = null
|
private var playlistsListView: ListView? = null
|
||||||
private var emptyTextView: View? = null
|
private var emptyTextView: View? = null
|
||||||
private var playlistAdapter: ArrayAdapter<Playlist>? = null
|
private var playlistAdapter: ArrayAdapter<Playlist>? = null
|
||||||
|
|
||||||
private val downloadHandler = inject<DownloadHandler>(
|
private val downloadHandler by inject<DownloadHandler>()
|
||||||
DownloadHandler::class.java
|
|
||||||
)
|
|
||||||
|
|
||||||
private var cancellationToken: CancellationToken? = null
|
private var cancellationToken: CancellationToken? = null
|
||||||
|
|
||||||
@ -148,7 +147,7 @@ class PlaylistsFragment : Fragment() {
|
|||||||
val playlist = playlistsListView!!.getItemAtPosition(info.position) as Playlist
|
val playlist = playlistsListView!!.getItemAtPosition(info.position) as Playlist
|
||||||
when (menuItem.itemId) {
|
when (menuItem.itemId) {
|
||||||
R.id.playlist_menu_pin -> {
|
R.id.playlist_menu_pin -> {
|
||||||
downloadHandler.value.justDownload(
|
downloadHandler.justDownload(
|
||||||
DownloadAction.PIN,
|
DownloadAction.PIN,
|
||||||
fragment = this,
|
fragment = this,
|
||||||
id = playlist.id,
|
id = playlist.id,
|
||||||
@ -158,7 +157,7 @@ class PlaylistsFragment : Fragment() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
R.id.playlist_menu_unpin -> {
|
R.id.playlist_menu_unpin -> {
|
||||||
downloadHandler.value.justDownload(
|
downloadHandler.justDownload(
|
||||||
DownloadAction.UNPIN,
|
DownloadAction.UNPIN,
|
||||||
fragment = this,
|
fragment = this,
|
||||||
id = playlist.id,
|
id = playlist.id,
|
||||||
@ -168,7 +167,7 @@ class PlaylistsFragment : Fragment() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
R.id.playlist_menu_download -> {
|
R.id.playlist_menu_download -> {
|
||||||
downloadHandler.value.justDownload(
|
downloadHandler.justDownload(
|
||||||
DownloadAction.DOWNLOAD,
|
DownloadAction.DOWNLOAD,
|
||||||
fragment = this,
|
fragment = this,
|
||||||
id = playlist.id,
|
id = playlist.id,
|
||||||
|
@ -12,9 +12,9 @@ import androidx.lifecycle.MutableLiveData
|
|||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.moire.ultrasonic.api.subsonic.models.AlbumListType
|
import org.moire.ultrasonic.api.subsonic.models.AlbumListType
|
||||||
|
import org.moire.ultrasonic.data.ActiveServerProvider
|
||||||
import org.moire.ultrasonic.domain.Album
|
import org.moire.ultrasonic.domain.Album
|
||||||
import org.moire.ultrasonic.service.MusicServiceFactory
|
import org.moire.ultrasonic.service.MusicServiceFactory
|
||||||
import org.moire.ultrasonic.util.Settings
|
|
||||||
|
|
||||||
class AlbumListModel(application: Application) : GenericListModel(application) {
|
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 appending the existing list, set the offset from where to load
|
||||||
if (append) offset += (size + loadedUntil)
|
if (append) offset += (size + loadedUntil)
|
||||||
|
|
||||||
musicDirectory = if (Settings.shouldUseId3Tags) {
|
musicDirectory = if (ActiveServerProvider.shouldUseId3Tags()) {
|
||||||
service.getAlbumList2(
|
service.getAlbumList2(
|
||||||
albumListType, size,
|
albumListType, size,
|
||||||
offset, musicFolderId
|
offset, musicFolderId
|
||||||
@ -119,7 +119,7 @@ class AlbumListModel(application: Application) : GenericListModel(application) {
|
|||||||
val isAlphabetical = (lastType == AlbumListType.SORTED_BY_NAME) ||
|
val isAlphabetical = (lastType == AlbumListType.SORTED_BY_NAME) ||
|
||||||
(lastType == AlbumListType.SORTED_BY_ARTIST)
|
(lastType == AlbumListType.SORTED_BY_ARTIST)
|
||||||
|
|
||||||
return !isOffline() && !Settings.shouldUseId3Tags && isAlphabetical
|
return !isOffline() && !ActiveServerProvider.shouldUseId3Tags() && isAlphabetical
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun isCollectionSortable(albumListType: AlbumListType): Boolean {
|
private fun isCollectionSortable(albumListType: AlbumListType): Boolean {
|
||||||
|
@ -43,7 +43,7 @@ class ArtistListModel(application: Application) : GenericListModel(application)
|
|||||||
|
|
||||||
val musicFolderId = activeServer.musicFolderId
|
val musicFolderId = activeServer.musicFolderId
|
||||||
|
|
||||||
val result = if (ActiveServerProvider.isID3Enabled()) {
|
val result = if (ActiveServerProvider.shouldUseId3Tags()) {
|
||||||
musicService.getArtists(refresh)
|
musicService.getArtists(refresh)
|
||||||
} else {
|
} else {
|
||||||
musicService.getIndexes(musicFolderId, refresh)
|
musicService.getIndexes(musicFolderId, refresh)
|
||||||
|
@ -10,7 +10,7 @@ package org.moire.ultrasonic.model
|
|||||||
import android.app.Application
|
import android.app.Application
|
||||||
import androidx.lifecycle.AndroidViewModel
|
import androidx.lifecycle.AndroidViewModel
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import kotlinx.coroutines.FlowPreview
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.asFlow
|
import kotlinx.coroutines.flow.asFlow
|
||||||
import kotlinx.coroutines.flow.flatMapMerge
|
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> {
|
suspend fun queryFeatureSupport(currentServerSetting: ServerSetting): Flow<FeatureSupport> {
|
||||||
val client = buildTestClient(currentServerSetting)
|
val client = buildTestClient(currentServerSetting)
|
||||||
// One line of magic:
|
// One line of magic:
|
||||||
|
@ -26,7 +26,6 @@ import org.moire.ultrasonic.domain.MusicFolder
|
|||||||
import org.moire.ultrasonic.service.MusicService
|
import org.moire.ultrasonic.service.MusicService
|
||||||
import org.moire.ultrasonic.service.MusicServiceFactory
|
import org.moire.ultrasonic.service.MusicServiceFactory
|
||||||
import org.moire.ultrasonic.util.CommunicationError
|
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
|
* 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) {
|
withContext(Dispatchers.IO) {
|
||||||
val musicService = MusicServiceFactory.getMusicService()
|
val musicService = MusicServiceFactory.getMusicService()
|
||||||
val isOffline = ActiveServerProvider.isOffline()
|
val isOffline = ActiveServerProvider.isOffline()
|
||||||
val useId3Tags = Settings.shouldUseId3Tags
|
val useId3Tags = ActiveServerProvider.shouldUseId3Tags()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
load(isOffline, useId3Tags, musicService, refresh)
|
load(isOffline, useId3Tags, musicService, refresh)
|
||||||
|
@ -13,12 +13,12 @@ import androidx.lifecycle.viewModelScope
|
|||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.moire.ultrasonic.data.ActiveServerProvider
|
||||||
import org.moire.ultrasonic.domain.MusicDirectory
|
import org.moire.ultrasonic.domain.MusicDirectory
|
||||||
import org.moire.ultrasonic.domain.Track
|
import org.moire.ultrasonic.domain.Track
|
||||||
import org.moire.ultrasonic.service.DownloadService
|
import org.moire.ultrasonic.service.DownloadService
|
||||||
import org.moire.ultrasonic.service.DownloadState
|
import org.moire.ultrasonic.service.DownloadState
|
||||||
import org.moire.ultrasonic.service.MusicServiceFactory
|
import org.moire.ultrasonic.service.MusicServiceFactory
|
||||||
import org.moire.ultrasonic.util.Settings
|
|
||||||
import org.moire.ultrasonic.util.Util
|
import org.moire.ultrasonic.util.Util
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@ -40,7 +40,7 @@ class TrackCollectionModel(application: Application) : GenericListModel(applicat
|
|||||||
|
|
||||||
val service = MusicServiceFactory.getMusicService()
|
val service = MusicServiceFactory.getMusicService()
|
||||||
val musicDirectory = service.getMusicDirectory(id, name, refresh)
|
val musicDirectory = service.getMusicDirectory(id, name, refresh)
|
||||||
|
currentListIsSortable = true
|
||||||
updateList(musicDirectory)
|
updateList(musicDirectory)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -51,7 +51,7 @@ class TrackCollectionModel(application: Application) : GenericListModel(applicat
|
|||||||
|
|
||||||
val service = MusicServiceFactory.getMusicService()
|
val service = MusicServiceFactory.getMusicService()
|
||||||
val musicDirectory: MusicDirectory = service.getAlbumAsDir(id, name, refresh)
|
val musicDirectory: MusicDirectory = service.getAlbumAsDir(id, name, refresh)
|
||||||
|
currentListIsSortable = true
|
||||||
updateList(musicDirectory)
|
updateList(musicDirectory)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -60,6 +60,7 @@ class TrackCollectionModel(application: Application) : GenericListModel(applicat
|
|||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
val service = MusicServiceFactory.getMusicService()
|
val service = MusicServiceFactory.getMusicService()
|
||||||
val musicDirectory = service.getSongsByGenre(genre, count, offset)
|
val musicDirectory = service.getSongsByGenre(genre, count, offset)
|
||||||
|
currentListIsSortable = false
|
||||||
updateList(musicDirectory, append)
|
updateList(musicDirectory, append)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -71,12 +72,12 @@ class TrackCollectionModel(application: Application) : GenericListModel(applicat
|
|||||||
val service = MusicServiceFactory.getMusicService()
|
val service = MusicServiceFactory.getMusicService()
|
||||||
val musicDirectory: MusicDirectory
|
val musicDirectory: MusicDirectory
|
||||||
|
|
||||||
musicDirectory = if (Settings.shouldUseId3Tags) {
|
musicDirectory = if (ActiveServerProvider.shouldUseId3Tags()) {
|
||||||
Util.getSongsFromSearchResult(service.getStarred2())
|
Util.getSongsFromSearchResult(service.getStarred2())
|
||||||
} else {
|
} else {
|
||||||
Util.getSongsFromSearchResult(service.getStarred())
|
Util.getSongsFromSearchResult(service.getStarred())
|
||||||
}
|
}
|
||||||
|
currentListIsSortable = false
|
||||||
updateList(musicDirectory)
|
updateList(musicDirectory)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -87,8 +88,8 @@ class TrackCollectionModel(application: Application) : GenericListModel(applicat
|
|||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
val service = MusicServiceFactory.getMusicService()
|
val service = MusicServiceFactory.getMusicService()
|
||||||
val videos = service.getVideos(refresh)
|
val videos = service.getVideos(refresh)
|
||||||
|
|
||||||
if (videos != null) {
|
if (videos != null) {
|
||||||
|
currentListIsSortable = false
|
||||||
updateList(videos)
|
updateList(videos)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -99,19 +100,16 @@ class TrackCollectionModel(application: Application) : GenericListModel(applicat
|
|||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
val service = MusicServiceFactory.getMusicService()
|
val service = MusicServiceFactory.getMusicService()
|
||||||
val musicDirectory = service.getRandomSongs(size)
|
val musicDirectory = service.getRandomSongs(size)
|
||||||
|
|
||||||
currentListIsSortable = false
|
currentListIsSortable = false
|
||||||
|
|
||||||
updateList(musicDirectory, append)
|
updateList(musicDirectory, append)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getPlaylist(playlistId: String, playlistName: String) {
|
suspend fun getPlaylist(playlistId: String, playlistName: String) {
|
||||||
|
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
val service = MusicServiceFactory.getMusicService()
|
val service = MusicServiceFactory.getMusicService()
|
||||||
val musicDirectory = service.getPlaylist(playlistId, playlistName)
|
val musicDirectory = service.getPlaylist(playlistId, playlistName)
|
||||||
|
currentListIsSortable = false
|
||||||
updateList(musicDirectory)
|
updateList(musicDirectory)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -121,8 +119,8 @@ class TrackCollectionModel(application: Application) : GenericListModel(applicat
|
|||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
val service = MusicServiceFactory.getMusicService()
|
val service = MusicServiceFactory.getMusicService()
|
||||||
val musicDirectory = service.getPodcastEpisodes(podcastChannelId)
|
val musicDirectory = service.getPodcastEpisodes(podcastChannelId)
|
||||||
|
|
||||||
if (musicDirectory != null) {
|
if (musicDirectory != null) {
|
||||||
|
currentListIsSortable = false
|
||||||
updateList(musicDirectory)
|
updateList(musicDirectory)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -144,7 +142,7 @@ class TrackCollectionModel(application: Application) : GenericListModel(applicat
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
currentListIsSortable = false
|
||||||
updateList(musicDirectory)
|
updateList(musicDirectory)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -153,7 +151,7 @@ class TrackCollectionModel(application: Application) : GenericListModel(applicat
|
|||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
val service = MusicServiceFactory.getMusicService()
|
val service = MusicServiceFactory.getMusicService()
|
||||||
val musicDirectory = Util.getSongsFromBookmarks(service.getBookmarks())
|
val musicDirectory = Util.getSongsFromBookmarks(service.getBookmarks())
|
||||||
|
currentListIsSortable = false
|
||||||
updateList(musicDirectory)
|
updateList(musicDirectory)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -9,8 +9,6 @@ package org.moire.ultrasonic.playback
|
|||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.widget.Toast
|
|
||||||
import android.widget.Toast.LENGTH_SHORT
|
|
||||||
import androidx.media3.common.HeartRating
|
import androidx.media3.common.HeartRating
|
||||||
import androidx.media3.common.MediaItem
|
import androidx.media3.common.MediaItem
|
||||||
import androidx.media3.common.MediaMetadata
|
import androidx.media3.common.MediaMetadata
|
||||||
@ -19,8 +17,9 @@ import androidx.media3.common.MediaMetadata.FOLDER_TYPE_ARTISTS
|
|||||||
import androidx.media3.common.MediaMetadata.FOLDER_TYPE_MIXED
|
import androidx.media3.common.MediaMetadata.FOLDER_TYPE_MIXED
|
||||||
import androidx.media3.common.MediaMetadata.FOLDER_TYPE_PLAYLISTS
|
import androidx.media3.common.MediaMetadata.FOLDER_TYPE_PLAYLISTS
|
||||||
import androidx.media3.common.MediaMetadata.FOLDER_TYPE_TITLES
|
import androidx.media3.common.MediaMetadata.FOLDER_TYPE_TITLES
|
||||||
import androidx.media3.common.Player
|
|
||||||
import androidx.media3.common.Rating
|
import androidx.media3.common.Rating
|
||||||
|
import androidx.media3.common.StarRating
|
||||||
|
import androidx.media3.session.CommandButton
|
||||||
import androidx.media3.session.LibraryResult
|
import androidx.media3.session.LibraryResult
|
||||||
import androidx.media3.session.MediaLibraryService
|
import androidx.media3.session.MediaLibraryService
|
||||||
import androidx.media3.session.MediaSession
|
import androidx.media3.session.MediaSession
|
||||||
@ -28,7 +27,6 @@ import androidx.media3.session.SessionCommand
|
|||||||
import androidx.media3.session.SessionResult
|
import androidx.media3.session.SessionResult
|
||||||
import androidx.media3.session.SessionResult.RESULT_SUCCESS
|
import androidx.media3.session.SessionResult.RESULT_SUCCESS
|
||||||
import com.google.common.collect.ImmutableList
|
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.Futures
|
||||||
import com.google.common.util.concurrent.ListenableFuture
|
import com.google.common.util.concurrent.ListenableFuture
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@ -47,11 +45,9 @@ import org.moire.ultrasonic.domain.MusicDirectory
|
|||||||
import org.moire.ultrasonic.domain.SearchCriteria
|
import org.moire.ultrasonic.domain.SearchCriteria
|
||||||
import org.moire.ultrasonic.domain.SearchResult
|
import org.moire.ultrasonic.domain.SearchResult
|
||||||
import org.moire.ultrasonic.domain.Track
|
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.service.MusicServiceFactory
|
||||||
import org.moire.ultrasonic.service.RatingManager
|
import org.moire.ultrasonic.service.RatingManager
|
||||||
import org.moire.ultrasonic.util.MainThreadExecutor
|
|
||||||
import org.moire.ultrasonic.util.Settings
|
|
||||||
import org.moire.ultrasonic.util.Util
|
import org.moire.ultrasonic.util.Util
|
||||||
import org.moire.ultrasonic.util.buildMediaItem
|
import org.moire.ultrasonic.util.buildMediaItem
|
||||||
import org.moire.ultrasonic.util.toMediaItem
|
import org.moire.ultrasonic.util.toMediaItem
|
||||||
@ -94,7 +90,6 @@ private const val DISPLAY_LIMIT = 100
|
|||||||
private const val SEARCH_LIMIT = 10
|
private const val SEARCH_LIMIT = 10
|
||||||
|
|
||||||
// List of available custom SessionCommands
|
// List of available custom SessionCommands
|
||||||
const val SESSION_CUSTOM_SET_RATING = "SESSION_CUSTOM_SET_RATING"
|
|
||||||
const val PLAY_COMMAND = "play "
|
const val PLAY_COMMAND = "play "
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -102,10 +97,10 @@ const val PLAY_COMMAND = "play "
|
|||||||
*/
|
*/
|
||||||
@Suppress("TooManyFunctions", "LargeClass", "UnusedPrivateMember")
|
@Suppress("TooManyFunctions", "LargeClass", "UnusedPrivateMember")
|
||||||
@SuppressLint("UnsafeOptInUsageError")
|
@SuppressLint("UnsafeOptInUsageError")
|
||||||
class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibraryService) :
|
class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) :
|
||||||
MediaLibraryService.MediaLibrarySession.Callback, KoinComponent {
|
MediaLibraryService.MediaLibrarySession.Callback, KoinComponent {
|
||||||
|
|
||||||
private val mediaPlayerController by inject<MediaPlayerController>()
|
private val mediaPlayerManager by inject<MediaPlayerManager>()
|
||||||
private val activeServerProvider: ActiveServerProvider by inject()
|
private val activeServerProvider: ActiveServerProvider by inject()
|
||||||
|
|
||||||
private val serviceJob = SupervisorJob()
|
private val serviceJob = SupervisorJob()
|
||||||
@ -119,9 +114,26 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
|
|||||||
|
|
||||||
private val musicService get() = MusicServiceFactory.getMusicService()
|
private val musicService get() = MusicServiceFactory.getMusicService()
|
||||||
private val isOffline get() = ActiveServerProvider.isOffline()
|
private val isOffline get() = ActiveServerProvider.isOffline()
|
||||||
private val useId3Tags get() = Settings.shouldUseId3Tags
|
|
||||||
private val musicFolderId get() = activeServerProvider.getActiveServer().musicFolderId
|
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
|
* Called when a {@link MediaBrowser} requests the root {@link MediaItem} by {@link
|
||||||
* MediaBrowser#getLibraryRoot(LibraryParams)}.
|
* MediaBrowser#getLibraryRoot(LibraryParams)}.
|
||||||
@ -179,11 +191,10 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
|
|||||||
val connectionResult = super.onConnect(session, controller)
|
val connectionResult = super.onConnect(session, controller)
|
||||||
val availableSessionCommands = connectionResult.availableSessionCommands.buildUpon()
|
val availableSessionCommands = connectionResult.availableSessionCommands.buildUpon()
|
||||||
|
|
||||||
/*
|
for (commandButton in customCommands) {
|
||||||
* TODO: Currently we need to create a custom session command, see https://github.com/androidx/media/issues/107
|
// Add custom command to available session commands.
|
||||||
* When this issue is fixed we should be able to remove this method again
|
commandButton.sessionCommand?.let { availableSessionCommands.add(it) }
|
||||||
*/
|
}
|
||||||
availableSessionCommands.add(SessionCommand(SESSION_CUSTOM_SET_RATING, Bundle()))
|
|
||||||
|
|
||||||
return MediaSession.ConnectionResult.accept(
|
return MediaSession.ConnectionResult.accept(
|
||||||
availableSessionCommands.build(),
|
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(
|
override fun onGetItem(
|
||||||
session: MediaLibraryService.MediaLibrarySession,
|
session: MediaLibraryService.MediaLibrarySession,
|
||||||
browser: MediaSession.ControllerInfo,
|
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
|
// Create LRU Cache of MediaItems, fill it in the other calls
|
||||||
// and retrieve it here.
|
// and retrieve it here.
|
||||||
|
|
||||||
if (mediaItem != null) {
|
return if (mediaItem != null) {
|
||||||
return Futures.immediateFuture(
|
Futures.immediateFuture(
|
||||||
LibraryResult.ofItem(mediaItem, null)
|
LibraryResult.ofItem(mediaItem, null)
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
return Futures.immediateFuture(
|
Futures.immediateFuture(
|
||||||
LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE)
|
LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -237,39 +270,13 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
|
|||||||
var customCommandFuture: ListenableFuture<SessionResult>? = null
|
var customCommandFuture: ListenableFuture<SessionResult>? = null
|
||||||
|
|
||||||
when (customCommand.customAction) {
|
when (customCommand.customAction) {
|
||||||
SESSION_CUSTOM_SET_RATING -> {
|
PlaybackService.CUSTOM_COMMAND_TOGGLE_HEART_ON -> {
|
||||||
/*
|
customCommandFuture = onSetRating(session, controller, HeartRating(true))
|
||||||
* It is currently not possible to edit a MediaItem after creation so the isRated value
|
updateCustomHeartButton(session, true)
|
||||||
* is stored in the track.starred value
|
}
|
||||||
* See https://github.com/androidx/media/issues/33
|
PlaybackService.CUSTOM_COMMAND_TOGGLE_HEART_OFF -> {
|
||||||
*/
|
customCommandFuture = onSetRating(session, controller, HeartRating(false))
|
||||||
val track = mediaPlayerController.currentMediaItem?.toTrack()
|
updateCustomHeartButton(session, false)
|
||||||
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()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
Timber.d(
|
Timber.d(
|
||||||
@ -283,19 +290,25 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
|
|||||||
return customCommandFuture
|
return customCommandFuture
|
||||||
return super.onCustomCommand(session, controller, customCommand, args)
|
return super.onCustomCommand(session, controller, customCommand, args)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onSetRating(
|
override fun onSetRating(
|
||||||
session: MediaSession,
|
session: MediaSession,
|
||||||
controller: MediaSession.ControllerInfo,
|
controller: MediaSession.ControllerInfo,
|
||||||
rating: Rating
|
rating: Rating
|
||||||
): ListenableFuture<SessionResult> {
|
): 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(
|
return onSetRating(
|
||||||
session,
|
session,
|
||||||
controller,
|
controller,
|
||||||
session.player.currentMediaItem!!.mediaId,
|
mediaItem.mediaId,
|
||||||
rating
|
rating
|
||||||
)
|
)
|
||||||
|
}
|
||||||
return super.onSetRating(session, controller, rating)
|
return super.onSetRating(session, controller, rating)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -305,6 +318,9 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
|
|||||||
mediaId: String,
|
mediaId: String,
|
||||||
rating: Rating
|
rating: Rating
|
||||||
): ListenableFuture<SessionResult> {
|
): 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 {
|
return serviceScope.future {
|
||||||
Timber.i(controller.packageName)
|
Timber.i(controller.packageName)
|
||||||
// This function even though its declared in AutoMediaBrowserCallback.kt is
|
// This function even though its declared in AutoMediaBrowserCallback.kt is
|
||||||
@ -326,7 +342,6 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
|
|||||||
* and thereby customarily it is required to rebuild it..
|
* 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
|
* See also: https://stackoverflow.com/questions/70096715/adding-mediaitem-when-using-the-media3-library-caused-an-error
|
||||||
*/
|
*/
|
||||||
|
|
||||||
override fun onAddMediaItems(
|
override fun onAddMediaItems(
|
||||||
mediaSession: MediaSession,
|
mediaSession: MediaSession,
|
||||||
controller: MediaSession.ControllerInfo,
|
controller: MediaSession.ControllerInfo,
|
||||||
@ -661,7 +676,7 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
|
|||||||
var childMediaId: String = MEDIA_ARTIST_ITEM
|
var childMediaId: String = MEDIA_ARTIST_ITEM
|
||||||
|
|
||||||
var artists = serviceScope.future {
|
var artists = serviceScope.future {
|
||||||
if (!isOffline && useId3Tags) {
|
if (ActiveServerProvider.shouldUseId3Tags()) {
|
||||||
// TODO this list can be big so we're not refreshing.
|
// TODO this list can be big so we're not refreshing.
|
||||||
// Maybe a refresh menu item can be added
|
// Maybe a refresh menu item can be added
|
||||||
callWithErrorHandling { musicService.getArtists(false) }
|
callWithErrorHandling { musicService.getArtists(false) }
|
||||||
@ -716,7 +731,7 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
|
|||||||
|
|
||||||
return mainScope.future {
|
return mainScope.future {
|
||||||
val albums = serviceScope.future {
|
val albums = serviceScope.future {
|
||||||
if (!isOffline && useId3Tags) {
|
if (ActiveServerProvider.shouldUseId3Tags()) {
|
||||||
callWithErrorHandling { musicService.getAlbumsOfArtist(id, name, false) }
|
callWithErrorHandling { musicService.getAlbumsOfArtist(id, name, false) }
|
||||||
} else {
|
} else {
|
||||||
callWithErrorHandling {
|
callWithErrorHandling {
|
||||||
@ -788,7 +803,7 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
|
|||||||
val offset = (page ?: 0) * DISPLAY_LIMIT
|
val offset = (page ?: 0) * DISPLAY_LIMIT
|
||||||
|
|
||||||
val albums = serviceScope.future {
|
val albums = serviceScope.future {
|
||||||
if (useId3Tags) {
|
if (ActiveServerProvider.shouldUseId3Tags()) {
|
||||||
callWithErrorHandling {
|
callWithErrorHandling {
|
||||||
musicService.getAlbumList2(
|
musicService.getAlbumList2(
|
||||||
type, DISPLAY_LIMIT, offset, null
|
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? {
|
private fun listSongsInMusicService(id: String, name: String?): MusicDirectory? {
|
||||||
return serviceScope.future {
|
return serviceScope.future {
|
||||||
if (!ActiveServerProvider.isOffline() && Settings.shouldUseId3Tags) {
|
if (ActiveServerProvider.shouldUseId3Tags()) {
|
||||||
callWithErrorHandling { musicService.getAlbumAsDir(id, name, false) }
|
callWithErrorHandling { musicService.getAlbumAsDir(id, name, false) }
|
||||||
} else {
|
} else {
|
||||||
callWithErrorHandling { musicService.getMusicDirectory(id, name, false) }
|
callWithErrorHandling { musicService.getMusicDirectory(id, name, false) }
|
||||||
@ -1200,7 +1215,7 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
|
|||||||
|
|
||||||
private fun listStarredSongsInMusicService(): SearchResult? {
|
private fun listStarredSongsInMusicService(): SearchResult? {
|
||||||
return serviceScope.future {
|
return serviceScope.future {
|
||||||
if (Settings.shouldUseId3Tags) {
|
if (ActiveServerProvider.shouldUseId3Tags()) {
|
||||||
callWithErrorHandling { musicService.getStarred2() }
|
callWithErrorHandling { musicService.getStarred2() }
|
||||||
} else {
|
} else {
|
||||||
callWithErrorHandling { musicService.getStarred() }
|
callWithErrorHandling { musicService.getStarred() }
|
||||||
@ -1278,4 +1293,15 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
|
|||||||
null
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -7,79 +7,22 @@
|
|||||||
package org.moire.ultrasonic.playback
|
package org.moire.ultrasonic.playback
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.core.app.NotificationCompat
|
|
||||||
import androidx.media3.common.HeartRating
|
|
||||||
import androidx.media3.common.Player
|
import androidx.media3.common.Player
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
import androidx.media3.session.CommandButton
|
import androidx.media3.session.CommandButton
|
||||||
import androidx.media3.session.DefaultMediaNotificationProvider
|
import androidx.media3.session.DefaultMediaNotificationProvider
|
||||||
import androidx.media3.session.MediaNotification
|
|
||||||
import androidx.media3.session.MediaSession
|
import androidx.media3.session.MediaSession
|
||||||
import androidx.media3.session.SessionCommand
|
|
||||||
import com.google.common.collect.ImmutableList
|
import com.google.common.collect.ImmutableList
|
||||||
import org.koin.core.component.KoinComponent
|
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
|
@UnstableApi
|
||||||
class CustomNotificationProvider(ctx: Context) :
|
class CustomNotificationProvider(ctx: Context) :
|
||||||
DefaultMediaNotificationProvider(ctx),
|
DefaultMediaNotificationProvider(ctx),
|
||||||
KoinComponent {
|
KoinComponent {
|
||||||
|
|
||||||
/*
|
// By default the skip buttons are not shown in compact view.
|
||||||
* It is currently not possible to edit a MediaItem after creation so the isRated value
|
// We add the COMMAND_KEY_COMPACT_VIEW_INDEX to show them
|
||||||
* is stored in the track.starred value. See https://github.com/androidx/media/issues/33
|
// See also: https://github.com/androidx/media/issues/410
|
||||||
* 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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getMediaButtons(
|
override fun getMediaButtons(
|
||||||
session: MediaSession,
|
session: MediaSession,
|
||||||
playerCommands: Player.Commands,
|
playerCommands: Player.Commands,
|
||||||
|
@ -26,8 +26,7 @@ import androidx.media3.datasource.okhttp.OkHttpDataSource
|
|||||||
import androidx.media3.exoplayer.DefaultRenderersFactory
|
import androidx.media3.exoplayer.DefaultRenderersFactory
|
||||||
import androidx.media3.exoplayer.ExoPlayer
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||||
import androidx.media3.exoplayer.source.ShuffleOrder.DefaultShuffleOrder
|
import androidx.media3.exoplayer.source.ShuffleOrder
|
||||||
import androidx.media3.exoplayer.source.ShuffleOrder.UnshuffledShuffleOrder
|
|
||||||
import androidx.media3.session.MediaLibraryService
|
import androidx.media3.session.MediaLibraryService
|
||||||
import androidx.media3.session.MediaSession
|
import androidx.media3.session.MediaSession
|
||||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||||
@ -37,6 +36,7 @@ import kotlinx.coroutines.Dispatchers
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import org.koin.core.component.KoinComponent
|
import org.koin.core.component.KoinComponent
|
||||||
|
import org.koin.core.component.inject
|
||||||
import org.moire.ultrasonic.R
|
import org.moire.ultrasonic.R
|
||||||
import org.moire.ultrasonic.activity.NavigationActivity
|
import org.moire.ultrasonic.activity.NavigationActivity
|
||||||
import org.moire.ultrasonic.app.UApp
|
import org.moire.ultrasonic.app.UApp
|
||||||
@ -46,6 +46,8 @@ import org.moire.ultrasonic.domain.Track
|
|||||||
import org.moire.ultrasonic.imageloader.ArtworkBitmapLoader
|
import org.moire.ultrasonic.imageloader.ArtworkBitmapLoader
|
||||||
import org.moire.ultrasonic.provider.UltrasonicAppWidgetProvider
|
import org.moire.ultrasonic.provider.UltrasonicAppWidgetProvider
|
||||||
import org.moire.ultrasonic.service.DownloadService
|
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.MusicServiceFactory.getMusicService
|
||||||
import org.moire.ultrasonic.service.RxBus
|
import org.moire.ultrasonic.service.RxBus
|
||||||
import org.moire.ultrasonic.service.plusAssign
|
import org.moire.ultrasonic.service.plusAssign
|
||||||
@ -61,11 +63,12 @@ class PlaybackService :
|
|||||||
MediaLibraryService(),
|
MediaLibraryService(),
|
||||||
KoinComponent,
|
KoinComponent,
|
||||||
CoroutineScope by CoroutineScope(Dispatchers.IO) {
|
CoroutineScope by CoroutineScope(Dispatchers.IO) {
|
||||||
private lateinit var player: ExoPlayer
|
private lateinit var player: Player
|
||||||
private lateinit var mediaLibrarySession: MediaLibrarySession
|
private lateinit var mediaLibrarySession: MediaLibrarySession
|
||||||
private var equalizer: EqualizerController? = null
|
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()
|
private var rxBusSubscription = CompositeDisposable()
|
||||||
|
|
||||||
@ -76,6 +79,7 @@ class PlaybackService :
|
|||||||
super.onCreate()
|
super.onCreate()
|
||||||
initializeSessionAndPlayer()
|
initializeSessionAndPlayer()
|
||||||
setListener(MediaSessionServiceListener())
|
setListener(MediaSessionServiceListener())
|
||||||
|
instance = this
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getWakeModeFlag(): Int {
|
private fun getWakeModeFlag(): Int {
|
||||||
@ -99,6 +103,7 @@ class PlaybackService :
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun releasePlayerAndSession() {
|
private fun releasePlayerAndSession() {
|
||||||
|
Timber.i("Releasing player and session")
|
||||||
// Broadcast that the service is being shutdown
|
// Broadcast that the service is being shutdown
|
||||||
RxBus.stopServiceCommandPublisher.onNext(Unit)
|
RxBus.stopServiceCommandPublisher.onNext(Unit)
|
||||||
|
|
||||||
@ -127,6 +132,106 @@ class PlaybackService :
|
|||||||
|
|
||||||
setMediaNotificationProvider(CustomNotificationProvider(UApp.applicationContext()))
|
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
|
// Create a new plain OkHttpClient
|
||||||
val builder = OkHttpClient.Builder()
|
val builder = OkHttpClient.Builder()
|
||||||
val client = builder.build()
|
val client = builder.build()
|
||||||
@ -147,7 +252,7 @@ class PlaybackService :
|
|||||||
renderer.setEnableAudioOffload(true)
|
renderer.setEnableAudioOffload(true)
|
||||||
|
|
||||||
// Create the player
|
// Create the player
|
||||||
player = ExoPlayer.Builder(this)
|
val player = ExoPlayer.Builder(this)
|
||||||
.setAudioAttributes(getAudioAttributes(), true)
|
.setAudioAttributes(getAudioAttributes(), true)
|
||||||
.setWakeMode(getWakeModeFlag())
|
.setWakeMode(getWakeModeFlag())
|
||||||
.setHandleAudioBecomingNoisy(true)
|
.setHandleAudioBecomingNoisy(true)
|
||||||
@ -157,59 +262,17 @@ class PlaybackService :
|
|||||||
.setSeekForwardIncrementMs(Settings.seekInterval.toLong())
|
.setSeekForwardIncrementMs(Settings.seekInterval.toLong())
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
|
// Setup Equalizer
|
||||||
equalizer = EqualizerController.create(player.audioSessionId)
|
equalizer = EqualizerController.create(player.audioSessionId)
|
||||||
|
|
||||||
// Enable audio offload
|
// Enable audio offload
|
||||||
if (Settings.useHwOffload)
|
if (Settings.useHwOffload)
|
||||||
player.experimentalSetOffloadSchedulingEnabled(true)
|
player.experimentalSetOffloadSchedulingEnabled(true)
|
||||||
|
|
||||||
// Create browser interface
|
return player
|
||||||
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())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set a listener to reset the ShuffleOrder
|
|
||||||
rxBusSubscription += RxBus.shufflePlayObservable.subscribe { shuffle ->
|
|
||||||
val len = player.currentTimeline.windowCount
|
|
||||||
|
|
||||||
Timber.i("Resetting shuffle order, isShuffled: %s", shuffle)
|
|
||||||
|
|
||||||
// If disabling Shuffle return early
|
|
||||||
if (!shuffle) {
|
|
||||||
return@subscribe player.setShuffleOrder(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() })
|
|
||||||
player.setShuffleOrder(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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun createShuffleListFromCurrentIndex(
|
private fun createShuffleListFromCurrentIndex(
|
||||||
currentIndex: Int,
|
currentIndex: Int,
|
||||||
length: Int,
|
length: Int,
|
||||||
random: Random
|
random: Random
|
||||||
@ -233,7 +296,14 @@ class PlaybackService :
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
|
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()
|
cacheNextSongs()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -243,7 +313,12 @@ class PlaybackService :
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun updateCustomHeartButton(isHeart: Boolean) {
|
||||||
|
librarySessionCallback.updateCustomHeartButton(mediaLibrarySession, isHeart)
|
||||||
|
}
|
||||||
|
|
||||||
private fun cacheNextSongs() {
|
private fun cacheNextSongs() {
|
||||||
|
if (actualBackend == MediaPlayerManager.PlayerBackend.JUKEBOX) return
|
||||||
Timber.d("PlaybackService caching the next songs")
|
Timber.d("PlaybackService caching the next songs")
|
||||||
val nextSongs = Util.getPlayListFromTimeline(
|
val nextSongs = Util.getPlayListFromTimeline(
|
||||||
player.currentTimeline,
|
player.currentTimeline,
|
||||||
@ -333,8 +408,22 @@ class PlaybackService :
|
|||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
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_ID = "org.moire.ultrasonic.error"
|
||||||
private const val NOTIFICATION_CHANNEL_NAME = "Ultrasonic error messages"
|
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
|
private const val NOTIFICATION_ID = 3009
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -11,6 +11,7 @@ import android.app.Notification
|
|||||||
import android.app.Service
|
import android.app.Service
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.wifi.WifiManager
|
import android.net.wifi.WifiManager
|
||||||
|
import android.os.Binder
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
@ -39,7 +40,6 @@ import org.moire.ultrasonic.util.FileUtil.getCompleteFile
|
|||||||
import org.moire.ultrasonic.util.FileUtil.getPartialFile
|
import org.moire.ultrasonic.util.FileUtil.getPartialFile
|
||||||
import org.moire.ultrasonic.util.FileUtil.getPinnedFile
|
import org.moire.ultrasonic.util.FileUtil.getPinnedFile
|
||||||
import org.moire.ultrasonic.util.Settings
|
import org.moire.ultrasonic.util.Settings
|
||||||
import org.moire.ultrasonic.util.SimpleServiceBinder
|
|
||||||
import org.moire.ultrasonic.util.Storage
|
import org.moire.ultrasonic.util.Storage
|
||||||
import org.moire.ultrasonic.util.Util
|
import org.moire.ultrasonic.util.Util
|
||||||
import org.moire.ultrasonic.util.Util.stopForegroundRemoveNotification
|
import org.moire.ultrasonic.util.Util.stopForegroundRemoveNotification
|
||||||
@ -452,3 +452,5 @@ class DownloadService : Service(), KoinComponent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class SimpleServiceBinder<S>(val service: S) : Binder()
|
||||||
|
@ -92,8 +92,8 @@ class DownloadTask(
|
|||||||
// Attempt partial HTTP GET, appending to the file if it exists.
|
// Attempt partial HTTP GET, appending to the file if it exists.
|
||||||
val (inStream, isPartial) = musicService.getDownloadInputStream(
|
val (inStream, isPartial) = musicService.getDownloadInputStream(
|
||||||
downloadTrack.track, fileLength,
|
downloadTrack.track, fileLength,
|
||||||
Settings.maxBitRate,
|
if (downloadTrack.pinned) Settings.maxBitRatePinning else Settings.maxBitRate,
|
||||||
downloadTrack.pinned
|
downloadTrack.pinned && Settings.pinWithHighestQuality
|
||||||
)
|
)
|
||||||
|
|
||||||
inputStream = inStream
|
inputStream = inStream
|
||||||
|
@ -7,29 +7,12 @@
|
|||||||
package org.moire.ultrasonic.service
|
package org.moire.ultrasonic.service
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
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.Handler
|
||||||
import android.os.IBinder
|
|
||||||
import android.os.Looper
|
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.AudioAttributes
|
||||||
import androidx.media3.common.C
|
import androidx.media3.common.C
|
||||||
import androidx.media3.common.DeviceInfo
|
import androidx.media3.common.DeviceInfo
|
||||||
|
import androidx.media3.common.FlagSet
|
||||||
import androidx.media3.common.MediaItem
|
import androidx.media3.common.MediaItem
|
||||||
import androidx.media3.common.MediaMetadata
|
import androidx.media3.common.MediaMetadata
|
||||||
import androidx.media3.common.PlaybackException
|
import androidx.media3.common.PlaybackException
|
||||||
@ -39,34 +22,27 @@ import androidx.media3.common.Timeline
|
|||||||
import androidx.media3.common.TrackSelectionParameters
|
import androidx.media3.common.TrackSelectionParameters
|
||||||
import androidx.media3.common.VideoSize
|
import androidx.media3.common.VideoSize
|
||||||
import androidx.media3.common.text.CueGroup
|
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.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.Executors
|
||||||
import java.util.concurrent.LinkedBlockingQueue
|
import java.util.concurrent.LinkedBlockingQueue
|
||||||
import java.util.concurrent.ScheduledFuture
|
import java.util.concurrent.ScheduledFuture
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
import java.util.concurrent.atomic.AtomicBoolean
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
import java.util.concurrent.atomic.AtomicLong
|
import java.util.concurrent.atomic.AtomicLong
|
||||||
import kotlin.math.roundToInt
|
|
||||||
import org.moire.ultrasonic.R
|
import org.moire.ultrasonic.R
|
||||||
import org.moire.ultrasonic.api.subsonic.ApiNotSupportedException
|
import org.moire.ultrasonic.api.subsonic.ApiNotSupportedException
|
||||||
import org.moire.ultrasonic.api.subsonic.SubsonicRESTException
|
import org.moire.ultrasonic.api.subsonic.SubsonicRESTException
|
||||||
import org.moire.ultrasonic.app.UApp.Companion.applicationContext
|
import org.moire.ultrasonic.app.UApp.Companion.applicationContext
|
||||||
import org.moire.ultrasonic.data.ActiveServerProvider.Companion.isOffline
|
import org.moire.ultrasonic.data.ActiveServerProvider.Companion.isOffline
|
||||||
import org.moire.ultrasonic.domain.JukeboxStatus
|
import org.moire.ultrasonic.domain.JukeboxStatus
|
||||||
import org.moire.ultrasonic.playback.CustomNotificationProvider
|
|
||||||
import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService
|
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.sleepQuietly
|
||||||
import org.moire.ultrasonic.util.Util.stopForegroundRemoveNotification
|
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
||||||
private const val STATUS_UPDATE_INTERVAL_SECONDS = 5L
|
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
|
private const val QUEUE_POLL_INTERVAL_SECONDS = 1L
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -86,135 +62,64 @@ class JukeboxMediaPlayer : JukeboxUnimplementedFunctions(), Player {
|
|||||||
private val timeOfLastUpdate = AtomicLong()
|
private val timeOfLastUpdate = AtomicLong()
|
||||||
private var jukeboxStatus: JukeboxStatus? = null
|
private var jukeboxStatus: JukeboxStatus? = null
|
||||||
private var previousJukeboxStatus: JukeboxStatus? = null
|
private var previousJukeboxStatus: JukeboxStatus? = null
|
||||||
private var gain = 0.5f
|
private var gain = (MAX_GAIN / 3)
|
||||||
private var volumeToast: VolumeToast? = null
|
private val floatGain: Float
|
||||||
|
get() = gain.toFloat() / MAX_GAIN
|
||||||
|
|
||||||
private var serviceThread: Thread? = null
|
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 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")
|
private var _currentIndex: Int = 0
|
||||||
override fun onCreate() {
|
private var currentIndex: Int
|
||||||
super.onCreate()
|
get() = _currentIndex
|
||||||
if (running.get()) return
|
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)
|
running.set(true)
|
||||||
|
|
||||||
|
listeners = ListenerSet(
|
||||||
|
applicationLooper,
|
||||||
|
Clock.DEFAULT
|
||||||
|
) { listener: Player.Listener, flags: FlagSet? ->
|
||||||
|
listener.onEvents(
|
||||||
|
this,
|
||||||
|
Player.Events(
|
||||||
|
flags!!
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
tasks.clear()
|
tasks.clear()
|
||||||
updatePlaylist()
|
updatePlaylist()
|
||||||
stop()
|
stop()
|
||||||
|
|
||||||
startFuture?.set(this)
|
|
||||||
|
|
||||||
startProcessTasks()
|
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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Timber.d("Started Jukebox Service")
|
|
||||||
}
|
}
|
||||||
|
@Suppress("MagicNumber")
|
||||||
|
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
override fun release() {
|
||||||
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() {
|
|
||||||
tasks.clear()
|
tasks.clear()
|
||||||
stop()
|
stop()
|
||||||
|
|
||||||
if (!running.get()) return
|
if (!running.get()) return
|
||||||
running.set(false)
|
running.set(false)
|
||||||
|
|
||||||
serviceThread!!.join()
|
serviceThread?.join()
|
||||||
|
|
||||||
stopForegroundRemoveNotification()
|
|
||||||
mediaSession.release()
|
|
||||||
|
|
||||||
super.onDestroy()
|
|
||||||
Timber.d("Stopped Jukebox Service")
|
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) {
|
override fun addListener(listener: Player.Listener) {
|
||||||
listeners.add(listener)
|
listeners.add(listener)
|
||||||
}
|
}
|
||||||
@ -263,14 +168,20 @@ class JukeboxMediaPlayer : JukeboxUnimplementedFunctions(), Player {
|
|||||||
}
|
}
|
||||||
tasks.add(Skip(mediaItemIndex, positionSeconds))
|
tasks.add(Skip(mediaItemIndex, positionSeconds))
|
||||||
currentIndex = mediaItemIndex
|
currentIndex = mediaItemIndex
|
||||||
|
updateAvailableCommands()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun seekBack() {
|
override fun seekBack() {
|
||||||
seekTo(0L.coerceAtMost((jukeboxStatus?.positionSeconds ?: 0) - SEEK_INCREMENT_SECONDS))
|
seekTo(
|
||||||
|
0L.coerceAtMost(
|
||||||
|
(jukeboxStatus?.positionSeconds ?: 0) -
|
||||||
|
Settings.seekIntervalMillis
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun seekForward() {
|
override fun seekForward() {
|
||||||
seekTo((jukeboxStatus?.positionSeconds ?: 0) + SEEK_INCREMENT_SECONDS)
|
seekTo((jukeboxStatus?.positionSeconds ?: 0) + Settings.seekIntervalMillis)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun isCurrentMediaItemSeekable() = true
|
override fun isCurrentMediaItemSeekable() = true
|
||||||
@ -292,8 +203,11 @@ class JukeboxMediaPlayer : JukeboxUnimplementedFunctions(), Player {
|
|||||||
|
|
||||||
override fun getAvailableCommands(): Player.Commands {
|
override fun getAvailableCommands(): Player.Commands {
|
||||||
val commandsBuilder = Player.Commands.Builder().addAll(
|
val commandsBuilder = Player.Commands.Builder().addAll(
|
||||||
Player.COMMAND_SET_VOLUME,
|
Player.COMMAND_CHANGE_MEDIA_ITEMS,
|
||||||
Player.COMMAND_GET_VOLUME
|
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 (isPlaying) commandsBuilder.add(Player.COMMAND_STOP)
|
||||||
if (playlist.isNotEmpty()) {
|
if (playlist.isNotEmpty()) {
|
||||||
@ -306,8 +220,7 @@ class JukeboxMediaPlayer : JukeboxUnimplementedFunctions(), Player {
|
|||||||
Player.COMMAND_SEEK_FORWARD,
|
Player.COMMAND_SEEK_FORWARD,
|
||||||
Player.COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM,
|
Player.COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM,
|
||||||
Player.COMMAND_SEEK_TO_MEDIA_ITEM,
|
Player.COMMAND_SEEK_TO_MEDIA_ITEM,
|
||||||
)
|
// Seeking back is always available
|
||||||
if (currentIndex > 0) commandsBuilder.addAll(
|
|
||||||
Player.COMMAND_SEEK_TO_PREVIOUS,
|
Player.COMMAND_SEEK_TO_PREVIOUS,
|
||||||
Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM
|
Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM
|
||||||
)
|
)
|
||||||
@ -323,6 +236,18 @@ class JukeboxMediaPlayer : JukeboxUnimplementedFunctions(), Player {
|
|||||||
return availableCommands.contains(command)
|
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 {
|
override fun getPlayWhenReady(): Boolean {
|
||||||
return isPlaying
|
return isPlaying
|
||||||
}
|
}
|
||||||
@ -358,21 +283,43 @@ class JukeboxMediaPlayer : JukeboxUnimplementedFunctions(), Player {
|
|||||||
|
|
||||||
override fun setShuffleModeEnabled(shuffleModeEnabled: Boolean) {}
|
override fun setShuffleModeEnabled(shuffleModeEnabled: Boolean) {}
|
||||||
|
|
||||||
override fun setVolume(volume: Float) {
|
override fun setDeviceVolume(volume: Int) {
|
||||||
gain = volume
|
gain = volume
|
||||||
tasks.remove(SetGain::class.java)
|
tasks.remove(SetGain::class.java)
|
||||||
tasks.add(SetGain(volume))
|
tasks.add(SetGain(floatGain))
|
||||||
val context = applicationContext()
|
|
||||||
if (volumeToast == null) volumeToast = VolumeToast(context)
|
// We must trigger an event so that the Controller knows the new volume
|
||||||
volumeToast!!.setVolume(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 {
|
override fun getVolume(): Float {
|
||||||
return gain
|
return floatGain
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getDeviceVolume(): Int {
|
override fun getDeviceVolume(): Int {
|
||||||
return (gain * 100).toInt()
|
return gain
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun addMediaItems(index: Int, mediaItems: MutableList<MediaItem>) {
|
override fun addMediaItems(index: Int, mediaItems: MutableList<MediaItem>) {
|
||||||
@ -444,7 +391,7 @@ class JukeboxMediaPlayer : JukeboxUnimplementedFunctions(), Player {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun seekToPrevious() {
|
override fun seekToPrevious() {
|
||||||
if ((jukeboxStatus?.positionSeconds ?: 0) > SEEK_START_AFTER_SECONDS) {
|
if ((jukeboxStatus?.positionSeconds ?: 0) > (Settings.seekIntervalMillis)) {
|
||||||
seekTo(currentIndex, 0)
|
seekTo(currentIndex, 0)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -499,97 +446,104 @@ class JukeboxMediaPlayer : JukeboxUnimplementedFunctions(), Player {
|
|||||||
@Suppress("LoopWithTooManyJumpStatements")
|
@Suppress("LoopWithTooManyJumpStatements")
|
||||||
private fun processTasks() {
|
private fun processTasks() {
|
||||||
Timber.d("JukeboxMediaPlayer processTasks starting")
|
Timber.d("JukeboxMediaPlayer processTasks starting")
|
||||||
while (true) {
|
while (running.get()) {
|
||||||
// Sleep a bit to spare processor time if we loop a lot
|
// Sleep a bit to spare processor time if we loop a lot
|
||||||
sleepQuietly(10)
|
sleepQuietly(10)
|
||||||
// This is only necessary if Ultrasonic goes offline sooner than the thread stops
|
// This is only necessary if Ultrasonic goes offline sooner than the thread stops
|
||||||
if (isOffline()) continue
|
if (isOffline()) continue
|
||||||
var task: JukeboxTask? = null
|
var task: JukeboxTask? = null
|
||||||
try {
|
try {
|
||||||
task = tasks.poll()
|
task = tasks.poll() ?: continue
|
||||||
// If running is false, exit when the queue is empty
|
|
||||||
if (task == null && !running.get()) break
|
|
||||||
if (task == null) continue
|
|
||||||
Timber.v("JukeBoxMediaPlayer processTasks processes Task %s", task::class)
|
Timber.v("JukeBoxMediaPlayer processTasks processes Task %s", task::class)
|
||||||
val status = task.execute()
|
val status = task.execute()
|
||||||
onStatusUpdate(status)
|
onStatusUpdate(status)
|
||||||
} catch (x: Throwable) {
|
} catch (all: Throwable) {
|
||||||
onError(task, x)
|
onError(task, all)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Timber.d("JukeboxMediaPlayer processTasks stopped")
|
Timber.d("JukeboxMediaPlayer processTasks stopped")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Jukebox status contains data received from the server, we need to validate it!
|
||||||
private fun onStatusUpdate(jukeboxStatus: JukeboxStatus) {
|
private fun onStatusUpdate(jukeboxStatus: JukeboxStatus) {
|
||||||
timeOfLastUpdate.set(System.currentTimeMillis())
|
timeOfLastUpdate.set(System.currentTimeMillis())
|
||||||
previousJukeboxStatus = this.jukeboxStatus
|
previousJukeboxStatus = this.jukeboxStatus
|
||||||
this.jukeboxStatus = 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
|
currentIndex = jukeboxStatus.currentPlayingIndex ?: currentIndex
|
||||||
|
|
||||||
if (jukeboxStatus.isPlaying != previousJukeboxStatus?.isPlaying) {
|
if (jukeboxStatus.isPlaying != previousJukeboxStatus?.isPlaying) {
|
||||||
|
shouldUpdateCommands = true
|
||||||
Handler(Looper.getMainLooper()).post {
|
Handler(Looper.getMainLooper()).post {
|
||||||
listeners.forEach {
|
listeners.queueEvent(Player.EVENT_PLAYBACK_STATE_CHANGED) {
|
||||||
it.onPlaybackStateChanged(
|
it.onPlaybackStateChanged(
|
||||||
if (jukeboxStatus.isPlaying) Player.STATE_READY else Player.STATE_IDLE
|
if (jukeboxStatus.isPlaying) Player.STATE_READY else Player.STATE_IDLE
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
listeners.queueEvent(Player.EVENT_IS_PLAYING_CHANGED) {
|
||||||
it.onIsPlayingChanged(jukeboxStatus.isPlaying)
|
it.onIsPlayingChanged(jukeboxStatus.isPlaying)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (jukeboxStatus.currentPlayingIndex != previousJukeboxStatus?.currentPlayingIndex) {
|
if (jukeboxStatus.currentPlayingIndex != previousJukeboxStatus?.currentPlayingIndex) {
|
||||||
|
shouldUpdateCommands = true
|
||||||
currentIndex = jukeboxStatus.currentPlayingIndex ?: 0
|
currentIndex = jukeboxStatus.currentPlayingIndex ?: 0
|
||||||
val currentMedia =
|
val currentMedia =
|
||||||
if (currentIndex > 0 && currentIndex < playlist.size) playlist[currentIndex]
|
if (currentIndex > 0 && currentIndex < playlist.size) playlist[currentIndex]
|
||||||
else MediaItem.EMPTY
|
else MediaItem.EMPTY
|
||||||
|
|
||||||
Handler(Looper.getMainLooper()).post {
|
Handler(Looper.getMainLooper()).post {
|
||||||
listeners.forEach {
|
listeners.queueEvent(Player.EVENT_MEDIA_ITEM_TRANSITION) {
|
||||||
it.onMediaItemTransition(
|
it.onMediaItemTransition(
|
||||||
currentMedia,
|
currentMedia,
|
||||||
Player.MEDIA_ITEM_TRANSITION_REASON_SEEK
|
Player.MEDIA_ITEM_TRANSITION_REASON_AUTO
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
updateNotification()
|
if (shouldUpdateCommands) updateAvailableCommands()
|
||||||
|
|
||||||
|
Handler(Looper.getMainLooper()).post {
|
||||||
|
listeners.flushEvents()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onError(task: JukeboxTask?, x: Throwable) {
|
private fun onError(task: JukeboxTask?, x: Throwable) {
|
||||||
|
var exception: PlaybackException? = null
|
||||||
if (x is ApiNotSupportedException && task !is Stop) {
|
if (x is ApiNotSupportedException && task !is Stop) {
|
||||||
Handler(Looper.getMainLooper()).post {
|
exception = PlaybackException(
|
||||||
listeners.forEach {
|
"Jukebox server too old",
|
||||||
it.onPlayerError(
|
null,
|
||||||
PlaybackException(
|
R.string.download_jukebox_server_too_old
|
||||||
"Jukebox server too old",
|
)
|
||||||
null,
|
|
||||||
R.string.download_jukebox_server_too_old
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (x is OfflineException && task !is Stop) {
|
} else if (x is OfflineException && task !is Stop) {
|
||||||
Handler(Looper.getMainLooper()).post {
|
exception = PlaybackException(
|
||||||
listeners.forEach {
|
"Jukebox offline",
|
||||||
it.onPlayerError(
|
null,
|
||||||
PlaybackException(
|
R.string.download_jukebox_offline
|
||||||
"Jukebox offline",
|
)
|
||||||
null,
|
|
||||||
R.string.download_jukebox_offline
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (x is SubsonicRESTException && x.code == 50 && task !is Stop) {
|
} else if (x is SubsonicRESTException && x.code == 50 && task !is Stop) {
|
||||||
|
exception = PlaybackException(
|
||||||
|
"Jukebox not authorized",
|
||||||
|
null,
|
||||||
|
R.string.download_jukebox_not_authorized
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (exception != null) {
|
||||||
Handler(Looper.getMainLooper()).post {
|
Handler(Looper.getMainLooper()).post {
|
||||||
listeners.forEach {
|
listeners.sendEvent(Player.EVENT_PLAYER_ERROR) {
|
||||||
it.onPlayerError(
|
it.onPlayerError(exception)
|
||||||
PlaybackException(
|
|
||||||
"Jukebox not authorized",
|
|
||||||
null,
|
|
||||||
R.string.download_jukebox_not_authorized
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@ -608,8 +562,10 @@ class JukeboxMediaPlayer : JukeboxUnimplementedFunctions(), Player {
|
|||||||
}
|
}
|
||||||
tasks.add(SetPlaylist(ids))
|
tasks.add(SetPlaylist(ids))
|
||||||
Handler(Looper.getMainLooper()).post {
|
Handler(Looper.getMainLooper()).post {
|
||||||
listeners.forEach {
|
listeners.sendEvent(
|
||||||
it.onTimelineChanged(
|
Player.EVENT_TIMELINE_CHANGED
|
||||||
|
) { listener: Player.Listener ->
|
||||||
|
listener.onTimelineChanged(
|
||||||
PlaylistTimeline(playlist),
|
PlaylistTimeline(playlist),
|
||||||
Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED
|
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
|
// The constants below are necessary so a MediaSession can be built from the Jukebox Service
|
||||||
override fun isCurrentMediaItemDynamic(): Boolean {
|
override fun isCurrentMediaItemDynamic(): Boolean {
|
||||||
return false
|
return false
|
||||||
@ -748,15 +685,15 @@ class JukeboxMediaPlayer : JukeboxUnimplementedFunctions(), Player {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun getMaxSeekToPreviousPosition(): Long {
|
override fun getMaxSeekToPreviousPosition(): Long {
|
||||||
return SEEK_START_AFTER_SECONDS * 1000L
|
return Settings.seekInterval.toLong()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getSeekBackIncrement(): Long {
|
override fun getSeekBackIncrement(): Long {
|
||||||
return SEEK_INCREMENT_SECONDS * 1000L
|
return Settings.seekInterval.toLong()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getSeekForwardIncrement(): Long {
|
override fun getSeekForwardIncrement(): Long {
|
||||||
return SEEK_INCREMENT_SECONDS * 1000L
|
return Settings.seekInterval.toLong()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun isLoading(): Boolean {
|
override fun isLoading(): Boolean {
|
||||||
@ -779,6 +716,8 @@ class JukeboxMediaPlayer : JukeboxUnimplementedFunctions(), Player {
|
|||||||
return AudioAttributes.DEFAULT
|
return AudioAttributes.DEFAULT
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun setVolume(volume: Float) {}
|
||||||
|
|
||||||
override fun getVideoSize(): VideoSize {
|
override fun getVideoSize(): VideoSize {
|
||||||
return VideoSize(0, 0)
|
return VideoSize(0, 0)
|
||||||
}
|
}
|
||||||
@ -824,7 +763,7 @@ class JukeboxMediaPlayer : JukeboxUnimplementedFunctions(), Player {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun getDeviceInfo(): DeviceInfo {
|
override fun getDeviceInfo(): DeviceInfo {
|
||||||
return DeviceInfo(DeviceInfo.PLAYBACK_TYPE_REMOTE, 0, 1)
|
return DEVICE_INFO
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getPlayerError(): PlaybackException? {
|
override fun getPlayerError(): PlaybackException? {
|
||||||
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -8,7 +8,6 @@
|
|||||||
package org.moire.ultrasonic.service
|
package org.moire.ultrasonic.service
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.app.Service
|
|
||||||
import android.view.Surface
|
import android.view.Surface
|
||||||
import android.view.SurfaceHolder
|
import android.view.SurfaceHolder
|
||||||
import android.view.SurfaceView
|
import android.view.SurfaceView
|
||||||
@ -26,7 +25,7 @@ import androidx.media3.common.Tracks
|
|||||||
*/
|
*/
|
||||||
@Suppress("TooManyFunctions")
|
@Suppress("TooManyFunctions")
|
||||||
@SuppressLint("UnsafeOptInUsageError")
|
@SuppressLint("UnsafeOptInUsageError")
|
||||||
abstract class JukeboxUnimplementedFunctions : Service(), Player {
|
abstract class JukeboxUnimplementedFunctions : Player {
|
||||||
|
|
||||||
override fun setMediaItems(mediaItems: MutableList<MediaItem>) {
|
override fun setMediaItems(mediaItems: MutableList<MediaItem>) {
|
||||||
TODO("Not yet implemented")
|
TODO("Not yet implemented")
|
||||||
@ -140,10 +139,6 @@ abstract class JukeboxUnimplementedFunctions : Service(), Player {
|
|||||||
TODO("Not yet implemented")
|
TODO("Not yet implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun release() {
|
|
||||||
TODO("Not yet implemented")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getCurrentTracks(): Tracks {
|
override fun getCurrentTracks(): Tracks {
|
||||||
// TODO Dummy information is returned for now, this seems to work
|
// TODO Dummy information is returned for now, this seems to work
|
||||||
return Tracks.EMPTY
|
return Tracks.EMPTY
|
||||||
@ -228,20 +223,4 @@ abstract class JukeboxUnimplementedFunctions : Service(), Player {
|
|||||||
override fun clearVideoTextureView(textureView: TextureView?) {
|
override fun clearVideoTextureView(textureView: TextureView?) {
|
||||||
TODO("Not yet implemented")
|
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")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -32,7 +32,7 @@ import timber.log.Timber
|
|||||||
class MediaPlayerLifecycleSupport : KoinComponent {
|
class MediaPlayerLifecycleSupport : KoinComponent {
|
||||||
private lateinit var ratingManager: RatingManager
|
private lateinit var ratingManager: RatingManager
|
||||||
private val playbackStateSerializer by inject<PlaybackStateSerializer>()
|
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 val imageLoaderProvider: ImageLoaderProvider by inject()
|
||||||
|
|
||||||
private var created = false
|
private var created = false
|
||||||
@ -64,7 +64,7 @@ class MediaPlayerLifecycleSupport : KoinComponent {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
mediaPlayerController.onCreate {
|
mediaPlayerManager.onCreate {
|
||||||
restoreLastSession(autoPlay, afterRestore)
|
restoreLastSession(autoPlay, afterRestore)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -81,7 +81,7 @@ class MediaPlayerLifecycleSupport : KoinComponent {
|
|||||||
|
|
||||||
Timber.i("Restoring %s songs", it!!.songs.size)
|
Timber.i("Restoring %s songs", it!!.songs.size)
|
||||||
|
|
||||||
mediaPlayerController.restore(
|
mediaPlayerManager.restore(
|
||||||
it,
|
it,
|
||||||
autoPlay,
|
autoPlay,
|
||||||
false
|
false
|
||||||
@ -110,7 +110,7 @@ class MediaPlayerLifecycleSupport : KoinComponent {
|
|||||||
if (intent == null) return
|
if (intent == null) return
|
||||||
|
|
||||||
val intentAction = intent.action
|
val intentAction = intent.action
|
||||||
if (intentAction == null || intentAction.isEmpty()) return
|
if (intentAction.isNullOrEmpty()) return
|
||||||
|
|
||||||
Timber.i("Received intent: %s", intentAction)
|
Timber.i("Received intent: %s", intentAction)
|
||||||
|
|
||||||
@ -146,15 +146,15 @@ class MediaPlayerLifecycleSupport : KoinComponent {
|
|||||||
val state = extras.getInt("state")
|
val state = extras.getInt("state")
|
||||||
|
|
||||||
if (state == 0) {
|
if (state == 0) {
|
||||||
if (!mediaPlayerController.isJukeboxEnabled) {
|
if (!mediaPlayerManager.isJukeboxEnabled) {
|
||||||
mediaPlayerController.pause()
|
mediaPlayerManager.pause()
|
||||||
}
|
}
|
||||||
} else if (state == 1) {
|
} else if (state == 1) {
|
||||||
if (!mediaPlayerController.isJukeboxEnabled &&
|
if (!mediaPlayerManager.isJukeboxEnabled &&
|
||||||
Settings.resumePlayOnHeadphonePlug && !mediaPlayerController.isPlaying
|
Settings.resumePlayOnHeadphonePlug && !mediaPlayerManager.isPlaying
|
||||||
) {
|
) {
|
||||||
mediaPlayerController.prepare()
|
mediaPlayerManager.prepare()
|
||||||
mediaPlayerController.play()
|
mediaPlayerManager.play()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -183,18 +183,18 @@ class MediaPlayerLifecycleSupport : KoinComponent {
|
|||||||
onCreate(autoStart) {
|
onCreate(autoStart) {
|
||||||
when (keyCode) {
|
when (keyCode) {
|
||||||
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE,
|
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE,
|
||||||
KeyEvent.KEYCODE_HEADSETHOOK -> mediaPlayerController.togglePlayPause()
|
KeyEvent.KEYCODE_HEADSETHOOK -> mediaPlayerManager.togglePlayPause()
|
||||||
KeyEvent.KEYCODE_MEDIA_PREVIOUS -> mediaPlayerController.seekToPrevious()
|
KeyEvent.KEYCODE_MEDIA_PREVIOUS -> mediaPlayerManager.seekToPrevious()
|
||||||
KeyEvent.KEYCODE_MEDIA_NEXT -> mediaPlayerController.seekToNext()
|
KeyEvent.KEYCODE_MEDIA_NEXT -> mediaPlayerManager.seekToNext()
|
||||||
KeyEvent.KEYCODE_MEDIA_STOP -> mediaPlayerController.stop()
|
KeyEvent.KEYCODE_MEDIA_STOP -> mediaPlayerManager.stop()
|
||||||
KeyEvent.KEYCODE_MEDIA_PLAY -> mediaPlayerController.play()
|
KeyEvent.KEYCODE_MEDIA_PLAY -> mediaPlayerManager.play()
|
||||||
KeyEvent.KEYCODE_MEDIA_PAUSE -> mediaPlayerController.pause()
|
KeyEvent.KEYCODE_MEDIA_PAUSE -> mediaPlayerManager.pause()
|
||||||
KeyEvent.KEYCODE_1 -> mediaPlayerController.legacySetRating(1)
|
KeyEvent.KEYCODE_1 -> mediaPlayerManager.legacySetRating(1)
|
||||||
KeyEvent.KEYCODE_2 -> mediaPlayerController.legacySetRating(2)
|
KeyEvent.KEYCODE_2 -> mediaPlayerManager.legacySetRating(2)
|
||||||
KeyEvent.KEYCODE_3 -> mediaPlayerController.legacySetRating(3)
|
KeyEvent.KEYCODE_3 -> mediaPlayerManager.legacySetRating(3)
|
||||||
KeyEvent.KEYCODE_4 -> mediaPlayerController.legacySetRating(4)
|
KeyEvent.KEYCODE_4 -> mediaPlayerManager.legacySetRating(4)
|
||||||
KeyEvent.KEYCODE_5 -> mediaPlayerController.legacySetRating(5)
|
KeyEvent.KEYCODE_5 -> mediaPlayerManager.legacySetRating(5)
|
||||||
KeyEvent.KEYCODE_STAR -> mediaPlayerController.legacyToggleStar()
|
KeyEvent.KEYCODE_STAR -> mediaPlayerManager.legacyToggleStar()
|
||||||
else -> {
|
else -> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -222,17 +222,17 @@ class MediaPlayerLifecycleSupport : KoinComponent {
|
|||||||
// We can receive intents when everything is stopped, so we need to start
|
// We can receive intents when everything is stopped, so we need to start
|
||||||
onCreate(autoStart) {
|
onCreate(autoStart) {
|
||||||
when (action) {
|
when (action) {
|
||||||
Constants.CMD_PLAY -> mediaPlayerController.play()
|
Constants.CMD_PLAY -> mediaPlayerManager.play()
|
||||||
Constants.CMD_RESUME_OR_PLAY ->
|
Constants.CMD_RESUME_OR_PLAY ->
|
||||||
// If Ultrasonic wasn't running, the autoStart is enough to resume,
|
// If Ultrasonic wasn't running, the autoStart is enough to resume,
|
||||||
// no need to call anything
|
// no need to call anything
|
||||||
if (isRunning) mediaPlayerController.resumeOrPlay()
|
if (isRunning) mediaPlayerManager.resumeOrPlay()
|
||||||
|
|
||||||
Constants.CMD_NEXT -> mediaPlayerController.seekToNext()
|
Constants.CMD_NEXT -> mediaPlayerManager.seekToNext()
|
||||||
Constants.CMD_PREVIOUS -> mediaPlayerController.seekToPrevious()
|
Constants.CMD_PREVIOUS -> mediaPlayerManager.seekToPrevious()
|
||||||
Constants.CMD_TOGGLEPAUSE -> mediaPlayerController.togglePlayPause()
|
Constants.CMD_TOGGLEPAUSE -> mediaPlayerManager.togglePlayPause()
|
||||||
Constants.CMD_STOP -> mediaPlayerController.stop()
|
Constants.CMD_STOP -> mediaPlayerManager.stop()
|
||||||
Constants.CMD_PAUSE -> mediaPlayerController.pause()
|
Constants.CMD_PAUSE -> mediaPlayerManager.pause()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -16,8 +16,6 @@ import androidx.media3.common.HeartRating
|
|||||||
import androidx.media3.common.MediaItem
|
import androidx.media3.common.MediaItem
|
||||||
import androidx.media3.common.PlaybackException
|
import androidx.media3.common.PlaybackException
|
||||||
import androidx.media3.common.Player
|
import androidx.media3.common.Player
|
||||||
import androidx.media3.common.Player.COMMAND_SEEK_TO_NEXT
|
|
||||||
import androidx.media3.common.Player.COMMAND_SEEK_TO_PREVIOUS
|
|
||||||
import androidx.media3.common.Player.MEDIA_ITEM_TRANSITION_REASON_AUTO
|
import androidx.media3.common.Player.MEDIA_ITEM_TRANSITION_REASON_AUTO
|
||||||
import androidx.media3.common.Player.REPEAT_MODE_OFF
|
import androidx.media3.common.Player.REPEAT_MODE_OFF
|
||||||
import androidx.media3.common.Rating
|
import androidx.media3.common.Rating
|
||||||
@ -50,12 +48,13 @@ private const val CONTROLLER_SWITCH_DELAY = 500L
|
|||||||
private const val VOLUME_DELTA = 0.05f
|
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
|
* This class contains everything that is necessary for the Application UI
|
||||||
* to control the Media Player implementation.
|
* to control the Media Player implementation.
|
||||||
*/
|
*/
|
||||||
@Suppress("TooManyFunctions")
|
@Suppress("TooManyFunctions")
|
||||||
class MediaPlayerController(
|
class MediaPlayerManager(
|
||||||
private val playbackStateSerializer: PlaybackStateSerializer,
|
private val playbackStateSerializer: PlaybackStateSerializer,
|
||||||
private val externalStorageMonitor: ExternalStorageMonitor,
|
private val externalStorageMonitor: ExternalStorageMonitor,
|
||||||
val context: Context
|
val context: Context
|
||||||
@ -97,15 +96,15 @@ class MediaPlayerController(
|
|||||||
* We run the event through RxBus in order to throttle them
|
* We run the event through RxBus in order to throttle them
|
||||||
*/
|
*/
|
||||||
override fun onTimelineChanged(timeline: Timeline, reason: Int) {
|
override fun onTimelineChanged(timeline: Timeline, reason: Int) {
|
||||||
val start = controller?.currentTimeline?.getFirstWindowIndex(isShufflePlayEnabled)
|
val start = timeline.getFirstWindowIndex(isShufflePlayEnabled)
|
||||||
Timber.w("On timeline changed. First shuffle play at index: %s", start)
|
Timber.w("On timeline changed. First shuffle play at index: %s", start)
|
||||||
deferredPlay?.let {
|
deferredPlay?.let {
|
||||||
Timber.w("Executing deferred shuffle play")
|
Timber.w("Executing deferred shuffle play")
|
||||||
it()
|
it()
|
||||||
deferredPlay = null
|
deferredPlay = null
|
||||||
}
|
}
|
||||||
|
val playlist = Util.getPlayListFromTimeline(timeline, false).map(MediaItem::toTrack)
|
||||||
RxBus.playlistPublisher.onNext(playlist.map(MediaItem::toTrack))
|
RxBus.playlistPublisher.onNext(playlist)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||||
@ -179,11 +178,8 @@ class MediaPlayerController(
|
|||||||
fun onCreate(onCreated: () -> Unit) {
|
fun onCreate(onCreated: () -> Unit) {
|
||||||
if (created) return
|
if (created) return
|
||||||
externalStorageMonitor.onCreate { reset() }
|
externalStorageMonitor.onCreate { reset() }
|
||||||
if (activeServerProvider.getActiveServer().jukeboxByDefault) {
|
|
||||||
switchToJukebox(onCreated)
|
createMediaController(onCreated)
|
||||||
} else {
|
|
||||||
switchToLocalPlayer(onCreated)
|
|
||||||
}
|
|
||||||
|
|
||||||
rxBusSubscription += RxBus.activeServerChangingObservable.subscribe { oldServer ->
|
rxBusSubscription += RxBus.activeServerChangingObservable.subscribe { oldServer ->
|
||||||
if (oldServer != OFFLINE_DB_ID) {
|
if (oldServer != OFFLINE_DB_ID) {
|
||||||
@ -195,8 +191,7 @@ class MediaPlayerController(
|
|||||||
if (controller is JukeboxMediaPlayer) {
|
if (controller is JukeboxMediaPlayer) {
|
||||||
// When the server changes, the Jukebox should be released.
|
// When the server changes, the Jukebox should be released.
|
||||||
// The new server won't understand the jukebox requests of the old one.
|
// The new server won't understand the jukebox requests of the old one.
|
||||||
releaseJukebox(controller)
|
switchToLocalPlayer()
|
||||||
controller = null
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -246,6 +241,22 @@ class MediaPlayerController(
|
|||||||
Timber.i("MediaPlayerController started")
|
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() {
|
private fun playerStateChangedHandler() {
|
||||||
val currentPlaying = controller?.currentMediaItem?.toTrack() ?: return
|
val currentPlaying = controller?.currentMediaItem?.toTrack() ?: return
|
||||||
|
|
||||||
@ -262,6 +273,10 @@ class MediaPlayerController(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun addListener(listener: Player.Listener) {
|
||||||
|
controller?.addListener(listener)
|
||||||
|
}
|
||||||
|
|
||||||
private fun clearBookmark() {
|
private fun clearBookmark() {
|
||||||
// This method is called just before we update the cachedMediaItem,
|
// This method is called just before we update the cachedMediaItem,
|
||||||
// so in fact cachedMediaItem will refer to the track that has just finished.
|
// so in fact cachedMediaItem will refer to the track that has just finished.
|
||||||
@ -336,7 +351,6 @@ class MediaPlayerController(
|
|||||||
@Synchronized
|
@Synchronized
|
||||||
fun play(index: Int) {
|
fun play(index: Int) {
|
||||||
controller?.seekTo(index, 0L)
|
controller?.seekTo(index, 0L)
|
||||||
// FIXME CHECK ITS NOT MAKING PROBLEMS
|
|
||||||
controller?.prepare()
|
controller?.prepare()
|
||||||
controller?.play()
|
controller?.play()
|
||||||
}
|
}
|
||||||
@ -538,7 +552,7 @@ class MediaPlayerController(
|
|||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun canSeekToPrevious(): Boolean {
|
fun canSeekToPrevious(): Boolean {
|
||||||
return controller?.isCommandAvailable(COMMAND_SEEK_TO_PREVIOUS) == true
|
return controller?.isCommandAvailable(Player.COMMAND_SEEK_TO_PREVIOUS) == true
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
@ -548,7 +562,7 @@ class MediaPlayerController(
|
|||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun canSeekToNext(): Boolean {
|
fun canSeekToNext(): Boolean {
|
||||||
return controller?.isCommandAvailable(COMMAND_SEEK_TO_NEXT) == true
|
return controller?.isCommandAvailable(Player.COMMAND_SEEK_TO_NEXT) == true
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
@ -580,102 +594,49 @@ class MediaPlayerController(
|
|||||||
|
|
||||||
@set:Synchronized
|
@set:Synchronized
|
||||||
var isJukeboxEnabled: Boolean
|
var isJukeboxEnabled: Boolean
|
||||||
get() = controller is JukeboxMediaPlayer
|
get() = PlaybackService.actualBackend == PlayerBackend.JUKEBOX
|
||||||
set(jukeboxEnabled) {
|
set(shouldEnable) {
|
||||||
if (jukeboxEnabled) {
|
if (shouldEnable) {
|
||||||
switchToJukebox {}
|
switchToJukebox()
|
||||||
} else {
|
} else {
|
||||||
switchToLocalPlayer {}
|
switchToLocalPlayer()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun switchToJukebox(onCreated: () -> Unit) {
|
private fun switchToJukebox() {
|
||||||
if (controller is JukeboxMediaPlayer) return
|
if (isJukeboxEnabled) return
|
||||||
val currentPlaylist = playlist
|
scheduleSwitchTo(PlayerBackend.JUKEBOX)
|
||||||
val currentIndex = controller?.currentMediaItemIndex ?: 0
|
|
||||||
val currentPosition = controller?.currentPosition ?: 0
|
|
||||||
DownloadService.requestStop()
|
DownloadService.requestStop()
|
||||||
controller?.pause()
|
controller?.pause()
|
||||||
controller?.stop()
|
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) {
|
private fun switchToLocalPlayer() {
|
||||||
if (controller is MediaController) return
|
if (!isJukeboxEnabled) return
|
||||||
val currentPlaylist = playlist
|
scheduleSwitchTo(PlayerBackend.LOCAL)
|
||||||
|
controller?.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scheduleSwitchTo(newBackend: PlayerBackend) {
|
||||||
|
val currentPlaylist = playlist.toList()
|
||||||
val currentIndex = controller?.currentMediaItemIndex ?: 0
|
val currentIndex = controller?.currentMediaItemIndex ?: 0
|
||||||
val currentPosition = controller?.currentPosition ?: 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({
|
Handler(Looper.getMainLooper()).postDelayed({
|
||||||
if (oldController != null) releaseJukebox(oldController)
|
// Change the backend
|
||||||
setupLocalPlayer {
|
PlaybackService.setBackend(newBackend)
|
||||||
controller?.setMediaItems(currentPlaylist, currentIndex, currentPosition)
|
// Restore the media items
|
||||||
onCreated()
|
controller?.setMediaItems(currentPlaylist, currentIndex, currentPosition)
|
||||||
}
|
|
||||||
}, CONTROLLER_SWITCH_DELAY)
|
}, CONTROLLER_SWITCH_DELAY)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun releaseController() {
|
private fun releaseController() {
|
||||||
when (controller) {
|
controller?.removeListener(listeners)
|
||||||
null -> return
|
controller?.release()
|
||||||
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()
|
|
||||||
if (mediaControllerFuture != null) MediaController.releaseFuture(mediaControllerFuture!!)
|
if (mediaControllerFuture != null) MediaController.releaseFuture(mediaControllerFuture!!)
|
||||||
Timber.i("MediaPlayerController released")
|
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
|
* This function calls the music service directly and
|
||||||
* therefore can't be called from the main thread
|
* therefore can't be called from the main thread
|
||||||
@ -700,10 +661,6 @@ class MediaPlayerController(
|
|||||||
controller?.volume = gain
|
controller?.volume = gain
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setVolume(volume: Float) {
|
|
||||||
controller?.volume = volume
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Sets the rating of the current track
|
* Sets the rating of the current track
|
||||||
*/
|
*/
|
||||||
@ -841,4 +798,6 @@ class MediaPlayerController(
|
|||||||
enum class InsertionMode {
|
enum class InsertionMode {
|
||||||
CLEAR, APPEND, AFTER_CURRENT
|
CLEAR, APPEND, AFTER_CURRENT
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum class PlayerBackend { JUKEBOX, LOCAL }
|
||||||
}
|
}
|
@ -14,6 +14,6 @@ data class PlaybackState(
|
|||||||
var repeatMode: Int = 0
|
var repeatMode: Int = 0
|
||||||
) : Serializable {
|
) : Serializable {
|
||||||
companion object {
|
companion object {
|
||||||
const val serialVersionUID = -293487987L
|
private const val serialVersionUID = -293487987L
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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.throwOnFailure
|
||||||
import org.moire.ultrasonic.api.subsonic.toStreamResponse
|
import org.moire.ultrasonic.api.subsonic.toStreamResponse
|
||||||
import org.moire.ultrasonic.data.ActiveServerProvider
|
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.Album
|
||||||
import org.moire.ultrasonic.domain.Artist
|
import org.moire.ultrasonic.domain.Artist
|
||||||
import org.moire.ultrasonic.domain.Bookmark
|
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.toMusicDirectoryDomainEntity
|
||||||
import org.moire.ultrasonic.domain.toTrackEntity
|
import org.moire.ultrasonic.domain.toTrackEntity
|
||||||
import org.moire.ultrasonic.util.FileUtil
|
import org.moire.ultrasonic.util.FileUtil
|
||||||
import org.moire.ultrasonic.util.Settings
|
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -181,7 +180,7 @@ open class RESTMusicService(
|
|||||||
criteria: SearchCriteria
|
criteria: SearchCriteria
|
||||||
): SearchResult {
|
): SearchResult {
|
||||||
return try {
|
return try {
|
||||||
if (!isOffline() && Settings.shouldUseId3Tags) {
|
if (shouldUseId3Tags()) {
|
||||||
search3(criteria)
|
search3(criteria)
|
||||||
} else {
|
} else {
|
||||||
search2(criteria)
|
search2(criteria)
|
||||||
|
@ -14,11 +14,11 @@ import kotlinx.coroutines.CoroutineScope
|
|||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.moire.ultrasonic.R
|
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.MusicDirectory
|
||||||
import org.moire.ultrasonic.domain.Track
|
import org.moire.ultrasonic.domain.Track
|
||||||
import org.moire.ultrasonic.service.DownloadService
|
import org.moire.ultrasonic.service.DownloadService
|
||||||
import org.moire.ultrasonic.service.MediaPlayerController
|
import org.moire.ultrasonic.service.MediaPlayerManager
|
||||||
import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService
|
import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService
|
||||||
import org.moire.ultrasonic.util.Settings
|
import org.moire.ultrasonic.util.Settings
|
||||||
import org.moire.ultrasonic.util.executeTaskWithToast
|
import org.moire.ultrasonic.util.executeTaskWithToast
|
||||||
@ -28,7 +28,7 @@ import org.moire.ultrasonic.util.executeTaskWithToast
|
|||||||
*/
|
*/
|
||||||
@Suppress("LongParameterList")
|
@Suppress("LongParameterList")
|
||||||
class DownloadHandler(
|
class DownloadHandler(
|
||||||
val mediaPlayerController: MediaPlayerController,
|
val mediaPlayerManager: MediaPlayerManager,
|
||||||
private val networkAndStorageChecker: NetworkAndStorageChecker
|
private val networkAndStorageChecker: NetworkAndStorageChecker
|
||||||
) : CoroutineScope by CoroutineScope(Dispatchers.IO) {
|
) : CoroutineScope by CoroutineScope(Dispatchers.IO) {
|
||||||
private val maxSongs = 500
|
private val maxSongs = 500
|
||||||
@ -46,7 +46,7 @@ class DownloadHandler(
|
|||||||
var successString: String? = null
|
var successString: String? = null
|
||||||
|
|
||||||
// Launch the Job
|
// Launch the Job
|
||||||
executeTaskWithToast(fragment, {
|
executeTaskWithToast({
|
||||||
val tracksToDownload: List<Track> = tracks
|
val tracksToDownload: List<Track> = tracks
|
||||||
?: getTracksFromServer(isArtist, id!!, isDirectory, name, isShare)
|
?: getTracksFromServer(isArtist, id!!, isDirectory, name, isShare)
|
||||||
|
|
||||||
@ -104,7 +104,7 @@ class DownloadHandler(
|
|||||||
) {
|
) {
|
||||||
var successString: String? = null
|
var successString: String? = null
|
||||||
// Launch the Job
|
// Launch the Job
|
||||||
executeTaskWithToast(fragment, {
|
executeTaskWithToast({
|
||||||
val songs: MutableList<Track> =
|
val songs: MutableList<Track> =
|
||||||
getTracksFromServer(isArtist, id, isDirectory, name, isShare)
|
getTracksFromServer(isArtist, id, isDirectory, name, isShare)
|
||||||
|
|
||||||
@ -150,16 +150,16 @@ class DownloadHandler(
|
|||||||
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
|
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
|
||||||
|
|
||||||
val insertionMode = when {
|
val insertionMode = when {
|
||||||
append -> MediaPlayerController.InsertionMode.APPEND
|
append -> MediaPlayerManager.InsertionMode.APPEND
|
||||||
playNext -> MediaPlayerController.InsertionMode.AFTER_CURRENT
|
playNext -> MediaPlayerManager.InsertionMode.AFTER_CURRENT
|
||||||
else -> MediaPlayerController.InsertionMode.CLEAR
|
else -> MediaPlayerManager.InsertionMode.CLEAR
|
||||||
}
|
}
|
||||||
|
|
||||||
if (playlistName != null) {
|
if (playlistName != null) {
|
||||||
mediaPlayerController.suggestedPlaylistName = playlistName
|
mediaPlayerManager.suggestedPlaylistName = playlistName
|
||||||
}
|
}
|
||||||
|
|
||||||
mediaPlayerController.addToPlaylist(
|
mediaPlayerManager.addToPlaylist(
|
||||||
songs,
|
songs,
|
||||||
autoPlay,
|
autoPlay,
|
||||||
shuffle,
|
shuffle,
|
||||||
@ -181,11 +181,11 @@ class DownloadHandler(
|
|||||||
val musicService = getMusicService()
|
val musicService = getMusicService()
|
||||||
val songs: MutableList<Track> = LinkedList()
|
val songs: MutableList<Track> = LinkedList()
|
||||||
val root: MusicDirectory
|
val root: MusicDirectory
|
||||||
if (!isOffline() && isArtist && Settings.shouldUseId3Tags) {
|
if (shouldUseId3Tags() && isArtist) {
|
||||||
getSongsForArtist(id, songs)
|
return getSongsForArtist(id)
|
||||||
} else {
|
} else {
|
||||||
if (isDirectory) {
|
if (isDirectory) {
|
||||||
root = if (!isOffline() && Settings.shouldUseId3Tags)
|
root = if (shouldUseId3Tags())
|
||||||
musicService.getAlbumAsDir(id, name, false)
|
musicService.getAlbumAsDir(id, name, false)
|
||||||
else
|
else
|
||||||
musicService.getMusicDirectory(id, name, false)
|
musicService.getMusicDirectory(id, name, false)
|
||||||
@ -219,23 +219,19 @@ class DownloadHandler(
|
|||||||
}
|
}
|
||||||
val musicService = getMusicService()
|
val musicService = getMusicService()
|
||||||
for ((id1, _, _, title) in parent.getAlbums()) {
|
for ((id1, _, _, title) in parent.getAlbums()) {
|
||||||
val root: MusicDirectory = if (
|
val root: MusicDirectory = if (shouldUseId3Tags())
|
||||||
!isOffline() &&
|
musicService.getAlbumAsDir(id1, title, false)
|
||||||
Settings.shouldUseId3Tags
|
else
|
||||||
) musicService.getAlbumAsDir(id1, title, false)
|
musicService.getMusicDirectory(id1, title, false)
|
||||||
else musicService.getMusicDirectory(id1, title, false)
|
|
||||||
getSongsRecursively(root, songs)
|
getSongsRecursively(root, songs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Throws(Exception::class)
|
@Throws(Exception::class)
|
||||||
private fun getSongsForArtist(
|
private fun getSongsForArtist(
|
||||||
id: String,
|
id: String
|
||||||
songs: MutableCollection<Track>
|
): MutableList<Track> {
|
||||||
) {
|
val songs: MutableList<Track> = LinkedList()
|
||||||
if (songs.size > maxSongs) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val musicService = getMusicService()
|
val musicService = getMusicService()
|
||||||
val artist = musicService.getAlbumsOfArtist(id, "", false)
|
val artist = musicService.getAlbumsOfArtist(id, "", false)
|
||||||
for ((id1) in artist) {
|
for ((id1) in artist) {
|
||||||
@ -250,6 +246,7 @@ class DownloadHandler(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return songs
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -19,7 +19,7 @@ import org.koin.java.KoinJavaComponent.inject
|
|||||||
import org.moire.ultrasonic.data.ActiveServerProvider
|
import org.moire.ultrasonic.data.ActiveServerProvider
|
||||||
import org.moire.ultrasonic.domain.Playlist
|
import org.moire.ultrasonic.domain.Playlist
|
||||||
import org.moire.ultrasonic.domain.Track
|
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.getAlbumArtFile
|
||||||
import org.moire.ultrasonic.util.FileUtil.getCompleteFile
|
import org.moire.ultrasonic.util.FileUtil.getCompleteFile
|
||||||
import org.moire.ultrasonic.util.FileUtil.getPartialFile
|
import org.moire.ultrasonic.util.FileUtil.getPartialFile
|
||||||
@ -235,8 +235,8 @@ class CacheCleaner : CoroutineScope by CoroutineScope(Dispatchers.IO), KoinCompo
|
|||||||
|
|
||||||
private fun findFilesToNotDelete(): Set<String> {
|
private fun findFilesToNotDelete(): Set<String> {
|
||||||
val filesToNotDelete: MutableSet<String> = HashSet(5)
|
val filesToNotDelete: MutableSet<String> = HashSet(5)
|
||||||
val mediaController = inject<MediaPlayerController>(
|
val mediaController = inject<MediaPlayerManager>(
|
||||||
MediaPlayerController::class.java
|
MediaPlayerManager::class.java
|
||||||
)
|
)
|
||||||
|
|
||||||
val playlist = mainScope.future { mediaController.value.playlist }.get()
|
val playlist = mainScope.future { mediaController.value.playlist }.get()
|
||||||
|
@ -20,6 +20,7 @@ import kotlinx.coroutines.CoroutineExceptionHandler
|
|||||||
import org.moire.ultrasonic.R
|
import org.moire.ultrasonic.R
|
||||||
import org.moire.ultrasonic.api.subsonic.ApiNotSupportedException
|
import org.moire.ultrasonic.api.subsonic.ApiNotSupportedException
|
||||||
import org.moire.ultrasonic.api.subsonic.SubsonicRESTException
|
import org.moire.ultrasonic.api.subsonic.SubsonicRESTException
|
||||||
|
import org.moire.ultrasonic.app.UApp
|
||||||
import org.moire.ultrasonic.subsonic.getLocalizedErrorMessage
|
import org.moire.ultrasonic.subsonic.getLocalizedErrorMessage
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
||||||
@ -46,14 +47,14 @@ object CommunicationError {
|
|||||||
|
|
||||||
ErrorDialog(
|
ErrorDialog(
|
||||||
context = context,
|
context = context,
|
||||||
message = getErrorMessage(error, context)
|
message = getErrorMessage(error)
|
||||||
).show()
|
).show()
|
||||||
}
|
}
|
||||||
|
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
@Suppress("ReturnCount")
|
@Suppress("ReturnCount")
|
||||||
fun getErrorMessage(error: Throwable, context: Context?): String {
|
fun getErrorMessage(error: Throwable): String {
|
||||||
if (context == null) return "Couldn't get Error message, Context is null"
|
val context = UApp.applicationContext()
|
||||||
if (error is IOException && !Util.hasUsableNetwork()) {
|
if (error is IOException && !Util.hasUsableNetwork()) {
|
||||||
return context.resources.getString(R.string.background_task_no_network)
|
return context.resources.getString(R.string.background_task_no_network)
|
||||||
} else if (error is FileNotFoundException) {
|
} else if (error is FileNotFoundException) {
|
||||||
|
@ -17,6 +17,7 @@ import kotlinx.coroutines.Dispatchers
|
|||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.moire.ultrasonic.R
|
import org.moire.ultrasonic.R
|
||||||
|
import org.moire.ultrasonic.app.UApp
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
||||||
object CoroutinePatterns {
|
object CoroutinePatterns {
|
||||||
@ -30,7 +31,6 @@ object CoroutinePatterns {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun CoroutineScope.executeTaskWithToast(
|
fun CoroutineScope.executeTaskWithToast(
|
||||||
fragment: Fragment,
|
|
||||||
task: suspend CoroutineScope.() -> Unit,
|
task: suspend CoroutineScope.() -> Unit,
|
||||||
successString: () -> String?
|
successString: () -> String?
|
||||||
): Job {
|
): Job {
|
||||||
@ -40,7 +40,7 @@ fun CoroutineScope.executeTaskWithToast(
|
|||||||
// Setup a handler when the job is done
|
// Setup a handler when the job is done
|
||||||
job.invokeOnCompletion {
|
job.invokeOnCompletion {
|
||||||
val toastString = if (it != null && it !is CancellationException) {
|
val toastString = if (it != null && it !is CancellationException) {
|
||||||
CommunicationError.getErrorMessage(it, fragment.context)
|
CommunicationError.getErrorMessage(it)
|
||||||
} else {
|
} else {
|
||||||
successString()
|
successString()
|
||||||
}
|
}
|
||||||
@ -49,7 +49,7 @@ fun CoroutineScope.executeTaskWithToast(
|
|||||||
if (toastString == null) return@invokeOnCompletion
|
if (toastString == null) return@invokeOnCompletion
|
||||||
|
|
||||||
launch(Dispatchers.Main) {
|
launch(Dispatchers.Main) {
|
||||||
Util.toast(fragment.context, toastString)
|
Util.toast(UApp.applicationContext(), toastString)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -62,7 +62,7 @@ fun CoroutineScope.executeTaskWithModalDialog(
|
|||||||
successString: () -> String
|
successString: () -> String
|
||||||
) {
|
) {
|
||||||
// Create the job
|
// Create the job
|
||||||
val job = executeTaskWithToast(fragment, task, successString)
|
val job = executeTaskWithToast(task, successString)
|
||||||
|
|
||||||
// Create the dialog
|
// Create the dialog
|
||||||
val builder = InfoDialog.Builder(fragment.requireContext())
|
val builder = InfoDialog.Builder(fragment.requireContext())
|
||||||
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
@ -33,22 +33,26 @@ object Settings {
|
|||||||
val maxBitRate: Int
|
val maxBitRate: Int
|
||||||
get() {
|
get() {
|
||||||
return if (Util.isNetworkRestricted()) {
|
return if (Util.isNetworkRestricted()) {
|
||||||
maxMobileBitRate
|
maxBitRateMobile
|
||||||
} else {
|
} else {
|
||||||
maxWifiBitRate
|
maxBitRateWifi
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var maxWifiBitRate
|
private var maxBitRateWifi
|
||||||
by StringIntSetting(getKey(R.string.setting_key_max_bitrate_wifi))
|
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))
|
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
|
@JvmStatic
|
||||||
val preloadCount: Int
|
val preloadCount: Int
|
||||||
get() {
|
get() {
|
||||||
val preferences = preferences
|
|
||||||
val preloadCount =
|
val preloadCount =
|
||||||
preferences.getString(getKey(R.string.setting_key_preload_count), "-1")!!
|
preferences.getString(getKey(R.string.setting_key_preload_count), "-1")!!
|
||||||
.toInt()
|
.toInt()
|
||||||
@ -60,7 +64,6 @@ object Settings {
|
|||||||
@JvmStatic
|
@JvmStatic
|
||||||
val cacheSizeMB: Int
|
val cacheSizeMB: Int
|
||||||
get() {
|
get() {
|
||||||
val preferences = preferences
|
|
||||||
val cacheSize = preferences.getString(
|
val cacheSize = preferences.getString(
|
||||||
getKey(R.string.setting_key_cache_size),
|
getKey(R.string.setting_key_cache_size),
|
||||||
"-1"
|
"-1"
|
||||||
@ -130,6 +133,9 @@ object Settings {
|
|||||||
var seekInterval
|
var seekInterval
|
||||||
by StringIntSetting(getKey(R.string.setting_key_increment_time), 5000)
|
by StringIntSetting(getKey(R.string.setting_key_increment_time), 5000)
|
||||||
|
|
||||||
|
val seekIntervalMillis: Long
|
||||||
|
get() = (seekInterval / 1000).toLong()
|
||||||
|
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
var mediaButtonsEnabled
|
var mediaButtonsEnabled
|
||||||
by BooleanSetting(getKey(R.string.setting_key_media_buttons), true)
|
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,
|
// Normally you don't need to use these Settings directly,
|
||||||
// use ActiveServerProvider.isID3Enabled() instead
|
// use ActiveServerProvider.isID3Enabled() instead
|
||||||
@JvmStatic
|
@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.
|
// See comment above.
|
||||||
@JvmStatic
|
@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)
|
var activeServer by IntSetting(getKey(R.string.setting_key_server_instance), -1)
|
||||||
|
|
||||||
@ -209,7 +215,6 @@ object Settings {
|
|||||||
@JvmStatic
|
@JvmStatic
|
||||||
val shareGreeting: String?
|
val shareGreeting: String?
|
||||||
get() {
|
get() {
|
||||||
val preferences = preferences
|
|
||||||
val context = Util.appContext()
|
val context = Util.appContext()
|
||||||
val defaultVal = String.format(
|
val defaultVal = String.format(
|
||||||
context.resources.getString(R.string.share_default_greeting),
|
context.resources.getString(R.string.share_default_greeting),
|
||||||
@ -278,8 +283,7 @@ object Settings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun getAllKeys(): List<String> {
|
fun getAllKeys(): List<String> {
|
||||||
val prefs = PreferenceManager.getDefaultSharedPreferences(UApp.applicationContext())
|
return preferences.all.keys.toList()
|
||||||
return prefs.all.keys.toList()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private val appContext: Context
|
private val appContext: Context
|
||||||
|
@ -833,6 +833,7 @@ object Util {
|
|||||||
Timber.d("Current user preferences")
|
Timber.d("Current user preferences")
|
||||||
Timber.d("========================")
|
Timber.d("========================")
|
||||||
val keys = Settings.preferences.all
|
val keys = Settings.preferences.all
|
||||||
|
|
||||||
keys.forEach {
|
keys.forEach {
|
||||||
Timber.d("${it.key}: ${it.value}")
|
Timber.d("${it.key}: ${it.value}")
|
||||||
}
|
}
|
||||||
|
@ -119,7 +119,7 @@
|
|||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
</FrameLayout>
|
</FrameLayout>
|
||||||
|
|
||||||
<include layout="@layout/current_playlist" />
|
<include layout="@layout/current_playlist" a:id="@+id/playlist"/>
|
||||||
</ViewFlipper>
|
</ViewFlipper>
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
@ -112,7 +112,7 @@
|
|||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
</RelativeLayout>
|
</RelativeLayout>
|
||||||
|
|
||||||
<include layout="@layout/current_playlist" />
|
<include layout="@layout/current_playlist" a:id="@+id/playlist"/>
|
||||||
</ViewFlipper>
|
</ViewFlipper>
|
||||||
|
|
||||||
<include layout="@layout/player_media_info" />
|
<include layout="@layout/player_media_info" />
|
||||||
|
@ -5,13 +5,17 @@
|
|||||||
a:layout_height="fill_parent"
|
a:layout_height="fill_parent"
|
||||||
a:orientation="vertical">
|
a:orientation="vertical">
|
||||||
|
|
||||||
<TextView
|
<com.google.android.material.progressindicator.CircularProgressIndicator
|
||||||
a:id="@+id/playlist_empty"
|
a:id="@+id/progress_indicator"
|
||||||
a:layout_width="fill_parent"
|
a:layout_width="wrap_content"
|
||||||
a:layout_height="wrap_content"
|
a:layout_height="0dip"
|
||||||
a:padding="10dip"
|
a:indeterminate="true"
|
||||||
a:text="@string/playlist.empty" />
|
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
|
<com.simplecityapps.recyclerview_fastscroll.views.FastScrollRecyclerView
|
||||||
a:id="@+id/playlist_view"
|
a:id="@+id/playlist_view"
|
||||||
|
@ -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>
|
|
@ -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_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_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_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_equalizer">Ekvalizér</string>
|
||||||
<string name="download.menu_jukebox_off">Jukebox vypnut</string>
|
<string name="download.menu_jukebox_off">Jukebox vypnut</string>
|
||||||
<string name="download.menu_jukebox_on">Jukebox zapnut</string>
|
<string name="download.menu_jukebox_on">Jukebox zapnut</string>
|
||||||
|
@ -61,7 +61,6 @@
|
|||||||
<string name="download.jukebox_offline">Fernbedienungs-Modus is Offline nicht verfügbar.</string>
|
<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_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_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_equalizer">Equalizer</string>
|
||||||
<string name="download.menu_jukebox_off">Jukebox Aus</string>
|
<string name="download.menu_jukebox_off">Jukebox Aus</string>
|
||||||
<string name="download.menu_jukebox_on">Jukebox An</string>
|
<string name="download.menu_jukebox_on">Jukebox An</string>
|
||||||
|
@ -62,7 +62,6 @@
|
|||||||
<string name="download.jukebox_offline">Control remoto no disponible en modo fuera de línea.</string>
|
<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_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_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_equalizer">Ecualizador</string>
|
||||||
<string name="download.menu_jukebox_off">Apagar Jukebox</string>
|
<string name="download.menu_jukebox_off">Apagar Jukebox</string>
|
||||||
<string name="download.menu_jukebox_on">Encender Jukebox</string>
|
<string name="download.menu_jukebox_on">Encender Jukebox</string>
|
||||||
@ -453,4 +452,5 @@
|
|||||||
<string name="supported_server_features">Funciones soportadas</string>
|
<string name="supported_server_features">Funciones soportadas</string>
|
||||||
<string name="foreground_exception_title">No se puede reanudar la reproducción</string>
|
<string name="foreground_exception_title">No se puede reanudar la reproducción</string>
|
||||||
<string name="foreground_exception_text">Presione el botón de reproducción en la notificación de medios si aún está presente; de lo contrario, abra la aplicación para iniciar la reproducción y vuelva a conectar la sesión al controlador</string>
|
<string name="foreground_exception_text">Presione el botón de reproducción en la notificación de medios si aún está presente; de lo contrario, abra la aplicación para iniciar la reproducción y vuelva a conectar la sesión al controlador</string>
|
||||||
|
<string name="settings.max_bitrate_pinning">Tasa de bits máxima: al fijar una canción de forma permanente</string>
|
||||||
</resources>
|
</resources>
|
@ -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_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_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_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_equalizer">Égaliseur</string>
|
||||||
<string name="download.menu_jukebox_off">Désactiver le mode jukebox</string>
|
<string name="download.menu_jukebox_off">Désactiver le mode jukebox</string>
|
||||||
<string name="download.menu_jukebox_on">Activer le mode jukebox</string>
|
<string name="download.menu_jukebox_on">Activer le mode jukebox</string>
|
||||||
|
@ -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_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_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_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_equalizer">Equalizer</string>
|
||||||
<string name="download.menu_jukebox_off">Jukebox ki</string>
|
<string name="download.menu_jukebox_off">Jukebox ki</string>
|
||||||
<string name="download.menu_jukebox_on">Jukebox be</string>
|
<string name="download.menu_jukebox_on">Jukebox be</string>
|
||||||
|
@ -45,7 +45,6 @@
|
|||||||
<string name="download.jukebox_offline">Il controllo remoto non è disponibile nella modalità offline. </string>
|
<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_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_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_equalizer">Equalizzatore</string>
|
||||||
<string name="download.menu_jukebox_off">Jukebox spento</string>
|
<string name="download.menu_jukebox_off">Jukebox spento</string>
|
||||||
<string name="download.menu_jukebox_on">Jukebox acceso</string>
|
<string name="download.menu_jukebox_on">Jukebox acceso</string>
|
||||||
|
@ -42,7 +42,6 @@
|
|||||||
<string name="download.jukebox_on">リモートコントロールがオンになりました。サーバーで音楽が再生されます。</string>
|
<string name="download.jukebox_on">リモートコントロールがオンになりました。サーバーで音楽が再生されます。</string>
|
||||||
<string name="download.jukebox_off">リモートコントロールがオフになりました。モバイル端末で音楽が再生されます。</string>
|
<string name="download.jukebox_off">リモートコントロールがオフになりました。モバイル端末で音楽が再生されます。</string>
|
||||||
<string name="download.jukebox_server_too_old">リモートコントロールがサポートされていません。Subsonicサーバーをアップグレードしてください。</string>
|
<string name="download.jukebox_server_too_old">リモートコントロールがサポートされていません。Subsonicサーバーをアップグレードしてください。</string>
|
||||||
<string name="download.jukebox_volume">リモート音量</string>
|
|
||||||
<string name="download.menu_jukebox_on">ジュークボックス ON</string>
|
<string name="download.menu_jukebox_on">ジュークボックス ON</string>
|
||||||
<string name="download.menu_lyrics">歌詞</string>
|
<string name="download.menu_lyrics">歌詞</string>
|
||||||
<string name="download.menu_show_album">アルバムを表示</string>
|
<string name="download.menu_show_album">アルバムを表示</string>
|
||||||
|
@ -339,7 +339,6 @@
|
|||||||
<string name="download.jukebox_not_authorized">Fjernkontroll er avskrudd. Skru på jukebox-modus i <b>Brukere > Innstillinger</b> på din Subsonic-tjener.</string>
|
<string name="download.jukebox_not_authorized">Fjernkontroll er avskrudd. Skru på jukebox-modus i <b>Brukere > Innstillinger</b> på din Subsonic-tjener.</string>
|
||||||
<string name="download.jukebox_off">Fjernkontroll avskrudd. Musikk spilles på enheten.</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_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_off">Jukebox avslått</string>
|
||||||
<string name="download.menu_jukebox_on">Jukebox påslått</string>
|
<string name="download.menu_jukebox_on">Jukebox påslått</string>
|
||||||
<string name="download.menu_shuffle">Omstokking</string>
|
<string name="download.menu_shuffle">Omstokking</string>
|
||||||
|
@ -63,7 +63,6 @@
|
|||||||
<string name="download.jukebox_offline">Afstandsbediening is niet beschikbaar in offline-modus.</string>
|
<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_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_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_equalizer">Equalizer</string>
|
||||||
<string name="download.menu_jukebox_off">Jukebox uitgeschakeld</string>
|
<string name="download.menu_jukebox_off">Jukebox uitgeschakeld</string>
|
||||||
<string name="download.menu_jukebox_on">Jukebox ingeschakeld</string>
|
<string name="download.menu_jukebox_on">Jukebox ingeschakeld</string>
|
||||||
|
@ -47,7 +47,6 @@
|
|||||||
<string name="download.jukebox_offline">Pilot jest niedostępny w trybie offline.</string>
|
<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_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_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_equalizer">Korektor dźwięku</string>
|
||||||
<string name="download.menu_jukebox_off">Jukebox wyłączony</string>
|
<string name="download.menu_jukebox_off">Jukebox wyłączony</string>
|
||||||
<string name="download.menu_jukebox_on">Jukebox włączony</string>
|
<string name="download.menu_jukebox_on">Jukebox włączony</string>
|
||||||
|
@ -62,7 +62,6 @@
|
|||||||
<string name="download.jukebox_offline">Controle remoto não está disponível no modo offline.</string>
|
<string name="download.jukebox_offline">Controle remoto não está disponível no modo offline.</string>
|
||||||
<string name="download.jukebox_on">Controle remoto ligado. Música tocada no servidor.</string>
|
<string name="download.jukebox_on">Controle remoto ligado. Música tocada no servidor.</string>
|
||||||
<string name="download.jukebox_server_too_old">Controle remoto não suportado. Atualize seu servidor Subsonic.</string>
|
<string name="download.jukebox_server_too_old">Controle remoto não suportado. Atualize seu servidor Subsonic.</string>
|
||||||
<string name="download.jukebox_volume">Volume Remoto</string>
|
|
||||||
<string name="download.menu_equalizer">Equalizador</string>
|
<string name="download.menu_equalizer">Equalizador</string>
|
||||||
<string name="download.menu_jukebox_off">Jukebox Desligado</string>
|
<string name="download.menu_jukebox_off">Jukebox Desligado</string>
|
||||||
<string name="download.menu_jukebox_on">Jukebox Ligado</string>
|
<string name="download.menu_jukebox_on">Jukebox Ligado</string>
|
||||||
|
@ -47,7 +47,6 @@
|
|||||||
<string name="download.jukebox_offline">Controle remoto não está disponível no modo offline.</string>
|
<string name="download.jukebox_offline">Controle remoto não está disponível no modo offline.</string>
|
||||||
<string name="download.jukebox_on">Controle remoto ligado. Música tocada no servidor.</string>
|
<string name="download.jukebox_on">Controle remoto ligado. Música tocada no servidor.</string>
|
||||||
<string name="download.jukebox_server_too_old">Controle remoto não suportado. Atualize seu servidor Subsonic.</string>
|
<string name="download.jukebox_server_too_old">Controle remoto não suportado. Atualize seu servidor Subsonic.</string>
|
||||||
<string name="download.jukebox_volume">Volume Remoto</string>
|
|
||||||
<string name="download.menu_equalizer">Equalizador</string>
|
<string name="download.menu_equalizer">Equalizador</string>
|
||||||
<string name="download.menu_jukebox_off">Jukebox Desligado</string>
|
<string name="download.menu_jukebox_off">Jukebox Desligado</string>
|
||||||
<string name="download.menu_jukebox_on">Jukebox Ligado</string>
|
<string name="download.menu_jukebox_on">Jukebox Ligado</string>
|
||||||
|
@ -59,7 +59,6 @@
|
|||||||
<string name="download.jukebox_offline">Пульт дистанционного управления недоступен в автономном режиме.</string>
|
<string name="download.jukebox_offline">Пульт дистанционного управления недоступен в автономном режиме.</string>
|
||||||
<string name="download.jukebox_on">Включен пульт управления. Музыка играет на сервере.</string>
|
<string name="download.jukebox_on">Включен пульт управления. Музыка играет на сервере.</string>
|
||||||
<string name="download.jukebox_server_too_old">Пульт дистанционного управления не поддерживается. Пожалуйста, обновите ваш дозвуковой сервер.</string>
|
<string name="download.jukebox_server_too_old">Пульт дистанционного управления не поддерживается. Пожалуйста, обновите ваш дозвуковой сервер.</string>
|
||||||
<string name="download.jukebox_volume">Удаленная громкость</string>
|
|
||||||
<string name="download.menu_equalizer">Эквалайзер</string>
|
<string name="download.menu_equalizer">Эквалайзер</string>
|
||||||
<string name="download.menu_jukebox_off">Jukebox выключен</string>
|
<string name="download.menu_jukebox_off">Jukebox выключен</string>
|
||||||
<string name="download.menu_jukebox_on">Jukebox включен</string>
|
<string name="download.menu_jukebox_on">Jukebox включен</string>
|
||||||
|
@ -60,7 +60,6 @@
|
|||||||
<string name="download.jukebox_offline">离线模式不支持远程控制。</string>
|
<string name="download.jukebox_offline">离线模式不支持远程控制。</string>
|
||||||
<string name="download.jukebox_on">已打开远程控制,音乐将在服务端播放。</string>
|
<string name="download.jukebox_on">已打开远程控制,音乐将在服务端播放。</string>
|
||||||
<string name="download.jukebox_server_too_old">远程控制不支持,请升级您的 Subsonic 服务器。</string>
|
<string name="download.jukebox_server_too_old">远程控制不支持,请升级您的 Subsonic 服务器。</string>
|
||||||
<string name="download.jukebox_volume">远程音量</string>
|
|
||||||
<string name="download.menu_equalizer">均衡器</string>
|
<string name="download.menu_equalizer">均衡器</string>
|
||||||
<string name="download.menu_jukebox_off">关闭点唱机</string>
|
<string name="download.menu_jukebox_off">关闭点唱机</string>
|
||||||
<string name="download.menu_jukebox_on">开启点唱机</string>
|
<string name="download.menu_jukebox_on">开启点唱机</string>
|
||||||
@ -68,7 +67,7 @@
|
|||||||
<string name="download.menu_save">保存播放列表</string>
|
<string name="download.menu_save">保存播放列表</string>
|
||||||
<string name="download.menu_screen_off">关闭屏幕常亮</string>
|
<string name="download.menu_screen_off">关闭屏幕常亮</string>
|
||||||
<string name="download.menu_screen_on">开启屏幕常亮</string>
|
<string name="download.menu_screen_on">开启屏幕常亮</string>
|
||||||
<string name="download.menu_show_album">显示专辑</string>
|
<string name="download.menu_show_album">转到专辑</string>
|
||||||
<string name="download.menu_shuffle">随机</string>
|
<string name="download.menu_shuffle">随机</string>
|
||||||
<string name="download.menu_shuffle_on">随机播放模式已启用</string>
|
<string name="download.menu_shuffle_on">随机播放模式已启用</string>
|
||||||
<string name="download.menu_shuffle_off">随机播放模式已禁用</string>
|
<string name="download.menu_shuffle_off">随机播放模式已禁用</string>
|
||||||
@ -281,7 +280,7 @@
|
|||||||
<string name="settings.test_connection_title">测试连接</string>
|
<string name="settings.test_connection_title">测试连接</string>
|
||||||
<string name="settings.theme_light">亮色</string>
|
<string name="settings.theme_light">亮色</string>
|
||||||
<string name="settings.theme_dark">暗色</string>
|
<string name="settings.theme_dark">暗色</string>
|
||||||
<string name="settings.theme_black">Black</string>
|
<string name="settings.theme_black">黑色</string>
|
||||||
<string name="settings.theme_title">主题</string>
|
<string name="settings.theme_title">主题</string>
|
||||||
<string name="settings.title.allow_self_signed_certificate">允许自签名 HTTPS 证书</string>
|
<string name="settings.title.allow_self_signed_certificate">允许自签名 HTTPS 证书</string>
|
||||||
<string name="settings.title.force_plain_text_password">强制原始密码认证</string>
|
<string name="settings.title.force_plain_text_password">强制原始密码认证</string>
|
||||||
@ -338,7 +337,7 @@
|
|||||||
<string name="share_default_greeting">看看我从 %s 分享的这首音乐</string>
|
<string name="share_default_greeting">看看我从 %s 分享的这首音乐</string>
|
||||||
<string name="share_via">分享歌曲通过</string>
|
<string name="share_via">分享歌曲通过</string>
|
||||||
<string name="menu.share">分享</string>
|
<string name="menu.share">分享</string>
|
||||||
<string name="download.menu_show_artist">显示艺术家</string>
|
<string name="download.menu_show_artist">转到艺术家</string>
|
||||||
<string name="common_multiple_years">数年</string>
|
<string name="common_multiple_years">数年</string>
|
||||||
<string name="settings.debug.title">调试选项</string>
|
<string name="settings.debug.title">调试选项</string>
|
||||||
<string name="settings.debug.log_to_file">将调试日志写入文件</string>
|
<string name="settings.debug.log_to_file">将调试日志写入文件</string>
|
||||||
@ -449,4 +448,5 @@
|
|||||||
<string name="language.cs">捷克语</string>
|
<string name="language.cs">捷克语</string>
|
||||||
<string name="language.de">德语</string>
|
<string name="language.de">德语</string>
|
||||||
<string name="language.pt_BR">葡萄牙语(巴西)</string>
|
<string name="language.pt_BR">葡萄牙语(巴西)</string>
|
||||||
|
<string name="settings.max_bitrate_pinning">最大比特率 - 永久固定歌曲时</string>
|
||||||
</resources>
|
</resources>
|
@ -84,7 +84,7 @@
|
|||||||
<string name="settings.increment_time_8">8 秒</string>
|
<string name="settings.increment_time_8">8 秒</string>
|
||||||
<string name="settings.custom_cache_location">使用自訂緩衝路徑</string>
|
<string name="settings.custom_cache_location">使用自訂緩衝路徑</string>
|
||||||
<string name="settings.cache_location">緩衝路径</string>
|
<string name="settings.cache_location">緩衝路径</string>
|
||||||
<string name="settings.cache_location_error">錯誤緩衝路徑,使用預設緩衝路徑</string>
|
<string name="settings.cache_location_error">錯誤緩衝路徑,使用預設緩衝路徑。</string>
|
||||||
<string name="settings.cache_size">緩衝大小</string>
|
<string name="settings.cache_size">緩衝大小</string>
|
||||||
<string name="settings.cache_size_100">100 MB</string>
|
<string name="settings.cache_size_100">100 MB</string>
|
||||||
<string name="settings.cache_size_1000">1 GB</string>
|
<string name="settings.cache_size_1000">1 GB</string>
|
||||||
@ -129,7 +129,7 @@
|
|||||||
<string name="time_span_disabled">已停用</string>
|
<string name="time_span_disabled">已停用</string>
|
||||||
<string name="share_comment">註記</string>
|
<string name="share_comment">註記</string>
|
||||||
<string name="server_menu.delete">刪除</string>
|
<string name="server_menu.delete">刪除</string>
|
||||||
<string name="download.menu_show_album">顯示專輯</string>
|
<string name="download.menu_show_album">轉至專輯</string>
|
||||||
<string name="language.zh_CN">簡體中文(中國)</string>
|
<string name="language.zh_CN">簡體中文(中國)</string>
|
||||||
<string name="download.menu_save">儲存播放清單</string>
|
<string name="download.menu_save">儲存播放清單</string>
|
||||||
<string name="download.bookmark_set_at_position" formatted="false">書籤設置在 %s。</string>
|
<string name="download.bookmark_set_at_position" formatted="false">書籤設置在 %s。</string>
|
||||||
@ -141,7 +141,6 @@
|
|||||||
<string name="common.pin">固定</string>
|
<string name="common.pin">固定</string>
|
||||||
<string name="chat.send_button">傳送</string>
|
<string name="chat.send_button">傳送</string>
|
||||||
<string name="button_bar.chat">聊天</string>
|
<string name="button_bar.chat">聊天</string>
|
||||||
<string name="download.jukebox_volume">遠端音量</string>
|
|
||||||
<string name="chat.user_avatar">頭像</string>
|
<string name="chat.user_avatar">頭像</string>
|
||||||
<string name="common.delete_selection_confirmation">您真的要刪除目前選取的項目嗎?</string>
|
<string name="common.delete_selection_confirmation">您真的要刪除目前選取的項目嗎?</string>
|
||||||
<string name="background_task.parse_error">無法理解答覆,請檢查伺服器位址。</string>
|
<string name="background_task.parse_error">無法理解答覆,請檢查伺服器位址。</string>
|
||||||
@ -233,4 +232,68 @@
|
|||||||
<string name="settings.hide_media_toast">在 Android 系統下次掃描裝置內音樂時生效。</string>
|
<string name="settings.hide_media_toast">在 Android 系統下次掃描裝置內音樂時生效。</string>
|
||||||
<string name="settings.download_transition">播放時顯示正在播放介面</string>
|
<string name="settings.download_transition">播放時顯示正在播放介面</string>
|
||||||
<string name="settings.download_transition_summary">在媒體庫介面開始播放後切換到正在播放介面</string>
|
<string name="settings.download_transition_summary">在媒體庫介面開始播放後切換到正在播放介面</string>
|
||||||
|
<string name="settings.search_50">50</string>
|
||||||
|
<string name="settings.preload_3">3 首歌</string>
|
||||||
|
<string name="settings.search_1">1</string>
|
||||||
|
<string name="settings.search_20">20</string>
|
||||||
|
<string name="settings.search_75">75</string>
|
||||||
|
<string name="settings.share_minutes">分鐘</string>
|
||||||
|
<string name="settings.preload_500">500 首歌</string>
|
||||||
|
<string name="settings.max_bitrate_112">112 Kbps</string>
|
||||||
|
<string name="settings.preload_1">1 首歌</string>
|
||||||
|
<string name="settings.search_3">3</string>
|
||||||
|
<string name="settings.search_40">40</string>
|
||||||
|
<string name="settings.search_500">500</string>
|
||||||
|
<string name="settings.max_bitrate_160">160 Kbps</string>
|
||||||
|
<string name="util.zero_time">0:00</string>
|
||||||
|
<string name="settings.network_timeout_120000">120 秒</string>
|
||||||
|
<string name="settings.share_hours">小時</string>
|
||||||
|
<string name="settings.theme_black">黑色</string>
|
||||||
|
<string name="server_editor.authentication">認證</string>
|
||||||
|
<string name="settings.preload_2">2 首歌</string>
|
||||||
|
<string name="settings.search_10">10</string>
|
||||||
|
<string name="settings.server_address">伺服器地址</string>
|
||||||
|
<string name="settings.network_timeout_60000">60 秒</string>
|
||||||
|
<string name="settings.search_5">5</string>
|
||||||
|
<string name="settings.preload_100">100 首歌</string>
|
||||||
|
<string name="settings.theme_light">明色</string>
|
||||||
|
<string name="settings.max_bitrate_96">96 Kbps</string>
|
||||||
|
<string name="util.bytes_format.kilobyte">0 KB</string>
|
||||||
|
<string name="settings.override_language">覆寫當前語言</string>
|
||||||
|
<string name="settings.preload_5">5 首歌</string>
|
||||||
|
<string name="settings.search_250">250</string>
|
||||||
|
<string name="settings.max_bitrate_192">192 Kbps</string>
|
||||||
|
<string name="settings.max_bitrate_80">80 Kbps</string>
|
||||||
|
<string name="settings.search_25">25</string>
|
||||||
|
<string name="settings.search_30">30</string>
|
||||||
|
<string name="settings.search_100">100</string>
|
||||||
|
<string name="main.video" tools:ignore="UnusedResources">影片</string>
|
||||||
|
<string name="time_span_disable">禁用</string>
|
||||||
|
<string name="settings.preload_50">50 首歌</string>
|
||||||
|
<string name="song_details.kbps">%d kbps</string>
|
||||||
|
<string name="settings.preload_10">10 首歌</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.network_timeout">網路延時</string>
|
||||||
|
<string name="settings.network_timeout_105000">105 秒</string>
|
||||||
|
<string name="settings.network_timeout_45000">45 秒</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.search_15">15</string>
|
||||||
|
<string name="settings.server_color">伺服器顏色</string>
|
||||||
|
<string name="util.bytes_format.byte">0 B</string>
|
||||||
|
<string name="util.bytes_format.gigabyte">0.00 GB</string>
|
||||||
|
<string name="settings.server_username">用戶名</string>
|
||||||
|
<string name="settings.theme_dark">暗色</string>
|
||||||
|
<string name="util.no_time" tools:ignore="TypographyDashes">-:--</string>
|
||||||
|
<string name="util.bytes_format.megabyte">0.00 MB</string>
|
||||||
|
<string name="settings.preload_1000">1000 首歌</string>
|
||||||
|
<string name="settings.server_password">密碼</string>
|
||||||
|
<string name="settings.share_days">天</string>
|
||||||
|
<string name="settings.max_bitrate_128">128 Kbps</string>
|
||||||
</resources>
|
</resources>
|
@ -12,6 +12,7 @@
|
|||||||
<string name="setting_key.show_track_number" translatable="false">showTrackNumber</string>
|
<string name="setting_key.show_track_number" translatable="false">showTrackNumber</string>
|
||||||
<string name="setting_key.max_bitrate_wifi" translatable="false">maxBitrateWifi</string>
|
<string name="setting_key.max_bitrate_wifi" translatable="false">maxBitrateWifi</string>
|
||||||
<string name="setting_key.max_bitrate_mobile" translatable="false">maxBitrateMobile</string>
|
<string name="setting_key.max_bitrate_mobile" translatable="false">maxBitrateMobile</string>
|
||||||
|
<string name="setting_key.max_bitrate_pinning" translatable="false">maxBitratePinning</string>
|
||||||
<string name="setting_key.cache_size" translatable="false">cacheSize</string>
|
<string name="setting_key.cache_size" translatable="false">cacheSize</string>
|
||||||
<string name="setting_key.custom_cache_location" translatable="false">customCacheLocation</string>
|
<string name="setting_key.custom_cache_location" translatable="false">customCacheLocation</string>
|
||||||
<string name="setting_key.cache_location" translatable="false">cacheLocation</string>
|
<string name="setting_key.cache_location" translatable="false">cacheLocation</string>
|
||||||
|
@ -63,7 +63,6 @@
|
|||||||
<string name="download.jukebox_offline">Remote control is not available in offline mode.</string>
|
<string name="download.jukebox_offline">Remote control is not available in offline mode.</string>
|
||||||
<string name="download.jukebox_on">Turned on remote control. Music is played on server.</string>
|
<string name="download.jukebox_on">Turned on remote control. Music is played on server.</string>
|
||||||
<string name="download.jukebox_server_too_old">Remote control is not supported. Please upgrade your Subsonic server.</string>
|
<string name="download.jukebox_server_too_old">Remote control is not supported. Please upgrade your Subsonic server.</string>
|
||||||
<string name="download.jukebox_volume">Remote Volume</string>
|
|
||||||
<string name="download.menu_equalizer">Equalizer</string>
|
<string name="download.menu_equalizer">Equalizer</string>
|
||||||
<string name="download.menu_jukebox_off">Jukebox Off</string>
|
<string name="download.menu_jukebox_off">Jukebox Off</string>
|
||||||
<string name="download.menu_jukebox_on">Jukebox On</string>
|
<string name="download.menu_jukebox_on">Jukebox On</string>
|
||||||
@ -234,6 +233,7 @@
|
|||||||
<string name="settings.max_bitrate_mobile">Max Bitrate - Mobile</string>
|
<string name="settings.max_bitrate_mobile">Max Bitrate - Mobile</string>
|
||||||
<string name="settings.max_bitrate_unlimited">Unlimited</string>
|
<string name="settings.max_bitrate_unlimited">Unlimited</string>
|
||||||
<string name="settings.max_bitrate_wifi">Max Bitrate - Wi-Fi</string>
|
<string name="settings.max_bitrate_wifi">Max Bitrate - Wi-Fi</string>
|
||||||
|
<string name="settings.max_bitrate_pinning">Max Bitrate - When pinning a song permanently</string>
|
||||||
<string name="settings.max_songs">Max Songs</string>
|
<string name="settings.max_songs">Max Songs</string>
|
||||||
<string name="settings.media_button_summary">Respond to phone, headset and Bluetooth media buttons</string>
|
<string name="settings.media_button_summary">Respond to phone, headset and Bluetooth media buttons</string>
|
||||||
<string name="settings.media_button_title">Media Buttons</string>
|
<string name="settings.media_button_title">Media Buttons</string>
|
||||||
|
@ -167,18 +167,25 @@
|
|||||||
a:title="@string/settings.network_title"
|
a:title="@string/settings.network_title"
|
||||||
app:iconSpaceReserved="false">
|
app:iconSpaceReserved="false">
|
||||||
<ListPreference
|
<ListPreference
|
||||||
a:defaultValue="0"
|
a:defaultValue="256"
|
||||||
|
a:entries="@array/maxBitrateNames"
|
||||||
|
a:entryValues="@array/maxBitrateValues"
|
||||||
|
a:key="@string/setting_key.max_bitrate_mobile"
|
||||||
|
a:title="@string/settings.max_bitrate_mobile"
|
||||||
|
app:iconSpaceReserved="false"/>
|
||||||
|
<ListPreference
|
||||||
|
a:defaultValue="320"
|
||||||
a:entries="@array/maxBitrateNames"
|
a:entries="@array/maxBitrateNames"
|
||||||
a:entryValues="@array/maxBitrateValues"
|
a:entryValues="@array/maxBitrateValues"
|
||||||
a:key="@string/setting_key.max_bitrate_wifi"
|
a:key="@string/setting_key.max_bitrate_wifi"
|
||||||
a:title="@string/settings.max_bitrate_wifi"
|
a:title="@string/settings.max_bitrate_wifi"
|
||||||
app:iconSpaceReserved="false"/>
|
app:iconSpaceReserved="false"/>
|
||||||
<ListPreference
|
<ListPreference
|
||||||
a:defaultValue="0"
|
a:defaultValue="320"
|
||||||
a:entries="@array/maxBitrateNames"
|
a:entries="@array/maxBitrateNames"
|
||||||
a:entryValues="@array/maxBitrateValues"
|
a:entryValues="@array/maxBitrateValues"
|
||||||
a:key="@string/setting_key.max_bitrate_mobile"
|
a:key="@string/setting_key.max_bitrate_pinning"
|
||||||
a:title="@string/settings.max_bitrate_mobile"
|
a:title="@string/settings.max_bitrate_pinning"
|
||||||
app:iconSpaceReserved="false"/>
|
app:iconSpaceReserved="false"/>
|
||||||
<CheckBoxPreference
|
<CheckBoxPreference
|
||||||
a:defaultValue="false"
|
a:defaultValue="false"
|
||||||
|
Loading…
x
Reference in New Issue
Block a user