mirror of
https://gitlab.com/ultrasonic/ultrasonic.git
synced 2025-07-20 18:31:58 +03:00
Compare commits
133 Commits
b90de49dfc
...
e284cd1fa1
Author | SHA1 | Date | |
---|---|---|---|
|
e284cd1fa1 | ||
|
9f4858becd | ||
|
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 | ||
|
33e4913761 | ||
|
aede9be97c | ||
|
97556a36e5 | ||
|
fb970ffb80 | ||
|
6de6cda7a4 | ||
|
5eed5c70b5 | ||
|
6e1078a256 | ||
|
c6c58262af | ||
|
2df89f4d81 | ||
|
e83026f29a | ||
|
233e4f7a67 | ||
|
dba12d147f | ||
|
ccdd994756 | ||
|
dfcac45669 | ||
|
f72fc1885c | ||
|
db40d95215 | ||
|
c2f4b58088 | ||
|
82d2596c66 | ||
|
ccd7f5881d | ||
|
c7edfbcae6 | ||
|
b1839c9562 | ||
|
dbef8307ea | ||
|
ee52070925 | ||
|
a406b8d211 | ||
|
367c1508b5 | ||
|
e5fce6a832 | ||
|
6b5c96ea74 | ||
|
d2faad60ca | ||
|
2d7c26f13d | ||
|
116e5aa4cf | ||
|
3e8f45a073 | ||
|
8090d4e039 |
@ -1,5 +1,5 @@
|
||||
default:
|
||||
image: registry.gitlab.com/ultrasonic/ci-android:latest
|
||||
image: registry.gitlab.com/ultrasonic/ci-android:1.1.0
|
||||
cache: &global_cache
|
||||
key:
|
||||
files:
|
||||
|
2
.idea/compiler.xml
generated
2
.idea/compiler.xml
generated
@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="CompilerConfiguration">
|
||||
<bytecodeTargetLevel target="11" />
|
||||
<bytecodeTargetLevel target="17" />
|
||||
</component>
|
||||
</project>
|
@ -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
|
||||
open [a new issue][newissue].
|
||||
|
||||
### Known (not our) bugs
|
||||
|
||||
If you are using *Madsonic 5.1.X* several sections of Ultrasonic will not
|
||||
work. This is caused by bad implementation of Subsonic API by Madsonic. For
|
||||
more info about this you can read [this bug][madbug].
|
||||
|
||||
## Contributing
|
||||
|
||||
See [CONTRIBUTING](CONTRIBUTING.md).
|
||||
@ -62,7 +56,6 @@ Full text of the license is available in the [LICENSE](LICENSE) file and
|
||||
[wikiaa]: https://gitlab.com/ultrasonic/ultrasonic/-/wikis/Using-Ultrasonic-with-Android-Auto
|
||||
[issues]: https://gitlab.com/ultrasonic/ultrasonic/-/issues
|
||||
[newissue]: https://gitlab.com/ultrasonic/ultrasonic/-/issues/new
|
||||
[madbug]: https://gitlab.com/ultrasonic/ultrasonic/-/issues/129
|
||||
[subsonic]: http://www.subsonic.org/
|
||||
[subapi]: http://www.subsonic.org/pages/api.jsp
|
||||
[airsonic]: https://github.com/airsonic-advanced/airsonic-advanced
|
||||
|
@ -11,7 +11,6 @@ buildscript {
|
||||
google()
|
||||
mavenCentral()
|
||||
maven { url "https://plugins.gradle.org/m2/" }
|
||||
maven { url 'https://jitpack.io' }
|
||||
}
|
||||
dependencies {
|
||||
classpath libs.gradle
|
||||
@ -34,13 +33,12 @@ allprojects {
|
||||
repositories {
|
||||
mavenCentral()
|
||||
google()
|
||||
maven { url 'https://jitpack.io' }
|
||||
}
|
||||
|
||||
// Set Kotlin JVM target to the same for all subprojects
|
||||
tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).all {
|
||||
kotlinOptions {
|
||||
jvmTarget = "11"
|
||||
jvmTarget = "17"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -48,4 +46,4 @@ allprojects {
|
||||
wrapper {
|
||||
gradleVersion(libs.versions.gradle.get())
|
||||
distributionType("all")
|
||||
}
|
||||
}
|
@ -2,6 +2,7 @@ apply from: bootstrap.androidModule
|
||||
apply plugin: 'kotlin-kapt'
|
||||
|
||||
dependencies {
|
||||
implementation libs.core
|
||||
implementation libs.roomRuntime
|
||||
implementation libs.roomKtx
|
||||
kapt libs.room
|
||||
|
@ -13,7 +13,6 @@ dependencies {
|
||||
|
||||
testImplementation libs.kotlinJunit
|
||||
testImplementation libs.mockito
|
||||
testImplementation libs.mockitoInline
|
||||
testImplementation libs.mockitoKotlin
|
||||
testImplementation libs.kluent
|
||||
testImplementation libs.mockWebServer
|
||||
|
4
fastlane/metadata/android/en-US/changelogs/114.txt
Normal file
4
fastlane/metadata/android/en-US/changelogs/114.txt
Normal file
@ -0,0 +1,4 @@
|
||||
Bug fixes
|
||||
- Fix a crash when a ID3 tag date is in a wrong format.
|
||||
- Fix a crash on API 31 (newest Android).
|
||||
- Fix empty search results.
|
2
fastlane/metadata/android/en-US/changelogs/115.txt
Normal file
2
fastlane/metadata/android/en-US/changelogs/115.txt
Normal file
@ -0,0 +1,2 @@
|
||||
Bug fixes
|
||||
- Fix a crash when downloading the album art.
|
8
fastlane/metadata/android/en-US/changelogs/116.txt
Normal file
8
fastlane/metadata/android/en-US/changelogs/116.txt
Normal file
@ -0,0 +1,8 @@
|
||||
Bug fixes
|
||||
- Fix various crashes
|
||||
|
||||
Changes since 4.2.0
|
||||
- #827: Make app full compliant Android Auto to publish in Play Store.
|
||||
- #878: "Play shuffled" option for playlists always begins with the first track.
|
||||
- #891: Dump config to log file when logging is enabled.
|
||||
- #854: Remove Videos menu option for servers which don't support it.
|
8
fastlane/metadata/android/en-US/changelogs/117.txt
Normal file
8
fastlane/metadata/android/en-US/changelogs/117.txt
Normal file
@ -0,0 +1,8 @@
|
||||
Bug fixes
|
||||
- Fix more exceptions
|
||||
|
||||
Changes since 4.2.0
|
||||
- #827: Make app full compliant Android Auto to publish in Play Store.
|
||||
- #878: "Play shuffled" option for playlists always begins with the first track.
|
||||
- #891: Dump config to log file when logging is enabled.
|
||||
- #854: Remove Videos menu option for servers which don't support it.
|
10
fastlane/metadata/android/en-US/changelogs/119.txt
Normal file
10
fastlane/metadata/android/en-US/changelogs/119.txt
Normal file
@ -0,0 +1,10 @@
|
||||
Features:
|
||||
- This releases focuses on shuffled playback. The view of the playlist will now present itself in the order it will actually play. You can toggle the shuffle mode to create a new order, while the past playback history will be preserved.
|
||||
- Use Coroutines for triggering the download or playback of music through the context menus
|
||||
- Enable Artists pictures by Default
|
||||
|
||||
Bug fixes:
|
||||
- Remove an unhelpful popup that "ID must be set"
|
||||
- Shuffle mode doesn't always play all tracks
|
||||
- Shuffle mode starts with the first track most of the time
|
||||
|
10
fastlane/metadata/android/en-US/changelogs/120.txt
Normal file
10
fastlane/metadata/android/en-US/changelogs/120.txt
Normal file
@ -0,0 +1,10 @@
|
||||
Features:
|
||||
- This releases focuses on shuffled playback. The view of the playlist will now present itself in the order it will actually play. You can toggle the shuffle mode to create a new order, while the past playback history will be preserved.
|
||||
- Use Coroutines for triggering the download or playback of music through the context menus
|
||||
- Enable Artists pictures by Default
|
||||
|
||||
Bug fixes:
|
||||
- Remove an unhelpful popup that "ID must be set"
|
||||
- Shuffle mode doesn't always play all tracks
|
||||
- Shuffle mode starts with the first track most of the time
|
||||
|
@ -3,12 +3,12 @@ Ultrasonic is a Subsonic (and compatible servers) client to Android. You can use
|
||||
Main features:
|
||||
* Thin
|
||||
* Fast
|
||||
* Dark and light theme
|
||||
* Material theme with dark and light variants
|
||||
* Multiple server support
|
||||
* Offline Mode
|
||||
* Bookmarks
|
||||
* Playlists on server
|
||||
* Ramdom play
|
||||
* Random play
|
||||
* Jukebox mode
|
||||
* Server chat
|
||||
* And much more!!!
|
||||
|
4
fastlane/metadata/android/es-ES/changelogs/114.txt
Normal file
4
fastlane/metadata/android/es-ES/changelogs/114.txt
Normal file
@ -0,0 +1,4 @@
|
||||
Corrección de errores
|
||||
- Corrección de un fallo cuando la fecha de una etiqueta ID3 tiene un formato incorrecto.
|
||||
- Corrección de un fallo en la API 31 (versión de Android más reciente).
|
||||
- Corrección de resultados de búsqueda vacíos.
|
2
fastlane/metadata/android/es-ES/changelogs/115.txt
Normal file
2
fastlane/metadata/android/es-ES/changelogs/115.txt
Normal file
@ -0,0 +1,2 @@
|
||||
Corrección de errores
|
||||
- Corrección de un fallo al descargar la carátula del álbum.
|
@ -4,10 +4,22 @@ org.gradle.configureondemand=true
|
||||
org.gradle.caching=true
|
||||
org.gradle.jvmargs=-Xmx2g -XX:+UseParallelGC
|
||||
|
||||
|
||||
kotlin.incremental=true
|
||||
kotlin.caching.enabled=true
|
||||
kotlin.incremental.usePreciseJavaTracking=true
|
||||
|
||||
android.useAndroidX=true
|
||||
android.enableJetifier=false
|
||||
|
||||
# This properties enables transitive Resource classes, which decreases build time,
|
||||
# but could lead to problems referencing Resources. Set them to false if needed.
|
||||
android.nonTransitiveRClass=true
|
||||
android.nonFinalResIds=true
|
||||
|
||||
# This config was suggested by Android Studio to reduce build time
|
||||
# It can be removed if it makes problems
|
||||
org.gradle.unsafe.configuration-cache=true
|
||||
|
||||
# TODO Renable on day (check that Retrofit, Jackson, and Imageloader are working)
|
||||
android.enableR8.fullMode=false
|
||||
|
||||
|
@ -1,40 +1,40 @@
|
||||
[versions]
|
||||
# You need to run ./gradlew wrapper after updating the version
|
||||
gradle = "7.6"
|
||||
gradle = "8.1.1"
|
||||
|
||||
navigation = "2.5.3"
|
||||
gradlePlugin = "7.4.2"
|
||||
androidxcore = "1.10.0"
|
||||
gradlePlugin = "8.0.1"
|
||||
androidxcore = "1.10.1"
|
||||
ktlint = "0.43.2"
|
||||
ktlintGradle = "11.3.1"
|
||||
ktlintGradle = "11.3.2"
|
||||
detekt = "1.22.0"
|
||||
preferences = "1.2.0"
|
||||
media3 = "1.0.0"
|
||||
media3 = "1.0.2"
|
||||
|
||||
androidSupport = "1.6.0"
|
||||
materialDesign = "1.8.0"
|
||||
constraintLayout = "2.1.4"
|
||||
multidex = "2.0.1"
|
||||
room = "2.5.1"
|
||||
kotlin = "1.8.20"
|
||||
kotlinxCoroutines = "1.6.4"
|
||||
kotlinxGuava = "1.6.4"
|
||||
kotlin = "1.8.21"
|
||||
kotlinxCoroutines = "1.7.1"
|
||||
viewModelKtx = "2.6.1"
|
||||
swipeRefresh = "1.1.0"
|
||||
|
||||
retrofit = "2.9.0"
|
||||
jackson = "2.14.2"
|
||||
okhttp = "4.10.0"
|
||||
## KEEP ON 2.13 branch (https://github.com/FasterXML/jackson-databind/issues/3658#issuecomment-1312633064) for compatibility with API 24
|
||||
jackson = "2.13.5"
|
||||
okhttp = "4.11.0"
|
||||
koin = "3.3.2"
|
||||
picasso = "2.8"
|
||||
|
||||
junit4 = "4.13.2"
|
||||
junit5 = "5.9.2"
|
||||
mockito = "5.2.0"
|
||||
junit5 = "5.9.3"
|
||||
mockito = "5.3.1"
|
||||
mockitoKotlin = "4.1.0"
|
||||
kluent = "1.72"
|
||||
kluent = "1.73"
|
||||
apacheCodecs = "1.15"
|
||||
robolectric = "4.9.2"
|
||||
robolectric = "4.10.3"
|
||||
timber = "5.0.1"
|
||||
fastScroll = "2.0.1"
|
||||
colorPicker = "2.2.4"
|
||||
@ -73,7 +73,7 @@ swipeRefresh = { module = "androidx.swiperefreshlayout:swiperefreshla
|
||||
kotlinStdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" }
|
||||
kotlinReflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" }
|
||||
kotlinxCoroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" }
|
||||
kotlinxGuava = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-guava", version.ref = "kotlinxGuava"}
|
||||
kotlinxGuava = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-guava", version.ref = "kotlinxCoroutines"}
|
||||
retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" }
|
||||
gsonConverter = { module = "com.squareup.retrofit2:converter-gson", version.ref = "retrofit" }
|
||||
jacksonConverter = { module = "com.squareup.retrofit2:converter-jackson", version.ref = "retrofit" }
|
||||
@ -95,7 +95,6 @@ junitVintage = { module = "org.junit.vintage:junit-vintage-engine", v
|
||||
kotlinJunit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" }
|
||||
mockitoKotlin = { module = "org.mockito.kotlin:mockito-kotlin", version.ref = "mockitoKotlin" }
|
||||
mockito = { module = "org.mockito:mockito-core", version.ref = "mockito" }
|
||||
mockitoInline = { module = "org.mockito:mockito-inline", version.ref = "mockito" }
|
||||
kluent = { module = "org.amshove.kluent:kluent", version.ref = "kluent" }
|
||||
kluentAndroid = { module = "org.amshove.kluent:kluent-android", version.ref = "kluent" }
|
||||
mockWebServer = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" }
|
||||
|
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Binary file not shown.
2
gradle/wrapper/gradle-wrapper.properties
vendored
2
gradle/wrapper/gradle-wrapper.properties
vendored
@ -1,6 +1,6 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.0.2-all.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.1.1-all.zip
|
||||
networkTimeout=10000
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
|
@ -2,9 +2,9 @@
|
||||
* This module provides a base for for submodules which depend on the Android runtime
|
||||
*/
|
||||
apply plugin: 'com.android.library'
|
||||
apply plugin: 'kotlin-android'
|
||||
apply plugin: 'org.jetbrains.kotlin.android'
|
||||
apply from: "${project.rootDir}/gradle_scripts/code_quality.gradle"
|
||||
apply plugin: 'kotlin-kapt'
|
||||
apply plugin: 'org.jetbrains.kotlin.kapt'
|
||||
|
||||
android {
|
||||
compileSdkVersion versions.compileSdk
|
||||
@ -16,8 +16,8 @@ android {
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_11
|
||||
targetCompatibility JavaVersion.VERSION_11
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
|
@ -25,10 +25,8 @@ if (isCodeQualityEnabled) {
|
||||
// Builds the AST in parallel. Rules are always executed in parallel.
|
||||
// Can lead to speedups in larger projects.
|
||||
parallel = true
|
||||
baseline = file("${rootProject.projectDir}/detekt-baseline.xml")
|
||||
config = files("${rootProject.projectDir}/detekt-config.yml")
|
||||
}
|
||||
}
|
||||
tasks.detekt.jvmTarget = "11"
|
||||
tasks.detekt.jvmTarget = "17"
|
||||
}
|
||||
}
|
||||
|
@ -2,7 +2,7 @@
|
||||
* This module provides a base for for pure kotlin modules
|
||||
*/
|
||||
apply plugin: 'kotlin'
|
||||
apply plugin: 'kotlin-kapt'
|
||||
apply plugin: 'org.jetbrains.kotlin.kapt'
|
||||
apply from: "${project.rootDir}/gradle_scripts/code_quality.gradle"
|
||||
|
||||
sourceSets {
|
||||
|
7
gradlew
vendored
7
gradlew
vendored
@ -85,9 +85,6 @@ done
|
||||
APP_BASE_NAME=${0##*/}
|
||||
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
@ -197,6 +194,10 @@ if "$cygwin" || "$msys" ; then
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command;
|
||||
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
|
||||
# shell script including quotes and variable substitutions, so put them in
|
||||
|
@ -1,6 +1,6 @@
|
||||
apply plugin: 'com.android.application'
|
||||
apply plugin: 'kotlin-android'
|
||||
apply plugin: 'kotlin-kapt'
|
||||
apply plugin: 'org.jetbrains.kotlin.android'
|
||||
apply plugin: 'org.jetbrains.kotlin.kapt'
|
||||
apply plugin: "androidx.navigation.safeargs.kotlin"
|
||||
apply from: "../gradle_scripts/code_quality.gradle"
|
||||
|
||||
@ -9,12 +9,12 @@ android {
|
||||
|
||||
defaultConfig {
|
||||
applicationId "org.moire.ultrasonic"
|
||||
versionCode 113
|
||||
versionName "4.3.0"
|
||||
versionCode 120
|
||||
versionName "4.4.1"
|
||||
|
||||
minSdkVersion versions.minSdk
|
||||
targetSdkVersion versions.targetSdk
|
||||
resConfigs 'cs', 'de', 'en', 'es', 'fr', 'hu', 'it', 'nl', 'pl', 'pt', 'pt-rBR', 'ru', 'zh-rCN', 'zh-rTW'
|
||||
resConfigs 'cs', 'de', 'en', 'es', 'fr', 'gl', 'hu', 'it', 'ja', 'nb-rNO', 'nl', 'pl', 'pt', 'pt-rBR', 'ru', 'zh-rCN', 'zh-rTW'
|
||||
}
|
||||
|
||||
bundle.language.enableSplit = false
|
||||
@ -34,7 +34,7 @@ android {
|
||||
minifyEnabled false
|
||||
multiDexEnabled true
|
||||
testCoverageEnabled true
|
||||
applicationIdSuffix ".debug"
|
||||
applicationIdSuffix '.debug'
|
||||
}
|
||||
}
|
||||
|
||||
@ -50,17 +50,18 @@ android {
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "11"
|
||||
jvmTarget = "17"
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
viewBinding true
|
||||
dataBinding true
|
||||
buildConfig true
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_11
|
||||
targetCompatibility JavaVersion.VERSION_11
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kapt {
|
||||
@ -136,7 +137,6 @@ dependencies {
|
||||
testImplementation libs.kotlinJunit
|
||||
testImplementation libs.kluent
|
||||
testImplementation libs.mockito
|
||||
testImplementation libs.mockitoInline
|
||||
testImplementation libs.mockitoKotlin
|
||||
testImplementation libs.robolectric
|
||||
|
||||
|
@ -1,9 +1,9 @@
|
||||
<?xml version='1.0' encoding='UTF-8'?>
|
||||
<?xml version="1.0" ?>
|
||||
<SmellBaseline>
|
||||
<ManuallySuppressedIssues>
|
||||
<ID>TooManyFunctions:PlaybackService.kt$PlaybackService : MediaLibraryServiceKoinComponentCoroutineScope</ID>
|
||||
<ID>UnusedPrivateMember:UApp.kt$private fun VmPolicy.Builder.detectAllExceptSocket(): VmPolicy.Builder</ID>
|
||||
<ID>ImplicitDefaultLocale:EditServerFragment.kt$EditServerFragment.<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("Log file rotated, logging into file %s", file?.name)</ID>
|
||||
<ID>ImplicitDefaultLocale:FileLoggerTree.kt$FileLoggerTree$String.format("Logging into file %s", file?.name)</ID>
|
||||
@ -13,7 +13,7 @@
|
||||
<ID>LongMethod:PlaylistsFragment.kt$PlaylistsFragment$override fun onContextItemSelected(menuItem: MenuItem): Boolean</ID>
|
||||
<ID>LongMethod:ShareHandler.kt$ShareHandler$private fun showDialog( fragment: Fragment, shareDetails: ShareDetails, swipe: SwipeRefreshLayout?, cancellationToken: CancellationToken, additionalId: String? )</ID>
|
||||
<ID>LongMethod:SharesFragment.kt$SharesFragment$override fun onContextItemSelected(menuItem: MenuItem): Boolean</ID>
|
||||
<ID>LongParameterList:ServerRowAdapter.kt$ServerRowAdapter$( private var context: Context, passedData: Array<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:JukeboxMediaPlayer.kt$JukeboxMediaPlayer$0.05f</ID>
|
||||
<ID>MagicNumber:JukeboxMediaPlayer.kt$JukeboxMediaPlayer$50</ID>
|
@ -1,27 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<issues format="6" by="lint 7.4.0" type="baseline" client="gradle" dependencies="true" name="AGP (7.4.0)" variant="all" version="7.4.0">
|
||||
|
||||
<issue
|
||||
id="MissingPermission"
|
||||
message="Call requires permission which may be rejected by user: code should explicitly check to see if permission is available (with `checkPermission`) or explicitly handle a potential `SecurityException`"
|
||||
errorLine1=" manager.notify(NOTIFICATION_ID, notification)"
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/kotlin/org/moire/ultrasonic/service/DownloadService.kt"
|
||||
line="260"
|
||||
column="17"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="MissingPermission"
|
||||
message="Call requires permission which may be rejected by user: code should explicitly check to see if permission is available (with `checkPermission`) or explicitly handle a potential `SecurityException`"
|
||||
errorLine1=" notificationManagerCompat.notify(notification.notificationId, notification.notification)"
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/kotlin/org/moire/ultrasonic/service/JukeboxMediaPlayer.kt"
|
||||
line="194"
|
||||
column="9"/>
|
||||
</issue>
|
||||
<issues format="6" by="lint 8.0.1" type="baseline" client="gradle" dependencies="true" name="AGP (8.0.1)" variant="all" version="8.0.1">
|
||||
|
||||
<issue
|
||||
id="PluralsCandidate"
|
||||
@ -30,7 +8,7 @@
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/values/strings.xml"
|
||||
line="152"
|
||||
line="151"
|
||||
column="5"/>
|
||||
</issue>
|
||||
|
||||
@ -48,50 +26,6 @@
|
||||
file="../core/subsonic-api/build/libs/subsonic-api.jar"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="ExportedContentProvider"
|
||||
message="Exported content providers can provide access to potentially sensitive data"
|
||||
errorLine1=" <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
|
||||
id="UnusedResources"
|
||||
message="The resource `R.drawable.media3_notification_pause` appears to be unused"
|
||||
@ -136,17 +70,6 @@
|
||||
column="1"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="UnusedResources"
|
||||
message="The resource `R.drawable.media3_notification_small_icon` appears to be unused"
|
||||
errorLine1="<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
|
||||
id="Autofill"
|
||||
message="Missing `autofillHints` attribute"
|
||||
|
@ -1,10 +1,41 @@
|
||||
#### From retrofit
|
||||
# Retrofit does reflection on generic parameters. InnerClasses is required to use Signature and
|
||||
# EnclosingMethod is required to use InnerClasses.
|
||||
-keepattributes Signature, InnerClasses, EnclosingMethod
|
||||
|
||||
# Retain generic type information for use by reflection by converters and adapters.
|
||||
-keepattributes Signature
|
||||
# Retain service method parameters.
|
||||
-keepclassmembernames,allowobfuscation interface * {
|
||||
# Retrofit does reflection on method and parameter annotations.
|
||||
-keepattributes RuntimeVisibleAnnotations, RuntimeVisibleParameterAnnotations
|
||||
|
||||
# Keep annotation default values (e.g., retrofit2.http.Field.encoded).
|
||||
-keepattributes AnnotationDefault
|
||||
|
||||
# Retain service method parameters when optimizing.
|
||||
-keepclassmembers,allowshrinking,allowobfuscation interface * {
|
||||
@retrofit2.http.* <methods>;
|
||||
}
|
||||
|
||||
# Ignore annotation used for build tooling.
|
||||
-dontwarn org.codehaus.mojo.animal_sniffer.IgnoreJRERequirement
|
||||
|
||||
# Ignore JSR 305 annotations for embedding nullability information.
|
||||
-dontwarn javax.annotation.**
|
||||
|
||||
# Guarded by a NoClassDefFoundError try/catch and only used when on the classpath.
|
||||
-dontwarn kotlin.Unit
|
||||
|
||||
# Top-level functions that can only be used by Kotlin.
|
||||
-dontwarn retrofit2.KotlinExtensions
|
||||
-dontwarn retrofit2.KotlinExtensions$*
|
||||
|
||||
# With R8 full mode, it sees no subtypes of Retrofit interfaces since they are created with a Proxy
|
||||
# and replaces all potential values with null. Explicitly keeping the interfaces prevents this.
|
||||
-if interface * { @retrofit2.http.* <methods>; }
|
||||
-keep,allowobfuscation interface <1>
|
||||
|
||||
# Keep generic signature of Call, Response (R8 full mode strips signatures from non-kept items).
|
||||
-keep,allowobfuscation,allowshrinking interface retrofit2.Call
|
||||
-keep,allowobfuscation,allowshrinking class retrofit2.Response
|
||||
|
||||
# With R8 full mode generic signatures are stripped for classes that are not
|
||||
# kept. Suspend functions are wrapped in continuations where the type argument
|
||||
# is used.
|
||||
-keep,allowobfuscation,allowshrinking class kotlin.coroutines.Continuation
|
@ -3,11 +3,11 @@
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:installLocation="auto">
|
||||
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK"/>
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS"/>
|
||||
<uses-permission android:name="android.permission.BLUETOOTH"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
@ -22,6 +22,7 @@
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:fullBackupContent="@xml/backup_descriptor"
|
||||
android:hasFragileUserData="true" tools:targetApi="q"
|
||||
android:dataExtractionRules="@xml/backup_rules"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
@ -66,13 +67,6 @@
|
||||
android:exported="false">
|
||||
</service>
|
||||
|
||||
<service
|
||||
android:name=".service.JukeboxMediaPlayer"
|
||||
android:label="Ultrasonic Jukebox Media Player Service"
|
||||
android:foregroundServiceType="mediaPlayback"
|
||||
android:exported="false">
|
||||
</service>
|
||||
|
||||
<!-- Needs to be exported: https://android.googlesource.com/platform/developers/build/+/4de32d4/prebuilts/gradle/MediaBrowserService/README.md -->
|
||||
<service android:name=".playback.PlaybackService"
|
||||
android:label="@string/common.appname"
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -46,7 +46,7 @@ public class GenreAdapter extends ArrayAdapter<Genre> implements SectionIndexer
|
||||
private final Object[] sections;
|
||||
private final Integer[] positions;
|
||||
|
||||
public GenreAdapter(Context context, List<Genre> genres)
|
||||
public GenreAdapter(@NonNull Context context, List<Genre> genres)
|
||||
{
|
||||
super(context, R.layout.list_item_generic, genres);
|
||||
|
||||
|
@ -20,7 +20,6 @@ import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.MediaStore
|
||||
import android.provider.SearchRecentSuggestions
|
||||
import android.view.KeyEvent
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
@ -59,9 +58,9 @@ import org.moire.ultrasonic.data.ServerSettingDao
|
||||
import org.moire.ultrasonic.fragment.OnBackPressedHandler
|
||||
import org.moire.ultrasonic.model.ServerSettingsModel
|
||||
import org.moire.ultrasonic.provider.SearchSuggestionProvider
|
||||
import org.moire.ultrasonic.service.MediaPlayerController
|
||||
import org.moire.ultrasonic.service.MediaPlayerLifecycleSupport
|
||||
import org.moire.ultrasonic.service.MusicServiceFactory
|
||||
import org.moire.ultrasonic.service.MediaPlayerManager
|
||||
import org.moire.ultrasonic.service.RxBus
|
||||
import org.moire.ultrasonic.service.plusAssign
|
||||
import org.moire.ultrasonic.subsonic.DownloadHandler
|
||||
@ -104,7 +103,7 @@ class NavigationActivity : AppCompatActivity() {
|
||||
|
||||
private val serverSettingsModel: ServerSettingsModel by viewModel()
|
||||
private val lifecycleSupport: MediaPlayerLifecycleSupport by inject()
|
||||
private val mediaPlayerController: MediaPlayerController by inject()
|
||||
private val mediaPlayerManager: MediaPlayerManager by inject()
|
||||
private val activeServerProvider: ActiveServerProvider by inject()
|
||||
private val serverRepository: ServerSettingDao by inject()
|
||||
|
||||
@ -284,18 +283,6 @@ class NavigationActivity : AppCompatActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
|
||||
val isVolumeDown = keyCode == KeyEvent.KEYCODE_VOLUME_DOWN
|
||||
val isVolumeUp = keyCode == KeyEvent.KEYCODE_VOLUME_UP
|
||||
val isVolumeAdjust = isVolumeDown || isVolumeUp
|
||||
val isJukebox = mediaPlayerController.isJukeboxEnabled
|
||||
if (isVolumeAdjust && isJukebox) {
|
||||
mediaPlayerController.adjustVolume(isVolumeUp)
|
||||
return true
|
||||
}
|
||||
return super.onKeyDown(keyCode, event)
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.N_MR1)
|
||||
private fun setupAppShortcut() {
|
||||
|
||||
@ -337,7 +324,7 @@ class NavigationActivity : AppCompatActivity() {
|
||||
}
|
||||
R.id.menu_exit -> {
|
||||
setResult(Constants.RESULT_CLOSE_ALL)
|
||||
mediaPlayerController.onDestroy()
|
||||
mediaPlayerManager.onDestroy()
|
||||
finish()
|
||||
exit()
|
||||
}
|
||||
@ -415,9 +402,14 @@ class NavigationActivity : AppCompatActivity() {
|
||||
val service = MusicServiceFactory.getMusicService()
|
||||
val musicDirectory = service.getRandomSongs(Settings.maxSongs)
|
||||
val downloadHandler: DownloadHandler by inject()
|
||||
downloadHandler.download(
|
||||
currentFragment, append = false, save = false, autoPlay = true, playNext = false,
|
||||
shuffle = false, songs = musicDirectory.getTracks(), playlistName = null
|
||||
downloadHandler.addTracksToMediaController(
|
||||
songs = musicDirectory.getTracks(),
|
||||
append = false,
|
||||
playNext = false,
|
||||
autoPlay = true,
|
||||
shuffle = false,
|
||||
fragment = currentFragment,
|
||||
playlistName = null
|
||||
)
|
||||
return
|
||||
}
|
||||
@ -516,9 +508,9 @@ class NavigationActivity : AppCompatActivity() {
|
||||
}
|
||||
|
||||
if (nowPlayingView != null) {
|
||||
val playerState: Int = mediaPlayerController.playbackState
|
||||
val playerState: Int = mediaPlayerManager.playbackState
|
||||
if (playerState == STATE_BUFFERING || playerState == STATE_READY) {
|
||||
val item: MediaItem? = mediaPlayerController.currentMediaItem
|
||||
val item: MediaItem? = mediaPlayerManager.currentMediaItem
|
||||
if (item != null) {
|
||||
nowPlayingView?.visibility = View.VISIBLE
|
||||
}
|
||||
|
@ -15,17 +15,17 @@ import android.view.ViewGroup
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import androidx.media3.common.HeartRating
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.drakeet.multitype.ItemViewDelegate
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
import org.moire.ultrasonic.R
|
||||
import org.moire.ultrasonic.data.RatingUpdate
|
||||
import org.moire.ultrasonic.domain.Album
|
||||
import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService
|
||||
import org.moire.ultrasonic.service.RxBus
|
||||
import org.moire.ultrasonic.subsonic.ImageLoaderProvider
|
||||
import org.moire.ultrasonic.util.LayoutType
|
||||
import org.moire.ultrasonic.util.Settings.shouldUseId3Tags
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
* Creates a Row in a RecyclerView which contains the details of an Album
|
||||
@ -112,27 +112,13 @@ open class AlbumRowDelegate(
|
||||
private fun onStarClick(entry: Album, star: ImageView) {
|
||||
entry.starred = !entry.starred
|
||||
star.setImageResource(if (entry.starred) starDrawable else starHollowDrawable)
|
||||
val musicService = getMusicService()
|
||||
Thread {
|
||||
val useId3 = shouldUseId3Tags
|
||||
try {
|
||||
if (entry.starred) {
|
||||
musicService.star(
|
||||
if (!useId3) entry.id else null,
|
||||
if (useId3) entry.id else null,
|
||||
null
|
||||
)
|
||||
} else {
|
||||
musicService.unstar(
|
||||
if (!useId3) entry.id else null,
|
||||
if (useId3) entry.id else null,
|
||||
null
|
||||
)
|
||||
}
|
||||
} catch (all: Exception) {
|
||||
Timber.e(all)
|
||||
}
|
||||
}.start()
|
||||
|
||||
RxBus.ratingSubmitter.onNext(
|
||||
RatingUpdate(
|
||||
entry.id,
|
||||
HeartRating(entry.starred)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(context: Context, parent: ViewGroup): ListViewHolder {
|
||||
|
@ -115,7 +115,7 @@ class ArtistRowBinder(
|
||||
}
|
||||
|
||||
private fun showArtistPicture(): Boolean {
|
||||
return ActiveServerProvider.isID3Enabled() && Settings.shouldShowArtistPicture
|
||||
return ActiveServerProvider.shouldUseId3Tags() && Settings.shouldShowArtistPicture
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -51,11 +51,14 @@ class HeaderViewBinder(
|
||||
val resources = context.resources
|
||||
|
||||
val artworkSelection = random.nextInt(item.childCount)
|
||||
val size = Util.getAlbumImageSize(context)
|
||||
|
||||
imageLoaderProvider.executeOn {
|
||||
it.loadImage(
|
||||
holder.coverArtView, item.entries[artworkSelection], false,
|
||||
Util.getAlbumImageSize(context)
|
||||
holder.coverArtView,
|
||||
item.entries[artworkSelection],
|
||||
false,
|
||||
size
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -62,8 +62,6 @@ class TrackViewBinder(
|
||||
diffAdapter.isSelected(item.longId)
|
||||
)
|
||||
|
||||
// Timber.v("Setting listeners")
|
||||
|
||||
holder.itemView.setOnLongClickListener {
|
||||
if (onContextMenuClick != null) {
|
||||
val popup = createContextMenu(holder.itemView, track)
|
||||
@ -116,8 +114,6 @@ class TrackViewBinder(
|
||||
|
||||
if (newStatus != holder.check.isChecked) holder.check.isChecked = newStatus
|
||||
}
|
||||
|
||||
// Timber.v("Setting listeners done")
|
||||
}
|
||||
|
||||
override fun onViewRecycled(holder: TrackViewHolder) {
|
||||
|
@ -10,6 +10,8 @@ import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.isGone
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.media3.common.HeartRating
|
||||
import androidx.media3.common.StarRating
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.progressindicator.CircularProgressIndicator
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
@ -19,10 +21,10 @@ import kotlinx.coroutines.launch
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.moire.ultrasonic.R
|
||||
import org.moire.ultrasonic.data.ActiveServerProvider
|
||||
import org.moire.ultrasonic.data.RatingUpdate
|
||||
import org.moire.ultrasonic.domain.Track
|
||||
import org.moire.ultrasonic.service.DownloadService
|
||||
import org.moire.ultrasonic.service.DownloadState
|
||||
import org.moire.ultrasonic.service.MusicServiceFactory
|
||||
import org.moire.ultrasonic.service.RxBus
|
||||
import org.moire.ultrasonic.service.plusAssign
|
||||
import org.moire.ultrasonic.util.Settings
|
||||
@ -81,7 +83,6 @@ class TrackViewHolder(val view: View) :
|
||||
draggable: Boolean,
|
||||
isSelected: Boolean = false
|
||||
) {
|
||||
// Timber.v("Setting song")
|
||||
val useFiveStarRating = Settings.useFiveStarRating
|
||||
entry = song
|
||||
|
||||
@ -118,9 +119,9 @@ class TrackViewHolder(val view: View) :
|
||||
}
|
||||
|
||||
if (useFiveStarRating) {
|
||||
setFiveStars(entry?.userRating ?: 0)
|
||||
updateFiveStars(entry?.userRating ?: 0)
|
||||
} else {
|
||||
setSingleStar(entry!!.starred)
|
||||
updateSingleStar(entry!!.starred)
|
||||
}
|
||||
|
||||
if (song.isVideo) {
|
||||
@ -131,7 +132,7 @@ class TrackViewHolder(val view: View) :
|
||||
// Create new Disposable for the new Subscriptions
|
||||
rxBusSubscription = CompositeDisposable()
|
||||
rxBusSubscription!! += RxBus.playerStateObservable.subscribe {
|
||||
setPlayIcon(it.index == bindingAdapterPosition && it.track?.id == song.id)
|
||||
setPlayIcon(it.track?.id == song.id && it.index == bindingAdapterPosition)
|
||||
}
|
||||
|
||||
rxBusSubscription!! += RxBus.trackDownloadStateObservable.subscribe {
|
||||
@ -139,7 +140,17 @@ class TrackViewHolder(val view: View) :
|
||||
updateStatus(it.state, it.progress)
|
||||
}
|
||||
|
||||
// Timber.v("Setting song done")
|
||||
// Listen for rating updates
|
||||
rxBusSubscription!! += RxBus.ratingPublishedObservable.subscribe {
|
||||
// Ignore updates which are not for the current song
|
||||
if (it.id != song.id) return@subscribe
|
||||
|
||||
if (it.rating is HeartRating) {
|
||||
updateSingleStar(it.rating.isHeart)
|
||||
} else if (it.rating is StarRating) {
|
||||
updateFiveStars(it.rating.starRating.toInt())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// This is called when the Holder is recycled and receives a new Song
|
||||
@ -165,48 +176,32 @@ class TrackViewHolder(val view: View) :
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupStarButtons(song: Track, useFiveStarRating: Boolean) {
|
||||
private fun setupStarButtons(track: Track, useFiveStarRating: Boolean) {
|
||||
if (useFiveStarRating) {
|
||||
// Hide single star
|
||||
star.isGone = true
|
||||
rating.isVisible = true
|
||||
val rating = if (song.userRating == null) 0 else song.userRating!!
|
||||
setFiveStars(rating)
|
||||
val rating = if (track.userRating == null) 0 else track.userRating!!
|
||||
updateFiveStars(rating)
|
||||
|
||||
// Five star rating has no click handler because in the
|
||||
// track view theres not enough space
|
||||
} else {
|
||||
star.isVisible = true
|
||||
rating.isGone = true
|
||||
setSingleStar(song.starred)
|
||||
updateSingleStar(track.starred)
|
||||
star.setOnClickListener {
|
||||
val isStarred = song.starred
|
||||
val id = song.id
|
||||
|
||||
if (!isStarred) {
|
||||
star.setImageResource(R.drawable.ic_star_full)
|
||||
song.starred = true
|
||||
} else {
|
||||
star.setImageResource(R.drawable.ic_star_hollow)
|
||||
song.starred = false
|
||||
}
|
||||
|
||||
// Should this be done here ?
|
||||
Thread {
|
||||
val musicService = MusicServiceFactory.getMusicService()
|
||||
try {
|
||||
if (!isStarred) {
|
||||
musicService.star(id, null, null)
|
||||
} else {
|
||||
musicService.unstar(id, null, null)
|
||||
}
|
||||
} catch (all: Exception) {
|
||||
Timber.e(all)
|
||||
}
|
||||
}.start()
|
||||
track.starred = !track.starred
|
||||
updateSingleStar(track.starred)
|
||||
RxBus.ratingSubmitter.onNext(
|
||||
RatingUpdate(track.id, HeartRating(track.starred))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
private fun setFiveStars(rating: Int) {
|
||||
private fun updateFiveStars(rating: Int) {
|
||||
fiveStar1.setImageResource(
|
||||
if (rating > 0) R.drawable.ic_star_full else R.drawable.ic_star_hollow
|
||||
)
|
||||
@ -224,7 +219,7 @@ class TrackViewHolder(val view: View) :
|
||||
)
|
||||
}
|
||||
|
||||
private fun setSingleStar(starred: Boolean) {
|
||||
private fun updateSingleStar(starred: Boolean) {
|
||||
if (starred) {
|
||||
star.setImageResource(R.drawable.ic_star_full)
|
||||
} else {
|
||||
|
@ -270,8 +270,8 @@ class ActiveServerProvider(
|
||||
/**
|
||||
* Queries if ID3 tags should be used
|
||||
*/
|
||||
fun isID3Enabled(): Boolean {
|
||||
return Settings.shouldUseId3Tags && (!isOffline() || Settings.useId3TagsOffline)
|
||||
fun shouldUseId3Tags(): Boolean {
|
||||
return Settings.id3TagsEnabledOnline && (!isOffline() || Settings.id3TagsEnabledOffline)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -0,0 +1,16 @@
|
||||
/*
|
||||
* RatingUpdate.kt
|
||||
* Copyright (C) 2009-2023 Ultrasonic developers
|
||||
*
|
||||
* Distributed under terms of the GNU GPLv3 license.
|
||||
*/
|
||||
|
||||
package org.moire.ultrasonic.data
|
||||
|
||||
import androidx.media3.common.Rating
|
||||
|
||||
data class RatingUpdate(
|
||||
val id: String,
|
||||
val rating: Rating,
|
||||
val success: Boolean? = null
|
||||
)
|
@ -2,8 +2,8 @@ package org.moire.ultrasonic.di
|
||||
|
||||
import org.koin.dsl.module
|
||||
import org.moire.ultrasonic.service.ExternalStorageMonitor
|
||||
import org.moire.ultrasonic.service.MediaPlayerController
|
||||
import org.moire.ultrasonic.service.MediaPlayerLifecycleSupport
|
||||
import org.moire.ultrasonic.service.MediaPlayerManager
|
||||
import org.moire.ultrasonic.service.PlaybackStateSerializer
|
||||
|
||||
/**
|
||||
@ -15,5 +15,5 @@ val mediaPlayerModule = module {
|
||||
single { ExternalStorageMonitor() }
|
||||
|
||||
// TODO Ideally this can be cleaned up when all circular references are removed.
|
||||
single { MediaPlayerController(get(), get(), get()) }
|
||||
single { MediaPlayerManager(get(), get(), get()) }
|
||||
}
|
||||
|
@ -138,8 +138,8 @@ class AlbumListFragment(
|
||||
)
|
||||
|
||||
private fun getListOfSortOrders(): List<SortOrder> {
|
||||
val useId3 = Settings.shouldUseId3Tags
|
||||
val useId3Offline = Settings.useId3TagsOffline
|
||||
val useId3 = Settings.id3TagsEnabledOnline
|
||||
val useId3Offline = Settings.id3TagsEnabledOffline
|
||||
val isOnline = !ActiveServerProvider.isOffline()
|
||||
|
||||
val supported = mutableListOf<SortOrder>()
|
||||
|
@ -72,7 +72,7 @@ class BookmarksFragment : TrackCollectionFragment() {
|
||||
currentPlayingPosition = songs[0].bookmarkPosition
|
||||
)
|
||||
|
||||
mediaPlayerController.restore(
|
||||
mediaPlayerManager.restore(
|
||||
state = state,
|
||||
autoPlay = true,
|
||||
newPlaylist = true
|
||||
|
@ -16,13 +16,14 @@ import androidx.navigation.fragment.findNavController
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import org.moire.ultrasonic.R
|
||||
import org.moire.ultrasonic.adapters.FolderSelectorBinder
|
||||
import org.moire.ultrasonic.data.ActiveServerProvider
|
||||
import org.moire.ultrasonic.domain.Artist
|
||||
import org.moire.ultrasonic.domain.GenericEntry
|
||||
import org.moire.ultrasonic.domain.Identifiable
|
||||
import org.moire.ultrasonic.service.RxBus
|
||||
import org.moire.ultrasonic.service.plusAssign
|
||||
import org.moire.ultrasonic.subsonic.DownloadAction
|
||||
import org.moire.ultrasonic.subsonic.DownloadHandler
|
||||
import org.moire.ultrasonic.util.Settings
|
||||
|
||||
/**
|
||||
* An extension of the MultiListFragment, with a few helper functions geared
|
||||
@ -38,7 +39,7 @@ abstract class EntryListFragment<T : GenericEntry> : MultiListFragment<T>() {
|
||||
*/
|
||||
private fun showFolderHeader(): Boolean {
|
||||
return listModel.showSelectFolderHeader() && !listModel.isOffline() &&
|
||||
!Settings.shouldUseId3Tags
|
||||
!ActiveServerProvider.shouldUseId3Tags()
|
||||
}
|
||||
|
||||
override fun onContextMenuItemSelected(menuItem: MenuItem, item: T): Boolean {
|
||||
@ -129,81 +130,54 @@ abstract class EntryListFragment<T : GenericEntry> : MultiListFragment<T>() {
|
||||
): Boolean {
|
||||
when (menuItem.itemId) {
|
||||
R.id.menu_play_now ->
|
||||
downloadHandler.downloadRecursively(
|
||||
downloadHandler.fetchTracksAndAddToController(
|
||||
fragment,
|
||||
item.id,
|
||||
save = false,
|
||||
append = false,
|
||||
autoPlay = true,
|
||||
shuffle = false,
|
||||
background = false,
|
||||
playNext = false,
|
||||
unpin = false,
|
||||
isArtist = isArtist
|
||||
)
|
||||
R.id.menu_play_next ->
|
||||
downloadHandler.downloadRecursively(
|
||||
downloadHandler.fetchTracksAndAddToController(
|
||||
fragment,
|
||||
item.id,
|
||||
save = false,
|
||||
append = false,
|
||||
autoPlay = true,
|
||||
shuffle = true,
|
||||
background = false,
|
||||
playNext = true,
|
||||
unpin = false,
|
||||
isArtist = isArtist
|
||||
)
|
||||
R.id.menu_play_last ->
|
||||
downloadHandler.downloadRecursively(
|
||||
downloadHandler.fetchTracksAndAddToController(
|
||||
fragment,
|
||||
item.id,
|
||||
save = false,
|
||||
append = true,
|
||||
autoPlay = false,
|
||||
shuffle = false,
|
||||
background = false,
|
||||
playNext = false,
|
||||
unpin = false,
|
||||
isArtist = isArtist
|
||||
)
|
||||
R.id.menu_pin ->
|
||||
downloadHandler.downloadRecursively(
|
||||
downloadHandler.justDownload(
|
||||
action = DownloadAction.PIN,
|
||||
fragment,
|
||||
item.id,
|
||||
save = true,
|
||||
append = true,
|
||||
autoPlay = false,
|
||||
shuffle = false,
|
||||
background = false,
|
||||
playNext = false,
|
||||
unpin = false,
|
||||
isArtist = isArtist
|
||||
)
|
||||
R.id.menu_unpin ->
|
||||
downloadHandler.downloadRecursively(
|
||||
downloadHandler.justDownload(
|
||||
action = DownloadAction.UNPIN,
|
||||
fragment,
|
||||
item.id,
|
||||
save = false,
|
||||
append = false,
|
||||
autoPlay = false,
|
||||
shuffle = false,
|
||||
background = false,
|
||||
playNext = false,
|
||||
unpin = true,
|
||||
isArtist = isArtist
|
||||
)
|
||||
R.id.menu_download ->
|
||||
downloadHandler.downloadRecursively(
|
||||
downloadHandler.justDownload(
|
||||
action = DownloadAction.DOWNLOAD,
|
||||
fragment,
|
||||
item.id,
|
||||
save = false,
|
||||
append = false,
|
||||
autoPlay = false,
|
||||
shuffle = false,
|
||||
background = true,
|
||||
playNext = false,
|
||||
unpin = false,
|
||||
isArtist = isArtist
|
||||
)
|
||||
else -> return false
|
||||
|
@ -25,7 +25,7 @@ import kotlin.math.abs
|
||||
import org.koin.android.ext.android.inject
|
||||
import org.moire.ultrasonic.NavigationGraphDirections
|
||||
import org.moire.ultrasonic.R
|
||||
import org.moire.ultrasonic.service.MediaPlayerController
|
||||
import org.moire.ultrasonic.service.MediaPlayerManager
|
||||
import org.moire.ultrasonic.service.RxBus
|
||||
import org.moire.ultrasonic.subsonic.ImageLoaderProvider
|
||||
import org.moire.ultrasonic.util.Settings
|
||||
@ -48,7 +48,7 @@ class NowPlayingFragment : Fragment() {
|
||||
private var nowPlayingArtist: TextView? = null
|
||||
|
||||
private var rxBusSubscription: Disposable? = null
|
||||
private val mediaPlayerController: MediaPlayerController by inject()
|
||||
private val mediaPlayerManager: MediaPlayerManager by inject()
|
||||
private val imageLoaderProvider: ImageLoaderProvider by inject()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
@ -85,24 +85,25 @@ class NowPlayingFragment : Fragment() {
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
private fun update() {
|
||||
try {
|
||||
if (mediaPlayerController.isPlaying) {
|
||||
if (mediaPlayerManager.isPlaying) {
|
||||
playButton!!.setIconResource(R.drawable.media_pause)
|
||||
} else {
|
||||
playButton!!.setIconResource(R.drawable.media_start)
|
||||
}
|
||||
|
||||
val file = mediaPlayerController.currentMediaItem?.toTrack()
|
||||
val file = mediaPlayerManager.currentMediaItem?.toTrack()
|
||||
|
||||
if (file != null) {
|
||||
val title = file.title
|
||||
val artist = file.artist
|
||||
val size = getNotificationImageSize(requireContext())
|
||||
|
||||
imageLoaderProvider.executeOn {
|
||||
it.loadImage(
|
||||
nowPlayingAlbumArtImage,
|
||||
file,
|
||||
false,
|
||||
getNotificationImageSize(requireContext())
|
||||
size
|
||||
)
|
||||
}
|
||||
|
||||
@ -110,7 +111,7 @@ class NowPlayingFragment : Fragment() {
|
||||
nowPlayingArtist!!.text = artist
|
||||
|
||||
nowPlayingAlbumArtImage!!.setOnClickListener {
|
||||
val id3 = Settings.shouldUseId3Tags
|
||||
val id3 = Settings.id3TagsEnabledOnline
|
||||
val action = NavigationGraphDirections.toTrackCollection(
|
||||
isAlbum = id3,
|
||||
id = if (id3) file.albumId else file.parent,
|
||||
@ -126,7 +127,7 @@ class NowPlayingFragment : Fragment() {
|
||||
|
||||
// This empty onClickListener is necessary for the onTouchListener to work
|
||||
requireView().setOnClickListener { }
|
||||
playButton!!.setOnClickListener { mediaPlayerController.togglePlayPause() }
|
||||
playButton!!.setOnClickListener { mediaPlayerManager.togglePlayPause() }
|
||||
} catch (all: Exception) {
|
||||
Timber.w(all, "Failed to get notification cover art")
|
||||
}
|
||||
@ -148,10 +149,10 @@ class NowPlayingFragment : Fragment() {
|
||||
if (abs(deltaX) > MIN_DISTANCE) {
|
||||
// left or right
|
||||
if (deltaX < 0) {
|
||||
mediaPlayerController.previous()
|
||||
mediaPlayerManager.seekToPrevious()
|
||||
}
|
||||
if (deltaX > 0) {
|
||||
mediaPlayerController.next()
|
||||
mediaPlayerManager.seekToNext()
|
||||
}
|
||||
} else if (abs(deltaY) > MIN_DISTANCE) {
|
||||
if (deltaY < 0) {
|
||||
|
@ -34,12 +34,17 @@ import android.widget.SeekBar.OnSeekBarChangeListener
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import android.widget.ViewFlipper
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.core.content.res.ResourcesCompat
|
||||
import androidx.core.view.MenuHost
|
||||
import androidx.core.view.MenuProvider
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.media3.common.HeartRating
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.session.SessionResult
|
||||
import androidx.media3.common.StarRating
|
||||
import androidx.navigation.Navigation
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.recyclerview.widget.ItemTouchHelper
|
||||
@ -49,8 +54,7 @@ import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.LinearSmoothScroller
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.button.MaterialButton
|
||||
import com.google.common.util.concurrent.FutureCallback
|
||||
import com.google.common.util.concurrent.Futures
|
||||
import com.google.android.material.progressindicator.CircularProgressIndicator
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import java.text.DateFormat
|
||||
import java.text.SimpleDateFormat
|
||||
@ -76,11 +80,14 @@ import org.moire.ultrasonic.adapters.TrackViewBinder
|
||||
import org.moire.ultrasonic.api.subsonic.models.AlbumListType
|
||||
import org.moire.ultrasonic.audiofx.EqualizerController
|
||||
import org.moire.ultrasonic.data.ActiveServerProvider.Companion.isOffline
|
||||
import org.moire.ultrasonic.data.ActiveServerProvider.Companion.shouldUseId3Tags
|
||||
import org.moire.ultrasonic.data.RatingUpdate
|
||||
import org.moire.ultrasonic.databinding.CurrentPlayingBinding
|
||||
import org.moire.ultrasonic.domain.Identifiable
|
||||
import org.moire.ultrasonic.domain.MusicDirectory
|
||||
import org.moire.ultrasonic.domain.Track
|
||||
import org.moire.ultrasonic.fragment.FragmentTitle.Companion.setTitle
|
||||
import org.moire.ultrasonic.service.MediaPlayerController
|
||||
import org.moire.ultrasonic.service.MediaPlayerManager
|
||||
import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService
|
||||
import org.moire.ultrasonic.service.RxBus
|
||||
import org.moire.ultrasonic.service.plusAssign
|
||||
@ -98,7 +105,7 @@ import timber.log.Timber
|
||||
|
||||
/**
|
||||
* Contains the Music Player screen of Ultrasonic with playback controls and the playlist
|
||||
* TODO: Add timeline lister -> updateProgressBar().
|
||||
*
|
||||
*/
|
||||
@Suppress("LargeClass", "TooManyFunctions", "MagicNumber")
|
||||
class PlayerFragment :
|
||||
@ -121,7 +128,7 @@ class PlayerFragment :
|
||||
|
||||
// Data & Services
|
||||
private val networkAndStorageChecker: NetworkAndStorageChecker by inject()
|
||||
private val mediaPlayerController: MediaPlayerController by inject()
|
||||
private val mediaPlayerManager: MediaPlayerManager by inject()
|
||||
private val shareHandler: ShareHandler by inject()
|
||||
private val imageLoaderProvider: ImageLoaderProvider by inject()
|
||||
private var currentSong: Track? = null
|
||||
@ -132,7 +139,6 @@ class PlayerFragment :
|
||||
|
||||
// Views and UI Elements
|
||||
private lateinit var playlistNameView: EditText
|
||||
private lateinit var starMenuItem: MenuItem
|
||||
private lateinit var fiveStar1ImageView: ImageView
|
||||
private lateinit var fiveStar2ImageView: ImageView
|
||||
private lateinit var fiveStar3ImageView: ImageView
|
||||
@ -140,6 +146,7 @@ class PlayerFragment :
|
||||
private lateinit var fiveStar5ImageView: ImageView
|
||||
private lateinit var playlistFlipper: ViewFlipper
|
||||
private lateinit var emptyTextView: TextView
|
||||
private lateinit var emptyView: ConstraintLayout
|
||||
private lateinit var songTitleTextView: TextView
|
||||
private lateinit var artistTextView: TextView
|
||||
private lateinit var albumTextView: TextView
|
||||
@ -154,12 +161,20 @@ class PlayerFragment :
|
||||
private lateinit var pauseButton: View
|
||||
private lateinit var stopButton: View
|
||||
private lateinit var playButton: View
|
||||
private lateinit var previousButton: MaterialButton
|
||||
private lateinit var nextButton: MaterialButton
|
||||
private lateinit var shuffleButton: View
|
||||
private lateinit var repeatButton: MaterialButton
|
||||
private lateinit var progressBar: SeekBar
|
||||
private lateinit var progressIndicator: CircularProgressIndicator
|
||||
private val hollowStar = R.drawable.ic_star_hollow
|
||||
private val fullStar = R.drawable.ic_star_full
|
||||
|
||||
private var _binding: CurrentPlayingBinding? = null
|
||||
// This property is only valid between onCreateView and
|
||||
// onDestroyView.
|
||||
private val binding get() = _binding!!
|
||||
|
||||
private val viewAdapter: BaseAdapter<Identifiable> by lazy {
|
||||
BaseAdapter()
|
||||
}
|
||||
@ -173,13 +188,17 @@ class PlayerFragment :
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View? {
|
||||
return inflater.inflate(R.layout.current_playing, container, false)
|
||||
): View {
|
||||
_binding = CurrentPlayingBinding.inflate(layoutInflater, container, false)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
// TODO: Switch them all over to use the view binding
|
||||
private fun findViews(view: View) {
|
||||
playlistFlipper = view.findViewById(R.id.current_playing_playlist_flipper)
|
||||
emptyTextView = view.findViewById(R.id.playlist_empty)
|
||||
emptyTextView = view.findViewById(R.id.empty_list_text)
|
||||
emptyView = view.findViewById(R.id.emptyListView)
|
||||
progressIndicator = view.findViewById(R.id.progress_indicator)
|
||||
songTitleTextView = view.findViewById(R.id.current_playing_song)
|
||||
artistTextView = view.findViewById(R.id.current_playing_artist)
|
||||
albumTextView = view.findViewById(R.id.current_playing_album)
|
||||
@ -196,6 +215,8 @@ class PlayerFragment :
|
||||
pauseButton = view.findViewById(R.id.button_pause)
|
||||
stopButton = view.findViewById(R.id.button_stop)
|
||||
playButton = view.findViewById(R.id.button_start)
|
||||
nextButton = view.findViewById(R.id.button_next)
|
||||
previousButton = view.findViewById(R.id.button_previous)
|
||||
repeatButton = view.findViewById(R.id.button_repeat)
|
||||
fiveStar1ImageView = view.findViewById(R.id.song_five_star_1)
|
||||
fiveStar2ImageView = view.findViewById(R.id.song_five_star_2)
|
||||
@ -204,7 +225,7 @@ class PlayerFragment :
|
||||
fiveStar5ImageView = view.findViewById(R.id.song_five_star_5)
|
||||
}
|
||||
|
||||
@Suppress("LongMethod", "DEPRECATION")
|
||||
@Suppress("LongMethod")
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
cancellationToken = CancellationToken()
|
||||
@ -214,6 +235,7 @@ class PlayerFragment :
|
||||
val width: Int
|
||||
val height: Int
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
val bounds = windowManager.currentWindowMetrics.bounds
|
||||
width = bounds.width()
|
||||
@ -226,7 +248,13 @@ class PlayerFragment :
|
||||
height = size.y
|
||||
}
|
||||
|
||||
setHasOptionsMenu(true)
|
||||
// Register our options menu
|
||||
(requireActivity() as MenuHost).addMenuProvider(
|
||||
menuProvider,
|
||||
viewLifecycleOwner,
|
||||
Lifecycle.State.RESUMED
|
||||
)
|
||||
|
||||
useFiveStarRating = Settings.useFiveStarRating
|
||||
swipeDistance = (width + height) * PERCENTAGE_OF_SCREEN_FOR_SWIPE / 100
|
||||
swipeVelocity = swipeDistance
|
||||
@ -236,8 +264,8 @@ class PlayerFragment :
|
||||
val previousButton: AutoRepeatButton = view.findViewById(R.id.button_previous)
|
||||
val nextButton: AutoRepeatButton = view.findViewById(R.id.button_next)
|
||||
shuffleButton = view.findViewById(R.id.button_shuffle)
|
||||
updateShuffleButtonState(mediaPlayerController.isShufflePlayEnabled)
|
||||
updateRepeatButtonState(mediaPlayerController.repeatMode)
|
||||
updateShuffleButtonState(mediaPlayerManager.isShufflePlayEnabled)
|
||||
updateRepeatButtonState(mediaPlayerManager.repeatMode)
|
||||
|
||||
val ratingLinearLayout = view.findViewById<LinearLayout>(R.id.song_rating)
|
||||
if (!useFiveStarRating) ratingLinearLayout.isVisible = false
|
||||
@ -259,9 +287,7 @@ class PlayerFragment :
|
||||
previousButton.setOnClickListener {
|
||||
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
|
||||
launch(CommunicationError.getHandler(context)) {
|
||||
mediaPlayerController.previous()
|
||||
onCurrentChanged()
|
||||
onSliderProgressChanged()
|
||||
mediaPlayerManager.seekToPrevious()
|
||||
}
|
||||
}
|
||||
|
||||
@ -272,9 +298,7 @@ class PlayerFragment :
|
||||
nextButton.setOnClickListener {
|
||||
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
|
||||
launch(CommunicationError.getHandler(context)) {
|
||||
mediaPlayerController.next()
|
||||
onCurrentChanged()
|
||||
onSliderProgressChanged()
|
||||
mediaPlayerManager.seekToNext()
|
||||
}
|
||||
}
|
||||
|
||||
@ -284,28 +308,22 @@ class PlayerFragment :
|
||||
|
||||
pauseButton.setOnClickListener {
|
||||
launch(CommunicationError.getHandler(context)) {
|
||||
mediaPlayerController.pause()
|
||||
onCurrentChanged()
|
||||
onSliderProgressChanged()
|
||||
mediaPlayerManager.pause()
|
||||
}
|
||||
}
|
||||
|
||||
stopButton.setOnClickListener {
|
||||
launch(CommunicationError.getHandler(context)) {
|
||||
mediaPlayerController.reset()
|
||||
onCurrentChanged()
|
||||
onSliderProgressChanged()
|
||||
mediaPlayerManager.reset()
|
||||
}
|
||||
}
|
||||
|
||||
playButton.setOnClickListener {
|
||||
if (!mediaPlayerController.isJukeboxEnabled)
|
||||
if (!mediaPlayerManager.isJukeboxEnabled)
|
||||
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
|
||||
|
||||
launch(CommunicationError.getHandler(context)) {
|
||||
mediaPlayerController.play()
|
||||
onCurrentChanged()
|
||||
onSliderProgressChanged()
|
||||
mediaPlayerManager.play()
|
||||
}
|
||||
}
|
||||
|
||||
@ -314,12 +332,12 @@ class PlayerFragment :
|
||||
}
|
||||
|
||||
repeatButton.setOnClickListener {
|
||||
var newRepeat = mediaPlayerController.repeatMode + 1
|
||||
var newRepeat = mediaPlayerManager.repeatMode + 1
|
||||
if (newRepeat == 3) {
|
||||
newRepeat = 0
|
||||
}
|
||||
|
||||
mediaPlayerController.repeatMode = newRepeat
|
||||
mediaPlayerManager.repeatMode = newRepeat
|
||||
|
||||
onPlaylistChanged()
|
||||
|
||||
@ -341,8 +359,7 @@ class PlayerFragment :
|
||||
progressBar.setOnSeekBarChangeListener(object : OnSeekBarChangeListener {
|
||||
override fun onStopTrackingTouch(seekBar: SeekBar) {
|
||||
launch(CommunicationError.getHandler(context)) {
|
||||
mediaPlayerController.seekTo(progressBar.progress)
|
||||
onSliderProgressChanged()
|
||||
mediaPlayerManager.seekTo(progressBar.progress)
|
||||
}
|
||||
}
|
||||
|
||||
@ -367,22 +384,31 @@ class PlayerFragment :
|
||||
// Observe playlist changes and update the UI
|
||||
rxBusSubscription += RxBus.playlistObservable.subscribe {
|
||||
onPlaylistChanged()
|
||||
onSliderProgressChanged()
|
||||
updateSeekBar()
|
||||
}
|
||||
|
||||
rxBusSubscription += RxBus.playerStateObservable.subscribe {
|
||||
update()
|
||||
updateTitle(it.state)
|
||||
updateButtonStates(it.state)
|
||||
}
|
||||
|
||||
// Query the Jukebox state in an IO Context
|
||||
ioScope.launch(CommunicationError.getHandler(context)) {
|
||||
try {
|
||||
jukeboxAvailable = mediaPlayerController.isJukeboxAvailable
|
||||
jukeboxAvailable = mediaPlayerManager.isJukeboxAvailable
|
||||
} catch (all: Exception) {
|
||||
Timber.e(all)
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe to change in command availability
|
||||
mediaPlayerManager.addListener(object : Player.Listener {
|
||||
override fun onAvailableCommandsChanged(availableCommands: Player.Commands) {
|
||||
updateMediaButtonActivationState()
|
||||
}
|
||||
})
|
||||
|
||||
view.setOnTouchListener { _, event -> gestureScanner.onTouchEvent(event) }
|
||||
}
|
||||
|
||||
@ -414,7 +440,7 @@ class PlayerFragment :
|
||||
}
|
||||
|
||||
private fun toggleShuffle() {
|
||||
val isEnabled = mediaPlayerController.toggleShuffle()
|
||||
val isEnabled = mediaPlayerManager.toggleShuffle()
|
||||
|
||||
if (isEnabled) {
|
||||
Util.toast(activity, R.string.download_menu_shuffle_on)
|
||||
@ -427,12 +453,12 @@ class PlayerFragment :
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
if (mediaPlayerController.currentMediaItem == null) {
|
||||
if (mediaPlayerManager.currentMediaItem == null) {
|
||||
playlistFlipper.displayedChild = 1
|
||||
} else {
|
||||
// Download list and Album art must be updated when resumed
|
||||
onPlaylistChanged()
|
||||
onCurrentChanged()
|
||||
onTrackChanged()
|
||||
}
|
||||
|
||||
val handler = Handler(Looper.getMainLooper())
|
||||
@ -440,7 +466,7 @@ class PlayerFragment :
|
||||
executorService = Executors.newSingleThreadScheduledExecutor()
|
||||
executorService.scheduleWithFixedDelay(runnable, 0L, 500L, TimeUnit.MILLISECONDS)
|
||||
|
||||
if (mediaPlayerController.keepScreenOn) {
|
||||
if (mediaPlayerManager.keepScreenOn) {
|
||||
requireActivity().window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
} else {
|
||||
requireActivity().window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
@ -451,7 +477,7 @@ class PlayerFragment :
|
||||
|
||||
// Scroll to current playing.
|
||||
private fun scrollToCurrent() {
|
||||
val index = mediaPlayerController.currentMediaItemIndex
|
||||
val index = mediaPlayerManager.currentMediaItemIndex
|
||||
|
||||
if (index != -1) {
|
||||
val smoothScroller = LinearSmoothScroller(context)
|
||||
@ -469,26 +495,59 @@ class PlayerFragment :
|
||||
rxBusSubscription.dispose()
|
||||
cancel("CoroutineScope cancelled because the view was destroyed")
|
||||
cancellationToken.cancel()
|
||||
_binding = null
|
||||
super.onDestroyView()
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
|
||||
inflater.inflate(R.menu.nowplaying, menu)
|
||||
super.onCreateOptionsMenu(menu, inflater)
|
||||
private val menuProvider: MenuProvider = object : MenuProvider {
|
||||
override fun onPrepareMenu(menu: Menu) {
|
||||
setupOptionsMenu(menu)
|
||||
}
|
||||
|
||||
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
|
||||
menuInflater.inflate(R.menu.nowplaying, menu)
|
||||
}
|
||||
|
||||
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
|
||||
return menuItemSelected(menuItem.itemId, currentSong)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("ComplexMethod", "LongMethod", "NestedBlockDepth")
|
||||
override fun onPrepareOptionsMenu(menu: Menu) {
|
||||
super.onPrepareOptionsMenu(menu)
|
||||
fun setupOptionsMenu(menu: Menu) {
|
||||
// Seems there is nothing like ViewBinding for Menus
|
||||
val screenOption = menu.findItem(R.id.menu_item_screen_on_off)
|
||||
val goToAlbum = menu.findItem(R.id.menu_show_album)
|
||||
val goToArtist = menu.findItem(R.id.menu_show_artist)
|
||||
val jukeboxOption = menu.findItem(R.id.menu_item_jukebox)
|
||||
val equalizerMenuItem = menu.findItem(R.id.menu_item_equalizer)
|
||||
val shareMenuItem = menu.findItem(R.id.menu_item_share)
|
||||
val shareSongMenuItem = menu.findItem(R.id.menu_item_share_song)
|
||||
starMenuItem = menu.findItem(R.id.menu_item_star)
|
||||
val starMenuItem = menu.findItem(R.id.menu_item_star)
|
||||
val bookmarkMenuItem = menu.findItem(R.id.menu_item_bookmark_set)
|
||||
val bookmarkRemoveMenuItem = menu.findItem(R.id.menu_item_bookmark_delete)
|
||||
|
||||
// Listen to rating changes and update the UI
|
||||
rxBusSubscription += RxBus.ratingPublishedObservable.subscribe { update ->
|
||||
|
||||
// Ignore updates which are not for the current song
|
||||
if (update.id != currentSong?.id) return@subscribe
|
||||
|
||||
// Ensure UI thread
|
||||
launch {
|
||||
if (update.success == true && update.rating is HeartRating) {
|
||||
if (update.rating.isHeart) {
|
||||
starMenuItem.setIcon(fullStar)
|
||||
} else {
|
||||
starMenuItem.setIcon(hollowStar)
|
||||
}
|
||||
} else if (update.success == false) {
|
||||
Toast.makeText(context, "Setting rating failed", Toast.LENGTH_SHORT)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isOffline()) {
|
||||
if (shareMenuItem != null) {
|
||||
shareMenuItem.isVisible = false
|
||||
@ -505,7 +564,8 @@ class PlayerFragment :
|
||||
equalizerMenuItem.isEnabled = isEqualizerAvailable
|
||||
equalizerMenuItem.isVisible = isEqualizerAvailable
|
||||
}
|
||||
val mediaPlayerController = mediaPlayerController
|
||||
|
||||
val mediaPlayerController = mediaPlayerManager
|
||||
val track = mediaPlayerController.currentMediaItem?.toTrack()
|
||||
|
||||
if (track != null) {
|
||||
@ -517,9 +577,13 @@ class PlayerFragment :
|
||||
if (currentSong != null) {
|
||||
starMenuItem.setIcon(if (currentSong!!.starred) fullStar else hollowStar)
|
||||
shareSongMenuItem.isVisible = true
|
||||
goToAlbum.isVisible = true
|
||||
goToArtist.isVisible = true
|
||||
} else {
|
||||
starMenuItem.setIcon(hollowStar)
|
||||
shareSongMenuItem.isVisible = false
|
||||
goToAlbum.isVisible = false
|
||||
goToArtist.isVisible = false
|
||||
}
|
||||
|
||||
if (mediaPlayerController.keepScreenOn) {
|
||||
@ -551,19 +615,15 @@ class PlayerFragment :
|
||||
}
|
||||
}
|
||||
|
||||
if (isOffline() || !Settings.shouldUseId3Tags) {
|
||||
popup.menu.findItem(R.id.menu_show_artist)?.isVisible = false
|
||||
}
|
||||
// Only show the menu if the ID3 tags are available
|
||||
popup.menu.findItem(R.id.menu_show_artist)?.isVisible = shouldUseId3Tags()
|
||||
|
||||
// Only show the lyrics when the user is online
|
||||
popup.menu.findItem(R.id.menu_lyrics)?.isVisible = !isOffline()
|
||||
popup.show()
|
||||
return popup
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
return menuItemSelected(item.itemId, currentSong) || super.onOptionsItemSelected(item)
|
||||
}
|
||||
|
||||
private fun onContextMenuItemSelected(
|
||||
menuItem: MenuItem,
|
||||
item: MusicDirectory.Child
|
||||
@ -578,7 +638,7 @@ class PlayerFragment :
|
||||
R.id.menu_show_artist -> {
|
||||
if (track == null) return false
|
||||
|
||||
if (Settings.shouldUseId3Tags) {
|
||||
if (Settings.id3TagsEnabledOnline) {
|
||||
val action = PlayerFragmentDirections.playerToAlbumsList(
|
||||
type = AlbumListType.SORTED_BY_NAME,
|
||||
byArtist = true,
|
||||
@ -594,7 +654,7 @@ class PlayerFragment :
|
||||
R.id.menu_show_album -> {
|
||||
if (track == null) return false
|
||||
|
||||
val albumId = if (Settings.shouldUseId3Tags) track.albumId else track.parent
|
||||
val albumId = if (shouldUseId3Tags()) track.albumId else track.parent
|
||||
|
||||
val action = PlayerFragmentDirections.playerToSelectAlbum(
|
||||
id = albumId,
|
||||
@ -614,12 +674,12 @@ class PlayerFragment :
|
||||
}
|
||||
R.id.menu_item_screen_on_off -> {
|
||||
val window = requireActivity().window
|
||||
if (mediaPlayerController.keepScreenOn) {
|
||||
if (mediaPlayerManager.keepScreenOn) {
|
||||
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
mediaPlayerController.keepScreenOn = false
|
||||
mediaPlayerManager.keepScreenOn = false
|
||||
} else {
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
mediaPlayerController.keepScreenOn = true
|
||||
mediaPlayerManager.keepScreenOn = true
|
||||
}
|
||||
return true
|
||||
}
|
||||
@ -632,8 +692,8 @@ class PlayerFragment :
|
||||
return true
|
||||
}
|
||||
R.id.menu_item_jukebox -> {
|
||||
val jukeboxEnabled = !mediaPlayerController.isJukeboxEnabled
|
||||
mediaPlayerController.isJukeboxEnabled = jukeboxEnabled
|
||||
val jukeboxEnabled = !mediaPlayerManager.isJukeboxEnabled
|
||||
mediaPlayerManager.isJukeboxEnabled = jukeboxEnabled
|
||||
Util.toast(
|
||||
context,
|
||||
if (jukeboxEnabled) R.string.download_jukebox_on
|
||||
@ -647,44 +707,24 @@ class PlayerFragment :
|
||||
return true
|
||||
}
|
||||
R.id.menu_item_clear_playlist -> {
|
||||
mediaPlayerController.isShufflePlayEnabled = false
|
||||
mediaPlayerController.clear()
|
||||
mediaPlayerManager.isShufflePlayEnabled = false
|
||||
mediaPlayerManager.clear()
|
||||
onPlaylistChanged()
|
||||
return true
|
||||
}
|
||||
R.id.menu_item_save_playlist -> {
|
||||
if (mediaPlayerController.playlistSize > 0) {
|
||||
if (mediaPlayerManager.playlistSize > 0) {
|
||||
showSavePlaylistDialog()
|
||||
}
|
||||
return true
|
||||
}
|
||||
R.id.menu_item_star -> {
|
||||
if (track == null) return true
|
||||
track.starred = !track.starred
|
||||
|
||||
val isStarred = track.starred
|
||||
|
||||
mediaPlayerController.toggleSongStarred()?.let {
|
||||
Futures.addCallback(
|
||||
it,
|
||||
object : FutureCallback<SessionResult> {
|
||||
override fun onSuccess(result: SessionResult?) {
|
||||
if (isStarred) {
|
||||
starMenuItem.setIcon(hollowStar)
|
||||
track.starred = false
|
||||
} else {
|
||||
starMenuItem.setIcon(fullStar)
|
||||
track.starred = true
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(t: Throwable) {
|
||||
Toast.makeText(context, "SetRating failed", Toast.LENGTH_SHORT)
|
||||
.show()
|
||||
}
|
||||
},
|
||||
this.executorService
|
||||
)
|
||||
}
|
||||
RxBus.ratingSubmitter.onNext(
|
||||
RatingUpdate(track.id, HeartRating(track.starred))
|
||||
)
|
||||
|
||||
return true
|
||||
}
|
||||
@ -692,7 +732,7 @@ class PlayerFragment :
|
||||
if (track == null) return true
|
||||
|
||||
val songId = track.id
|
||||
val playerPosition = mediaPlayerController.playerPosition
|
||||
val playerPosition = mediaPlayerManager.playerPosition
|
||||
track.bookmarkPosition = playerPosition
|
||||
val bookmarkTime = Util.formatTotalDuration(playerPosition.toLong(), true)
|
||||
Thread {
|
||||
@ -727,7 +767,7 @@ class PlayerFragment :
|
||||
return true
|
||||
}
|
||||
R.id.menu_item_share -> {
|
||||
val mediaPlayerController = mediaPlayerController
|
||||
val mediaPlayerController = mediaPlayerManager
|
||||
val tracks: MutableList<Track?> = ArrayList()
|
||||
val playlist = mediaPlayerController.playlist
|
||||
for (item in playlist) {
|
||||
@ -762,20 +802,18 @@ class PlayerFragment :
|
||||
|
||||
private fun update(cancel: CancellationToken? = null) {
|
||||
if (cancel?.isCancellationRequested == true) return
|
||||
val mediaPlayerController = mediaPlayerController
|
||||
if (currentSong?.id != mediaPlayerController.currentMediaItem?.mediaId) {
|
||||
onCurrentChanged()
|
||||
if (currentSong?.id != mediaPlayerManager.currentMediaItem?.mediaId) {
|
||||
onTrackChanged()
|
||||
}
|
||||
onSliderProgressChanged()
|
||||
requireActivity().invalidateOptionsMenu()
|
||||
updateSeekBar()
|
||||
}
|
||||
|
||||
private fun savePlaylistInBackground(playlistName: String) {
|
||||
Util.toast(context, resources.getString(R.string.download_playlist_saving, playlistName))
|
||||
mediaPlayerController.suggestedPlaylistName = playlistName
|
||||
mediaPlayerManager.suggestedPlaylistName = playlistName
|
||||
|
||||
// The playlist can be acquired only from the main thread
|
||||
val entries = mediaPlayerController.playlist.map {
|
||||
val entries = mediaPlayerManager.playlist.map {
|
||||
it.toTrack()
|
||||
}
|
||||
|
||||
@ -827,12 +865,9 @@ class PlayerFragment :
|
||||
}
|
||||
|
||||
// Create listener
|
||||
val clickHandler: ((Track, Int) -> Unit) = { _, pos ->
|
||||
mediaPlayerController.seekTo(pos, 0)
|
||||
mediaPlayerController.prepare()
|
||||
mediaPlayerController.play()
|
||||
onCurrentChanged()
|
||||
onSliderProgressChanged()
|
||||
val clickHandler: ((Track, Int) -> Unit) = { _, listPos ->
|
||||
val mediaIndex = mediaPlayerManager.getUnshuffledIndexOf(listPos)
|
||||
mediaPlayerManager.play(mediaIndex)
|
||||
}
|
||||
|
||||
viewAdapter.register(
|
||||
@ -896,7 +931,7 @@ class PlayerFragment :
|
||||
@SuppressLint("NotifyDataSetChanged")
|
||||
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
|
||||
val pos = viewHolder.bindingAdapterPosition
|
||||
val item = mediaPlayerController.getMediaItemAt(pos)
|
||||
val item = mediaPlayerManager.getMediaItemAt(pos)
|
||||
|
||||
// Remove the item from the list quickly
|
||||
val items = viewAdapter.getCurrentList().toMutableList()
|
||||
@ -912,7 +947,7 @@ class PlayerFragment :
|
||||
Util.toast(context, songRemoved)
|
||||
|
||||
// Remove the item from the playlist
|
||||
mediaPlayerController.removeFromPlaylist(pos)
|
||||
mediaPlayerManager.removeFromPlaylist(pos)
|
||||
}
|
||||
|
||||
override fun onSelectedChanged(
|
||||
@ -931,7 +966,8 @@ class PlayerFragment :
|
||||
if (actionState == ACTION_STATE_IDLE && dragging) {
|
||||
dragging = false
|
||||
// Move the item in the playlist separately
|
||||
mediaPlayerController.moveItemInPlaylist(startPosition, endPosition)
|
||||
Timber.i("Moving item %s to %s", startPosition, endPosition)
|
||||
mediaPlayerManager.moveItemInPlaylist(startPosition, endPosition)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1009,24 +1045,24 @@ class PlayerFragment :
|
||||
}
|
||||
|
||||
private fun onPlaylistChanged() {
|
||||
val mediaPlayerController = mediaPlayerController
|
||||
val list = mediaPlayerController.playlist
|
||||
val mediaPlayerController = mediaPlayerManager
|
||||
// Try to display playlist in play order
|
||||
val list = mediaPlayerController.playlistInPlayOrder
|
||||
emptyTextView.setText(R.string.playlist_empty)
|
||||
|
||||
viewAdapter.submitList(list.map(MediaItem::toTrack))
|
||||
|
||||
emptyTextView.isVisible = list.isEmpty()
|
||||
progressIndicator.isVisible = false
|
||||
emptyView.isVisible = list.isEmpty()
|
||||
|
||||
updateRepeatButtonState(mediaPlayerController.repeatMode)
|
||||
}
|
||||
|
||||
private fun onCurrentChanged() {
|
||||
currentSong = mediaPlayerController.currentMediaItem?.toTrack()
|
||||
private fun onTrackChanged() {
|
||||
currentSong = mediaPlayerManager.currentMediaItem?.toTrack()
|
||||
|
||||
scrollToCurrent()
|
||||
val totalDuration = mediaPlayerController.playListDuration
|
||||
val totalSongs = mediaPlayerController.playlistSize
|
||||
val currentSongIndex = mediaPlayerController.currentMediaItemIndex + 1
|
||||
val totalDuration = mediaPlayerManager.playListDuration
|
||||
val totalSongs = mediaPlayerManager.playlistSize
|
||||
val currentSongIndex = mediaPlayerManager.currentMediaItemIndex + 1
|
||||
val duration = Util.formatTotalDuration(totalDuration)
|
||||
val trackFormat =
|
||||
String.format(Locale.getDefault(), "%d / %d", currentSongIndex, totalSongs)
|
||||
@ -1064,7 +1100,7 @@ class PlayerFragment :
|
||||
it.loadImage(albumArtImageView, currentSong, true, 0)
|
||||
}
|
||||
|
||||
displaySongRating()
|
||||
updateSongRating()
|
||||
} else {
|
||||
currentSong = null
|
||||
songTitleTextView.text = null
|
||||
@ -1078,26 +1114,30 @@ class PlayerFragment :
|
||||
it.loadImage(albumArtImageView, null, true, 0)
|
||||
}
|
||||
}
|
||||
|
||||
updateSongRating()
|
||||
|
||||
updateMediaButtonActivationState()
|
||||
}
|
||||
|
||||
private fun updateMediaButtonActivationState() {
|
||||
nextButton.isEnabled = mediaPlayerManager.canSeekToNext()
|
||||
previousButton.isEnabled = mediaPlayerManager.canSeekToPrevious()
|
||||
}
|
||||
|
||||
@Suppress("LongMethod")
|
||||
@Synchronized
|
||||
private fun onSliderProgressChanged() {
|
||||
private fun updateSeekBar() {
|
||||
val isJukeboxEnabled: Boolean = mediaPlayerManager.isJukeboxEnabled
|
||||
val millisPlayed: Int = max(0, mediaPlayerManager.playerPosition)
|
||||
val duration: Int = mediaPlayerManager.playerDuration
|
||||
val playbackState: Int = mediaPlayerManager.playbackState
|
||||
|
||||
val isJukeboxEnabled: Boolean = mediaPlayerController.isJukeboxEnabled
|
||||
val millisPlayed: Int = max(0, mediaPlayerController.playerPosition)
|
||||
val duration: Int = mediaPlayerController.playerDuration
|
||||
val playbackState: Int = mediaPlayerController.playbackState
|
||||
val isPlaying = mediaPlayerController.isPlaying
|
||||
|
||||
if (cancellationToken.isCancellationRequested) return
|
||||
if (currentSong != null) {
|
||||
positionTextView.text = Util.formatTotalDuration(millisPlayed.toLong(), true)
|
||||
durationTextView.text = Util.formatTotalDuration(duration.toLong(), true)
|
||||
progressBar.max =
|
||||
if (duration == 0) 100 else duration // Work-around for apparent bug.
|
||||
progressBar.max = if (duration == 0) 100 else duration // Work-around for apparent bug.
|
||||
progressBar.progress = millisPlayed
|
||||
progressBar.isEnabled = mediaPlayerController.isPlaying || isJukeboxEnabled
|
||||
progressBar.isEnabled = mediaPlayerManager.isPlaying || isJukeboxEnabled
|
||||
} else {
|
||||
positionTextView.setText(R.string.util_zero_time)
|
||||
durationTextView.setText(R.string.util_no_time)
|
||||
@ -1106,20 +1146,20 @@ class PlayerFragment :
|
||||
progressBar.isEnabled = false
|
||||
}
|
||||
|
||||
val progress = mediaPlayerController.bufferedPercentage
|
||||
val progress = mediaPlayerManager.bufferedPercentage
|
||||
updateBufferProgress(playbackState, progress)
|
||||
}
|
||||
|
||||
private fun updateTitle(playbackState: Int) {
|
||||
when (playbackState) {
|
||||
Player.STATE_BUFFERING -> {
|
||||
|
||||
val downloadStatus = resources.getString(
|
||||
R.string.download_playerstate_loading
|
||||
)
|
||||
progressBar.secondaryProgress = progress
|
||||
setTitle(this@PlayerFragment, downloadStatus)
|
||||
}
|
||||
Player.STATE_READY -> {
|
||||
progressBar.secondaryProgress = progress
|
||||
if (mediaPlayerController.isShufflePlayEnabled) {
|
||||
if (mediaPlayerManager.isShufflePlayEnabled) {
|
||||
setTitle(
|
||||
this@PlayerFragment,
|
||||
R.string.download_playerstate_playing_shuffle
|
||||
@ -1128,13 +1168,22 @@ class PlayerFragment :
|
||||
setTitle(this@PlayerFragment, R.string.common_appname)
|
||||
}
|
||||
}
|
||||
Player.STATE_IDLE,
|
||||
Player.STATE_ENDED,
|
||||
-> {
|
||||
}
|
||||
Player.STATE_IDLE, Player.STATE_ENDED -> {}
|
||||
else -> setTitle(this@PlayerFragment, R.string.common_appname)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateBufferProgress(playbackState: Int, progress: Int) {
|
||||
when (playbackState) {
|
||||
Player.STATE_BUFFERING, Player.STATE_READY -> {
|
||||
progressBar.secondaryProgress = progress
|
||||
}
|
||||
else -> { }
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateButtonStates(playbackState: Int) {
|
||||
val isPlaying = mediaPlayerManager.isPlaying
|
||||
when (playbackState) {
|
||||
Player.STATE_READY -> {
|
||||
pauseButton.isVisible = isPlaying
|
||||
@ -1152,18 +1201,14 @@ class PlayerFragment :
|
||||
playButton.isVisible = true
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: It would be a lot nicer if MediaPlayerController would send an event
|
||||
// when this is necessary instead of updating every time
|
||||
displaySongRating()
|
||||
}
|
||||
|
||||
private fun seek(forward: Boolean) {
|
||||
launch(CommunicationError.getHandler(context)) {
|
||||
if (forward) {
|
||||
mediaPlayerController.seekForward()
|
||||
mediaPlayerManager.seekForward()
|
||||
} else {
|
||||
mediaPlayerController.seekBack()
|
||||
mediaPlayerManager.seekBack()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1189,34 +1234,28 @@ class PlayerFragment :
|
||||
// Right to Left swipe
|
||||
if (e1X - e2X > swipeDistance && absX > swipeVelocity) {
|
||||
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
|
||||
mediaPlayerController.next()
|
||||
onCurrentChanged()
|
||||
onSliderProgressChanged()
|
||||
mediaPlayerManager.seekToNext()
|
||||
return true
|
||||
}
|
||||
|
||||
// Left to Right swipe
|
||||
if (e2X - e1X > swipeDistance && absX > swipeVelocity) {
|
||||
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
|
||||
mediaPlayerController.previous()
|
||||
onCurrentChanged()
|
||||
onSliderProgressChanged()
|
||||
mediaPlayerManager.seekToPrevious()
|
||||
return true
|
||||
}
|
||||
|
||||
// Top to Bottom swipe
|
||||
if (e2Y - e1Y > swipeDistance && absY > swipeVelocity) {
|
||||
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
|
||||
mediaPlayerController.seekTo(mediaPlayerController.playerPosition + 30000)
|
||||
onSliderProgressChanged()
|
||||
mediaPlayerManager.seekTo(mediaPlayerManager.playerPosition + 30000)
|
||||
return true
|
||||
}
|
||||
|
||||
// Bottom to Top swipe
|
||||
if (e1Y - e2Y > swipeDistance && absY > swipeVelocity) {
|
||||
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
|
||||
mediaPlayerController.seekTo(mediaPlayerController.playerPosition - 8000)
|
||||
onSliderProgressChanged()
|
||||
mediaPlayerManager.seekTo(mediaPlayerManager.playerPosition - 8000)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
@ -1237,12 +1276,8 @@ class PlayerFragment :
|
||||
return false
|
||||
}
|
||||
|
||||
private fun displaySongRating() {
|
||||
var rating = 0
|
||||
|
||||
if (currentSong?.userRating != null) {
|
||||
rating = currentSong!!.userRating!!
|
||||
}
|
||||
private fun updateSongRating() {
|
||||
val rating = currentSong?.userRating ?: 0
|
||||
|
||||
fiveStar1ImageView.setImageResource(if (rating > 0) fullStar else hollowStar)
|
||||
fiveStar2ImageView.setImageResource(if (rating > 1) fullStar else hollowStar)
|
||||
@ -1253,8 +1288,15 @@ class PlayerFragment :
|
||||
|
||||
private fun setSongRating(rating: Int) {
|
||||
if (currentSong == null) return
|
||||
displaySongRating()
|
||||
mediaPlayerController.setSongRating(rating)
|
||||
currentSong?.userRating = rating
|
||||
updateSongRating()
|
||||
|
||||
RxBus.ratingSubmitter.onNext(
|
||||
RatingUpdate(
|
||||
currentSong!!.id,
|
||||
StarRating(5, rating.toFloat())
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@SuppressLint("InflateParams")
|
||||
@ -1278,7 +1320,7 @@ class PlayerFragment :
|
||||
builder.setView(layout)
|
||||
builder.setCancelable(true)
|
||||
val dialog = builder.create()
|
||||
val playlistName = mediaPlayerController.suggestedPlaylistName
|
||||
val playlistName = mediaPlayerManager.suggestedPlaylistName
|
||||
if (playlistName != null) {
|
||||
playlistNameView.setText(playlistName)
|
||||
} else {
|
||||
|
@ -15,8 +15,11 @@ import android.view.MenuInflater
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import androidx.appcompat.widget.SearchView
|
||||
import androidx.core.view.MenuHost
|
||||
import androidx.core.view.MenuProvider
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.navigation.fragment.navArgs
|
||||
@ -41,7 +44,8 @@ import org.moire.ultrasonic.domain.SearchResult
|
||||
import org.moire.ultrasonic.domain.Track
|
||||
import org.moire.ultrasonic.fragment.FragmentTitle.Companion.setTitle
|
||||
import org.moire.ultrasonic.model.SearchListModel
|
||||
import org.moire.ultrasonic.service.MediaPlayerController
|
||||
import org.moire.ultrasonic.service.DownloadService
|
||||
import org.moire.ultrasonic.service.MediaPlayerManager
|
||||
import org.moire.ultrasonic.subsonic.NetworkAndStorageChecker
|
||||
import org.moire.ultrasonic.subsonic.ShareHandler
|
||||
import org.moire.ultrasonic.subsonic.VideoPlayer.Companion.playVideo
|
||||
@ -54,15 +58,14 @@ import timber.log.Timber
|
||||
|
||||
/**
|
||||
* Initiates a search on the media library and displays the results
|
||||
*
|
||||
* TODO: Implement the search field without using the deprecated OptionsMenu calls
|
||||
* TODO: Switch to material3 class
|
||||
*/
|
||||
class SearchFragment : MultiListFragment<Identifiable>(), KoinComponent {
|
||||
private var searchResult: SearchResult? = null
|
||||
private var searchRefresh: SwipeRefreshLayout? = null
|
||||
private var searchView: SearchView? = null
|
||||
|
||||
private val mediaPlayerController: MediaPlayerController by inject()
|
||||
private val mediaPlayerManager: MediaPlayerManager by inject()
|
||||
|
||||
private val shareHandler: ShareHandler by inject()
|
||||
private val networkAndStorageChecker: NetworkAndStorageChecker by inject()
|
||||
@ -79,7 +82,13 @@ class SearchFragment : MultiListFragment<Identifiable>(), KoinComponent {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
cancellationToken = CancellationToken()
|
||||
setTitle(this, R.string.search_title)
|
||||
setHasOptionsMenu(true)
|
||||
|
||||
// Register our options menu
|
||||
(requireActivity() as MenuHost).addMenuProvider(
|
||||
menuProvider,
|
||||
viewLifecycleOwner,
|
||||
Lifecycle.State.RESUMED
|
||||
)
|
||||
|
||||
listModel.searchResult.observe(
|
||||
viewLifecycleOwner
|
||||
@ -140,12 +149,24 @@ class SearchFragment : MultiListFragment<Identifiable>(), KoinComponent {
|
||||
}
|
||||
|
||||
/**
|
||||
* This method creates the search bar above the recycler view
|
||||
* This provide creates the search bar above the recycler view
|
||||
*/
|
||||
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
|
||||
private val menuProvider: MenuProvider = object : MenuProvider {
|
||||
override fun onPrepareMenu(menu: Menu) {
|
||||
setupOptionsMenu(menu)
|
||||
}
|
||||
|
||||
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
|
||||
menuInflater.inflate(R.menu.search, menu)
|
||||
}
|
||||
|
||||
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
|
||||
return true
|
||||
}
|
||||
}
|
||||
fun setupOptionsMenu(menu: Menu) {
|
||||
val activity = activity ?: return
|
||||
val searchManager = activity.getSystemService(Context.SEARCH_SERVICE) as SearchManager
|
||||
inflater.inflate(R.menu.search, menu)
|
||||
val searchItem = menu.findItem(R.id.search_item)
|
||||
searchView = searchItem.actionView as SearchView
|
||||
val searchableInfo = searchManager.getSearchableInfo(requireActivity().componentName)
|
||||
@ -203,7 +224,7 @@ class SearchFragment : MultiListFragment<Identifiable>(), KoinComponent {
|
||||
private fun downloadBackground(save: Boolean, songs: List<Track?>) {
|
||||
val onValid = Runnable {
|
||||
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
|
||||
mediaPlayerController.downloadBackground(songs, save)
|
||||
DownloadService.download(songs.filterNotNull(), save)
|
||||
}
|
||||
onValid.run()
|
||||
}
|
||||
@ -274,7 +295,7 @@ class SearchFragment : MultiListFragment<Identifiable>(), KoinComponent {
|
||||
id = item.id,
|
||||
name = item.name,
|
||||
parentId = item.id,
|
||||
isArtist = (item is Artist)
|
||||
isArtist = false
|
||||
)
|
||||
} else {
|
||||
SearchFragmentDirections.searchToAlbumsList(
|
||||
@ -304,16 +325,15 @@ class SearchFragment : MultiListFragment<Identifiable>(), KoinComponent {
|
||||
|
||||
private fun onSongSelected(song: Track, append: Boolean) {
|
||||
if (!append) {
|
||||
mediaPlayerController.clear()
|
||||
mediaPlayerManager.clear()
|
||||
}
|
||||
mediaPlayerController.addToPlaylist(
|
||||
mediaPlayerManager.addToPlaylist(
|
||||
listOf(song),
|
||||
cachePermanently = false,
|
||||
autoPlay = false,
|
||||
shuffle = false,
|
||||
insertionMode = MediaPlayerController.InsertionMode.APPEND
|
||||
insertionMode = MediaPlayerManager.InsertionMode.APPEND
|
||||
)
|
||||
mediaPlayerController.play(mediaPlayerController.mediaItemCount - 1)
|
||||
mediaPlayerManager.play(mediaPlayerManager.mediaItemCount - 1)
|
||||
toast(context, resources.getQuantityString(R.plurals.select_album_n_songs_added, 1, 1))
|
||||
}
|
||||
|
||||
@ -366,40 +386,37 @@ class SearchFragment : MultiListFragment<Identifiable>(), KoinComponent {
|
||||
when (menuItem.itemId) {
|
||||
R.id.song_menu_play_now -> {
|
||||
songs.add(item)
|
||||
downloadHandler.download(
|
||||
fragment = this,
|
||||
append = false,
|
||||
save = false,
|
||||
autoPlay = true,
|
||||
playNext = false,
|
||||
shuffle = false,
|
||||
downloadHandler.addTracksToMediaController(
|
||||
songs = songs,
|
||||
append = false,
|
||||
playNext = false,
|
||||
autoPlay = true,
|
||||
shuffle = false,
|
||||
fragment = this,
|
||||
playlistName = null
|
||||
)
|
||||
}
|
||||
R.id.song_menu_play_next -> {
|
||||
songs.add(item)
|
||||
downloadHandler.download(
|
||||
fragment = this,
|
||||
append = true,
|
||||
save = false,
|
||||
autoPlay = false,
|
||||
playNext = true,
|
||||
shuffle = false,
|
||||
downloadHandler.addTracksToMediaController(
|
||||
songs = songs,
|
||||
append = true,
|
||||
playNext = true,
|
||||
autoPlay = false,
|
||||
shuffle = false,
|
||||
fragment = this,
|
||||
playlistName = null
|
||||
)
|
||||
}
|
||||
R.id.song_menu_play_last -> {
|
||||
songs.add(item)
|
||||
downloadHandler.download(
|
||||
fragment = this,
|
||||
append = true,
|
||||
save = false,
|
||||
autoPlay = false,
|
||||
playNext = false,
|
||||
shuffle = false,
|
||||
downloadHandler.addTracksToMediaController(
|
||||
songs = songs,
|
||||
append = true,
|
||||
playNext = false,
|
||||
autoPlay = false,
|
||||
shuffle = false,
|
||||
fragment = this,
|
||||
playlistName = null
|
||||
)
|
||||
}
|
||||
@ -437,7 +454,7 @@ class SearchFragment : MultiListFragment<Identifiable>(), KoinComponent {
|
||||
songs.size
|
||||
)
|
||||
)
|
||||
mediaPlayerController.unpin(songs)
|
||||
DownloadService.unpin(songs)
|
||||
}
|
||||
R.id.song_menu_share -> {
|
||||
songs.add(item)
|
||||
|
@ -1,14 +1,11 @@
|
||||
package org.moire.ultrasonic.fragment
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.DialogInterface
|
||||
import android.content.Intent
|
||||
import android.content.SharedPreferences
|
||||
import android.content.SharedPreferences.OnSharedPreferenceChangeListener
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.DocumentsContract
|
||||
import android.provider.SearchRecentSuggestions
|
||||
import android.view.View
|
||||
import androidx.annotation.StringRes
|
||||
@ -31,16 +28,17 @@ import org.moire.ultrasonic.log.FileLoggerTree.Companion.getLogFileSizes
|
||||
import org.moire.ultrasonic.log.FileLoggerTree.Companion.plantToTimberForest
|
||||
import org.moire.ultrasonic.log.FileLoggerTree.Companion.uprootFromTimberForest
|
||||
import org.moire.ultrasonic.provider.SearchSuggestionProvider
|
||||
import org.moire.ultrasonic.service.MediaPlayerController
|
||||
import org.moire.ultrasonic.service.MediaPlayerManager
|
||||
import org.moire.ultrasonic.service.RxBus
|
||||
import org.moire.ultrasonic.util.ConfirmationDialog
|
||||
import org.moire.ultrasonic.util.Constants
|
||||
import org.moire.ultrasonic.util.ErrorDialog
|
||||
import org.moire.ultrasonic.util.FileUtil.ultrasonicDirectory
|
||||
import org.moire.ultrasonic.util.InfoDialog
|
||||
import org.moire.ultrasonic.util.SelectCacheActivityContract
|
||||
import org.moire.ultrasonic.util.Settings
|
||||
import org.moire.ultrasonic.util.Settings.id3TagsEnabledOnline
|
||||
import org.moire.ultrasonic.util.Settings.preferences
|
||||
import org.moire.ultrasonic.util.Settings.shouldUseId3Tags
|
||||
import org.moire.ultrasonic.util.Storage
|
||||
import org.moire.ultrasonic.util.TimeSpanPreference
|
||||
import org.moire.ultrasonic.util.TimeSpanPreferenceDialogFragmentCompat
|
||||
@ -64,7 +62,7 @@ class SettingsFragment :
|
||||
private var debugLogToFile: CheckBoxPreference? = null
|
||||
private var customCacheLocation: CheckBoxPreference? = null
|
||||
|
||||
private val mediaPlayerController: MediaPlayerController by inject()
|
||||
private val mediaPlayerManager: MediaPlayerManager by inject()
|
||||
|
||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||
setPreferencesFromResource(R.xml.settings, rootKey)
|
||||
@ -100,64 +98,14 @@ class SettingsFragment :
|
||||
updateCustomPreferences()
|
||||
}
|
||||
|
||||
/**
|
||||
* This function will be called when we return from the file picker
|
||||
* with a new custom cache location
|
||||
*
|
||||
* TODO: This method has been deprecated in favor of using the Activity Result API
|
||||
* which brings increased type safety via an ActivityResultContract and the prebuilt
|
||||
* contracts for common intents available in
|
||||
* androidx.activity.result.contract.ActivityResultContracts,
|
||||
* provides hooks for testing, and allow receiving results in separate,
|
||||
* testable classes independent from your fragment.
|
||||
* Use registerForActivityResult(ActivityResultContract, ActivityResultCallback) with the
|
||||
* appropriate ActivityResultContract and handling the result in the callback.
|
||||
*/
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, resultData: Intent?) {
|
||||
if (
|
||||
requestCode == SELECT_CACHE_ACTIVITY &&
|
||||
resultCode == Activity.RESULT_OK &&
|
||||
resultData != null
|
||||
) {
|
||||
val read = (resultData.flags and Intent.FLAG_GRANT_READ_URI_PERMISSION) != 0
|
||||
val write = (resultData.flags and Intent.FLAG_GRANT_WRITE_URI_PERMISSION) != 0
|
||||
val persist = (resultData.flags and Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION) != 0
|
||||
|
||||
if (read && write && persist) {
|
||||
if (resultData.data != null) {
|
||||
// The result data contains a URI for the document or directory that
|
||||
// the user selected.
|
||||
val uri = resultData.data!!
|
||||
val contentResolver = UApp.applicationContext().contentResolver
|
||||
|
||||
contentResolver.takePersistableUriPermission(uri, RW_FLAG)
|
||||
setCacheLocation(uri.toString())
|
||||
setupCacheLocationPreference()
|
||||
return
|
||||
}
|
||||
}
|
||||
ErrorDialog.Builder(requireContext())
|
||||
.setMessage(R.string.settings_cache_location_error)
|
||||
.show()
|
||||
}
|
||||
|
||||
if (Settings.cacheLocationUri == "") {
|
||||
Settings.customCacheLocation = false
|
||||
customCacheLocation?.isChecked = false
|
||||
setupCacheLocationPreference()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
val preferences = preferences
|
||||
preferences.registerOnSharedPreferenceChangeListener(this)
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
val prefs = preferences
|
||||
prefs.unregisterOnSharedPreferenceChangeListener(this)
|
||||
preferences.unregisterOnSharedPreferenceChangeListener(this)
|
||||
}
|
||||
|
||||
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
|
||||
@ -249,19 +197,31 @@ class SettingsFragment :
|
||||
}
|
||||
|
||||
private fun selectCacheLocation() {
|
||||
// Choose a directory using the system's file picker.
|
||||
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
|
||||
|
||||
if (Settings.cacheLocationUri != "" && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, Settings.cacheLocationUri)
|
||||
}
|
||||
|
||||
intent.addFlags(RW_FLAG)
|
||||
intent.addFlags(PERSISTABLE_FLAG)
|
||||
|
||||
startActivityForResult(intent, SELECT_CACHE_ACTIVITY)
|
||||
// Start the activity to pick a directory using the system's file picker.
|
||||
selectCacheActivityContract.launch(Settings.cacheLocationUri)
|
||||
}
|
||||
|
||||
// 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() {
|
||||
val resumeSetting = Settings.resumeOnBluetoothDevice
|
||||
val pauseSetting = Settings.pauseOnBluetoothDevice
|
||||
@ -354,8 +314,8 @@ class SettingsFragment :
|
||||
debugLogToFile?.summary = ""
|
||||
}
|
||||
|
||||
showArtistPicture?.isEnabled = shouldUseId3Tags
|
||||
useId3TagsOffline?.isEnabled = shouldUseId3Tags
|
||||
showArtistPicture?.isEnabled = id3TagsEnabledOnline
|
||||
useId3TagsOffline?.isEnabled = id3TagsEnabledOnline
|
||||
}
|
||||
|
||||
private fun setHideMedia(hide: Boolean) {
|
||||
@ -382,7 +342,7 @@ class SettingsFragment :
|
||||
Settings.cacheLocationUri = path
|
||||
|
||||
// Clear download queue.
|
||||
mediaPlayerController.clear()
|
||||
mediaPlayerManager.clear()
|
||||
Storage.reset()
|
||||
Storage.ensureRootIsAvailable()
|
||||
}
|
||||
@ -425,7 +385,6 @@ class SettingsFragment :
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val SELECT_CACHE_ACTIVITY = 161161
|
||||
const val RW_FLAG = Intent.FLAG_GRANT_READ_URI_PERMISSION or
|
||||
Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||
const val PERSISTABLE_FLAG = Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION
|
||||
|
@ -12,8 +12,11 @@ import android.view.Menu
|
||||
import android.view.MenuInflater
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import androidx.core.view.MenuHost
|
||||
import androidx.core.view.MenuProvider
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.viewModelScope
|
||||
@ -40,10 +43,10 @@ import org.moire.ultrasonic.domain.MusicDirectory
|
||||
import org.moire.ultrasonic.domain.Track
|
||||
import org.moire.ultrasonic.fragment.FragmentTitle.Companion.setTitle
|
||||
import org.moire.ultrasonic.model.TrackCollectionModel
|
||||
import org.moire.ultrasonic.service.MediaPlayerController
|
||||
import org.moire.ultrasonic.service.MediaPlayerManager
|
||||
import org.moire.ultrasonic.service.RxBus
|
||||
import org.moire.ultrasonic.service.plusAssign
|
||||
import org.moire.ultrasonic.subsonic.NetworkAndStorageChecker
|
||||
import org.moire.ultrasonic.subsonic.DownloadAction
|
||||
import org.moire.ultrasonic.subsonic.ShareHandler
|
||||
import org.moire.ultrasonic.subsonic.VideoPlayer
|
||||
import org.moire.ultrasonic.util.CancellationToken
|
||||
@ -82,8 +85,7 @@ open class TrackCollectionFragment(
|
||||
private var playAllButton: MenuItem? = null
|
||||
private var shareButton: MenuItem? = null
|
||||
|
||||
internal val mediaPlayerController: MediaPlayerController by inject()
|
||||
private val networkAndStorageChecker: NetworkAndStorageChecker by inject()
|
||||
internal val mediaPlayerManager: MediaPlayerManager by inject()
|
||||
private val shareHandler: ShareHandler by inject()
|
||||
internal var cancellationToken: CancellationToken? = null
|
||||
|
||||
@ -115,7 +117,13 @@ open class TrackCollectionFragment(
|
||||
setupButtons(view)
|
||||
|
||||
registerForContextMenu(listView!!)
|
||||
setHasOptionsMenu(true)
|
||||
|
||||
// Register our options menu
|
||||
(requireActivity() as MenuHost).addMenuProvider(
|
||||
menuProvider,
|
||||
viewLifecycleOwner,
|
||||
Lifecycle.State.RESUMED
|
||||
)
|
||||
|
||||
// Create a View Manager
|
||||
viewManager = LinearLayoutManager(this.context)
|
||||
@ -210,11 +218,14 @@ open class TrackCollectionFragment(
|
||||
}
|
||||
|
||||
playNextButton?.setOnClickListener {
|
||||
downloadHandler.download(
|
||||
this@TrackCollectionFragment, append = true,
|
||||
save = false, autoPlay = false, playNext = true, shuffle = false,
|
||||
downloadHandler.addTracksToMediaController(
|
||||
songs = getSelectedSongs(),
|
||||
playlistName = navArgs.playlistName
|
||||
append = true,
|
||||
playNext = true,
|
||||
autoPlay = false,
|
||||
shuffle = false,
|
||||
playlistName = navArgs.playlistName,
|
||||
this@TrackCollectionFragment
|
||||
)
|
||||
}
|
||||
|
||||
@ -255,41 +266,39 @@ open class TrackCollectionFragment(
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPrepareOptionsMenu(menu: Menu) {
|
||||
super.onPrepareOptionsMenu(menu)
|
||||
playAllButton = menu.findItem(R.id.select_album_play_all)
|
||||
private val menuProvider: MenuProvider = object : MenuProvider {
|
||||
override fun onPrepareMenu(menu: Menu) {
|
||||
playAllButton = menu.findItem(R.id.select_album_play_all)
|
||||
|
||||
if (playAllButton != null) {
|
||||
playAllButton!!.isVisible = playAllButtonVisible
|
||||
if (playAllButton != null) {
|
||||
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)
|
||||
|
||||
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
|
||||
override fun onCreateMenu(menu: Menu, inflater: MenuInflater) {
|
||||
inflater.inflate(R.menu.select_album, menu)
|
||||
}
|
||||
|
||||
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() {
|
||||
@ -303,9 +312,14 @@ open class TrackCollectionFragment(
|
||||
selectedSongs: List<Track> = getSelectedSongs()
|
||||
) {
|
||||
if (selectedSongs.isNotEmpty()) {
|
||||
downloadHandler.download(
|
||||
this, append, false, !append, playNext = false,
|
||||
shuffle = false, songs = selectedSongs, null
|
||||
downloadHandler.addTracksToMediaController(
|
||||
songs = selectedSongs,
|
||||
append = append,
|
||||
playNext = false,
|
||||
autoPlay = !append,
|
||||
shuffle = false,
|
||||
playlistName = null,
|
||||
fragment = this
|
||||
)
|
||||
} else {
|
||||
playAll(false, append)
|
||||
@ -336,31 +350,29 @@ open class TrackCollectionFragment(
|
||||
}
|
||||
|
||||
val isArtist = navArgs.isArtist
|
||||
val id = navArgs.id
|
||||
|
||||
// Need a valid id to download stuff
|
||||
val id = navArgs.id ?: return
|
||||
|
||||
if (hasSubFolders) {
|
||||
downloadHandler.downloadRecursively(
|
||||
downloadHandler.fetchTracksAndAddToController(
|
||||
fragment = this,
|
||||
id = id,
|
||||
save = false,
|
||||
append = append,
|
||||
autoPlay = !append,
|
||||
shuffle = shuffle,
|
||||
background = false,
|
||||
playNext = false,
|
||||
unpin = false,
|
||||
isArtist = isArtist
|
||||
)
|
||||
} else {
|
||||
downloadHandler.download(
|
||||
fragment = this,
|
||||
append = append,
|
||||
save = false,
|
||||
autoPlay = !append,
|
||||
playNext = false,
|
||||
shuffle = shuffle,
|
||||
downloadHandler.addTracksToMediaController(
|
||||
songs = getAllSongs(),
|
||||
playlistName = navArgs.playlistName
|
||||
append = append,
|
||||
playNext = false,
|
||||
autoPlay = !append,
|
||||
shuffle = shuffle,
|
||||
playlistName = navArgs.playlistName,
|
||||
fragment = this
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -374,20 +386,17 @@ open class TrackCollectionFragment(
|
||||
|
||||
private fun selectAllOrNone() {
|
||||
val someUnselected = viewAdapter.selectedSet.size < childCount
|
||||
|
||||
selectAll(someUnselected, true)
|
||||
selectAll(someUnselected)
|
||||
}
|
||||
|
||||
private fun selectAll(selected: Boolean, toast: Boolean) {
|
||||
private fun selectAll(selected: Boolean) {
|
||||
var selectedCount = viewAdapter.selectedSet.size * -1
|
||||
|
||||
selectedCount += viewAdapter.setSelectionStatusOfAll(selected)
|
||||
|
||||
// Display toast: N tracks selected
|
||||
if (toast) {
|
||||
val toastResId = R.string.select_album_n_selected
|
||||
Util.toast(activity, getString(toastResId, selectedCount.coerceAtLeast(0)))
|
||||
}
|
||||
val toastResId = R.string.select_album_n_selected
|
||||
Util.toast(activity, getString(toastResId, selectedCount.coerceAtLeast(0)))
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
@ -400,6 +409,8 @@ open class TrackCollectionFragment(
|
||||
) {
|
||||
// We are coming back from unknown context
|
||||
// and need to ensure Main Thread in order to manipulate the UI
|
||||
// If view is null, our view was disposed in the meantime
|
||||
if (view == null) return
|
||||
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.Main) {
|
||||
val multipleSelection = viewAdapter.hasMultipleSelection()
|
||||
|
||||
@ -413,62 +424,35 @@ open class TrackCollectionFragment(
|
||||
}
|
||||
}
|
||||
|
||||
private fun downloadBackground(save: Boolean) {
|
||||
var songs = getSelectedSongs()
|
||||
private fun downloadBackground(save: Boolean, tracks: List<Track> = getSelectedSongs()) {
|
||||
var songs = tracks
|
||||
|
||||
if (songs.isEmpty()) {
|
||||
songs = getAllSongs()
|
||||
}
|
||||
|
||||
downloadBackground(save, songs)
|
||||
}
|
||||
|
||||
private fun downloadBackground(
|
||||
save: Boolean,
|
||||
songs: List<Track?>
|
||||
) {
|
||||
val onValid = Runnable {
|
||||
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
|
||||
mediaPlayerController.downloadBackground(songs, save)
|
||||
|
||||
if (save) {
|
||||
Util.toast(
|
||||
context,
|
||||
resources.getQuantityString(
|
||||
R.plurals.select_album_n_songs_pinned, songs.size, songs.size
|
||||
)
|
||||
)
|
||||
} else {
|
||||
Util.toast(
|
||||
context,
|
||||
resources.getQuantityString(
|
||||
R.plurals.select_album_n_songs_downloaded, songs.size, songs.size
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
onValid.run()
|
||||
val action = if (save) DownloadAction.PIN else DownloadAction.DOWNLOAD
|
||||
downloadHandler.justDownload(
|
||||
action = action,
|
||||
fragment = this,
|
||||
tracks = songs
|
||||
)
|
||||
}
|
||||
|
||||
internal fun delete(songs: List<Track> = getSelectedSongs()) {
|
||||
Util.toast(
|
||||
context,
|
||||
resources.getQuantityString(
|
||||
R.plurals.select_album_n_songs_deleted, songs.size, songs.size
|
||||
)
|
||||
downloadHandler.justDownload(
|
||||
action = DownloadAction.DELETE,
|
||||
fragment = this,
|
||||
tracks = songs
|
||||
)
|
||||
|
||||
mediaPlayerController.delete(songs)
|
||||
}
|
||||
|
||||
internal fun unpin(songs: List<Track> = getSelectedSongs()) {
|
||||
Util.toast(
|
||||
context,
|
||||
resources.getQuantityString(
|
||||
R.plurals.select_album_n_songs_unpinned, songs.size, songs.size
|
||||
)
|
||||
downloadHandler.justDownload(
|
||||
action = DownloadAction.UNPIN,
|
||||
fragment = this,
|
||||
tracks = songs
|
||||
)
|
||||
mediaPlayerController.unpin(songs)
|
||||
}
|
||||
|
||||
override val defaultObserver: (List<MusicDirectory.Child>) -> Unit = {
|
||||
@ -594,15 +578,15 @@ open class TrackCollectionFragment(
|
||||
} else if (getVideos) {
|
||||
setTitle(R.string.main_videos)
|
||||
listModel.getVideos(refresh2)
|
||||
} else if (getRandomTracks) {
|
||||
} else if (id == null || getRandomTracks) {
|
||||
// There seems to be a bug in ViewPager when resuming the Activity that sub-fragments
|
||||
// arguments are empty. If we have no id, just show some random tracks
|
||||
setTitle(R.string.main_songs_random)
|
||||
listModel.getRandom(size, append)
|
||||
} else {
|
||||
setTitle(name)
|
||||
requireNotNull(id) {
|
||||
"ID must be set. NavArgs: ${navArgs.toBundle()}"
|
||||
}
|
||||
if (ActiveServerProvider.isID3Enabled()) {
|
||||
|
||||
if (ActiveServerProvider.shouldUseId3Tags()) {
|
||||
if (isAlbum) {
|
||||
listModel.getAlbum(refresh2, id, name)
|
||||
} else {
|
||||
@ -634,15 +618,14 @@ open class TrackCollectionFragment(
|
||||
playNow(false, songs)
|
||||
}
|
||||
R.id.song_menu_play_next -> {
|
||||
downloadHandler.download(
|
||||
fragment = this@TrackCollectionFragment,
|
||||
append = true,
|
||||
save = false,
|
||||
autoPlay = false,
|
||||
playNext = true,
|
||||
shuffle = false,
|
||||
downloadHandler.addTracksToMediaController(
|
||||
songs = songs,
|
||||
playlistName = navArgs.playlistName
|
||||
append = true,
|
||||
playNext = true,
|
||||
autoPlay = false,
|
||||
shuffle = false,
|
||||
playlistName = navArgs.playlistName,
|
||||
fragment = this@TrackCollectionFragment
|
||||
)
|
||||
}
|
||||
R.id.song_menu_play_last -> {
|
||||
@ -657,10 +640,6 @@ open class TrackCollectionFragment(
|
||||
R.id.song_menu_download -> {
|
||||
downloadBackground(false, songs)
|
||||
}
|
||||
R.id.select_album_play_all -> {
|
||||
// TODO: Why is this being handled here?!
|
||||
playAll()
|
||||
}
|
||||
R.id.song_menu_share -> {
|
||||
if (item is Track) {
|
||||
shareHandler.createShare(
|
||||
|
@ -29,7 +29,8 @@ import androidx.fragment.app.Fragment
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
import java.util.Locale
|
||||
import org.koin.java.KoinJavaComponent.inject
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
import org.moire.ultrasonic.NavigationGraphDirections
|
||||
import org.moire.ultrasonic.R
|
||||
import org.moire.ultrasonic.api.subsonic.ApiNotSupportedException
|
||||
@ -38,6 +39,7 @@ import org.moire.ultrasonic.domain.Playlist
|
||||
import org.moire.ultrasonic.fragment.FragmentTitle.Companion.setTitle
|
||||
import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService
|
||||
import org.moire.ultrasonic.service.OfflineException
|
||||
import org.moire.ultrasonic.subsonic.DownloadAction
|
||||
import org.moire.ultrasonic.subsonic.DownloadHandler
|
||||
import org.moire.ultrasonic.util.BackgroundTask
|
||||
import org.moire.ultrasonic.util.CacheCleaner
|
||||
@ -54,15 +56,13 @@ import org.moire.ultrasonic.util.Util.toast
|
||||
*
|
||||
* TODO: This file has been converted from Java, but not modernized yet.
|
||||
*/
|
||||
class PlaylistsFragment : Fragment() {
|
||||
class PlaylistsFragment : Fragment(), KoinComponent {
|
||||
private var refreshPlaylistsListView: SwipeRefreshLayout? = null
|
||||
private var playlistsListView: ListView? = null
|
||||
private var emptyTextView: View? = null
|
||||
private var playlistAdapter: ArrayAdapter<Playlist>? = null
|
||||
|
||||
private val downloadHandler = inject<DownloadHandler>(
|
||||
DownloadHandler::class.java
|
||||
)
|
||||
private val downloadHandler by inject<DownloadHandler>()
|
||||
|
||||
private var cancellationToken: CancellationToken? = null
|
||||
|
||||
@ -147,45 +147,33 @@ class PlaylistsFragment : Fragment() {
|
||||
val playlist = playlistsListView!!.getItemAtPosition(info.position) as Playlist
|
||||
when (menuItem.itemId) {
|
||||
R.id.playlist_menu_pin -> {
|
||||
downloadHandler.value.downloadPlaylist(
|
||||
this,
|
||||
downloadHandler.justDownload(
|
||||
DownloadAction.PIN,
|
||||
fragment = this,
|
||||
id = playlist.id,
|
||||
name = playlist.name,
|
||||
save = true,
|
||||
append = true,
|
||||
autoplay = false,
|
||||
shuffle = false,
|
||||
background = true,
|
||||
playNext = false,
|
||||
unpin = false
|
||||
isShare = false,
|
||||
isDirectory = false
|
||||
)
|
||||
}
|
||||
R.id.playlist_menu_unpin -> {
|
||||
downloadHandler.value.downloadPlaylist(
|
||||
this,
|
||||
downloadHandler.justDownload(
|
||||
DownloadAction.UNPIN,
|
||||
fragment = this,
|
||||
id = playlist.id,
|
||||
name = playlist.name,
|
||||
save = false,
|
||||
append = false,
|
||||
autoplay = false,
|
||||
shuffle = false,
|
||||
background = true,
|
||||
playNext = false,
|
||||
unpin = true
|
||||
isShare = false,
|
||||
isDirectory = false
|
||||
)
|
||||
}
|
||||
R.id.playlist_menu_download -> {
|
||||
downloadHandler.value.downloadPlaylist(
|
||||
this,
|
||||
downloadHandler.justDownload(
|
||||
DownloadAction.DOWNLOAD,
|
||||
fragment = this,
|
||||
id = playlist.id,
|
||||
name = playlist.name,
|
||||
save = false,
|
||||
append = false,
|
||||
autoplay = false,
|
||||
shuffle = false,
|
||||
background = true,
|
||||
playNext = false,
|
||||
unpin = false
|
||||
isShare = false,
|
||||
isDirectory = false
|
||||
)
|
||||
}
|
||||
R.id.playlist_menu_play_now -> {
|
||||
|
@ -102,7 +102,9 @@ class SelectGenreFragment : Fragment() {
|
||||
|
||||
override fun done(result: List<Genre>) {
|
||||
emptyView!!.isVisible = result.isEmpty()
|
||||
genreListView!!.adapter = GenreAdapter(context, result)
|
||||
if (context != null) {
|
||||
genreListView!!.adapter = GenreAdapter(context!!, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
task.execute()
|
||||
|
@ -28,7 +28,8 @@ import androidx.fragment.app.Fragment
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
import java.util.Locale
|
||||
import org.koin.java.KoinJavaComponent
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
import org.moire.ultrasonic.NavigationGraphDirections
|
||||
import org.moire.ultrasonic.R
|
||||
import org.moire.ultrasonic.api.subsonic.ApiNotSupportedException
|
||||
@ -36,6 +37,7 @@ import org.moire.ultrasonic.domain.Share
|
||||
import org.moire.ultrasonic.fragment.FragmentTitle
|
||||
import org.moire.ultrasonic.service.MusicServiceFactory
|
||||
import org.moire.ultrasonic.service.OfflineException
|
||||
import org.moire.ultrasonic.subsonic.DownloadAction
|
||||
import org.moire.ultrasonic.subsonic.DownloadHandler
|
||||
import org.moire.ultrasonic.util.BackgroundTask
|
||||
import org.moire.ultrasonic.util.CancellationToken
|
||||
@ -50,14 +52,12 @@ import org.moire.ultrasonic.view.ShareAdapter
|
||||
*
|
||||
* TODO: This file has been converted from Java, but not modernized yet.
|
||||
*/
|
||||
class SharesFragment : Fragment() {
|
||||
class SharesFragment : Fragment(), KoinComponent {
|
||||
private var refreshSharesListView: SwipeRefreshLayout? = null
|
||||
private var sharesListView: ListView? = null
|
||||
private var emptyTextView: View? = null
|
||||
private var shareAdapter: ShareAdapter? = null
|
||||
private val downloadHandler = KoinJavaComponent.inject<DownloadHandler>(
|
||||
DownloadHandler::class.java
|
||||
)
|
||||
private val downloadHandler = inject<DownloadHandler>()
|
||||
private var cancellationToken: CancellationToken? = null
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
Util.applyTheme(this.context)
|
||||
@ -72,7 +72,6 @@ class SharesFragment : Fragment() {
|
||||
return inflater.inflate(R.layout.select_share, container, false)
|
||||
}
|
||||
|
||||
@Suppress("NAME_SHADOWING")
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
cancellationToken = CancellationToken()
|
||||
refreshSharesListView = view.findViewById(R.id.select_share_refresh)
|
||||
@ -132,73 +131,55 @@ class SharesFragment : Fragment() {
|
||||
val share = sharesListView!!.getItemAtPosition(info.position) as Share
|
||||
when (menuItem.itemId) {
|
||||
R.id.share_menu_pin -> {
|
||||
downloadHandler.value.downloadShare(
|
||||
this,
|
||||
share.id,
|
||||
share.name,
|
||||
save = true,
|
||||
append = true,
|
||||
autoplay = false,
|
||||
shuffle = false,
|
||||
background = true,
|
||||
playNext = false,
|
||||
unpin = false
|
||||
downloadHandler.value.justDownload(
|
||||
DownloadAction.PIN,
|
||||
fragment = this,
|
||||
id = share.id,
|
||||
name = share.name,
|
||||
isShare = true,
|
||||
isDirectory = false
|
||||
)
|
||||
}
|
||||
R.id.share_menu_unpin -> {
|
||||
downloadHandler.value.downloadShare(
|
||||
this,
|
||||
share.id,
|
||||
share.name,
|
||||
save = false,
|
||||
append = false,
|
||||
autoplay = false,
|
||||
shuffle = false,
|
||||
background = true,
|
||||
playNext = false,
|
||||
unpin = true
|
||||
downloadHandler.value.justDownload(
|
||||
DownloadAction.UNPIN,
|
||||
fragment = this,
|
||||
id = share.id,
|
||||
name = share.name,
|
||||
isShare = true,
|
||||
isDirectory = false
|
||||
)
|
||||
}
|
||||
R.id.share_menu_download -> {
|
||||
downloadHandler.value.downloadShare(
|
||||
this,
|
||||
share.id,
|
||||
share.name,
|
||||
save = false,
|
||||
append = false,
|
||||
autoplay = false,
|
||||
shuffle = false,
|
||||
background = true,
|
||||
playNext = false,
|
||||
unpin = false
|
||||
downloadHandler.value.justDownload(
|
||||
DownloadAction.DOWNLOAD,
|
||||
fragment = this,
|
||||
id = share.id,
|
||||
name = share.name,
|
||||
isShare = true,
|
||||
isDirectory = false
|
||||
)
|
||||
}
|
||||
R.id.share_menu_play_now -> {
|
||||
downloadHandler.value.downloadShare(
|
||||
downloadHandler.value.fetchTracksAndAddToController(
|
||||
this,
|
||||
share.id,
|
||||
share.name,
|
||||
save = false,
|
||||
append = false,
|
||||
autoplay = true,
|
||||
autoPlay = true,
|
||||
shuffle = false,
|
||||
background = false,
|
||||
playNext = false,
|
||||
unpin = false
|
||||
)
|
||||
}
|
||||
R.id.share_menu_play_shuffled -> {
|
||||
downloadHandler.value.downloadShare(
|
||||
downloadHandler.value.fetchTracksAndAddToController(
|
||||
this,
|
||||
share.id,
|
||||
share.name,
|
||||
save = false,
|
||||
append = false,
|
||||
autoplay = true,
|
||||
autoPlay = true,
|
||||
shuffle = true,
|
||||
background = false,
|
||||
playNext = false,
|
||||
unpin = false
|
||||
)
|
||||
}
|
||||
R.id.share_menu_delete -> {
|
||||
|
@ -19,11 +19,12 @@ import java.io.IOException
|
||||
import java.util.concurrent.Executors
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
import org.moire.ultrasonic.subsonic.ImageLoaderProvider
|
||||
|
||||
@SuppressLint("UnsafeOptInUsageError")
|
||||
class ArtworkBitmapLoader : BitmapLoader, KoinComponent {
|
||||
|
||||
private val imageLoader: ImageLoader by inject()
|
||||
private val imageLoaderProvider: ImageLoaderProvider by inject()
|
||||
|
||||
private val executorService: ListeningExecutorService by lazy {
|
||||
MoreExecutors.listeningDecorator(
|
||||
@ -55,6 +56,6 @@ class ArtworkBitmapLoader : BitmapLoader, KoinComponent {
|
||||
val parts = uri.path?.trim('/')?.split('|')
|
||||
|
||||
require(parts!!.count() == 2) { "Invalid bitmap Uri" }
|
||||
return imageLoader.getImage(parts[0], parts[1], false, 0)
|
||||
return imageLoaderProvider.getImageLoader().getImage(parts[0], parts[1], false, 0)
|
||||
}
|
||||
}
|
||||
|
@ -18,6 +18,9 @@ import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.moire.ultrasonic.R
|
||||
import org.moire.ultrasonic.api.subsonic.SubsonicAPIClient
|
||||
import org.moire.ultrasonic.api.subsonic.throwOnFailure
|
||||
@ -36,7 +39,7 @@ class ImageLoader(
|
||||
context: Context,
|
||||
apiClient: SubsonicAPIClient,
|
||||
private val config: ImageLoaderConfig,
|
||||
) : CoroutineScope by CoroutineScope(Dispatchers.IO) {
|
||||
) : CoroutineScope by CoroutineScope(Dispatchers.Main + SupervisorJob()) {
|
||||
private val cacheInProgress: ConcurrentHashMap<String, CountDownLatch> = ConcurrentHashMap()
|
||||
|
||||
// Shortcut
|
||||
@ -126,10 +129,13 @@ class ImageLoader(
|
||||
large: Boolean,
|
||||
size: Int,
|
||||
defaultResourceId: Int = R.drawable.unknown_album
|
||||
) {
|
||||
) = launch {
|
||||
val id = entry?.coverArt
|
||||
// TODO getAlbumArtKey() accesses the disk from the UI thread..
|
||||
val key = FileUtil.getAlbumArtKey(entry, large)
|
||||
val key: String?
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
key = FileUtil.getAlbumArtKey(entry, large)
|
||||
}
|
||||
|
||||
loadImage(view, id, key, large, size, defaultResourceId)
|
||||
}
|
||||
@ -194,48 +200,51 @@ class ImageLoader(
|
||||
cacheCoverArt(track.coverArt!!, FileUtil.getAlbumArtFile(track))
|
||||
}
|
||||
|
||||
fun cacheCoverArt(id: String, file: String) {
|
||||
if (id.isBlank()) return
|
||||
// Return if have a cache hit
|
||||
if (File(file).exists()) return
|
||||
fun cacheCoverArt(id: String, file: String) = launch {
|
||||
if (id.isBlank()) return@launch
|
||||
|
||||
// If another thread has started caching, wait until it finishes
|
||||
val latch = cacheInProgress.putIfAbsent(file, CountDownLatch(1))
|
||||
if (latch != null) {
|
||||
latch.await()
|
||||
return
|
||||
}
|
||||
withContext(Dispatchers.IO) {
|
||||
// Return if have a cache hit
|
||||
if (File(file).exists()) return@withContext
|
||||
|
||||
// If another coroutine has started caching, abort
|
||||
if (cacheInProgress[file] != null) return@withContext
|
||||
|
||||
try {
|
||||
// Always download the large size..
|
||||
val size = config.largeSize
|
||||
|
||||
File(file).createNewFile()
|
||||
|
||||
// Query the API
|
||||
Timber.d("Loading cover art for: %s", id)
|
||||
val response = API.getCoverArt(id, size.toLong()).execute().toStreamResponse()
|
||||
response.throwOnFailure()
|
||||
|
||||
// Check for failure
|
||||
if (response.stream == null) return
|
||||
|
||||
// Write Response stream to file
|
||||
var inputStream: InputStream? = null
|
||||
try {
|
||||
inputStream = response.stream
|
||||
val bytes = inputStream!!.readBytes()
|
||||
var outputStream: OutputStream? = null
|
||||
val response = API.getCoverArt(id, size.toLong()).execute().toStreamResponse()
|
||||
|
||||
response.throwOnFailure()
|
||||
|
||||
// Check for failure
|
||||
if (response.stream == null) return@withContext
|
||||
|
||||
// Write Response stream to file
|
||||
var inputStream: InputStream? = null
|
||||
try {
|
||||
outputStream = FileOutputStream(file)
|
||||
outputStream.write(bytes)
|
||||
inputStream = response.stream
|
||||
val bytes = inputStream!!.readBytes()
|
||||
var outputStream: OutputStream? = null
|
||||
try {
|
||||
outputStream = FileOutputStream(file)
|
||||
outputStream.write(bytes)
|
||||
} finally {
|
||||
outputStream.safeClose()
|
||||
}
|
||||
} finally {
|
||||
outputStream.safeClose()
|
||||
inputStream.safeClose()
|
||||
}
|
||||
} catch (all: Exception) {
|
||||
Timber.w(all)
|
||||
} finally {
|
||||
inputStream.safeClose()
|
||||
cacheInProgress.remove(file)?.countDown()
|
||||
}
|
||||
} finally {
|
||||
cacheInProgress.remove(file)?.countDown()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -12,9 +12,9 @@ import androidx.lifecycle.MutableLiveData
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.moire.ultrasonic.api.subsonic.models.AlbumListType
|
||||
import org.moire.ultrasonic.data.ActiveServerProvider
|
||||
import org.moire.ultrasonic.domain.Album
|
||||
import org.moire.ultrasonic.service.MusicServiceFactory
|
||||
import org.moire.ultrasonic.util.Settings
|
||||
|
||||
class AlbumListModel(application: Application) : GenericListModel(application) {
|
||||
|
||||
@ -69,7 +69,7 @@ class AlbumListModel(application: Application) : GenericListModel(application) {
|
||||
// If appending the existing list, set the offset from where to load
|
||||
if (append) offset += (size + loadedUntil)
|
||||
|
||||
musicDirectory = if (Settings.shouldUseId3Tags) {
|
||||
musicDirectory = if (ActiveServerProvider.shouldUseId3Tags()) {
|
||||
service.getAlbumList2(
|
||||
albumListType, size,
|
||||
offset, musicFolderId
|
||||
@ -119,7 +119,7 @@ class AlbumListModel(application: Application) : GenericListModel(application) {
|
||||
val isAlphabetical = (lastType == AlbumListType.SORTED_BY_NAME) ||
|
||||
(lastType == AlbumListType.SORTED_BY_ARTIST)
|
||||
|
||||
return !isOffline() && !Settings.shouldUseId3Tags && isAlphabetical
|
||||
return !isOffline() && !ActiveServerProvider.shouldUseId3Tags() && isAlphabetical
|
||||
}
|
||||
|
||||
private fun isCollectionSortable(albumListType: AlbumListType): Boolean {
|
||||
|
@ -43,7 +43,7 @@ class ArtistListModel(application: Application) : GenericListModel(application)
|
||||
|
||||
val musicFolderId = activeServer.musicFolderId
|
||||
|
||||
val result = if (ActiveServerProvider.isID3Enabled()) {
|
||||
val result = if (ActiveServerProvider.shouldUseId3Tags()) {
|
||||
musicService.getArtists(refresh)
|
||||
} else {
|
||||
musicService.getIndexes(musicFolderId, refresh)
|
||||
|
@ -10,7 +10,7 @@ package org.moire.ultrasonic.model
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import java.io.IOException
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.asFlow
|
||||
import kotlinx.coroutines.flow.flatMapMerge
|
||||
@ -90,7 +90,7 @@ class EditServerModel(val app: Application) : AndroidViewModel(app), KoinCompone
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(FlowPreview::class)
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
suspend fun queryFeatureSupport(currentServerSetting: ServerSetting): Flow<FeatureSupport> {
|
||||
val client = buildTestClient(currentServerSetting)
|
||||
// One line of magic:
|
||||
|
@ -26,7 +26,6 @@ import org.moire.ultrasonic.domain.MusicFolder
|
||||
import org.moire.ultrasonic.service.MusicService
|
||||
import org.moire.ultrasonic.service.MusicServiceFactory
|
||||
import org.moire.ultrasonic.util.CommunicationError
|
||||
import org.moire.ultrasonic.util.Settings
|
||||
|
||||
/**
|
||||
* An abstract Model, which can be extended to retrieve a list of items from the API
|
||||
@ -89,7 +88,7 @@ open class GenericListModel(application: Application) :
|
||||
withContext(Dispatchers.IO) {
|
||||
val musicService = MusicServiceFactory.getMusicService()
|
||||
val isOffline = ActiveServerProvider.isOffline()
|
||||
val useId3Tags = Settings.shouldUseId3Tags
|
||||
val useId3Tags = ActiveServerProvider.shouldUseId3Tags()
|
||||
|
||||
try {
|
||||
load(isOffline, useId3Tags, musicService, refresh)
|
||||
|
@ -13,12 +13,12 @@ import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.moire.ultrasonic.data.ActiveServerProvider
|
||||
import org.moire.ultrasonic.domain.MusicDirectory
|
||||
import org.moire.ultrasonic.domain.Track
|
||||
import org.moire.ultrasonic.service.DownloadService
|
||||
import org.moire.ultrasonic.service.DownloadState
|
||||
import org.moire.ultrasonic.service.MusicServiceFactory
|
||||
import org.moire.ultrasonic.util.Settings
|
||||
import org.moire.ultrasonic.util.Util
|
||||
|
||||
/*
|
||||
@ -40,7 +40,7 @@ class TrackCollectionModel(application: Application) : GenericListModel(applicat
|
||||
|
||||
val service = MusicServiceFactory.getMusicService()
|
||||
val musicDirectory = service.getMusicDirectory(id, name, refresh)
|
||||
|
||||
currentListIsSortable = true
|
||||
updateList(musicDirectory)
|
||||
}
|
||||
}
|
||||
@ -51,7 +51,7 @@ class TrackCollectionModel(application: Application) : GenericListModel(applicat
|
||||
|
||||
val service = MusicServiceFactory.getMusicService()
|
||||
val musicDirectory: MusicDirectory = service.getAlbumAsDir(id, name, refresh)
|
||||
|
||||
currentListIsSortable = true
|
||||
updateList(musicDirectory)
|
||||
}
|
||||
}
|
||||
@ -60,6 +60,7 @@ class TrackCollectionModel(application: Application) : GenericListModel(applicat
|
||||
withContext(Dispatchers.IO) {
|
||||
val service = MusicServiceFactory.getMusicService()
|
||||
val musicDirectory = service.getSongsByGenre(genre, count, offset)
|
||||
currentListIsSortable = false
|
||||
updateList(musicDirectory, append)
|
||||
}
|
||||
}
|
||||
@ -71,12 +72,12 @@ class TrackCollectionModel(application: Application) : GenericListModel(applicat
|
||||
val service = MusicServiceFactory.getMusicService()
|
||||
val musicDirectory: MusicDirectory
|
||||
|
||||
musicDirectory = if (Settings.shouldUseId3Tags) {
|
||||
musicDirectory = if (ActiveServerProvider.shouldUseId3Tags()) {
|
||||
Util.getSongsFromSearchResult(service.getStarred2())
|
||||
} else {
|
||||
Util.getSongsFromSearchResult(service.getStarred())
|
||||
}
|
||||
|
||||
currentListIsSortable = false
|
||||
updateList(musicDirectory)
|
||||
}
|
||||
}
|
||||
@ -87,8 +88,8 @@ class TrackCollectionModel(application: Application) : GenericListModel(applicat
|
||||
withContext(Dispatchers.IO) {
|
||||
val service = MusicServiceFactory.getMusicService()
|
||||
val videos = service.getVideos(refresh)
|
||||
|
||||
if (videos != null) {
|
||||
currentListIsSortable = false
|
||||
updateList(videos)
|
||||
}
|
||||
}
|
||||
@ -99,19 +100,16 @@ class TrackCollectionModel(application: Application) : GenericListModel(applicat
|
||||
withContext(Dispatchers.IO) {
|
||||
val service = MusicServiceFactory.getMusicService()
|
||||
val musicDirectory = service.getRandomSongs(size)
|
||||
|
||||
currentListIsSortable = false
|
||||
|
||||
updateList(musicDirectory, append)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getPlaylist(playlistId: String, playlistName: String) {
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
val service = MusicServiceFactory.getMusicService()
|
||||
val musicDirectory = service.getPlaylist(playlistId, playlistName)
|
||||
|
||||
currentListIsSortable = false
|
||||
updateList(musicDirectory)
|
||||
}
|
||||
}
|
||||
@ -121,8 +119,8 @@ class TrackCollectionModel(application: Application) : GenericListModel(applicat
|
||||
withContext(Dispatchers.IO) {
|
||||
val service = MusicServiceFactory.getMusicService()
|
||||
val musicDirectory = service.getPodcastEpisodes(podcastChannelId)
|
||||
|
||||
if (musicDirectory != null) {
|
||||
currentListIsSortable = false
|
||||
updateList(musicDirectory)
|
||||
}
|
||||
}
|
||||
@ -144,7 +142,7 @@ class TrackCollectionModel(application: Application) : GenericListModel(applicat
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
currentListIsSortable = false
|
||||
updateList(musicDirectory)
|
||||
}
|
||||
}
|
||||
@ -153,7 +151,7 @@ class TrackCollectionModel(application: Application) : GenericListModel(applicat
|
||||
withContext(Dispatchers.IO) {
|
||||
val service = MusicServiceFactory.getMusicService()
|
||||
val musicDirectory = Util.getSongsFromBookmarks(service.getBookmarks())
|
||||
|
||||
currentListIsSortable = false
|
||||
updateList(musicDirectory)
|
||||
}
|
||||
}
|
||||
|
@ -9,8 +9,6 @@ package org.moire.ultrasonic.playback
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.os.Bundle
|
||||
import android.widget.Toast
|
||||
import android.widget.Toast.LENGTH_SHORT
|
||||
import androidx.media3.common.HeartRating
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.MediaMetadata
|
||||
@ -19,18 +17,16 @@ import androidx.media3.common.MediaMetadata.FOLDER_TYPE_ARTISTS
|
||||
import androidx.media3.common.MediaMetadata.FOLDER_TYPE_MIXED
|
||||
import androidx.media3.common.MediaMetadata.FOLDER_TYPE_PLAYLISTS
|
||||
import androidx.media3.common.MediaMetadata.FOLDER_TYPE_TITLES
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.Rating
|
||||
import androidx.media3.common.StarRating
|
||||
import androidx.media3.session.CommandButton
|
||||
import androidx.media3.session.LibraryResult
|
||||
import androidx.media3.session.MediaLibraryService
|
||||
import androidx.media3.session.MediaSession
|
||||
import androidx.media3.session.SessionCommand
|
||||
import androidx.media3.session.SessionResult
|
||||
import androidx.media3.session.SessionResult.RESULT_ERROR_BAD_VALUE
|
||||
import androidx.media3.session.SessionResult.RESULT_ERROR_UNKNOWN
|
||||
import androidx.media3.session.SessionResult.RESULT_SUCCESS
|
||||
import com.google.common.collect.ImmutableList
|
||||
import com.google.common.util.concurrent.FutureCallback
|
||||
import com.google.common.util.concurrent.Futures
|
||||
import com.google.common.util.concurrent.ListenableFuture
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@ -44,14 +40,14 @@ import org.moire.ultrasonic.R
|
||||
import org.moire.ultrasonic.api.subsonic.models.AlbumListType
|
||||
import org.moire.ultrasonic.app.UApp
|
||||
import org.moire.ultrasonic.data.ActiveServerProvider
|
||||
import org.moire.ultrasonic.data.RatingUpdate
|
||||
import org.moire.ultrasonic.domain.MusicDirectory
|
||||
import org.moire.ultrasonic.domain.SearchCriteria
|
||||
import org.moire.ultrasonic.domain.SearchResult
|
||||
import org.moire.ultrasonic.domain.Track
|
||||
import org.moire.ultrasonic.service.MediaPlayerController
|
||||
import org.moire.ultrasonic.service.MediaPlayerManager
|
||||
import org.moire.ultrasonic.service.MusicServiceFactory
|
||||
import org.moire.ultrasonic.util.MainThreadExecutor
|
||||
import org.moire.ultrasonic.util.Settings
|
||||
import org.moire.ultrasonic.service.RatingManager
|
||||
import org.moire.ultrasonic.util.Util
|
||||
import org.moire.ultrasonic.util.buildMediaItem
|
||||
import org.moire.ultrasonic.util.toMediaItem
|
||||
@ -94,7 +90,6 @@ private const val DISPLAY_LIMIT = 100
|
||||
private const val SEARCH_LIMIT = 10
|
||||
|
||||
// List of available custom SessionCommands
|
||||
const val SESSION_CUSTOM_SET_RATING = "SESSION_CUSTOM_SET_RATING"
|
||||
const val PLAY_COMMAND = "play "
|
||||
|
||||
/**
|
||||
@ -102,10 +97,10 @@ const val PLAY_COMMAND = "play "
|
||||
*/
|
||||
@Suppress("TooManyFunctions", "LargeClass", "UnusedPrivateMember")
|
||||
@SuppressLint("UnsafeOptInUsageError")
|
||||
class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibraryService) :
|
||||
class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) :
|
||||
MediaLibraryService.MediaLibrarySession.Callback, KoinComponent {
|
||||
|
||||
private val mediaPlayerController by inject<MediaPlayerController>()
|
||||
private val mediaPlayerManager by inject<MediaPlayerManager>()
|
||||
private val activeServerProvider: ActiveServerProvider by inject()
|
||||
|
||||
private val serviceJob = SupervisorJob()
|
||||
@ -119,9 +114,26 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
|
||||
|
||||
private val musicService get() = MusicServiceFactory.getMusicService()
|
||||
private val isOffline get() = ActiveServerProvider.isOffline()
|
||||
private val useId3Tags get() = Settings.shouldUseId3Tags
|
||||
private val musicFolderId get() = activeServerProvider.getActiveServer().musicFolderId
|
||||
|
||||
private var customCommands: List<CommandButton>
|
||||
internal var customLayout = ImmutableList.of<CommandButton>()
|
||||
|
||||
init {
|
||||
customCommands =
|
||||
listOf(
|
||||
// This button is used for an unstarred track, and its action will star the track
|
||||
getHeartCommandButton(
|
||||
SessionCommand(PlaybackService.CUSTOM_COMMAND_TOGGLE_HEART_ON, Bundle.EMPTY)
|
||||
),
|
||||
// This button is used for an starred track, and its action will unstar the track
|
||||
getHeartCommandButton(
|
||||
SessionCommand(PlaybackService.CUSTOM_COMMAND_TOGGLE_HEART_OFF, Bundle.EMPTY)
|
||||
)
|
||||
)
|
||||
customLayout = ImmutableList.of(customCommands[0])
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when a {@link MediaBrowser} requests the root {@link MediaItem} by {@link
|
||||
* MediaBrowser#getLibraryRoot(LibraryParams)}.
|
||||
@ -179,11 +191,10 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
|
||||
val connectionResult = super.onConnect(session, controller)
|
||||
val availableSessionCommands = connectionResult.availableSessionCommands.buildUpon()
|
||||
|
||||
/*
|
||||
* TODO: Currently we need to create a custom session command, see https://github.com/androidx/media/issues/107
|
||||
* When this issue is fixed we should be able to remove this method again
|
||||
*/
|
||||
availableSessionCommands.add(SessionCommand(SESSION_CUSTOM_SET_RATING, Bundle()))
|
||||
for (commandButton in customCommands) {
|
||||
// Add custom command to available session commands.
|
||||
commandButton.sessionCommand?.let { availableSessionCommands.add(it) }
|
||||
}
|
||||
|
||||
return MediaSession.ConnectionResult.accept(
|
||||
availableSessionCommands.build(),
|
||||
@ -191,6 +202,28 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
|
||||
)
|
||||
}
|
||||
|
||||
override fun onPostConnect(session: MediaSession, controller: MediaSession.ControllerInfo) {
|
||||
if (!customLayout.isEmpty() && controller.controllerVersion != 0) {
|
||||
// Let Media3 controller (for instance the MediaNotificationProvider)
|
||||
// know about the custom layout right after it connected.
|
||||
session.setCustomLayout(customLayout)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getHeartCommandButton(sessionCommand: SessionCommand): CommandButton {
|
||||
val willHeart =
|
||||
(sessionCommand.customAction == PlaybackService.CUSTOM_COMMAND_TOGGLE_HEART_ON)
|
||||
return CommandButton.Builder()
|
||||
.setDisplayName("Love")
|
||||
.setIconResId(
|
||||
if (willHeart) R.drawable.ic_star_hollow
|
||||
else R.drawable.ic_star_full
|
||||
)
|
||||
.setSessionCommand(sessionCommand)
|
||||
.setEnabled(true)
|
||||
.build()
|
||||
}
|
||||
|
||||
override fun onGetItem(
|
||||
session: MediaLibraryService.MediaLibrarySession,
|
||||
browser: MediaSession.ControllerInfo,
|
||||
@ -204,12 +237,12 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
|
||||
// Create LRU Cache of MediaItems, fill it in the other calls
|
||||
// and retrieve it here.
|
||||
|
||||
if (mediaItem != null) {
|
||||
return Futures.immediateFuture(
|
||||
return if (mediaItem != null) {
|
||||
Futures.immediateFuture(
|
||||
LibraryResult.ofItem(mediaItem, null)
|
||||
)
|
||||
} else {
|
||||
return Futures.immediateFuture(
|
||||
Futures.immediateFuture(
|
||||
LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE)
|
||||
)
|
||||
}
|
||||
@ -237,39 +270,13 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
|
||||
var customCommandFuture: ListenableFuture<SessionResult>? = null
|
||||
|
||||
when (customCommand.customAction) {
|
||||
SESSION_CUSTOM_SET_RATING -> {
|
||||
/*
|
||||
* It is currently not possible to edit a MediaItem after creation so the isRated value
|
||||
* is stored in the track.starred value
|
||||
* See https://github.com/androidx/media/issues/33
|
||||
*/
|
||||
val track = mediaPlayerController.currentMediaItem?.toTrack()
|
||||
if (track != null) {
|
||||
customCommandFuture = onSetRating(
|
||||
session,
|
||||
controller,
|
||||
HeartRating(!track.starred)
|
||||
)
|
||||
Futures.addCallback(
|
||||
customCommandFuture,
|
||||
object : FutureCallback<SessionResult> {
|
||||
override fun onSuccess(result: SessionResult) {
|
||||
track.starred = !track.starred
|
||||
// This needs to be called on the main Thread
|
||||
libraryService.onUpdateNotification(session)
|
||||
}
|
||||
|
||||
override fun onFailure(t: Throwable) {
|
||||
Toast.makeText(
|
||||
mediaPlayerController.context,
|
||||
"There was an error updating the rating",
|
||||
LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
},
|
||||
MainThreadExecutor()
|
||||
)
|
||||
}
|
||||
PlaybackService.CUSTOM_COMMAND_TOGGLE_HEART_ON -> {
|
||||
customCommandFuture = onSetRating(session, controller, HeartRating(true))
|
||||
updateCustomHeartButton(session, true)
|
||||
}
|
||||
PlaybackService.CUSTOM_COMMAND_TOGGLE_HEART_OFF -> {
|
||||
customCommandFuture = onSetRating(session, controller, HeartRating(false))
|
||||
updateCustomHeartButton(session, false)
|
||||
}
|
||||
else -> {
|
||||
Timber.d(
|
||||
@ -283,19 +290,25 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
|
||||
return customCommandFuture
|
||||
return super.onCustomCommand(session, controller, customCommand, args)
|
||||
}
|
||||
|
||||
override fun onSetRating(
|
||||
session: MediaSession,
|
||||
controller: MediaSession.ControllerInfo,
|
||||
rating: Rating
|
||||
): ListenableFuture<SessionResult> {
|
||||
if (session.player.currentMediaItem != null)
|
||||
val mediaItem = session.player.currentMediaItem
|
||||
if (mediaItem != null) {
|
||||
if (rating is HeartRating) {
|
||||
mediaItem.toTrack().starred = rating.isHeart
|
||||
} else if (rating is StarRating) {
|
||||
mediaItem.toTrack().userRating = rating.starRating.toInt()
|
||||
}
|
||||
return onSetRating(
|
||||
session,
|
||||
controller,
|
||||
session.player.currentMediaItem!!.mediaId,
|
||||
mediaItem.mediaId,
|
||||
rating
|
||||
)
|
||||
}
|
||||
return super.onSetRating(session, controller, rating)
|
||||
}
|
||||
|
||||
@ -305,22 +318,22 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
|
||||
mediaId: String,
|
||||
rating: Rating
|
||||
): ListenableFuture<SessionResult> {
|
||||
// TODO: Through this methods it is possible to set a rating on an arbitrary MediaItem.
|
||||
// Right now the ratings are submitted, yet the underlying track is only updated when
|
||||
// coming from the other onSetRating(session, controller, rating)
|
||||
return serviceScope.future {
|
||||
if (rating is HeartRating) {
|
||||
try {
|
||||
if (rating.isHeart) {
|
||||
musicService.star(mediaId, null, null)
|
||||
} else {
|
||||
musicService.unstar(mediaId, null, null)
|
||||
}
|
||||
} catch (all: Exception) {
|
||||
Timber.e(all)
|
||||
// TODO: Better handle exception
|
||||
return@future SessionResult(RESULT_ERROR_UNKNOWN)
|
||||
}
|
||||
return@future SessionResult(RESULT_SUCCESS)
|
||||
}
|
||||
return@future SessionResult(RESULT_ERROR_BAD_VALUE)
|
||||
Timber.i(controller.packageName)
|
||||
// This function even though its declared in AutoMediaBrowserCallback.kt is
|
||||
// actually called every time we set the rating on an MediaItem.
|
||||
// To avoid an event loop it does not emit a RatingUpdate event,
|
||||
// but calls the Manager directly
|
||||
RatingManager.instance.submitRating(
|
||||
RatingUpdate(
|
||||
id = mediaId,
|
||||
rating = rating
|
||||
)
|
||||
)
|
||||
return@future SessionResult(RESULT_SUCCESS)
|
||||
}
|
||||
}
|
||||
|
||||
@ -329,7 +342,6 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
|
||||
* and thereby customarily it is required to rebuild it..
|
||||
* See also: https://stackoverflow.com/questions/70096715/adding-mediaitem-when-using-the-media3-library-caused-an-error
|
||||
*/
|
||||
|
||||
override fun onAddMediaItems(
|
||||
mediaSession: MediaSession,
|
||||
controller: MediaSession.ControllerInfo,
|
||||
@ -445,6 +457,9 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
|
||||
private fun playFromSearch(
|
||||
query: String,
|
||||
): ListenableFuture<List<MediaItem>> {
|
||||
|
||||
Timber.w("App state: %s", UApp.instance != null)
|
||||
|
||||
Timber.i("AutoMediaBrowserService onSearch query: %s", query)
|
||||
val mediaItems: MutableList<MediaItem> = ArrayList()
|
||||
|
||||
@ -661,7 +676,7 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
|
||||
var childMediaId: String = MEDIA_ARTIST_ITEM
|
||||
|
||||
var artists = serviceScope.future {
|
||||
if (!isOffline && useId3Tags) {
|
||||
if (ActiveServerProvider.shouldUseId3Tags()) {
|
||||
// TODO this list can be big so we're not refreshing.
|
||||
// Maybe a refresh menu item can be added
|
||||
callWithErrorHandling { musicService.getArtists(false) }
|
||||
@ -716,7 +731,7 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
|
||||
|
||||
return mainScope.future {
|
||||
val albums = serviceScope.future {
|
||||
if (!isOffline && useId3Tags) {
|
||||
if (ActiveServerProvider.shouldUseId3Tags()) {
|
||||
callWithErrorHandling { musicService.getAlbumsOfArtist(id, name, false) }
|
||||
} else {
|
||||
callWithErrorHandling {
|
||||
@ -788,7 +803,7 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
|
||||
val offset = (page ?: 0) * DISPLAY_LIMIT
|
||||
|
||||
val albums = serviceScope.future {
|
||||
if (useId3Tags) {
|
||||
if (ActiveServerProvider.shouldUseId3Tags()) {
|
||||
callWithErrorHandling {
|
||||
musicService.getAlbumList2(
|
||||
type, DISPLAY_LIMIT, offset, null
|
||||
@ -1190,7 +1205,7 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
|
||||
|
||||
private fun listSongsInMusicService(id: String, name: String?): MusicDirectory? {
|
||||
return serviceScope.future {
|
||||
if (!ActiveServerProvider.isOffline() && Settings.shouldUseId3Tags) {
|
||||
if (ActiveServerProvider.shouldUseId3Tags()) {
|
||||
callWithErrorHandling { musicService.getAlbumAsDir(id, name, false) }
|
||||
} else {
|
||||
callWithErrorHandling { musicService.getMusicDirectory(id, name, false) }
|
||||
@ -1200,7 +1215,7 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
|
||||
|
||||
private fun listStarredSongsInMusicService(): SearchResult? {
|
||||
return serviceScope.future {
|
||||
if (Settings.shouldUseId3Tags) {
|
||||
if (ActiveServerProvider.shouldUseId3Tags()) {
|
||||
callWithErrorHandling { musicService.getStarred2() }
|
||||
} else {
|
||||
callWithErrorHandling { musicService.getStarred() }
|
||||
@ -1278,4 +1293,15 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun updateCustomHeartButton(
|
||||
session: MediaSession,
|
||||
isHeart: Boolean
|
||||
) {
|
||||
val command = if (isHeart) customCommands[1] else customCommands[0]
|
||||
// Change the custom layout to contain the right heart button
|
||||
customLayout = ImmutableList.of(command)
|
||||
// Send the updated custom layout to controllers.
|
||||
session.setCustomLayout(customLayout)
|
||||
}
|
||||
}
|
||||
|
@ -7,79 +7,22 @@
|
||||
package org.moire.ultrasonic.playback
|
||||
|
||||
import android.content.Context
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.media3.common.HeartRating
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.session.CommandButton
|
||||
import androidx.media3.session.DefaultMediaNotificationProvider
|
||||
import androidx.media3.session.MediaNotification
|
||||
import androidx.media3.session.MediaSession
|
||||
import androidx.media3.session.SessionCommand
|
||||
import com.google.common.collect.ImmutableList
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
import org.moire.ultrasonic.R
|
||||
import org.moire.ultrasonic.service.MediaPlayerController
|
||||
import org.moire.ultrasonic.util.toTrack
|
||||
|
||||
@UnstableApi
|
||||
class CustomNotificationProvider(ctx: Context) :
|
||||
DefaultMediaNotificationProvider(ctx),
|
||||
KoinComponent {
|
||||
|
||||
/*
|
||||
* It is currently not possible to edit a MediaItem after creation so the isRated value
|
||||
* is stored in the track.starred value. See https://github.com/androidx/media/issues/33
|
||||
* TODO: Once the bug is fixed remove this circular reference!
|
||||
*/
|
||||
private val mediaPlayerController by inject<MediaPlayerController>()
|
||||
|
||||
override fun addNotificationActions(
|
||||
mediaSession: MediaSession,
|
||||
mediaButtons: ImmutableList<CommandButton>,
|
||||
builder: NotificationCompat.Builder,
|
||||
actionFactory: MediaNotification.ActionFactory
|
||||
): IntArray {
|
||||
val tmp: MutableList<CommandButton> = mutableListOf()
|
||||
/*
|
||||
* TODO:
|
||||
* It is currently not possible to edit a MediaItem after creation so the isRated value
|
||||
* is stored in the track.starred value
|
||||
* See https://github.com/androidx/media/issues/33
|
||||
*/
|
||||
val rating = mediaPlayerController.currentMediaItem?.toTrack()?.starred?.let {
|
||||
HeartRating(
|
||||
it
|
||||
)
|
||||
}
|
||||
if (rating is HeartRating) {
|
||||
tmp.add(
|
||||
CommandButton.Builder()
|
||||
.setDisplayName("Love")
|
||||
.setIconResId(
|
||||
if (rating.isHeart) R.drawable.ic_star_full
|
||||
else R.drawable.ic_star_hollow
|
||||
)
|
||||
.setSessionCommand(
|
||||
SessionCommand(
|
||||
SESSION_CUSTOM_SET_RATING,
|
||||
HeartRating(rating.isHeart).toBundle()
|
||||
)
|
||||
)
|
||||
.setExtras(HeartRating(rating.isHeart).toBundle())
|
||||
.setEnabled(true)
|
||||
.build()
|
||||
)
|
||||
}
|
||||
return super.addNotificationActions(
|
||||
mediaSession,
|
||||
ImmutableList.copyOf((mediaButtons + tmp)),
|
||||
builder,
|
||||
actionFactory
|
||||
)
|
||||
}
|
||||
|
||||
// By default the skip buttons are not shown in compact view.
|
||||
// We add the COMMAND_KEY_COMPACT_VIEW_INDEX to show them
|
||||
// See also: https://github.com/androidx/media/issues/410
|
||||
override fun getMediaButtons(
|
||||
session: MediaSession,
|
||||
playerCommands: Player.Commands,
|
||||
|
@ -26,14 +26,17 @@ import androidx.media3.datasource.okhttp.OkHttpDataSource
|
||||
import androidx.media3.exoplayer.DefaultRenderersFactory
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||
import androidx.media3.exoplayer.source.ShuffleOrder
|
||||
import androidx.media3.session.MediaLibraryService
|
||||
import androidx.media3.session.MediaSession
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import java.util.Random
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.OkHttpClient
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
import org.moire.ultrasonic.R
|
||||
import org.moire.ultrasonic.activity.NavigationActivity
|
||||
import org.moire.ultrasonic.app.UApp
|
||||
@ -43,6 +46,8 @@ import org.moire.ultrasonic.domain.Track
|
||||
import org.moire.ultrasonic.imageloader.ArtworkBitmapLoader
|
||||
import org.moire.ultrasonic.provider.UltrasonicAppWidgetProvider
|
||||
import org.moire.ultrasonic.service.DownloadService
|
||||
import org.moire.ultrasonic.service.JukeboxMediaPlayer
|
||||
import org.moire.ultrasonic.service.MediaPlayerManager
|
||||
import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService
|
||||
import org.moire.ultrasonic.service.RxBus
|
||||
import org.moire.ultrasonic.service.plusAssign
|
||||
@ -58,11 +63,12 @@ class PlaybackService :
|
||||
MediaLibraryService(),
|
||||
KoinComponent,
|
||||
CoroutineScope by CoroutineScope(Dispatchers.IO) {
|
||||
private lateinit var player: ExoPlayer
|
||||
private lateinit var player: Player
|
||||
private lateinit var mediaLibrarySession: MediaLibrarySession
|
||||
private var equalizer: EqualizerController? = null
|
||||
private val activeServerProvider: ActiveServerProvider by inject()
|
||||
|
||||
private lateinit var librarySessionCallback: MediaLibrarySession.Callback
|
||||
private lateinit var librarySessionCallback: AutoMediaBrowserCallback
|
||||
|
||||
private var rxBusSubscription = CompositeDisposable()
|
||||
|
||||
@ -73,6 +79,7 @@ class PlaybackService :
|
||||
super.onCreate()
|
||||
initializeSessionAndPlayer()
|
||||
setListener(MediaSessionServiceListener())
|
||||
instance = this
|
||||
}
|
||||
|
||||
private fun getWakeModeFlag(): Int {
|
||||
@ -96,6 +103,7 @@ class PlaybackService :
|
||||
}
|
||||
|
||||
private fun releasePlayerAndSession() {
|
||||
Timber.i("Releasing player and session")
|
||||
// Broadcast that the service is being shutdown
|
||||
RxBus.stopServiceCommandPublisher.onNext(Unit)
|
||||
|
||||
@ -124,6 +132,106 @@ class PlaybackService :
|
||||
|
||||
setMediaNotificationProvider(CustomNotificationProvider(UApp.applicationContext()))
|
||||
|
||||
// TODO: Remove minor code duplication with updateBackend()
|
||||
val desiredBackend = if (activeServerProvider.getActiveServer().jukeboxByDefault) {
|
||||
MediaPlayerManager.PlayerBackend.JUKEBOX
|
||||
} else {
|
||||
MediaPlayerManager.PlayerBackend.LOCAL
|
||||
}
|
||||
|
||||
player = if (activeServerProvider.getActiveServer().jukeboxByDefault) {
|
||||
Timber.i("Jukebox enabled by default")
|
||||
getJukeboxPlayer()
|
||||
} else {
|
||||
getLocalPlayer()
|
||||
}
|
||||
|
||||
actualBackend = desiredBackend
|
||||
|
||||
// Create browser interface
|
||||
librarySessionCallback = AutoMediaBrowserCallback(this)
|
||||
|
||||
// This will need to use the AutoCalls
|
||||
mediaLibrarySession = MediaLibrarySession.Builder(this, player, librarySessionCallback)
|
||||
.setSessionActivity(getPendingIntentForContent())
|
||||
.setBitmapLoader(ArtworkBitmapLoader())
|
||||
.build()
|
||||
|
||||
if (!librarySessionCallback.customLayout.isEmpty()) {
|
||||
// Send custom layout to legacy session.
|
||||
mediaLibrarySession.setCustomLayout(librarySessionCallback.customLayout)
|
||||
}
|
||||
|
||||
// Set a listener to update the API client when the active server has changed
|
||||
rxBusSubscription += RxBus.activeServerChangedObservable.subscribe {
|
||||
// Set the player wake mode
|
||||
(player as? ExoPlayer)?.setWakeMode(getWakeModeFlag())
|
||||
}
|
||||
|
||||
// Set a listener to reset the ShuffleOrder
|
||||
rxBusSubscription += RxBus.shufflePlayObservable.subscribe { shuffle ->
|
||||
// This only applies for local playback
|
||||
val exo = if (player is ExoPlayer) {
|
||||
player as ExoPlayer
|
||||
} else {
|
||||
return@subscribe
|
||||
}
|
||||
val len = player.currentTimeline.windowCount
|
||||
|
||||
Timber.i("Resetting shuffle order, isShuffled: %s", shuffle)
|
||||
|
||||
// If disabling Shuffle return early
|
||||
if (!shuffle) {
|
||||
return@subscribe exo.setShuffleOrder(
|
||||
ShuffleOrder.UnshuffledShuffleOrder(len)
|
||||
)
|
||||
}
|
||||
|
||||
// Get the position of the current track in the unshuffled order
|
||||
val cur = player.currentMediaItemIndex
|
||||
val seed = System.currentTimeMillis()
|
||||
val random = Random(seed)
|
||||
|
||||
val list = createShuffleListFromCurrentIndex(cur, len, random)
|
||||
Timber.i("New Shuffle order: %s", list.joinToString { it.toString() })
|
||||
exo.setShuffleOrder(ShuffleOrder.DefaultShuffleOrder(list, seed))
|
||||
}
|
||||
|
||||
// Listen to the shutdown command
|
||||
rxBusSubscription += RxBus.shutdownCommandObservable.subscribe {
|
||||
Timber.i("Received destroy command via Rx")
|
||||
onDestroy()
|
||||
}
|
||||
|
||||
player.addListener(listener)
|
||||
isStarted = true
|
||||
}
|
||||
|
||||
private fun updateBackend(newBackend: MediaPlayerManager.PlayerBackend) {
|
||||
Timber.i("Switching player backends")
|
||||
// Remove old listeners
|
||||
player.removeListener(listener)
|
||||
player.release()
|
||||
|
||||
player = if (newBackend == MediaPlayerManager.PlayerBackend.JUKEBOX) {
|
||||
getJukeboxPlayer()
|
||||
} else {
|
||||
getLocalPlayer()
|
||||
}
|
||||
|
||||
// Add fresh listeners
|
||||
player.addListener(listener)
|
||||
|
||||
mediaLibrarySession.player = player
|
||||
|
||||
actualBackend = newBackend
|
||||
}
|
||||
|
||||
private fun getJukeboxPlayer(): Player {
|
||||
return JukeboxMediaPlayer()
|
||||
}
|
||||
|
||||
private fun getLocalPlayer(): Player {
|
||||
// Create a new plain OkHttpClient
|
||||
val builder = OkHttpClient.Builder()
|
||||
val client = builder.build()
|
||||
@ -144,7 +252,7 @@ class PlaybackService :
|
||||
renderer.setEnableAudioOffload(true)
|
||||
|
||||
// Create the player
|
||||
player = ExoPlayer.Builder(this)
|
||||
val player = ExoPlayer.Builder(this)
|
||||
.setAudioAttributes(getAudioAttributes(), true)
|
||||
.setWakeMode(getWakeModeFlag())
|
||||
.setHandleAudioBecomingNoisy(true)
|
||||
@ -154,35 +262,32 @@ class PlaybackService :
|
||||
.setSeekForwardIncrementMs(Settings.seekInterval.toLong())
|
||||
.build()
|
||||
|
||||
// Setup Equalizer
|
||||
equalizer = EqualizerController.create(player.audioSessionId)
|
||||
|
||||
// Enable audio offload
|
||||
if (Settings.useHwOffload)
|
||||
player.experimentalSetOffloadSchedulingEnabled(true)
|
||||
|
||||
// Create browser interface
|
||||
librarySessionCallback = AutoMediaBrowserCallback(player, this)
|
||||
return player
|
||||
}
|
||||
|
||||
// This will need to use the AutoCalls
|
||||
mediaLibrarySession = MediaLibrarySession.Builder(this, player, librarySessionCallback)
|
||||
.setSessionActivity(getPendingIntentForContent())
|
||||
.setBitmapLoader(ArtworkBitmapLoader())
|
||||
.build()
|
||||
private fun createShuffleListFromCurrentIndex(
|
||||
currentIndex: Int,
|
||||
length: Int,
|
||||
random: Random
|
||||
): IntArray {
|
||||
val list = IntArray(length) { it }
|
||||
|
||||
// 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())
|
||||
// Shuffle the remaining items using a swapping algorithm
|
||||
for (i in currentIndex + 1 until length) {
|
||||
val swapIndex = (currentIndex + 1) + random.nextInt(i - currentIndex)
|
||||
val swapItem = list[i]
|
||||
list[i] = list[swapIndex]
|
||||
list[swapIndex] = swapItem
|
||||
}
|
||||
|
||||
// Listen to the shutdown command
|
||||
rxBusSubscription += RxBus.shutdownCommandObservable.subscribe {
|
||||
Timber.i("Received destroy command via Rx")
|
||||
onDestroy()
|
||||
}
|
||||
|
||||
player.addListener(listener)
|
||||
isStarted = true
|
||||
return list
|
||||
}
|
||||
|
||||
private val listener: Player.Listener = object : Player.Listener {
|
||||
@ -191,7 +296,14 @@ class PlaybackService :
|
||||
}
|
||||
|
||||
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
|
||||
updateWidgetTrack(mediaItem?.toTrack())
|
||||
// Since we cannot update the metadata of the media item after creation,
|
||||
// we cannot set change the rating on it
|
||||
// Therefore the track must be our source of truth
|
||||
val track = mediaItem?.toTrack()
|
||||
if (track != null) {
|
||||
updateCustomHeartButton(track.starred)
|
||||
}
|
||||
updateWidgetTrack(track)
|
||||
cacheNextSongs()
|
||||
}
|
||||
|
||||
@ -201,7 +313,12 @@ class PlaybackService :
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateCustomHeartButton(isHeart: Boolean) {
|
||||
librarySessionCallback.updateCustomHeartButton(mediaLibrarySession, isHeart)
|
||||
}
|
||||
|
||||
private fun cacheNextSongs() {
|
||||
if (actualBackend == MediaPlayerManager.PlayerBackend.JUKEBOX) return
|
||||
Timber.d("PlaybackService caching the next songs")
|
||||
val nextSongs = Util.getPlayListFromTimeline(
|
||||
player.currentTimeline,
|
||||
@ -291,8 +408,22 @@ class PlaybackService :
|
||||
}
|
||||
|
||||
companion object {
|
||||
var actualBackend: MediaPlayerManager.PlayerBackend? = null
|
||||
|
||||
private var desiredBackend: MediaPlayerManager.PlayerBackend? = null
|
||||
fun setBackend(playerBackend: MediaPlayerManager.PlayerBackend) {
|
||||
desiredBackend = playerBackend
|
||||
instance?.updateBackend(playerBackend)
|
||||
}
|
||||
|
||||
var instance: PlaybackService? = null
|
||||
|
||||
private const val NOTIFICATION_CHANNEL_ID = "org.moire.ultrasonic.error"
|
||||
private const val NOTIFICATION_CHANNEL_NAME = "Ultrasonic error messages"
|
||||
const val CUSTOM_COMMAND_TOGGLE_HEART_ON =
|
||||
"org.moire.ultrasonic.HEART_ON"
|
||||
const val CUSTOM_COMMAND_TOGGLE_HEART_OFF =
|
||||
"org.moire.ultrasonic.HEART_OFF"
|
||||
private const val NOTIFICATION_ID = 3009
|
||||
}
|
||||
}
|
||||
|
@ -11,6 +11,7 @@ import android.app.Notification
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.net.wifi.WifiManager
|
||||
import android.os.Binder
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.IBinder
|
||||
@ -27,6 +28,7 @@ import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
import org.moire.ultrasonic.R
|
||||
@ -34,12 +36,10 @@ import org.moire.ultrasonic.app.UApp
|
||||
import org.moire.ultrasonic.domain.Track
|
||||
import org.moire.ultrasonic.service.DownloadState.Companion.isFinalState
|
||||
import org.moire.ultrasonic.util.CacheCleaner
|
||||
import org.moire.ultrasonic.util.FileUtil
|
||||
import org.moire.ultrasonic.util.FileUtil.getCompleteFile
|
||||
import org.moire.ultrasonic.util.FileUtil.getPartialFile
|
||||
import org.moire.ultrasonic.util.FileUtil.getPinnedFile
|
||||
import org.moire.ultrasonic.util.Settings
|
||||
import org.moire.ultrasonic.util.SimpleServiceBinder
|
||||
import org.moire.ultrasonic.util.Storage
|
||||
import org.moire.ultrasonic.util.Util
|
||||
import org.moire.ultrasonic.util.Util.stopForegroundRemoveNotification
|
||||
@ -77,8 +77,7 @@ class DownloadService : Service(), KoinComponent {
|
||||
|
||||
// Create Coroutine lifecycle scope. We use a SupervisorJob(), otherwise the failure of one
|
||||
// would mean the failure of all jobs!
|
||||
val supervisor = SupervisorJob()
|
||||
scope = CoroutineScope(Dispatchers.IO + supervisor)
|
||||
scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
val notificationManagerCompat = NotificationManagerCompat.from(this)
|
||||
|
||||
@ -147,7 +146,7 @@ class DownloadService : Service(), KoinComponent {
|
||||
|
||||
val downloadTask = DownloadTask(track, scope!!, ::downloadStateChangedCallback)
|
||||
activeDownloads[track.id] = downloadTask
|
||||
FileUtil.createDirectoryForParent(track.pinnedFile)
|
||||
|
||||
downloadTask.start()
|
||||
listChanged = true
|
||||
}
|
||||
@ -200,7 +199,7 @@ class DownloadService : Service(), KoinComponent {
|
||||
|
||||
private fun updateLiveData() {
|
||||
val temp: MutableList<Track> = ArrayList()
|
||||
temp.addAll(activeDownloads.values.map { it.track.track })
|
||||
temp.addAll(activeDownloads.values.map { it.downloadTrack.track })
|
||||
temp.addAll(downloadQueue.map { x -> x.track })
|
||||
observableDownloads.postValue(temp.distinct().sorted())
|
||||
}
|
||||
@ -257,7 +256,7 @@ class DownloadService : Service(), KoinComponent {
|
||||
return notificationBuilder.build()
|
||||
}
|
||||
|
||||
@Suppress("MagicNumber", "NestedBlockDepth")
|
||||
@Suppress("MagicNumber", "NestedBlockDepth", "TooManyFunctions")
|
||||
companion object {
|
||||
|
||||
private var startFuture: SettableFuture<DownloadService>? = null
|
||||
@ -278,57 +277,60 @@ class DownloadService : Service(), KoinComponent {
|
||||
save: Boolean,
|
||||
isHighPriority: Boolean = false
|
||||
) {
|
||||
// First handle and filter out those tracks that are already completed
|
||||
var filteredTracks: List<Track>
|
||||
if (save) {
|
||||
tracks.filter { Storage.isPathExists(it.getCompleteFile()) }.forEach { track ->
|
||||
Storage.getFromPath(track.getCompleteFile())?.let {
|
||||
Storage.renameOrDeleteIfAlreadyExists(it, track.getPinnedFile())
|
||||
postState(track, DownloadState.PINNED)
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
|
||||
// First handle and filter out those tracks that are already completed
|
||||
var filteredTracks: List<Track>
|
||||
if (save) {
|
||||
tracks.filter { Storage.isPathExists(it.getCompleteFile()) }.forEach { track ->
|
||||
Storage.getFromPath(track.getCompleteFile())?.let {
|
||||
Storage.renameOrDeleteIfAlreadyExists(it, track.getPinnedFile())
|
||||
postState(track, DownloadState.PINNED)
|
||||
}
|
||||
}
|
||||
}
|
||||
filteredTracks = tracks.filter { !Storage.isPathExists(it.getPinnedFile()) }
|
||||
} else {
|
||||
tracks.filter { Storage.isPathExists(it.getPinnedFile()) }.forEach { track ->
|
||||
Storage.getFromPath(track.getPinnedFile())?.let {
|
||||
Storage.renameOrDeleteIfAlreadyExists(it, track.getCompleteFile())
|
||||
postState(track, DownloadState.DONE)
|
||||
filteredTracks = tracks.filter { !Storage.isPathExists(it.getPinnedFile()) }
|
||||
} else {
|
||||
tracks.filter { Storage.isPathExists(it.getPinnedFile()) }.forEach { track ->
|
||||
Storage.getFromPath(track.getPinnedFile())?.let {
|
||||
Storage.renameOrDeleteIfAlreadyExists(it, track.getCompleteFile())
|
||||
postState(track, DownloadState.DONE)
|
||||
}
|
||||
}
|
||||
}
|
||||
filteredTracks = tracks.filter { !Storage.isPathExists(it.getCompleteFile()) }
|
||||
}
|
||||
|
||||
// Update Pinned flag of items in progress
|
||||
downloadQueue.filter { item -> tracks.any { it.id == item.id } }
|
||||
.forEach { it.pinned = save }
|
||||
tracks.forEach {
|
||||
activeDownloads[it.id]?.track?.pinned = save
|
||||
}
|
||||
tracks.forEach {
|
||||
failedList[it.id]?.pinned = save
|
||||
}
|
||||
|
||||
filteredTracks = filteredTracks.filter {
|
||||
!downloadQueue.any { i -> i.id == it.id } && !activeDownloads.containsKey(it.id)
|
||||
}
|
||||
|
||||
// The remainder tracks should be added to the download queue
|
||||
// By using the counter we ensure that the songs are added in the correct order
|
||||
var priority = 0
|
||||
val tracksToDownload =
|
||||
filteredTracks.map {
|
||||
DownloadableTrack(
|
||||
it,
|
||||
save,
|
||||
0,
|
||||
if (isHighPriority) priority++ else backgroundPriorityCounter++
|
||||
)
|
||||
filteredTracks = tracks.filter { !Storage.isPathExists(it.getCompleteFile()) }
|
||||
}
|
||||
|
||||
if (tracksToDownload.isNotEmpty()) {
|
||||
downloadQueue.addAll(tracksToDownload)
|
||||
tracksToDownload.forEach { postState(it.track, DownloadState.QUEUED) }
|
||||
processNextTracksOnService()
|
||||
// Update Pinned flag of items in progress
|
||||
downloadQueue.filter { item -> tracks.any { it.id == item.id } }
|
||||
.forEach { it.pinned = save }
|
||||
tracks.forEach {
|
||||
activeDownloads[it.id]?.downloadTrack?.pinned = save
|
||||
}
|
||||
tracks.forEach {
|
||||
failedList[it.id]?.pinned = save
|
||||
}
|
||||
|
||||
filteredTracks = filteredTracks.filter {
|
||||
!downloadQueue.any { i -> i.id == it.id } && !activeDownloads.containsKey(it.id)
|
||||
}
|
||||
|
||||
// The remainder tracks should be added to the download queue
|
||||
// By using the counter we ensure that the songs are added in the correct order
|
||||
var priority = 0
|
||||
val tracksToDownload =
|
||||
filteredTracks.map {
|
||||
DownloadableTrack(
|
||||
it,
|
||||
save,
|
||||
0,
|
||||
if (isHighPriority) priority++ else backgroundPriorityCounter++
|
||||
)
|
||||
}
|
||||
|
||||
if (tracksToDownload.isNotEmpty()) {
|
||||
downloadQueue.addAll(tracksToDownload)
|
||||
tracksToDownload.forEach { postState(it.track, DownloadState.QUEUED) }
|
||||
processNextTracksOnService()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -340,23 +342,34 @@ class DownloadService : Service(), KoinComponent {
|
||||
}
|
||||
|
||||
fun delete(track: Track) {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
downloadQueue.get(track.id)?.let { downloadQueue.remove(it) }
|
||||
failedList[track.id]?.let { downloadQueue.remove(it) }
|
||||
cancelDownload(track)
|
||||
|
||||
downloadQueue.get(track.id)?.let { downloadQueue.remove(it) }
|
||||
failedList[track.id]?.let { downloadQueue.remove(it) }
|
||||
cancelDownload(track)
|
||||
Storage.delete(track.getPartialFile())
|
||||
Storage.delete(track.getCompleteFile())
|
||||
Storage.delete(track.getPinnedFile())
|
||||
postState(track, DownloadState.IDLE)
|
||||
CacheCleaner().cleanDatabaseSelective(track)
|
||||
Util.scanMedia(track.getPinnedFile())
|
||||
}
|
||||
}
|
||||
|
||||
Storage.delete(track.getPartialFile())
|
||||
Storage.delete(track.getCompleteFile())
|
||||
Storage.delete(track.getPinnedFile())
|
||||
postState(track, DownloadState.IDLE)
|
||||
CacheCleaner().cleanDatabaseSelective(track)
|
||||
Util.scanMedia(track.getPinnedFile())
|
||||
@Synchronized
|
||||
fun unpin(tracks: List<Track>) {
|
||||
tracks.forEach(::unpin)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun delete(tracks: List<Track>) {
|
||||
tracks.forEach(::delete)
|
||||
}
|
||||
|
||||
fun unpin(track: Track) {
|
||||
// Update Pinned flag of items in progress
|
||||
downloadQueue.get(track.id)?.pinned = false
|
||||
activeDownloads[track.id]?.track?.pinned = false
|
||||
activeDownloads[track.id]?.downloadTrack?.pinned = false
|
||||
failedList[track.id]?.pinned = false
|
||||
|
||||
val pinnedFile = track.getPinnedFile()
|
||||
@ -376,7 +389,7 @@ class DownloadService : Service(), KoinComponent {
|
||||
if (activeDownloads.contains(track.id)) return DownloadState.QUEUED
|
||||
if (downloadQueue.contains(track.id)) return DownloadState.QUEUED
|
||||
|
||||
val downloadableTrack = activeDownloads[track.id]?.track
|
||||
val downloadableTrack = activeDownloads[track.id]?.downloadTrack
|
||||
if (downloadableTrack != null) {
|
||||
if (downloadableTrack.tryCount > 0) return DownloadState.RETRYING
|
||||
return DownloadState.DOWNLOADING
|
||||
@ -439,3 +452,5 @@ class DownloadService : Service(), KoinComponent {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class SimpleServiceBinder<S>(val service: S) : Binder()
|
||||
|
@ -36,7 +36,7 @@ private const val MAX_RETRIES = 5
|
||||
private const val REFRESH_INTERVAL = 50
|
||||
|
||||
class DownloadTask(
|
||||
private val item: DownloadableTrack,
|
||||
val downloadTrack: DownloadableTrack,
|
||||
private val scope: CoroutineScope,
|
||||
private val stateChangedCallback: (DownloadableTrack, DownloadState, progress: Int?) -> Unit
|
||||
) : KoinComponent {
|
||||
@ -49,38 +49,35 @@ class DownloadTask(
|
||||
private var outputStream: OutputStream? = null
|
||||
private var lastPostTime: Long = 0
|
||||
|
||||
val track: DownloadableTrack
|
||||
get() = item
|
||||
|
||||
private fun checkIfExists(): Boolean {
|
||||
if (Storage.isPathExists(item.pinnedFile)) {
|
||||
Timber.i("%s already exists. Skipping.", item.pinnedFile)
|
||||
stateChangedCallback(item, DownloadState.PINNED, null)
|
||||
if (Storage.isPathExists(downloadTrack.pinnedFile)) {
|
||||
Timber.i("%s already exists. Skipping.", downloadTrack.pinnedFile)
|
||||
stateChangedCallback(downloadTrack, DownloadState.PINNED, null)
|
||||
return true
|
||||
}
|
||||
|
||||
if (Storage.isPathExists(item.completeFile)) {
|
||||
if (Storage.isPathExists(downloadTrack.completeFile)) {
|
||||
var newStatus: DownloadState = DownloadState.DONE
|
||||
if (item.pinned) {
|
||||
if (downloadTrack.pinned) {
|
||||
Storage.rename(
|
||||
item.completeFile,
|
||||
item.pinnedFile
|
||||
downloadTrack.completeFile,
|
||||
downloadTrack.pinnedFile
|
||||
)
|
||||
newStatus = DownloadState.PINNED
|
||||
} else {
|
||||
Timber.i(
|
||||
"%s already exists. Skipping.",
|
||||
item.completeFile
|
||||
downloadTrack.completeFile
|
||||
)
|
||||
}
|
||||
|
||||
// Hidden feature: If track is toggled between pinned/saved, refresh the metadata..
|
||||
try {
|
||||
item.track.cacheMetadataAndArtwork()
|
||||
downloadTrack.track.cacheMetadataAndArtwork()
|
||||
} catch (ignore: Exception) {
|
||||
Timber.w(ignore)
|
||||
}
|
||||
stateChangedCallback(item, newStatus, null)
|
||||
stateChangedCallback(downloadTrack, newStatus, null)
|
||||
return true
|
||||
}
|
||||
|
||||
@ -88,15 +85,15 @@ class DownloadTask(
|
||||
}
|
||||
|
||||
fun download() {
|
||||
stateChangedCallback(item, DownloadState.DOWNLOADING, null)
|
||||
stateChangedCallback(downloadTrack, DownloadState.DOWNLOADING, null)
|
||||
|
||||
val fileLength = Storage.getFromPath(item.partialFile)?.length ?: 0
|
||||
val fileLength = Storage.getFromPath(downloadTrack.partialFile)?.length ?: 0
|
||||
|
||||
// Attempt partial HTTP GET, appending to the file if it exists.
|
||||
val (inStream, isPartial) = musicService.getDownloadInputStream(
|
||||
item.track, fileLength,
|
||||
Settings.maxBitRate,
|
||||
item.pinned
|
||||
downloadTrack.track, fileLength,
|
||||
if (downloadTrack.pinned) Settings.maxBitRatePinning else Settings.maxBitRate,
|
||||
downloadTrack.pinned && Settings.pinWithHighestQuality
|
||||
)
|
||||
|
||||
inputStream = inStream
|
||||
@ -105,7 +102,7 @@ class DownloadTask(
|
||||
Timber.i("Executed partial HTTP GET, skipping %d bytes", fileLength)
|
||||
}
|
||||
|
||||
outputStream = Storage.getOrCreateFileFromPath(item.partialFile)
|
||||
outputStream = Storage.getOrCreateFileFromPath(downloadTrack.partialFile)
|
||||
.getFileOutputStream(isPartial)
|
||||
|
||||
val len = inputStream!!.copyWithProgress(outputStream!!) { totalBytesCopied ->
|
||||
@ -113,7 +110,7 @@ class DownloadTask(
|
||||
publishProgressUpdate(fileLength + totalBytesCopied)
|
||||
}
|
||||
|
||||
Timber.i("Downloaded %d bytes to %s", len, item.partialFile)
|
||||
Timber.i("Downloaded %d bytes to %s", len, downloadTrack.partialFile)
|
||||
|
||||
inputStream?.close()
|
||||
outputStream?.flush()
|
||||
@ -131,7 +128,7 @@ class DownloadTask(
|
||||
lastPostTime = SystemClock.elapsedRealtime()
|
||||
|
||||
// If the file size is unknown we can only provide null as the progress
|
||||
val size = item.track.size ?: 0
|
||||
val size = downloadTrack.track.size ?: 0
|
||||
val progress = if (size <= 0) {
|
||||
null
|
||||
} else {
|
||||
@ -139,7 +136,7 @@ class DownloadTask(
|
||||
}
|
||||
|
||||
stateChangedCallback(
|
||||
item,
|
||||
downloadTrack,
|
||||
DownloadState.DOWNLOADING,
|
||||
progress
|
||||
)
|
||||
@ -148,39 +145,39 @@ class DownloadTask(
|
||||
|
||||
private fun afterDownload() {
|
||||
try {
|
||||
item.track.cacheMetadataAndArtwork()
|
||||
downloadTrack.track.cacheMetadataAndArtwork()
|
||||
} catch (ignore: Exception) {
|
||||
Timber.w(ignore)
|
||||
}
|
||||
|
||||
if (item.pinned) {
|
||||
if (downloadTrack.pinned) {
|
||||
Storage.rename(
|
||||
item.partialFile,
|
||||
item.pinnedFile
|
||||
downloadTrack.partialFile,
|
||||
downloadTrack.pinnedFile
|
||||
)
|
||||
Timber.i("Renamed file to ${item.pinnedFile}")
|
||||
stateChangedCallback(item, DownloadState.PINNED, null)
|
||||
Util.scanMedia(item.pinnedFile)
|
||||
Timber.i("Renamed file to ${downloadTrack.pinnedFile}")
|
||||
stateChangedCallback(downloadTrack, DownloadState.PINNED, null)
|
||||
Util.scanMedia(downloadTrack.pinnedFile)
|
||||
} else {
|
||||
Storage.rename(
|
||||
item.partialFile,
|
||||
item.completeFile
|
||||
downloadTrack.partialFile,
|
||||
downloadTrack.completeFile
|
||||
)
|
||||
Timber.i("Renamed file to ${item.completeFile}")
|
||||
stateChangedCallback(item, DownloadState.DONE, null)
|
||||
Timber.i("Renamed file to ${downloadTrack.completeFile}")
|
||||
stateChangedCallback(downloadTrack, DownloadState.DONE, null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onCompletion(e: Throwable?) {
|
||||
if (e is CancellationException) {
|
||||
Timber.w(e, "CompletionHandler ${item.pinnedFile}")
|
||||
stateChangedCallback(item, DownloadState.CANCELLED, null)
|
||||
Timber.w(e, "CompletionHandler ${downloadTrack.pinnedFile}")
|
||||
stateChangedCallback(downloadTrack, DownloadState.CANCELLED, null)
|
||||
} else if (e != null) {
|
||||
Timber.w(e, "CompletionHandler ${item.pinnedFile}")
|
||||
if (item.tryCount < MAX_RETRIES) {
|
||||
stateChangedCallback(item, DownloadState.RETRYING, null)
|
||||
Timber.w(e, "CompletionHandler ${downloadTrack.pinnedFile}")
|
||||
if (downloadTrack.tryCount < MAX_RETRIES) {
|
||||
stateChangedCallback(downloadTrack, DownloadState.RETRYING, null)
|
||||
} else {
|
||||
stateChangedCallback(item, DownloadState.FAILED, null)
|
||||
stateChangedCallback(downloadTrack, DownloadState.FAILED, null)
|
||||
}
|
||||
}
|
||||
inputStream.safeClose()
|
||||
@ -189,15 +186,16 @@ class DownloadTask(
|
||||
|
||||
private fun exceptionHandler(): CoroutineExceptionHandler {
|
||||
return CoroutineExceptionHandler { _, exception ->
|
||||
Timber.w(exception, "Exception in DownloadTask ${item.pinnedFile}")
|
||||
Storage.delete(item.completeFile)
|
||||
Storage.delete(item.pinnedFile)
|
||||
Timber.w(exception, "Exception in DownloadTask ${downloadTrack.pinnedFile}")
|
||||
Storage.delete(downloadTrack.completeFile)
|
||||
Storage.delete(downloadTrack.pinnedFile)
|
||||
}
|
||||
}
|
||||
|
||||
fun start() {
|
||||
Timber.i("Launching new Job ${item.pinnedFile}")
|
||||
Timber.i("Launching new Job ${downloadTrack.pinnedFile}")
|
||||
job = scope.launch(exceptionHandler()) {
|
||||
FileUtil.createDirectoryForParent(downloadTrack.pinnedFile)
|
||||
if (!checkIfExists() && isActive) {
|
||||
download()
|
||||
afterDownload()
|
||||
|
@ -7,29 +7,12 @@
|
||||
package org.moire.ultrasonic.service
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.IBinder
|
||||
import android.os.Looper
|
||||
import android.view.Gravity
|
||||
import android.view.KeyEvent
|
||||
import android.view.KeyEvent.KEYCODE_MEDIA_NEXT
|
||||
import android.view.KeyEvent.KEYCODE_MEDIA_PAUSE
|
||||
import android.view.KeyEvent.KEYCODE_MEDIA_PLAY
|
||||
import android.view.KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE
|
||||
import android.view.KeyEvent.KEYCODE_MEDIA_PREVIOUS
|
||||
import android.view.KeyEvent.KEYCODE_MEDIA_STOP
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.widget.ProgressBar
|
||||
import android.widget.Toast
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.media3.common.AudioAttributes
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.DeviceInfo
|
||||
import androidx.media3.common.FlagSet
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.MediaMetadata
|
||||
import androidx.media3.common.PlaybackException
|
||||
@ -39,34 +22,27 @@ import androidx.media3.common.Timeline
|
||||
import androidx.media3.common.TrackSelectionParameters
|
||||
import androidx.media3.common.VideoSize
|
||||
import androidx.media3.common.text.CueGroup
|
||||
import androidx.media3.common.util.Clock
|
||||
import androidx.media3.common.util.ListenerSet
|
||||
import androidx.media3.common.util.Size
|
||||
import androidx.media3.session.MediaSession
|
||||
import com.google.common.collect.ImmutableList
|
||||
import com.google.common.util.concurrent.ListenableFuture
|
||||
import com.google.common.util.concurrent.SettableFuture
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.LinkedBlockingQueue
|
||||
import java.util.concurrent.ScheduledFuture
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
import kotlin.math.roundToInt
|
||||
import org.moire.ultrasonic.R
|
||||
import org.moire.ultrasonic.api.subsonic.ApiNotSupportedException
|
||||
import org.moire.ultrasonic.api.subsonic.SubsonicRESTException
|
||||
import org.moire.ultrasonic.app.UApp.Companion.applicationContext
|
||||
import org.moire.ultrasonic.data.ActiveServerProvider.Companion.isOffline
|
||||
import org.moire.ultrasonic.domain.JukeboxStatus
|
||||
import org.moire.ultrasonic.playback.CustomNotificationProvider
|
||||
import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService
|
||||
import org.moire.ultrasonic.util.Util.getPendingIntentToShowPlayer
|
||||
import org.moire.ultrasonic.util.Settings
|
||||
import org.moire.ultrasonic.util.Util.sleepQuietly
|
||||
import org.moire.ultrasonic.util.Util.stopForegroundRemoveNotification
|
||||
import timber.log.Timber
|
||||
|
||||
private const val STATUS_UPDATE_INTERVAL_SECONDS = 5L
|
||||
private const val SEEK_INCREMENT_SECONDS = 5L
|
||||
private const val SEEK_START_AFTER_SECONDS = 5
|
||||
private const val QUEUE_POLL_INTERVAL_SECONDS = 1L
|
||||
|
||||
/**
|
||||
@ -86,135 +62,64 @@ class JukeboxMediaPlayer : JukeboxUnimplementedFunctions(), Player {
|
||||
private val timeOfLastUpdate = AtomicLong()
|
||||
private var jukeboxStatus: JukeboxStatus? = null
|
||||
private var previousJukeboxStatus: JukeboxStatus? = null
|
||||
private var gain = 0.5f
|
||||
private var volumeToast: VolumeToast? = null
|
||||
private var gain = (MAX_GAIN / 3)
|
||||
private val floatGain: Float
|
||||
get() = gain.toFloat() / MAX_GAIN
|
||||
|
||||
private var serviceThread: Thread? = null
|
||||
|
||||
private var listeners: MutableList<Player.Listener> = mutableListOf()
|
||||
private var listeners: ListenerSet<Player.Listener>
|
||||
private val playlist: MutableList<MediaItem> = mutableListOf()
|
||||
private var currentIndex: Int = 0
|
||||
private val notificationProvider = CustomNotificationProvider(applicationContext())
|
||||
private lateinit var mediaSession: MediaSession
|
||||
private lateinit var notificationManagerCompat: NotificationManagerCompat
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
if (running.get()) return
|
||||
private var _currentIndex: Int = 0
|
||||
private var currentIndex: Int
|
||||
get() = _currentIndex
|
||||
set(value) {
|
||||
// This must never be smaller 0
|
||||
_currentIndex = if (value >= 0) value else 0
|
||||
}
|
||||
|
||||
companion object {
|
||||
// This is quite important, by setting the DeviceInfo the player is recognized by
|
||||
// Android as being a remote playback surface
|
||||
val DEVICE_INFO = DeviceInfo(DeviceInfo.PLAYBACK_TYPE_REMOTE, 0, 10)
|
||||
val running = AtomicBoolean()
|
||||
const val MAX_GAIN = 10
|
||||
}
|
||||
|
||||
init {
|
||||
running.set(true)
|
||||
|
||||
listeners = ListenerSet(
|
||||
applicationLooper,
|
||||
Clock.DEFAULT
|
||||
) { listener: Player.Listener, flags: FlagSet? ->
|
||||
listener.onEvents(
|
||||
this,
|
||||
Player.Events(
|
||||
flags!!
|
||||
)
|
||||
)
|
||||
}
|
||||
tasks.clear()
|
||||
updatePlaylist()
|
||||
stop()
|
||||
|
||||
startFuture?.set(this)
|
||||
|
||||
startProcessTasks()
|
||||
|
||||
notificationManagerCompat = NotificationManagerCompat.from(this)
|
||||
mediaSession = MediaSession.Builder(applicationContext(), this)
|
||||
.setId("jukebox")
|
||||
.setSessionActivity(getPendingIntentToShowPlayer(this))
|
||||
.build()
|
||||
val notification = notificationProvider.createNotification(
|
||||
mediaSession,
|
||||
ImmutableList.of(),
|
||||
JukeboxNotificationActionFactory()
|
||||
) {}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= 29) {
|
||||
startForeground(
|
||||
notification.notificationId,
|
||||
notification.notification,
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK
|
||||
)
|
||||
} else {
|
||||
startForeground(
|
||||
notification.notificationId, notification.notification
|
||||
)
|
||||
}
|
||||
|
||||
Timber.d("Started Jukebox Service")
|
||||
}
|
||||
@Suppress("MagicNumber")
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
super.onStartCommand(intent, flags, startId)
|
||||
if (Intent.ACTION_MEDIA_BUTTON != intent?.action) return START_STICKY
|
||||
|
||||
val extras = intent.extras
|
||||
if ((extras != null) && extras.containsKey(Intent.EXTRA_KEY_EVENT)) {
|
||||
val event = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
extras.getParcelable(Intent.EXTRA_KEY_EVENT, KeyEvent::class.java)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
extras.getParcelable<KeyEvent>(Intent.EXTRA_KEY_EVENT)
|
||||
}
|
||||
when (event?.keyCode) {
|
||||
KEYCODE_MEDIA_PLAY -> play()
|
||||
KEYCODE_MEDIA_PAUSE -> stop()
|
||||
KEYCODE_MEDIA_STOP -> stop()
|
||||
KEYCODE_MEDIA_PLAY_PAUSE -> if (isPlaying) stop() else play()
|
||||
KEYCODE_MEDIA_PREVIOUS -> seekToPrevious()
|
||||
KEYCODE_MEDIA_NEXT -> seekToNext()
|
||||
}
|
||||
}
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
override fun release() {
|
||||
tasks.clear()
|
||||
stop()
|
||||
|
||||
if (!running.get()) return
|
||||
running.set(false)
|
||||
|
||||
serviceThread!!.join()
|
||||
serviceThread?.join()
|
||||
|
||||
stopForegroundRemoveNotification()
|
||||
mediaSession.release()
|
||||
|
||||
super.onDestroy()
|
||||
Timber.d("Stopped Jukebox Service")
|
||||
}
|
||||
|
||||
override fun onBind(p0: Intent?): IBinder? {
|
||||
return null
|
||||
}
|
||||
|
||||
fun requestStop() {
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
private fun updateNotification() {
|
||||
val notification = notificationProvider.createNotification(
|
||||
mediaSession,
|
||||
ImmutableList.of(),
|
||||
JukeboxNotificationActionFactory()
|
||||
) {}
|
||||
notificationManagerCompat.notify(notification.notificationId, notification.notification)
|
||||
}
|
||||
|
||||
companion object {
|
||||
val running = AtomicBoolean()
|
||||
private var startFuture: SettableFuture<JukeboxMediaPlayer>? = null
|
||||
|
||||
@JvmStatic
|
||||
fun requestStart(): ListenableFuture<JukeboxMediaPlayer>? {
|
||||
if (running.get()) return null
|
||||
startFuture = SettableFuture.create()
|
||||
val context = applicationContext()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
context.startForegroundService(
|
||||
Intent(context, JukeboxMediaPlayer::class.java)
|
||||
)
|
||||
} else {
|
||||
context.startService(Intent(context, JukeboxMediaPlayer::class.java))
|
||||
}
|
||||
Timber.i("JukeboxMediaPlayer starting...")
|
||||
return startFuture
|
||||
}
|
||||
}
|
||||
|
||||
override fun addListener(listener: Player.Listener) {
|
||||
listeners.add(listener)
|
||||
}
|
||||
@ -263,14 +168,20 @@ class JukeboxMediaPlayer : JukeboxUnimplementedFunctions(), Player {
|
||||
}
|
||||
tasks.add(Skip(mediaItemIndex, positionSeconds))
|
||||
currentIndex = mediaItemIndex
|
||||
updateAvailableCommands()
|
||||
}
|
||||
|
||||
override fun seekBack() {
|
||||
seekTo(0L.coerceAtMost((jukeboxStatus?.positionSeconds ?: 0) - SEEK_INCREMENT_SECONDS))
|
||||
seekTo(
|
||||
0L.coerceAtMost(
|
||||
(jukeboxStatus?.positionSeconds ?: 0) -
|
||||
Settings.seekIntervalMillis
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun seekForward() {
|
||||
seekTo((jukeboxStatus?.positionSeconds ?: 0) + SEEK_INCREMENT_SECONDS)
|
||||
seekTo((jukeboxStatus?.positionSeconds ?: 0) + Settings.seekIntervalMillis)
|
||||
}
|
||||
|
||||
override fun isCurrentMediaItemSeekable() = true
|
||||
@ -292,8 +203,11 @@ class JukeboxMediaPlayer : JukeboxUnimplementedFunctions(), Player {
|
||||
|
||||
override fun getAvailableCommands(): Player.Commands {
|
||||
val commandsBuilder = Player.Commands.Builder().addAll(
|
||||
Player.COMMAND_SET_VOLUME,
|
||||
Player.COMMAND_GET_VOLUME
|
||||
Player.COMMAND_CHANGE_MEDIA_ITEMS,
|
||||
Player.COMMAND_GET_TIMELINE,
|
||||
Player.COMMAND_GET_DEVICE_VOLUME,
|
||||
Player.COMMAND_ADJUST_DEVICE_VOLUME,
|
||||
Player.COMMAND_SET_DEVICE_VOLUME
|
||||
)
|
||||
if (isPlaying) commandsBuilder.add(Player.COMMAND_STOP)
|
||||
if (playlist.isNotEmpty()) {
|
||||
@ -306,8 +220,7 @@ class JukeboxMediaPlayer : JukeboxUnimplementedFunctions(), Player {
|
||||
Player.COMMAND_SEEK_FORWARD,
|
||||
Player.COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM,
|
||||
Player.COMMAND_SEEK_TO_MEDIA_ITEM,
|
||||
)
|
||||
if (currentIndex > 0) commandsBuilder.addAll(
|
||||
// Seeking back is always available
|
||||
Player.COMMAND_SEEK_TO_PREVIOUS,
|
||||
Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM
|
||||
)
|
||||
@ -323,6 +236,18 @@ class JukeboxMediaPlayer : JukeboxUnimplementedFunctions(), Player {
|
||||
return availableCommands.contains(command)
|
||||
}
|
||||
|
||||
private fun updateAvailableCommands() {
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
listeners.sendEvent(
|
||||
Player.EVENT_AVAILABLE_COMMANDS_CHANGED
|
||||
) { listener: Player.Listener ->
|
||||
listener.onAvailableCommandsChanged(
|
||||
availableCommands
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getPlayWhenReady(): Boolean {
|
||||
return isPlaying
|
||||
}
|
||||
@ -358,21 +283,43 @@ class JukeboxMediaPlayer : JukeboxUnimplementedFunctions(), Player {
|
||||
|
||||
override fun setShuffleModeEnabled(shuffleModeEnabled: Boolean) {}
|
||||
|
||||
override fun setVolume(volume: Float) {
|
||||
override fun setDeviceVolume(volume: Int) {
|
||||
gain = volume
|
||||
tasks.remove(SetGain::class.java)
|
||||
tasks.add(SetGain(volume))
|
||||
val context = applicationContext()
|
||||
if (volumeToast == null) volumeToast = VolumeToast(context)
|
||||
volumeToast!!.setVolume(volume)
|
||||
tasks.add(SetGain(floatGain))
|
||||
|
||||
// We must trigger an event so that the Controller knows the new volume
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
listeners.queueEvent(Player.EVENT_DEVICE_VOLUME_CHANGED) {
|
||||
it.onDeviceVolumeChanged(
|
||||
gain,
|
||||
false
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun increaseDeviceVolume() {
|
||||
gain = (gain + 1).coerceAtMost(MAX_GAIN)
|
||||
deviceVolume = gain
|
||||
}
|
||||
|
||||
override fun decreaseDeviceVolume() {
|
||||
gain = (gain - 1).coerceAtLeast(0)
|
||||
deviceVolume = gain
|
||||
}
|
||||
|
||||
override fun setDeviceMuted(muted: Boolean) {
|
||||
gain = 0
|
||||
deviceVolume = gain
|
||||
}
|
||||
|
||||
override fun getVolume(): Float {
|
||||
return gain
|
||||
return floatGain
|
||||
}
|
||||
|
||||
override fun getDeviceVolume(): Int {
|
||||
return (gain * 100).toInt()
|
||||
return gain
|
||||
}
|
||||
|
||||
override fun addMediaItems(index: Int, mediaItems: MutableList<MediaItem>) {
|
||||
@ -444,7 +391,7 @@ class JukeboxMediaPlayer : JukeboxUnimplementedFunctions(), Player {
|
||||
}
|
||||
|
||||
override fun seekToPrevious() {
|
||||
if ((jukeboxStatus?.positionSeconds ?: 0) > SEEK_START_AFTER_SECONDS) {
|
||||
if ((jukeboxStatus?.positionSeconds ?: 0) > (Settings.seekIntervalMillis)) {
|
||||
seekTo(currentIndex, 0)
|
||||
return
|
||||
}
|
||||
@ -499,51 +446,63 @@ class JukeboxMediaPlayer : JukeboxUnimplementedFunctions(), Player {
|
||||
@Suppress("LoopWithTooManyJumpStatements")
|
||||
private fun processTasks() {
|
||||
Timber.d("JukeboxMediaPlayer processTasks starting")
|
||||
while (true) {
|
||||
while (running.get()) {
|
||||
// Sleep a bit to spare processor time if we loop a lot
|
||||
sleepQuietly(10)
|
||||
// This is only necessary if Ultrasonic goes offline sooner than the thread stops
|
||||
if (isOffline()) continue
|
||||
var task: JukeboxTask? = null
|
||||
try {
|
||||
task = tasks.poll()
|
||||
// If running is false, exit when the queue is empty
|
||||
if (task == null && !running.get()) break
|
||||
if (task == null) continue
|
||||
task = tasks.poll() ?: continue
|
||||
Timber.v("JukeBoxMediaPlayer processTasks processes Task %s", task::class)
|
||||
val status = task.execute()
|
||||
onStatusUpdate(status)
|
||||
} catch (x: Throwable) {
|
||||
onError(task, x)
|
||||
} catch (all: Throwable) {
|
||||
onError(task, all)
|
||||
}
|
||||
}
|
||||
Timber.d("JukeboxMediaPlayer processTasks stopped")
|
||||
}
|
||||
|
||||
// Jukebox status contains data received from the server, we need to validate it!
|
||||
private fun onStatusUpdate(jukeboxStatus: JukeboxStatus) {
|
||||
timeOfLastUpdate.set(System.currentTimeMillis())
|
||||
previousJukeboxStatus = this.jukeboxStatus
|
||||
this.jukeboxStatus = jukeboxStatus
|
||||
var shouldUpdateCommands = false
|
||||
|
||||
// Ensure that the index is never smaller than 0
|
||||
// If -1 assume that this means we are not playing
|
||||
if (jukeboxStatus.currentPlayingIndex != null && jukeboxStatus.currentPlayingIndex!! < 0) {
|
||||
jukeboxStatus.currentPlayingIndex = 0
|
||||
jukeboxStatus.isPlaying = false
|
||||
}
|
||||
currentIndex = jukeboxStatus.currentPlayingIndex ?: currentIndex
|
||||
|
||||
if (jukeboxStatus.isPlaying != previousJukeboxStatus?.isPlaying) {
|
||||
shouldUpdateCommands = true
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
listeners.forEach {
|
||||
listeners.queueEvent(Player.EVENT_PLAYBACK_STATE_CHANGED) {
|
||||
it.onPlaybackStateChanged(
|
||||
if (jukeboxStatus.isPlaying) Player.STATE_READY else Player.STATE_IDLE
|
||||
)
|
||||
}
|
||||
|
||||
listeners.queueEvent(Player.EVENT_IS_PLAYING_CHANGED) {
|
||||
it.onIsPlayingChanged(jukeboxStatus.isPlaying)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (jukeboxStatus.currentPlayingIndex != previousJukeboxStatus?.currentPlayingIndex) {
|
||||
shouldUpdateCommands = true
|
||||
currentIndex = jukeboxStatus.currentPlayingIndex ?: 0
|
||||
val currentMedia =
|
||||
if (currentIndex > 0 && currentIndex < playlist.size) playlist[currentIndex]
|
||||
else MediaItem.EMPTY
|
||||
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
listeners.forEach {
|
||||
listeners.queueEvent(Player.EVENT_MEDIA_ITEM_TRANSITION) {
|
||||
it.onMediaItemTransition(
|
||||
currentMedia,
|
||||
Player.MEDIA_ITEM_TRANSITION_REASON_SEEK
|
||||
@ -552,44 +511,39 @@ class JukeboxMediaPlayer : JukeboxUnimplementedFunctions(), Player {
|
||||
}
|
||||
}
|
||||
|
||||
updateNotification()
|
||||
if (shouldUpdateCommands) updateAvailableCommands()
|
||||
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
listeners.flushEvents()
|
||||
}
|
||||
}
|
||||
|
||||
private fun onError(task: JukeboxTask?, x: Throwable) {
|
||||
var exception: PlaybackException? = null
|
||||
if (x is ApiNotSupportedException && task !is Stop) {
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
listeners.forEach {
|
||||
it.onPlayerError(
|
||||
PlaybackException(
|
||||
"Jukebox server too old",
|
||||
null,
|
||||
R.string.download_jukebox_server_too_old
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
exception = PlaybackException(
|
||||
"Jukebox server too old",
|
||||
null,
|
||||
R.string.download_jukebox_server_too_old
|
||||
)
|
||||
} else if (x is OfflineException && task !is Stop) {
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
listeners.forEach {
|
||||
it.onPlayerError(
|
||||
PlaybackException(
|
||||
"Jukebox offline",
|
||||
null,
|
||||
R.string.download_jukebox_offline
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
exception = PlaybackException(
|
||||
"Jukebox offline",
|
||||
null,
|
||||
R.string.download_jukebox_offline
|
||||
)
|
||||
} 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 {
|
||||
listeners.forEach {
|
||||
it.onPlayerError(
|
||||
PlaybackException(
|
||||
"Jukebox not authorized",
|
||||
null,
|
||||
R.string.download_jukebox_not_authorized
|
||||
)
|
||||
)
|
||||
listeners.sendEvent(Player.EVENT_PLAYER_ERROR) {
|
||||
it.onPlayerError(exception)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@ -608,8 +562,10 @@ class JukeboxMediaPlayer : JukeboxUnimplementedFunctions(), Player {
|
||||
}
|
||||
tasks.add(SetPlaylist(ids))
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
listeners.forEach {
|
||||
it.onTimelineChanged(
|
||||
listeners.sendEvent(
|
||||
Player.EVENT_TIMELINE_CHANGED
|
||||
) { listener: Player.Listener ->
|
||||
listener.onTimelineChanged(
|
||||
PlaylistTimeline(playlist),
|
||||
Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED
|
||||
)
|
||||
@ -719,25 +675,6 @@ class JukeboxMediaPlayer : JukeboxUnimplementedFunctions(), Player {
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("InflateParams")
|
||||
private class VolumeToast(context: Context) : Toast(context) {
|
||||
private val progressBar: ProgressBar
|
||||
fun setVolume(volume: Float) {
|
||||
progressBar.progress = (100 * volume).roundToInt()
|
||||
show()
|
||||
}
|
||||
|
||||
init {
|
||||
duration = LENGTH_SHORT
|
||||
val inflater =
|
||||
context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater
|
||||
val view = inflater.inflate(R.layout.jukebox_volume, null)
|
||||
progressBar = view.findViewById<View>(R.id.jukebox_volume_progress_bar) as ProgressBar
|
||||
setView(view)
|
||||
setGravity(Gravity.TOP, 0, 0)
|
||||
}
|
||||
}
|
||||
|
||||
// The constants below are necessary so a MediaSession can be built from the Jukebox Service
|
||||
override fun isCurrentMediaItemDynamic(): Boolean {
|
||||
return false
|
||||
@ -748,15 +685,15 @@ class JukeboxMediaPlayer : JukeboxUnimplementedFunctions(), Player {
|
||||
}
|
||||
|
||||
override fun getMaxSeekToPreviousPosition(): Long {
|
||||
return SEEK_START_AFTER_SECONDS * 1000L
|
||||
return Settings.seekInterval.toLong()
|
||||
}
|
||||
|
||||
override fun getSeekBackIncrement(): Long {
|
||||
return SEEK_INCREMENT_SECONDS * 1000L
|
||||
return Settings.seekInterval.toLong()
|
||||
}
|
||||
|
||||
override fun getSeekForwardIncrement(): Long {
|
||||
return SEEK_INCREMENT_SECONDS * 1000L
|
||||
return Settings.seekInterval.toLong()
|
||||
}
|
||||
|
||||
override fun isLoading(): Boolean {
|
||||
@ -779,6 +716,8 @@ class JukeboxMediaPlayer : JukeboxUnimplementedFunctions(), Player {
|
||||
return AudioAttributes.DEFAULT
|
||||
}
|
||||
|
||||
override fun setVolume(volume: Float) {}
|
||||
|
||||
override fun getVideoSize(): VideoSize {
|
||||
return VideoSize(0, 0)
|
||||
}
|
||||
@ -824,7 +763,7 @@ class JukeboxMediaPlayer : JukeboxUnimplementedFunctions(), Player {
|
||||
}
|
||||
|
||||
override fun getDeviceInfo(): DeviceInfo {
|
||||
return DeviceInfo(DeviceInfo.PLAYBACK_TYPE_REMOTE, 0, 1)
|
||||
return DEVICE_INFO
|
||||
}
|
||||
|
||||
override fun getPlayerError(): PlaybackException? {
|
||||
|
@ -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
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Service
|
||||
import android.view.Surface
|
||||
import android.view.SurfaceHolder
|
||||
import android.view.SurfaceView
|
||||
@ -26,7 +25,7 @@ import androidx.media3.common.Tracks
|
||||
*/
|
||||
@Suppress("TooManyFunctions")
|
||||
@SuppressLint("UnsafeOptInUsageError")
|
||||
abstract class JukeboxUnimplementedFunctions : Service(), Player {
|
||||
abstract class JukeboxUnimplementedFunctions : Player {
|
||||
|
||||
override fun setMediaItems(mediaItems: MutableList<MediaItem>) {
|
||||
TODO("Not yet implemented")
|
||||
@ -140,10 +139,6 @@ abstract class JukeboxUnimplementedFunctions : Service(), Player {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override fun release() {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override fun getCurrentTracks(): Tracks {
|
||||
// TODO Dummy information is returned for now, this seems to work
|
||||
return Tracks.EMPTY
|
||||
@ -228,20 +223,4 @@ abstract class JukeboxUnimplementedFunctions : Service(), Player {
|
||||
override fun clearVideoTextureView(textureView: TextureView?) {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override fun setDeviceVolume(volume: Int) {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override fun increaseDeviceVolume() {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override fun decreaseDeviceVolume() {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override fun setDeviceMuted(muted: Boolean) {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
}
|
||||
|
@ -30,8 +30,9 @@ import timber.log.Timber
|
||||
* This class is responsible for handling received events for the Media Player implementation
|
||||
*/
|
||||
class MediaPlayerLifecycleSupport : KoinComponent {
|
||||
private lateinit var ratingManager: RatingManager
|
||||
private val playbackStateSerializer by inject<PlaybackStateSerializer>()
|
||||
private val mediaPlayerController by inject<MediaPlayerController>()
|
||||
private val mediaPlayerManager by inject<MediaPlayerManager>()
|
||||
private val imageLoaderProvider: ImageLoaderProvider by inject()
|
||||
|
||||
private var created = false
|
||||
@ -63,7 +64,7 @@ class MediaPlayerLifecycleSupport : KoinComponent {
|
||||
return
|
||||
}
|
||||
|
||||
mediaPlayerController.onCreate {
|
||||
mediaPlayerManager.onCreate {
|
||||
restoreLastSession(autoPlay, afterRestore)
|
||||
}
|
||||
|
||||
@ -71,6 +72,7 @@ class MediaPlayerLifecycleSupport : KoinComponent {
|
||||
|
||||
CacheCleaner().clean()
|
||||
created = true
|
||||
ratingManager = RatingManager.instance
|
||||
Timber.i("LifecycleSupport created")
|
||||
}
|
||||
|
||||
@ -79,7 +81,7 @@ class MediaPlayerLifecycleSupport : KoinComponent {
|
||||
|
||||
Timber.i("Restoring %s songs", it!!.songs.size)
|
||||
|
||||
mediaPlayerController.restore(
|
||||
mediaPlayerManager.restore(
|
||||
it,
|
||||
autoPlay,
|
||||
false
|
||||
@ -108,7 +110,7 @@ class MediaPlayerLifecycleSupport : KoinComponent {
|
||||
if (intent == null) return
|
||||
|
||||
val intentAction = intent.action
|
||||
if (intentAction == null || intentAction.isEmpty()) return
|
||||
if (intentAction.isNullOrEmpty()) return
|
||||
|
||||
Timber.i("Received intent: %s", intentAction)
|
||||
|
||||
@ -144,15 +146,15 @@ class MediaPlayerLifecycleSupport : KoinComponent {
|
||||
val state = extras.getInt("state")
|
||||
|
||||
if (state == 0) {
|
||||
if (!mediaPlayerController.isJukeboxEnabled) {
|
||||
mediaPlayerController.pause()
|
||||
if (!mediaPlayerManager.isJukeboxEnabled) {
|
||||
mediaPlayerManager.pause()
|
||||
}
|
||||
} else if (state == 1) {
|
||||
if (!mediaPlayerController.isJukeboxEnabled &&
|
||||
Settings.resumePlayOnHeadphonePlug && !mediaPlayerController.isPlaying
|
||||
if (!mediaPlayerManager.isJukeboxEnabled &&
|
||||
Settings.resumePlayOnHeadphonePlug && !mediaPlayerManager.isPlaying
|
||||
) {
|
||||
mediaPlayerController.prepare()
|
||||
mediaPlayerController.play()
|
||||
mediaPlayerManager.prepare()
|
||||
mediaPlayerManager.play()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -181,18 +183,18 @@ class MediaPlayerLifecycleSupport : KoinComponent {
|
||||
onCreate(autoStart) {
|
||||
when (keyCode) {
|
||||
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE,
|
||||
KeyEvent.KEYCODE_HEADSETHOOK -> mediaPlayerController.togglePlayPause()
|
||||
KeyEvent.KEYCODE_MEDIA_PREVIOUS -> mediaPlayerController.previous()
|
||||
KeyEvent.KEYCODE_MEDIA_NEXT -> mediaPlayerController.next()
|
||||
KeyEvent.KEYCODE_MEDIA_STOP -> mediaPlayerController.stop()
|
||||
KeyEvent.KEYCODE_MEDIA_PLAY -> mediaPlayerController.play()
|
||||
KeyEvent.KEYCODE_MEDIA_PAUSE -> mediaPlayerController.pause()
|
||||
KeyEvent.KEYCODE_1 -> mediaPlayerController.setSongRating(1)
|
||||
KeyEvent.KEYCODE_2 -> mediaPlayerController.setSongRating(2)
|
||||
KeyEvent.KEYCODE_3 -> mediaPlayerController.setSongRating(3)
|
||||
KeyEvent.KEYCODE_4 -> mediaPlayerController.setSongRating(4)
|
||||
KeyEvent.KEYCODE_5 -> mediaPlayerController.setSongRating(5)
|
||||
KeyEvent.KEYCODE_STAR -> mediaPlayerController.toggleSongStarred()
|
||||
KeyEvent.KEYCODE_HEADSETHOOK -> mediaPlayerManager.togglePlayPause()
|
||||
KeyEvent.KEYCODE_MEDIA_PREVIOUS -> mediaPlayerManager.seekToPrevious()
|
||||
KeyEvent.KEYCODE_MEDIA_NEXT -> mediaPlayerManager.seekToNext()
|
||||
KeyEvent.KEYCODE_MEDIA_STOP -> mediaPlayerManager.stop()
|
||||
KeyEvent.KEYCODE_MEDIA_PLAY -> mediaPlayerManager.play()
|
||||
KeyEvent.KEYCODE_MEDIA_PAUSE -> mediaPlayerManager.pause()
|
||||
KeyEvent.KEYCODE_1 -> mediaPlayerManager.legacySetRating(1)
|
||||
KeyEvent.KEYCODE_2 -> mediaPlayerManager.legacySetRating(2)
|
||||
KeyEvent.KEYCODE_3 -> mediaPlayerManager.legacySetRating(3)
|
||||
KeyEvent.KEYCODE_4 -> mediaPlayerManager.legacySetRating(4)
|
||||
KeyEvent.KEYCODE_5 -> mediaPlayerManager.legacySetRating(5)
|
||||
KeyEvent.KEYCODE_STAR -> mediaPlayerManager.legacyToggleStar()
|
||||
else -> {
|
||||
}
|
||||
}
|
||||
@ -220,17 +222,17 @@ class MediaPlayerLifecycleSupport : KoinComponent {
|
||||
// We can receive intents when everything is stopped, so we need to start
|
||||
onCreate(autoStart) {
|
||||
when (action) {
|
||||
Constants.CMD_PLAY -> mediaPlayerController.play()
|
||||
Constants.CMD_PLAY -> mediaPlayerManager.play()
|
||||
Constants.CMD_RESUME_OR_PLAY ->
|
||||
// If Ultrasonic wasn't running, the autoStart is enough to resume,
|
||||
// no need to call anything
|
||||
if (isRunning) mediaPlayerController.resumeOrPlay()
|
||||
if (isRunning) mediaPlayerManager.resumeOrPlay()
|
||||
|
||||
Constants.CMD_NEXT -> mediaPlayerController.next()
|
||||
Constants.CMD_PREVIOUS -> mediaPlayerController.previous()
|
||||
Constants.CMD_TOGGLEPAUSE -> mediaPlayerController.togglePlayPause()
|
||||
Constants.CMD_STOP -> mediaPlayerController.stop()
|
||||
Constants.CMD_PAUSE -> mediaPlayerController.pause()
|
||||
Constants.CMD_NEXT -> mediaPlayerManager.seekToNext()
|
||||
Constants.CMD_PREVIOUS -> mediaPlayerManager.seekToPrevious()
|
||||
Constants.CMD_TOGGLEPAUSE -> mediaPlayerManager.togglePlayPause()
|
||||
Constants.CMD_STOP -> mediaPlayerManager.stop()
|
||||
Constants.CMD_PAUSE -> mediaPlayerManager.pause()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -10,7 +10,7 @@ import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.widget.Toast
|
||||
import androidx.annotation.IntRange
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.HeartRating
|
||||
import androidx.media3.common.MediaItem
|
||||
@ -18,12 +18,11 @@ import androidx.media3.common.PlaybackException
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.Player.MEDIA_ITEM_TRANSITION_REASON_AUTO
|
||||
import androidx.media3.common.Player.REPEAT_MODE_OFF
|
||||
import androidx.media3.common.Rating
|
||||
import androidx.media3.common.StarRating
|
||||
import androidx.media3.common.Timeline
|
||||
import androidx.media3.session.MediaController
|
||||
import androidx.media3.session.SessionResult
|
||||
import androidx.media3.session.SessionToken
|
||||
import com.google.common.util.concurrent.FutureCallback
|
||||
import com.google.common.util.concurrent.Futures
|
||||
import com.google.common.util.concurrent.ListenableFuture
|
||||
import com.google.common.util.concurrent.MoreExecutors
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
@ -35,13 +34,12 @@ import org.koin.core.component.inject
|
||||
import org.moire.ultrasonic.app.UApp
|
||||
import org.moire.ultrasonic.data.ActiveServerProvider
|
||||
import org.moire.ultrasonic.data.ActiveServerProvider.Companion.OFFLINE_DB_ID
|
||||
import org.moire.ultrasonic.data.RatingUpdate
|
||||
import org.moire.ultrasonic.domain.Track
|
||||
import org.moire.ultrasonic.playback.PlaybackService
|
||||
import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService
|
||||
import org.moire.ultrasonic.util.MainThreadExecutor
|
||||
import org.moire.ultrasonic.util.Settings
|
||||
import org.moire.ultrasonic.util.Util
|
||||
import org.moire.ultrasonic.util.setPin
|
||||
import org.moire.ultrasonic.util.toMediaItem
|
||||
import org.moire.ultrasonic.util.toTrack
|
||||
import timber.log.Timber
|
||||
@ -50,16 +48,18 @@ private const val CONTROLLER_SWITCH_DELAY = 500L
|
||||
private const val VOLUME_DELTA = 0.05f
|
||||
|
||||
/**
|
||||
* The implementation of the Media Player Controller.
|
||||
* The Media Player Manager can forward commands to the Media3 controller as
|
||||
* well as switch between different player interfaces (local, remote, cast etc).
|
||||
* This class contains everything that is necessary for the Application UI
|
||||
* to control the Media Player implementation.
|
||||
*/
|
||||
@Suppress("TooManyFunctions")
|
||||
class MediaPlayerController(
|
||||
class MediaPlayerManager(
|
||||
private val playbackStateSerializer: PlaybackStateSerializer,
|
||||
private val externalStorageMonitor: ExternalStorageMonitor,
|
||||
val context: Context
|
||||
) : KoinComponent {
|
||||
|
||||
private val activeServerProvider: ActiveServerProvider by inject()
|
||||
|
||||
private var created = false
|
||||
@ -96,7 +96,15 @@ class MediaPlayerController(
|
||||
* We run the event through RxBus in order to throttle them
|
||||
*/
|
||||
override fun onTimelineChanged(timeline: Timeline, reason: Int) {
|
||||
RxBus.playlistPublisher.onNext(playlist.map(MediaItem::toTrack))
|
||||
val start = timeline.getFirstWindowIndex(isShufflePlayEnabled)
|
||||
Timber.w("On timeline changed. First shuffle play at index: %s", start)
|
||||
deferredPlay?.let {
|
||||
Timber.w("Executing deferred shuffle play")
|
||||
it()
|
||||
deferredPlay = null
|
||||
}
|
||||
val playlist = Util.getPlayListFromTimeline(timeline, false).map(MediaItem::toTrack)
|
||||
RxBus.playlistPublisher.onNext(playlist)
|
||||
}
|
||||
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
@ -150,29 +158,28 @@ class MediaPlayerController(
|
||||
|
||||
override fun onShuffleModeEnabledChanged(shuffleModeEnabled: Boolean) {
|
||||
val timeline: Timeline = controller!!.currentTimeline
|
||||
var windowIndex = timeline.getFirstWindowIndex( /* shuffleModeEnabled= */true)
|
||||
var windowIndex = timeline.getFirstWindowIndex(true)
|
||||
var count = 0
|
||||
Timber.d("Shuffle: windowIndex: $windowIndex, at: $count")
|
||||
while (windowIndex != C.INDEX_UNSET) {
|
||||
count++
|
||||
windowIndex = timeline.getNextWindowIndex(
|
||||
windowIndex, REPEAT_MODE_OFF, /* shuffleModeEnabled= */true
|
||||
windowIndex, REPEAT_MODE_OFF, true
|
||||
)
|
||||
Timber.d("Shuffle: windowIndex: $windowIndex, at: $count")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var deferredPlay: (() -> Unit)? = null
|
||||
|
||||
private var cachedMediaItem: MediaItem? = null
|
||||
|
||||
fun onCreate(onCreated: () -> Unit) {
|
||||
if (created) return
|
||||
externalStorageMonitor.onCreate { reset() }
|
||||
if (activeServerProvider.getActiveServer().jukeboxByDefault) {
|
||||
switchToJukebox(onCreated)
|
||||
} else {
|
||||
switchToLocalPlayer(onCreated)
|
||||
}
|
||||
|
||||
createMediaController(onCreated)
|
||||
|
||||
rxBusSubscription += RxBus.activeServerChangingObservable.subscribe { oldServer ->
|
||||
if (oldServer != OFFLINE_DB_ID) {
|
||||
@ -184,8 +191,7 @@ class MediaPlayerController(
|
||||
if (controller is JukeboxMediaPlayer) {
|
||||
// When the server changes, the Jukebox should be released.
|
||||
// The new server won't understand the jukebox requests of the old one.
|
||||
releaseJukebox(controller)
|
||||
controller = null
|
||||
switchToLocalPlayer()
|
||||
}
|
||||
}
|
||||
|
||||
@ -216,15 +222,41 @@ class MediaPlayerController(
|
||||
clear(false)
|
||||
onDestroy()
|
||||
}
|
||||
|
||||
rxBusSubscription += RxBus.stopServiceCommandObservable.subscribe {
|
||||
clear(false)
|
||||
onDestroy()
|
||||
}
|
||||
|
||||
rxBusSubscription += RxBus.ratingSubmitterObservable.subscribe {
|
||||
// Ensure correct thread
|
||||
mainScope.launch {
|
||||
// This deals only with the current track!
|
||||
if (it.id != currentMediaItem?.toTrack()?.id) return@launch
|
||||
setRating(it.rating)
|
||||
}
|
||||
}
|
||||
|
||||
created = true
|
||||
Timber.i("MediaPlayerController started")
|
||||
}
|
||||
|
||||
private fun createMediaController(onCreated: () -> Unit) {
|
||||
mediaControllerFuture = MediaController.Builder(
|
||||
context,
|
||||
sessionToken
|
||||
).buildAsync()
|
||||
|
||||
mediaControllerFuture?.addListener({
|
||||
controller = mediaControllerFuture?.get()
|
||||
|
||||
Timber.i("MediaController Instance received")
|
||||
controller?.addListener(listeners)
|
||||
onCreated()
|
||||
Timber.i("MediaPlayerController creation complete")
|
||||
}, MoreExecutors.directExecutor())
|
||||
}
|
||||
|
||||
private fun playerStateChangedHandler() {
|
||||
val currentPlaying = controller?.currentMediaItem?.toTrack() ?: return
|
||||
|
||||
@ -241,6 +273,10 @@ class MediaPlayerController(
|
||||
}
|
||||
}
|
||||
|
||||
fun addListener(listener: Player.Listener) {
|
||||
controller?.addListener(listener)
|
||||
}
|
||||
|
||||
private fun clearBookmark() {
|
||||
// This method is called just before we update the cachedMediaItem,
|
||||
// so in fact cachedMediaItem will refer to the track that has just finished.
|
||||
@ -259,7 +295,7 @@ class MediaPlayerController(
|
||||
private fun publishPlaybackState() {
|
||||
val newState = RxBus.StateWithTrack(
|
||||
track = currentMediaItem?.toTrack(),
|
||||
index = currentMediaItemIndex,
|
||||
index = if (isShufflePlayEnabled) getCurrentShuffleIndex() else currentMediaItemIndex,
|
||||
isPlaying = isPlaying,
|
||||
state = playbackState
|
||||
)
|
||||
@ -292,7 +328,6 @@ class MediaPlayerController(
|
||||
|
||||
addToPlaylist(
|
||||
state.songs,
|
||||
cachePermanently = false,
|
||||
autoPlay = false,
|
||||
shuffle = false,
|
||||
insertionMode = insertionMode
|
||||
@ -316,6 +351,7 @@ class MediaPlayerController(
|
||||
@Synchronized
|
||||
fun play(index: Int) {
|
||||
controller?.seekTo(index, 0L)
|
||||
controller?.prepare()
|
||||
controller?.play()
|
||||
}
|
||||
|
||||
@ -384,7 +420,6 @@ class MediaPlayerController(
|
||||
@Synchronized
|
||||
fun addToPlaylist(
|
||||
songs: List<Track>,
|
||||
cachePermanently: Boolean,
|
||||
autoPlay: Boolean,
|
||||
shuffle: Boolean,
|
||||
insertionMode: InsertionMode
|
||||
@ -399,11 +434,11 @@ class MediaPlayerController(
|
||||
|
||||
val mediaItems: List<MediaItem> = songs.map {
|
||||
val result = it.toMediaItem()
|
||||
if (cachePermanently) result.setPin(true)
|
||||
result
|
||||
}
|
||||
|
||||
if (shuffle) isShufflePlayEnabled = true
|
||||
Timber.w("Adding ${mediaItems.size} media items")
|
||||
controller?.addMediaItems(insertAt, mediaItems)
|
||||
|
||||
prepare()
|
||||
@ -411,24 +446,28 @@ class MediaPlayerController(
|
||||
// Playback doesn't start correctly when the player is in STATE_ENDED.
|
||||
// So we need to call seek before (this is what play(0,0)) does.
|
||||
// We can't just use play(0,0) then all random playlists will start with the first track.
|
||||
// This means that we need to generate the random first track ourselves.
|
||||
// Additionally the shuffle order becomes clear on after some time, so we need to wait for
|
||||
// the right event, and can start playback only then.
|
||||
if (autoPlay) {
|
||||
val start = controller?.currentTimeline?.getFirstWindowIndex(isShufflePlayEnabled) ?: 0
|
||||
play(start)
|
||||
if (isShufflePlayEnabled) {
|
||||
deferredPlay = {
|
||||
val start = controller?.currentTimeline
|
||||
?.getFirstWindowIndex(isShufflePlayEnabled) ?: 0
|
||||
Timber.i("Deferred shuffle play starting now at index: %s", start)
|
||||
play(start)
|
||||
}
|
||||
} else {
|
||||
play(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun downloadBackground(songs: List<Track?>?, save: Boolean) {
|
||||
if (songs == null) return
|
||||
val filteredSongs = songs.filterNotNull()
|
||||
DownloadService.download(filteredSongs, save)
|
||||
}
|
||||
|
||||
@set:Synchronized
|
||||
var isShufflePlayEnabled: Boolean
|
||||
get() = controller?.shuffleModeEnabled == true
|
||||
set(enabled) {
|
||||
Timber.i("Shuffle is now enabled: %s", enabled)
|
||||
RxBus.shufflePlayPublisher.onNext(enabled)
|
||||
controller?.shuffleModeEnabled = enabled
|
||||
}
|
||||
|
||||
@ -438,11 +477,17 @@ class MediaPlayerController(
|
||||
return isShufflePlayEnabled
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an estimate of the percentage in the current content up to which data is
|
||||
* buffered, or 0 if no estimate is available.
|
||||
*/
|
||||
@get:IntRange(from = 0, to = 100)
|
||||
val bufferedPercentage: Int
|
||||
get() = controller?.bufferedPercentage ?: 0
|
||||
|
||||
@Synchronized
|
||||
fun moveItemInPlaylist(oldPos: Int, newPos: Int) {
|
||||
// TODO: This currently does not care about shuffle position.
|
||||
controller?.moveMediaItem(oldPos, newPos)
|
||||
}
|
||||
|
||||
@ -501,31 +546,25 @@ class MediaPlayerController(
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
// TODO: Make it require not null
|
||||
fun delete(tracks: List<Track?>) {
|
||||
for (track in tracks.filterNotNull()) {
|
||||
DownloadService.delete(track)
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
// TODO: Make it require not null
|
||||
fun unpin(tracks: List<Track?>) {
|
||||
for (track in tracks.filterNotNull()) {
|
||||
DownloadService.unpin(track)
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun previous() {
|
||||
fun seekToPrevious() {
|
||||
controller?.seekToPrevious()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
operator fun next() {
|
||||
fun canSeekToPrevious(): Boolean {
|
||||
return controller?.isCommandAvailable(Player.COMMAND_SEEK_TO_PREVIOUS) == true
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun seekToNext() {
|
||||
controller?.seekToNext()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun canSeekToNext(): Boolean {
|
||||
return controller?.isCommandAvailable(Player.COMMAND_SEEK_TO_NEXT) == true
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun reset() {
|
||||
controller?.clearMediaItems()
|
||||
@ -555,102 +594,49 @@ class MediaPlayerController(
|
||||
|
||||
@set:Synchronized
|
||||
var isJukeboxEnabled: Boolean
|
||||
get() = controller is JukeboxMediaPlayer
|
||||
set(jukeboxEnabled) {
|
||||
if (jukeboxEnabled) {
|
||||
switchToJukebox {}
|
||||
get() = PlaybackService.actualBackend == PlayerBackend.JUKEBOX
|
||||
set(shouldEnable) {
|
||||
if (shouldEnable) {
|
||||
switchToJukebox()
|
||||
} else {
|
||||
switchToLocalPlayer {}
|
||||
switchToLocalPlayer()
|
||||
}
|
||||
}
|
||||
|
||||
private fun switchToJukebox(onCreated: () -> Unit) {
|
||||
if (controller is JukeboxMediaPlayer) return
|
||||
val currentPlaylist = playlist
|
||||
val currentIndex = controller?.currentMediaItemIndex ?: 0
|
||||
val currentPosition = controller?.currentPosition ?: 0
|
||||
private fun switchToJukebox() {
|
||||
if (isJukeboxEnabled) return
|
||||
scheduleSwitchTo(PlayerBackend.JUKEBOX)
|
||||
DownloadService.requestStop()
|
||||
controller?.pause()
|
||||
controller?.stop()
|
||||
val oldController = controller
|
||||
controller = null // While we switch, the controller shouldn't be available
|
||||
|
||||
// Stop() won't work if we don't give it time to be processed
|
||||
Handler(Looper.getMainLooper()).postDelayed({
|
||||
if (oldController != null) releaseLocalPlayer(oldController)
|
||||
setupJukebox {
|
||||
controller?.setMediaItems(currentPlaylist, currentIndex, currentPosition)
|
||||
onCreated()
|
||||
}
|
||||
}, CONTROLLER_SWITCH_DELAY)
|
||||
}
|
||||
|
||||
private fun switchToLocalPlayer(onCreated: () -> Unit) {
|
||||
if (controller is MediaController) return
|
||||
val currentPlaylist = playlist
|
||||
private fun switchToLocalPlayer() {
|
||||
if (!isJukeboxEnabled) return
|
||||
scheduleSwitchTo(PlayerBackend.LOCAL)
|
||||
controller?.stop()
|
||||
}
|
||||
|
||||
private fun scheduleSwitchTo(newBackend: PlayerBackend) {
|
||||
val currentPlaylist = playlist.toList()
|
||||
val currentIndex = controller?.currentMediaItemIndex ?: 0
|
||||
val currentPosition = controller?.currentPosition ?: 0
|
||||
controller?.stop()
|
||||
val oldController = controller
|
||||
controller = null // While we switch, the controller shouldn't be available
|
||||
|
||||
Handler(Looper.getMainLooper()).postDelayed({
|
||||
if (oldController != null) releaseJukebox(oldController)
|
||||
setupLocalPlayer {
|
||||
controller?.setMediaItems(currentPlaylist, currentIndex, currentPosition)
|
||||
onCreated()
|
||||
}
|
||||
// Change the backend
|
||||
PlaybackService.setBackend(newBackend)
|
||||
// Restore the media items
|
||||
controller?.setMediaItems(currentPlaylist, currentIndex, currentPosition)
|
||||
}, CONTROLLER_SWITCH_DELAY)
|
||||
}
|
||||
|
||||
private fun releaseController() {
|
||||
when (controller) {
|
||||
null -> return
|
||||
is JukeboxMediaPlayer -> releaseJukebox(controller)
|
||||
is MediaController -> releaseLocalPlayer(controller)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupLocalPlayer(onCreated: () -> Unit) {
|
||||
mediaControllerFuture = MediaController.Builder(
|
||||
context,
|
||||
sessionToken
|
||||
).buildAsync()
|
||||
|
||||
mediaControllerFuture?.addListener({
|
||||
controller = mediaControllerFuture?.get()
|
||||
|
||||
Timber.i("MediaController Instance received")
|
||||
controller?.addListener(listeners)
|
||||
onCreated()
|
||||
Timber.i("MediaPlayerController creation complete")
|
||||
}, MoreExecutors.directExecutor())
|
||||
}
|
||||
|
||||
private fun releaseLocalPlayer(player: Player?) {
|
||||
player?.removeListener(listeners)
|
||||
player?.release()
|
||||
controller?.removeListener(listeners)
|
||||
controller?.release()
|
||||
if (mediaControllerFuture != null) MediaController.releaseFuture(mediaControllerFuture!!)
|
||||
Timber.i("MediaPlayerController released")
|
||||
}
|
||||
|
||||
private fun setupJukebox(onCreated: () -> Unit) {
|
||||
val jukeboxFuture = JukeboxMediaPlayer.requestStart()
|
||||
jukeboxFuture?.addListener({
|
||||
controller = jukeboxFuture.get()
|
||||
onCreated()
|
||||
controller?.addListener(listeners)
|
||||
Timber.i("JukeboxService creation complete")
|
||||
}, MoreExecutors.directExecutor())
|
||||
}
|
||||
|
||||
private fun releaseJukebox(player: Player?) {
|
||||
val jukebox = player as JukeboxMediaPlayer?
|
||||
jukebox?.removeListener(listeners)
|
||||
jukebox?.requestStop()
|
||||
Timber.i("JukeboxService released")
|
||||
}
|
||||
|
||||
/**
|
||||
* This function calls the music service directly and
|
||||
* therefore can't be called from the main thread
|
||||
@ -675,56 +661,49 @@ class MediaPlayerController(
|
||||
controller?.volume = gain
|
||||
}
|
||||
|
||||
fun setVolume(volume: Float) {
|
||||
controller?.volume = volume
|
||||
}
|
||||
|
||||
fun toggleSongStarred(): ListenableFuture<SessionResult>? {
|
||||
if (currentMediaItem == null) return null
|
||||
val song = currentMediaItem!!.toTrack()
|
||||
|
||||
return (controller as? MediaController)?.setRating(
|
||||
HeartRating(!song.starred)
|
||||
)?.let {
|
||||
Futures.addCallback(
|
||||
it,
|
||||
object : FutureCallback<SessionResult> {
|
||||
override fun onSuccess(result: SessionResult?) {
|
||||
// Trigger an update
|
||||
// TODO Update Metadata of MediaItem...
|
||||
// localMediaPlayer.setCurrentPlaying(localMediaPlayer.currentPlaying)
|
||||
song.starred = !song.starred
|
||||
}
|
||||
|
||||
override fun onFailure(t: Throwable) {
|
||||
Toast.makeText(
|
||||
context,
|
||||
"There was an error updating the rating",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
},
|
||||
MainThreadExecutor()
|
||||
)
|
||||
it
|
||||
/*
|
||||
* Sets the rating of the current track
|
||||
*/
|
||||
fun setRating(rating: Rating) {
|
||||
if (controller is MediaController) {
|
||||
(controller as MediaController).setRating(rating)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("TooGenericExceptionCaught") // The interface throws only generic exceptions
|
||||
fun setSongRating(rating: Int) {
|
||||
if (!Settings.useFiveStarRating) return
|
||||
/*
|
||||
* This legacy function simply emits a rating update,
|
||||
* which will then be processed by both the RatingManager as well as the controller
|
||||
*/
|
||||
fun legacyToggleStar() {
|
||||
if (currentMediaItem == null) return
|
||||
val song = currentMediaItem!!.toTrack()
|
||||
song.userRating = rating
|
||||
Thread {
|
||||
try {
|
||||
getMusicService().setRating(song.id, rating)
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e)
|
||||
}
|
||||
}.start()
|
||||
// TODO this would be better handled with a Rx command
|
||||
// updateNotification()
|
||||
val track = currentMediaItem!!.toTrack()
|
||||
track.starred = !track.starred
|
||||
val rating = HeartRating(track.starred)
|
||||
|
||||
RxBus.ratingSubmitter.onNext(
|
||||
RatingUpdate(
|
||||
track.id,
|
||||
rating
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/*
|
||||
* This legacy function simply emits a rating update,
|
||||
* which will then be processed by both the RatingManager as well as the controller
|
||||
*/
|
||||
fun legacySetRating(num: Int) {
|
||||
if (currentMediaItem == null) return
|
||||
val track = currentMediaItem!!.toTrack()
|
||||
track.userRating = num
|
||||
val rating = StarRating(5, num.toFloat())
|
||||
|
||||
RxBus.ratingSubmitter.onNext(
|
||||
RatingUpdate(
|
||||
track.id,
|
||||
rating
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val currentMediaItem: MediaItem?
|
||||
@ -733,9 +712,64 @@ class MediaPlayerController(
|
||||
val currentMediaItemIndex: Int
|
||||
get() = controller?.currentMediaItemIndex ?: -1
|
||||
|
||||
fun getCurrentShuffleIndex(): Int {
|
||||
val currentMediaItemIndex = controller?.currentMediaItemIndex ?: return -1
|
||||
return getShuffledIndexOf(currentMediaItemIndex)
|
||||
}
|
||||
|
||||
/**
|
||||
* Loops over the timeline windows to find the entry which matches the given closure.
|
||||
*
|
||||
* @param searchClosure Determines the condition which the searched for window needs to match.
|
||||
* @return the index of the window that satisfies the search condition,
|
||||
* or [C.INDEX_UNSET] if not found.
|
||||
*/
|
||||
private fun getWindowIndexWhere(searchClosure: (Int, Int) -> Boolean): Int {
|
||||
val timeline = controller?.currentTimeline!!
|
||||
var windowIndex = timeline.getFirstWindowIndex(true)
|
||||
var count = 0
|
||||
while (windowIndex != C.INDEX_UNSET) {
|
||||
if (searchClosure(count, windowIndex)) return count
|
||||
count++
|
||||
windowIndex = timeline.getNextWindowIndex(
|
||||
windowIndex, REPEAT_MODE_OFF, true
|
||||
)
|
||||
}
|
||||
|
||||
return C.INDEX_UNSET
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the index of the shuffled position of the current playback item given its original
|
||||
* position in the unshuffled timeline.
|
||||
*
|
||||
* @param searchPosition The index of the item in the unshuffled timeline to search for
|
||||
* in the shuffled timeline.
|
||||
* @return The index of the item in the shuffled timeline, or [C.INDEX_UNSET] if not found.
|
||||
*/
|
||||
fun getShuffledIndexOf(searchPosition: Int): Int {
|
||||
return getWindowIndexWhere { _, windowIndex -> windowIndex == searchPosition }
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the index of the unshuffled position of the current playback item given its shuffled
|
||||
* position in the shuffled timeline.
|
||||
*
|
||||
* @param shufflePosition the index of the item in the shuffled timeline to search for in the
|
||||
* unshuffled timeline.
|
||||
* @return the index of the item in the unshuffled timeline, or [C.INDEX_UNSET] if not found.
|
||||
*/
|
||||
fun getUnshuffledIndexOf(shufflePosition: Int): Int {
|
||||
return getWindowIndexWhere { count, _ -> count == shufflePosition }
|
||||
}
|
||||
|
||||
val mediaItemCount: Int
|
||||
get() = controller?.mediaItemCount ?: 0
|
||||
|
||||
fun getMediaItemAt(index: Int): MediaItem? {
|
||||
return controller?.getMediaItemAt(index)
|
||||
}
|
||||
|
||||
val playlistSize: Int
|
||||
get() = controller?.currentTimeline?.windowCount ?: 0
|
||||
|
||||
@ -744,10 +778,6 @@ class MediaPlayerController(
|
||||
return Util.getPlayListFromTimeline(controller?.currentTimeline, false)
|
||||
}
|
||||
|
||||
fun getMediaItemAt(index: Int): MediaItem? {
|
||||
return controller?.getMediaItemAt(index)
|
||||
}
|
||||
|
||||
val playlistInPlayOrder: List<MediaItem>
|
||||
get() {
|
||||
return Util.getPlayListFromTimeline(
|
||||
@ -768,4 +798,6 @@ class MediaPlayerController(
|
||||
enum class InsertionMode {
|
||||
CLEAR, APPEND, AFTER_CURRENT
|
||||
}
|
||||
|
||||
enum class PlayerBackend { JUKEBOX, LOCAL }
|
||||
}
|
@ -39,10 +39,10 @@ interface MusicService {
|
||||
fun getGenres(refresh: Boolean): List<Genre>
|
||||
|
||||
@Throws(Exception::class)
|
||||
fun star(id: String?, albumId: String?, artistId: String?)
|
||||
fun star(id: String?, albumId: String? = null, artistId: String? = null)
|
||||
|
||||
@Throws(Exception::class)
|
||||
fun unstar(id: String?, albumId: String?, artistId: String?)
|
||||
fun unstar(id: String?, albumId: String? = null, artistId: String? = null)
|
||||
|
||||
@Throws(Exception::class)
|
||||
fun setRating(id: String, rating: Int)
|
||||
|
@ -18,7 +18,7 @@ import org.moire.ultrasonic.api.subsonic.models.JukeboxAction
|
||||
import org.moire.ultrasonic.api.subsonic.throwOnFailure
|
||||
import org.moire.ultrasonic.api.subsonic.toStreamResponse
|
||||
import org.moire.ultrasonic.data.ActiveServerProvider
|
||||
import org.moire.ultrasonic.data.ActiveServerProvider.Companion.isOffline
|
||||
import org.moire.ultrasonic.data.ActiveServerProvider.Companion.shouldUseId3Tags
|
||||
import org.moire.ultrasonic.domain.Album
|
||||
import org.moire.ultrasonic.domain.Artist
|
||||
import org.moire.ultrasonic.domain.Bookmark
|
||||
@ -44,7 +44,6 @@ import org.moire.ultrasonic.domain.toIndexList
|
||||
import org.moire.ultrasonic.domain.toMusicDirectoryDomainEntity
|
||||
import org.moire.ultrasonic.domain.toTrackEntity
|
||||
import org.moire.ultrasonic.util.FileUtil
|
||||
import org.moire.ultrasonic.util.Settings
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
@ -181,7 +180,7 @@ open class RESTMusicService(
|
||||
criteria: SearchCriteria
|
||||
): SearchResult {
|
||||
return try {
|
||||
if (!isOffline() && Settings.shouldUseId3Tags) {
|
||||
if (shouldUseId3Tags()) {
|
||||
search3(criteria)
|
||||
} else {
|
||||
search2(criteria)
|
||||
|
@ -0,0 +1,87 @@
|
||||
/*
|
||||
* RatingManager.kt
|
||||
* Copyright (C) 2009-2023 Ultrasonic developers
|
||||
*
|
||||
* Distributed under terms of the GNU GPLv3 license.
|
||||
*/
|
||||
|
||||
package org.moire.ultrasonic.service
|
||||
|
||||
import androidx.media3.common.HeartRating
|
||||
import androidx.media3.common.StarRating
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.moire.ultrasonic.data.RatingUpdate
|
||||
import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService
|
||||
import timber.log.Timber
|
||||
|
||||
/*
|
||||
* This class subscribes to RatingEvents and submits them to the server.
|
||||
* In the future it could be extended to store the ratings when offline
|
||||
* and submit them when back online.
|
||||
* Only the manager should publish RatingSubmitted events
|
||||
*/
|
||||
class RatingManager : CoroutineScope by CoroutineScope(Dispatchers.Default) {
|
||||
private val rxBusSubscription: CompositeDisposable = CompositeDisposable()
|
||||
|
||||
var lastUpdate: RatingUpdate? = null
|
||||
|
||||
init {
|
||||
rxBusSubscription += RxBus.ratingSubmitterObservable.subscribe {
|
||||
submitRating(it)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun submitRating(update: RatingUpdate) {
|
||||
// Don't submit the same rating twice
|
||||
if (update.id == lastUpdate?.id && update.rating == lastUpdate?.rating) return
|
||||
|
||||
val service = getMusicService()
|
||||
val id = update.id
|
||||
|
||||
Timber.i("Submitting rating to server: ${update.rating} for $id")
|
||||
|
||||
if (update.rating is HeartRating) {
|
||||
launch {
|
||||
var success = false
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
if (update.rating.isHeart) service.star(id)
|
||||
else service.unstar(id)
|
||||
success = true
|
||||
} catch (all: Exception) {
|
||||
Timber.e(all)
|
||||
}
|
||||
}
|
||||
RxBus.ratingPublished.onNext(
|
||||
update.copy(success = success)
|
||||
)
|
||||
}
|
||||
} else if (update.rating is StarRating) {
|
||||
launch {
|
||||
var success = false
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
getMusicService().setRating(id, update.rating.starRating.toInt())
|
||||
success = true
|
||||
} catch (all: Exception) {
|
||||
Timber.e(all)
|
||||
}
|
||||
}
|
||||
RxBus.ratingPublished.onNext(
|
||||
update.copy(success = success)
|
||||
)
|
||||
}
|
||||
}
|
||||
lastUpdate = update
|
||||
}
|
||||
|
||||
companion object {
|
||||
val instance: RatingManager by lazy {
|
||||
RatingManager()
|
||||
}
|
||||
}
|
||||
}
|
@ -7,6 +7,7 @@ import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import io.reactivex.rxjava3.disposables.Disposable
|
||||
import io.reactivex.rxjava3.subjects.PublishSubject
|
||||
import java.util.concurrent.TimeUnit
|
||||
import org.moire.ultrasonic.data.RatingUpdate
|
||||
import org.moire.ultrasonic.domain.Track
|
||||
|
||||
class RxBus {
|
||||
@ -20,9 +21,13 @@ class RxBus {
|
||||
|
||||
private fun mainThread() = AndroidSchedulers.from(Looper.getMainLooper())
|
||||
|
||||
val shufflePlayPublisher: PublishSubject<Boolean> =
|
||||
PublishSubject.create()
|
||||
val shufflePlayObservable: Observable<Boolean> =
|
||||
shufflePlayPublisher
|
||||
|
||||
var activeServerChangingPublisher: PublishSubject<Int> =
|
||||
PublishSubject.create()
|
||||
|
||||
// Subscribers should be called synchronously, not on another thread
|
||||
var activeServerChangingObservable: Observable<Int> =
|
||||
activeServerChangingPublisher
|
||||
@ -71,6 +76,18 @@ class RxBus {
|
||||
val trackDownloadStateObservable: Observable<TrackDownloadState> =
|
||||
trackDownloadStatePublisher.observeOn(mainThread())
|
||||
|
||||
// Sends a RatingUpdate which was just triggered by the user
|
||||
val ratingSubmitter: PublishSubject<RatingUpdate> =
|
||||
PublishSubject.create()
|
||||
val ratingSubmitterObservable: Observable<RatingUpdate> =
|
||||
ratingSubmitter
|
||||
|
||||
// Sends a RatingUpdate which was successfully submitted to the server or database
|
||||
val ratingPublished: PublishSubject<RatingUpdate> =
|
||||
PublishSubject.create()
|
||||
val ratingPublishedObservable: Observable<RatingUpdate> =
|
||||
ratingPublished
|
||||
|
||||
// Commands
|
||||
val dismissNowPlayingCommandPublisher: PublishSubject<Unit> =
|
||||
PublishSubject.create()
|
||||
|
@ -9,294 +9,165 @@ package org.moire.ultrasonic.subsonic
|
||||
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import java.util.Collections
|
||||
import java.util.LinkedList
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.moire.ultrasonic.R
|
||||
import org.moire.ultrasonic.data.ActiveServerProvider.Companion.isOffline
|
||||
import org.moire.ultrasonic.data.ActiveServerProvider.Companion.shouldUseId3Tags
|
||||
import org.moire.ultrasonic.domain.MusicDirectory
|
||||
import org.moire.ultrasonic.domain.Track
|
||||
import org.moire.ultrasonic.service.MediaPlayerController
|
||||
import org.moire.ultrasonic.service.DownloadService
|
||||
import org.moire.ultrasonic.service.MediaPlayerManager
|
||||
import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService
|
||||
import org.moire.ultrasonic.util.CommunicationError
|
||||
import org.moire.ultrasonic.util.EntryByDiscAndTrackComparator
|
||||
import org.moire.ultrasonic.util.InfoDialog
|
||||
import org.moire.ultrasonic.util.Settings
|
||||
import org.moire.ultrasonic.util.Util
|
||||
import org.moire.ultrasonic.util.executeTaskWithToast
|
||||
|
||||
/**
|
||||
* Retrieves a list of songs and adds them to the now playing list
|
||||
*/
|
||||
@Suppress("LongParameterList")
|
||||
class DownloadHandler(
|
||||
val mediaPlayerController: MediaPlayerController,
|
||||
val networkAndStorageChecker: NetworkAndStorageChecker
|
||||
val mediaPlayerManager: MediaPlayerManager,
|
||||
private val networkAndStorageChecker: NetworkAndStorageChecker
|
||||
) : CoroutineScope by CoroutineScope(Dispatchers.IO) {
|
||||
private val maxSongs = 500
|
||||
|
||||
fun download(
|
||||
fun justDownload(
|
||||
action: DownloadAction,
|
||||
fragment: Fragment,
|
||||
append: Boolean,
|
||||
save: Boolean,
|
||||
autoPlay: Boolean,
|
||||
playNext: Boolean,
|
||||
shuffle: Boolean,
|
||||
songs: List<Track>,
|
||||
playlistName: String?,
|
||||
id: String? = null,
|
||||
name: String? = "",
|
||||
isShare: Boolean = false,
|
||||
isDirectory: Boolean = true,
|
||||
isArtist: Boolean = false,
|
||||
tracks: List<Track>? = null
|
||||
) {
|
||||
val onValid = Runnable {
|
||||
// TODO: The logic here is different than in the controller...
|
||||
val insertionMode = when {
|
||||
playNext -> MediaPlayerController.InsertionMode.AFTER_CURRENT
|
||||
append -> MediaPlayerController.InsertionMode.APPEND
|
||||
else -> MediaPlayerController.InsertionMode.CLEAR
|
||||
}
|
||||
var successString: String? = null
|
||||
|
||||
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
|
||||
mediaPlayerController.addToPlaylist(
|
||||
songs,
|
||||
save,
|
||||
autoPlay,
|
||||
shuffle,
|
||||
insertionMode
|
||||
)
|
||||
|
||||
if (playlistName != null) {
|
||||
mediaPlayerController.suggestedPlaylistName = playlistName
|
||||
}
|
||||
if (autoPlay) {
|
||||
if (Settings.shouldTransitionOnPlayback) {
|
||||
fragment.findNavController().popBackStack(R.id.playerFragment, true)
|
||||
fragment.findNavController().navigate(R.id.playerFragment)
|
||||
}
|
||||
} else if (save) {
|
||||
Util.toast(
|
||||
fragment.context,
|
||||
fragment.resources.getQuantityString(
|
||||
R.plurals.select_album_n_songs_pinned,
|
||||
songs.size,
|
||||
songs.size
|
||||
)
|
||||
)
|
||||
} else if (playNext) {
|
||||
Util.toast(
|
||||
fragment.context,
|
||||
fragment.resources.getQuantityString(
|
||||
R.plurals.select_album_n_songs_play_next,
|
||||
songs.size,
|
||||
songs.size
|
||||
)
|
||||
)
|
||||
} else if (append) {
|
||||
Util.toast(
|
||||
fragment.context,
|
||||
fragment.resources.getQuantityString(
|
||||
R.plurals.select_album_n_songs_added,
|
||||
songs.size,
|
||||
songs.size
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
onValid.run()
|
||||
}
|
||||
|
||||
fun downloadPlaylist(
|
||||
fragment: Fragment,
|
||||
id: String,
|
||||
name: String?,
|
||||
save: Boolean,
|
||||
append: Boolean,
|
||||
autoplay: Boolean,
|
||||
shuffle: Boolean,
|
||||
background: Boolean,
|
||||
playNext: Boolean,
|
||||
unpin: Boolean
|
||||
) {
|
||||
downloadRecursively(
|
||||
fragment,
|
||||
id,
|
||||
name,
|
||||
isShare = false,
|
||||
isDirectory = false,
|
||||
save = save,
|
||||
append = append,
|
||||
autoPlay = autoplay,
|
||||
shuffle = shuffle,
|
||||
background = background,
|
||||
playNext = playNext,
|
||||
unpin = unpin,
|
||||
isArtist = false
|
||||
)
|
||||
}
|
||||
|
||||
fun downloadShare(
|
||||
fragment: Fragment,
|
||||
id: String,
|
||||
name: String?,
|
||||
save: Boolean,
|
||||
append: Boolean,
|
||||
autoplay: Boolean,
|
||||
shuffle: Boolean,
|
||||
background: Boolean,
|
||||
playNext: Boolean,
|
||||
unpin: Boolean
|
||||
) {
|
||||
downloadRecursively(
|
||||
fragment,
|
||||
id,
|
||||
name,
|
||||
isShare = true,
|
||||
isDirectory = false,
|
||||
save = save,
|
||||
append = append,
|
||||
autoPlay = autoplay,
|
||||
shuffle = shuffle,
|
||||
background = background,
|
||||
playNext = playNext,
|
||||
unpin = unpin,
|
||||
isArtist = false
|
||||
)
|
||||
}
|
||||
|
||||
fun downloadRecursively(
|
||||
fragment: Fragment,
|
||||
id: String?,
|
||||
save: Boolean,
|
||||
append: Boolean,
|
||||
autoPlay: Boolean,
|
||||
shuffle: Boolean,
|
||||
background: Boolean,
|
||||
playNext: Boolean,
|
||||
unpin: Boolean,
|
||||
isArtist: Boolean
|
||||
) {
|
||||
if (id.isNullOrEmpty()) return
|
||||
downloadRecursively(
|
||||
fragment,
|
||||
id,
|
||||
"",
|
||||
isShare = false,
|
||||
isDirectory = true,
|
||||
save = save,
|
||||
append = append,
|
||||
autoPlay = autoPlay,
|
||||
shuffle = shuffle,
|
||||
background = background,
|
||||
playNext = playNext,
|
||||
unpin = unpin,
|
||||
isArtist = isArtist
|
||||
)
|
||||
}
|
||||
|
||||
private fun downloadRecursively(
|
||||
fragment: Fragment,
|
||||
id: String,
|
||||
name: String?,
|
||||
isShare: Boolean,
|
||||
isDirectory: Boolean,
|
||||
save: Boolean,
|
||||
append: Boolean,
|
||||
autoPlay: Boolean,
|
||||
shuffle: Boolean,
|
||||
background: Boolean,
|
||||
playNext: Boolean,
|
||||
unpin: Boolean,
|
||||
isArtist: Boolean
|
||||
) {
|
||||
// Launch the Job
|
||||
val job = launch {
|
||||
executeTaskWithToast(fragment, {
|
||||
val tracksToDownload: List<Track> = tracks
|
||||
?: getTracksFromServer(isArtist, id!!, isDirectory, name, isShare)
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
// If we are just downloading tracks we don't need to add them to the controller
|
||||
when (action) {
|
||||
DownloadAction.DOWNLOAD -> DownloadService.download(tracksToDownload, false)
|
||||
DownloadAction.PIN -> DownloadService.download(tracksToDownload, true)
|
||||
DownloadAction.UNPIN -> DownloadService.unpin(tracksToDownload)
|
||||
DownloadAction.DELETE -> DownloadService.delete(tracksToDownload)
|
||||
}
|
||||
successString = when (action) {
|
||||
DownloadAction.DOWNLOAD -> fragment.resources.getQuantityString(
|
||||
R.plurals.select_album_n_songs_downloaded,
|
||||
tracksToDownload.size,
|
||||
tracksToDownload.size
|
||||
)
|
||||
DownloadAction.UNPIN -> {
|
||||
fragment.resources.getQuantityString(
|
||||
R.plurals.select_album_n_songs_unpinned,
|
||||
tracksToDownload.size,
|
||||
tracksToDownload.size
|
||||
)
|
||||
}
|
||||
DownloadAction.PIN -> {
|
||||
fragment.resources.getQuantityString(
|
||||
R.plurals.select_album_n_songs_pinned,
|
||||
tracksToDownload.size,
|
||||
tracksToDownload.size
|
||||
)
|
||||
}
|
||||
DownloadAction.DELETE -> {
|
||||
fragment.resources.getQuantityString(
|
||||
R.plurals.select_album_n_songs_deleted,
|
||||
tracksToDownload.size,
|
||||
tracksToDownload.size
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}) { successString }
|
||||
}
|
||||
|
||||
fun fetchTracksAndAddToController(
|
||||
fragment: Fragment,
|
||||
id: String,
|
||||
name: String? = "",
|
||||
isShare: Boolean = false,
|
||||
isDirectory: Boolean = true,
|
||||
append: Boolean,
|
||||
autoPlay: Boolean,
|
||||
shuffle: Boolean,
|
||||
playNext: Boolean,
|
||||
isArtist: Boolean = false
|
||||
) {
|
||||
var successString: String? = null
|
||||
// Launch the Job
|
||||
executeTaskWithToast(fragment, {
|
||||
val songs: MutableList<Track> =
|
||||
getTracksFromServer(isArtist, id, isDirectory, name, isShare)
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
addTracksToMediaController(
|
||||
songs,
|
||||
background,
|
||||
unpin,
|
||||
append,
|
||||
playNext,
|
||||
save,
|
||||
autoPlay,
|
||||
shuffle,
|
||||
fragment
|
||||
songs = songs,
|
||||
append = append,
|
||||
playNext = playNext,
|
||||
autoPlay = autoPlay,
|
||||
shuffle = shuffle,
|
||||
playlistName = null,
|
||||
fragment = fragment
|
||||
)
|
||||
// Play Now doesn't get a Toast :)
|
||||
if (playNext) {
|
||||
successString = fragment.resources.getQuantityString(
|
||||
R.plurals.select_album_n_songs_play_next,
|
||||
songs.size,
|
||||
songs.size
|
||||
)
|
||||
} else if (append) {
|
||||
successString = fragment.resources.getQuantityString(
|
||||
R.plurals.select_album_n_songs_added,
|
||||
songs.size,
|
||||
songs.size
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create the dialog
|
||||
val builder = InfoDialog.Builder(fragment.requireContext())
|
||||
builder.setTitle(R.string.background_task_wait)
|
||||
builder.setMessage(R.string.background_task_loading)
|
||||
builder.setOnCancelListener { job.cancel() }
|
||||
builder.setPositiveButton(R.string.common_cancel) { _, i -> job.cancel() }
|
||||
val dialog = builder.create()
|
||||
dialog.show()
|
||||
|
||||
job.invokeOnCompletion {
|
||||
dialog.dismiss()
|
||||
if (it != null && it !is CancellationException) {
|
||||
Util.toast(
|
||||
fragment.requireContext(),
|
||||
CommunicationError.getErrorMessage(it, fragment.requireContext())
|
||||
)
|
||||
}
|
||||
}
|
||||
}) { successString }
|
||||
}
|
||||
|
||||
private fun addTracksToMediaController(
|
||||
songs: MutableList<Track>,
|
||||
background: Boolean,
|
||||
unpin: Boolean,
|
||||
fun addTracksToMediaController(
|
||||
songs: List<Track>,
|
||||
append: Boolean,
|
||||
playNext: Boolean,
|
||||
save: Boolean,
|
||||
autoPlay: Boolean,
|
||||
shuffle: Boolean,
|
||||
playlistName: String? = null,
|
||||
fragment: Fragment
|
||||
) {
|
||||
if (songs.isEmpty()) return
|
||||
if (Settings.shouldSortByDisc) {
|
||||
Collections.sort(songs, EntryByDiscAndTrackComparator())
|
||||
}
|
||||
|
||||
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
|
||||
if (!background) {
|
||||
if (unpin) {
|
||||
mediaPlayerController.unpin(songs)
|
||||
} else {
|
||||
val insertionMode = when {
|
||||
append -> MediaPlayerController.InsertionMode.APPEND
|
||||
playNext -> MediaPlayerController.InsertionMode.AFTER_CURRENT
|
||||
else -> MediaPlayerController.InsertionMode.CLEAR
|
||||
}
|
||||
mediaPlayerController.addToPlaylist(
|
||||
songs,
|
||||
save,
|
||||
autoPlay,
|
||||
shuffle,
|
||||
insertionMode
|
||||
)
|
||||
if (
|
||||
!append &&
|
||||
Settings.shouldTransitionOnPlayback
|
||||
) {
|
||||
fragment.findNavController().popBackStack(
|
||||
R.id.playerFragment,
|
||||
true
|
||||
)
|
||||
fragment.findNavController().navigate(R.id.playerFragment)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (unpin) {
|
||||
mediaPlayerController.unpin(songs)
|
||||
} else {
|
||||
mediaPlayerController.downloadBackground(songs, save)
|
||||
}
|
||||
|
||||
val insertionMode = when {
|
||||
append -> MediaPlayerManager.InsertionMode.APPEND
|
||||
playNext -> MediaPlayerManager.InsertionMode.AFTER_CURRENT
|
||||
else -> MediaPlayerManager.InsertionMode.CLEAR
|
||||
}
|
||||
|
||||
if (playlistName != null) {
|
||||
mediaPlayerManager.suggestedPlaylistName = playlistName
|
||||
}
|
||||
|
||||
mediaPlayerManager.addToPlaylist(
|
||||
songs,
|
||||
autoPlay,
|
||||
shuffle,
|
||||
insertionMode
|
||||
)
|
||||
if (Settings.shouldTransitionOnPlayback && (!append || autoPlay)) {
|
||||
fragment.findNavController().popBackStack(R.id.playerFragment, true)
|
||||
fragment.findNavController().navigate(R.id.playerFragment)
|
||||
}
|
||||
}
|
||||
|
||||
@ -310,11 +181,11 @@ class DownloadHandler(
|
||||
val musicService = getMusicService()
|
||||
val songs: MutableList<Track> = LinkedList()
|
||||
val root: MusicDirectory
|
||||
if (!isOffline() && isArtist && Settings.shouldUseId3Tags) {
|
||||
getSongsForArtist(id, songs)
|
||||
if (shouldUseId3Tags() && isArtist) {
|
||||
return getSongsForArtist(id)
|
||||
} else {
|
||||
if (isDirectory) {
|
||||
root = if (!isOffline() && Settings.shouldUseId3Tags)
|
||||
root = if (shouldUseId3Tags())
|
||||
musicService.getAlbumAsDir(id, name, false)
|
||||
else
|
||||
musicService.getMusicDirectory(id, name, false)
|
||||
@ -348,23 +219,19 @@ class DownloadHandler(
|
||||
}
|
||||
val musicService = getMusicService()
|
||||
for ((id1, _, _, title) in parent.getAlbums()) {
|
||||
val root: MusicDirectory = if (
|
||||
!isOffline() &&
|
||||
Settings.shouldUseId3Tags
|
||||
) musicService.getAlbumAsDir(id1, title, false)
|
||||
else musicService.getMusicDirectory(id1, title, false)
|
||||
val root: MusicDirectory = if (shouldUseId3Tags())
|
||||
musicService.getAlbumAsDir(id1, title, false)
|
||||
else
|
||||
musicService.getMusicDirectory(id1, title, false)
|
||||
getSongsRecursively(root, songs)
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(Exception::class)
|
||||
private fun getSongsForArtist(
|
||||
id: String,
|
||||
songs: MutableCollection<Track>
|
||||
) {
|
||||
if (songs.size > maxSongs) {
|
||||
return
|
||||
}
|
||||
id: String
|
||||
): MutableList<Track> {
|
||||
val songs: MutableList<Track> = LinkedList()
|
||||
val musicService = getMusicService()
|
||||
val artist = musicService.getAlbumsOfArtist(id, "", false)
|
||||
for ((id1) in artist) {
|
||||
@ -379,5 +246,10 @@ class DownloadHandler(
|
||||
}
|
||||
}
|
||||
}
|
||||
return songs
|
||||
}
|
||||
}
|
||||
|
||||
enum class DownloadAction {
|
||||
DOWNLOAD, PIN, UNPIN, DELETE
|
||||
}
|
||||
|
@ -33,7 +33,7 @@ ImageLoaderProvider(val context: Context) :
|
||||
}
|
||||
|
||||
init {
|
||||
Timber.e("Prepping Loader")
|
||||
Timber.d("Prepping Loader")
|
||||
// Populate the ImageLoader async & early
|
||||
launch {
|
||||
getImageLoader()
|
||||
|
@ -19,7 +19,7 @@ import org.koin.java.KoinJavaComponent.inject
|
||||
import org.moire.ultrasonic.data.ActiveServerProvider
|
||||
import org.moire.ultrasonic.domain.Playlist
|
||||
import org.moire.ultrasonic.domain.Track
|
||||
import org.moire.ultrasonic.service.MediaPlayerController
|
||||
import org.moire.ultrasonic.service.MediaPlayerManager
|
||||
import org.moire.ultrasonic.util.FileUtil.getAlbumArtFile
|
||||
import org.moire.ultrasonic.util.FileUtil.getCompleteFile
|
||||
import org.moire.ultrasonic.util.FileUtil.getPartialFile
|
||||
@ -235,8 +235,8 @@ class CacheCleaner : CoroutineScope by CoroutineScope(Dispatchers.IO), KoinCompo
|
||||
|
||||
private fun findFilesToNotDelete(): Set<String> {
|
||||
val filesToNotDelete: MutableSet<String> = HashSet(5)
|
||||
val mediaController = inject<MediaPlayerController>(
|
||||
MediaPlayerController::class.java
|
||||
val mediaController = inject<MediaPlayerManager>(
|
||||
MediaPlayerManager::class.java
|
||||
)
|
||||
|
||||
val playlist = mainScope.future { mediaController.value.playlist }.get()
|
||||
|
@ -0,0 +1,82 @@
|
||||
/*
|
||||
* CoroutinePatterns.kt
|
||||
* Copyright (C) 2009-2023 Ultrasonic developers
|
||||
*
|
||||
* Distributed under terms of the GNU GPLv3 license.
|
||||
*/
|
||||
|
||||
package org.moire.ultrasonic.util
|
||||
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import androidx.fragment.app.Fragment
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineExceptionHandler
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import org.moire.ultrasonic.R
|
||||
import timber.log.Timber
|
||||
|
||||
object CoroutinePatterns {
|
||||
val loggingExceptionHandler by lazy {
|
||||
CoroutineExceptionHandler { _, exception ->
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
Timber.w(exception)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun CoroutineScope.executeTaskWithToast(
|
||||
fragment: Fragment,
|
||||
task: suspend CoroutineScope.() -> Unit,
|
||||
successString: () -> String?
|
||||
): Job {
|
||||
// Launch the Job
|
||||
val job = launch(CoroutinePatterns.loggingExceptionHandler, block = task)
|
||||
|
||||
// Setup a handler when the job is done
|
||||
job.invokeOnCompletion {
|
||||
val toastString = if (it != null && it !is CancellationException) {
|
||||
CommunicationError.getErrorMessage(it, fragment.context)
|
||||
} else {
|
||||
successString()
|
||||
}
|
||||
|
||||
// Return early if nothing to post
|
||||
if (toastString == null) return@invokeOnCompletion
|
||||
|
||||
launch(Dispatchers.Main) {
|
||||
Util.toast(fragment.context, toastString)
|
||||
}
|
||||
}
|
||||
|
||||
return job
|
||||
}
|
||||
|
||||
fun CoroutineScope.executeTaskWithModalDialog(
|
||||
fragment: Fragment,
|
||||
task: suspend CoroutineScope.() -> Unit,
|
||||
successString: () -> String
|
||||
) {
|
||||
// Create the job
|
||||
val job = executeTaskWithToast(fragment, task, successString)
|
||||
|
||||
// Create the dialog
|
||||
val builder = InfoDialog.Builder(fragment.requireContext())
|
||||
builder.setTitle(R.string.background_task_wait)
|
||||
builder.setMessage(R.string.background_task_loading)
|
||||
builder.setOnCancelListener { job.cancel() }
|
||||
builder.setPositiveButton(R.string.common_cancel) { _, _ -> job.cancel() }
|
||||
val dialog = builder.create()
|
||||
dialog.show()
|
||||
|
||||
// Add additional handler to close the dialog
|
||||
job.invokeOnCompletion {
|
||||
launch(Dispatchers.Main) {
|
||||
dialog.dismiss()
|
||||
}
|
||||
}
|
||||
}
|
@ -10,7 +10,9 @@ package org.moire.ultrasonic.util
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import java.lang.ref.WeakReference
|
||||
import org.moire.ultrasonic.R
|
||||
import timber.log.Timber
|
||||
|
||||
/*
|
||||
* InfoDialog can be used to show some information to the user. Typically it cannot be cancelled,
|
||||
@ -19,24 +21,30 @@ import org.moire.ultrasonic.R
|
||||
open class InfoDialog(
|
||||
context: Context,
|
||||
message: CharSequence?,
|
||||
private val activity: Activity? = null,
|
||||
activity: Activity? = null,
|
||||
private val finishActivityOnClose: Boolean = false
|
||||
) {
|
||||
|
||||
open var builder: MaterialAlertDialogBuilder = Builder(activity ?: context, message)
|
||||
private val activityRef: WeakReference<Activity?> = WeakReference(activity)
|
||||
open var builder: MaterialAlertDialogBuilder = Builder(activityRef.get() ?: context, message)
|
||||
|
||||
fun show() {
|
||||
builder.setOnCancelListener {
|
||||
if (finishActivityOnClose) {
|
||||
activity!!.finish()
|
||||
activityRef.get()?.finish()
|
||||
}
|
||||
}
|
||||
builder.setPositiveButton(R.string.common_ok) { _, _ ->
|
||||
if (finishActivityOnClose) {
|
||||
activity!!.finish()
|
||||
activityRef.get()?.finish()
|
||||
}
|
||||
}
|
||||
builder.create().show()
|
||||
|
||||
// If the app was put into the background in the meantime this would fail
|
||||
try {
|
||||
builder.create().show()
|
||||
} catch (all: Exception) {
|
||||
Timber.w(all, "Failed to create dialog")
|
||||
}
|
||||
}
|
||||
|
||||
class Builder(context: Context) : MaterialAlertDialogBuilder(context) {
|
||||
@ -93,7 +101,6 @@ class ConfirmationDialog(
|
||||
activity: Activity? = null,
|
||||
finishActivityOnClose: Boolean = false
|
||||
) : InfoDialog(context, message, activity, finishActivityOnClose) {
|
||||
|
||||
override var builder: MaterialAlertDialogBuilder = Builder(activity ?: context, message)
|
||||
|
||||
class Builder(context: Context) : MaterialAlertDialogBuilder(context) {
|
||||
|
@ -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
|
||||
get() {
|
||||
return if (Util.isNetworkRestricted()) {
|
||||
maxMobileBitRate
|
||||
maxBitRateMobile
|
||||
} else {
|
||||
maxWifiBitRate
|
||||
maxBitRateWifi
|
||||
}
|
||||
}
|
||||
|
||||
private var maxWifiBitRate
|
||||
private var maxBitRateWifi
|
||||
by StringIntSetting(getKey(R.string.setting_key_max_bitrate_wifi))
|
||||
|
||||
private var maxMobileBitRate
|
||||
private var maxBitRateMobile
|
||||
by StringIntSetting(getKey(R.string.setting_key_max_bitrate_mobile))
|
||||
|
||||
var maxBitRatePinning
|
||||
by StringIntSetting(getKey(R.string.setting_key_max_bitrate_pinning))
|
||||
val pinWithHighestQuality: Boolean
|
||||
get() = (maxBitRatePinning == 0)
|
||||
|
||||
@JvmStatic
|
||||
val preloadCount: Int
|
||||
get() {
|
||||
val preferences = preferences
|
||||
val preloadCount =
|
||||
preferences.getString(getKey(R.string.setting_key_preload_count), "-1")!!
|
||||
.toInt()
|
||||
@ -60,7 +64,6 @@ object Settings {
|
||||
@JvmStatic
|
||||
val cacheSizeMB: Int
|
||||
get() {
|
||||
val preferences = preferences
|
||||
val cacheSize = preferences.getString(
|
||||
getKey(R.string.setting_key_cache_size),
|
||||
"-1"
|
||||
@ -130,6 +133,9 @@ object Settings {
|
||||
var seekInterval
|
||||
by StringIntSetting(getKey(R.string.setting_key_increment_time), 5000)
|
||||
|
||||
val seekIntervalMillis: Long
|
||||
get() = (seekInterval / 1000).toLong()
|
||||
|
||||
@JvmStatic
|
||||
var mediaButtonsEnabled
|
||||
by BooleanSetting(getKey(R.string.setting_key_media_buttons), true)
|
||||
@ -168,11 +174,11 @@ object Settings {
|
||||
// Normally you don't need to use these Settings directly,
|
||||
// use ActiveServerProvider.isID3Enabled() instead
|
||||
@JvmStatic
|
||||
var shouldUseId3Tags by BooleanSetting(getKey(R.string.setting_key_id3_tags), true)
|
||||
var id3TagsEnabledOnline by BooleanSetting(getKey(R.string.setting_key_id3_tags), true)
|
||||
|
||||
// See comment above.
|
||||
@JvmStatic
|
||||
var useId3TagsOffline by BooleanSetting(getKey(R.string.setting_key_id3_tags_offline), true)
|
||||
var id3TagsEnabledOffline by BooleanSetting(getKey(R.string.setting_key_id3_tags_offline), true)
|
||||
|
||||
var activeServer by IntSetting(getKey(R.string.setting_key_server_instance), -1)
|
||||
|
||||
@ -181,7 +187,7 @@ object Settings {
|
||||
var firstRunExecuted by BooleanSetting(getKey(R.string.setting_key_first_run_executed), false)
|
||||
|
||||
val shouldShowArtistPicture
|
||||
by BooleanSetting(getKey(R.string.setting_key_show_artist_picture), false)
|
||||
by BooleanSetting(getKey(R.string.setting_key_show_artist_picture), true)
|
||||
|
||||
@JvmStatic
|
||||
var chatRefreshInterval by StringIntSetting(
|
||||
@ -209,7 +215,6 @@ object Settings {
|
||||
@JvmStatic
|
||||
val shareGreeting: String?
|
||||
get() {
|
||||
val preferences = preferences
|
||||
val context = Util.appContext()
|
||||
val defaultVal = String.format(
|
||||
context.resources.getString(R.string.share_default_greeting),
|
||||
@ -278,8 +283,7 @@ object Settings {
|
||||
}
|
||||
|
||||
fun getAllKeys(): List<String> {
|
||||
val prefs = PreferenceManager.getDefaultSharedPreferences(UApp.applicationContext())
|
||||
return prefs.all.keys.toList()
|
||||
return preferences.all.keys.toList()
|
||||
}
|
||||
|
||||
private val appContext: Context
|
||||
|
@ -16,7 +16,8 @@ import timber.log.Timber
|
||||
|
||||
/**
|
||||
* Provides filesystem access abstraction which works
|
||||
* both on File based paths and Storage Access Framework Uris
|
||||
* both on File based paths (when using the internal directory for storing media files)
|
||||
* and Storage Access Framework Uris (when using a custom directory)
|
||||
*/
|
||||
object Storage {
|
||||
|
||||
|
@ -273,7 +273,7 @@ class StorageFile(
|
||||
}
|
||||
|
||||
private fun getStorageFileForParentDirectory(path: String): StorageFile? {
|
||||
val parentPath = FileUtil.getParentPath(path)!!
|
||||
val parentPath = FileUtil.getParentPath(path) ?: return null
|
||||
if (storageFilePathDictionary.containsKey(parentPath))
|
||||
return storageFilePathDictionary[parentPath]!!
|
||||
if (notExistingPathDictionary.contains(parentPath)) return null
|
||||
|
@ -133,19 +133,24 @@ object Util {
|
||||
@JvmStatic
|
||||
@SuppressLint("ShowToast") // Invalid warning
|
||||
fun toast(context: Context?, message: CharSequence?, shortDuration: Boolean) {
|
||||
if (toast == null) {
|
||||
toast = Toast.makeText(
|
||||
context,
|
||||
message,
|
||||
if (shortDuration) Toast.LENGTH_SHORT else Toast.LENGTH_LONG
|
||||
)
|
||||
toast!!.setGravity(Gravity.CENTER, 0, 0)
|
||||
} else {
|
||||
toast!!.setText(message)
|
||||
toast!!.duration =
|
||||
if (shortDuration) Toast.LENGTH_SHORT else Toast.LENGTH_LONG
|
||||
// If called after doing some background processing, our context might have expired!
|
||||
try {
|
||||
if (toast == null) {
|
||||
toast = Toast.makeText(
|
||||
context,
|
||||
message,
|
||||
if (shortDuration) Toast.LENGTH_SHORT else Toast.LENGTH_LONG
|
||||
)
|
||||
toast!!.setGravity(Gravity.CENTER, 0, 0)
|
||||
} else {
|
||||
toast!!.setText(message)
|
||||
toast!!.duration =
|
||||
if (shortDuration) Toast.LENGTH_SHORT else Toast.LENGTH_LONG
|
||||
}
|
||||
toast!!.show()
|
||||
} catch (all: Exception) {
|
||||
Timber.w(all)
|
||||
}
|
||||
toast!!.show()
|
||||
}
|
||||
|
||||
/**
|
||||
@ -757,7 +762,7 @@ object Util {
|
||||
|
||||
fun getPlayListFromTimeline(
|
||||
timeline: Timeline?,
|
||||
shuffle: Boolean,
|
||||
isShuffled: Boolean,
|
||||
firstIndex: Int? = null,
|
||||
count: Int? = null
|
||||
): List<MediaItem> {
|
||||
@ -765,13 +770,13 @@ object Util {
|
||||
if (timeline.windowCount < 1) return emptyList()
|
||||
|
||||
val playlist: MutableList<MediaItem> = mutableListOf()
|
||||
var i = firstIndex ?: timeline.getFirstWindowIndex(false)
|
||||
var i = firstIndex ?: timeline.getFirstWindowIndex(isShuffled)
|
||||
if (i == C.INDEX_UNSET) return emptyList()
|
||||
|
||||
while (i != C.INDEX_UNSET && (count != playlist.count())) {
|
||||
val window = timeline.getWindow(i, Timeline.Window())
|
||||
playlist.add(window.mediaItem)
|
||||
i = timeline.getNextWindowIndex(i, Player.REPEAT_MODE_OFF, shuffle)
|
||||
i = timeline.getNextWindowIndex(i, Player.REPEAT_MODE_OFF, isShuffled)
|
||||
}
|
||||
return playlist
|
||||
}
|
||||
@ -828,6 +833,7 @@ object Util {
|
||||
Timber.d("Current user preferences")
|
||||
Timber.d("========================")
|
||||
val keys = Settings.preferences.all
|
||||
|
||||
keys.forEach {
|
||||
Timber.d("${it.key}: ${it.value}")
|
||||
}
|
||||
|
@ -119,7 +119,7 @@
|
||||
</LinearLayout>
|
||||
</FrameLayout>
|
||||
|
||||
<include layout="@layout/current_playlist" />
|
||||
<include layout="@layout/current_playlist" a:id="@+id/playlist"/>
|
||||
</ViewFlipper>
|
||||
|
||||
</LinearLayout>
|
||||
|
@ -112,7 +112,7 @@
|
||||
</LinearLayout>
|
||||
</RelativeLayout>
|
||||
|
||||
<include layout="@layout/current_playlist" />
|
||||
<include layout="@layout/current_playlist" a:id="@+id/playlist"/>
|
||||
</ViewFlipper>
|
||||
|
||||
<include layout="@layout/player_media_info" />
|
||||
|
@ -5,13 +5,17 @@
|
||||
a:layout_height="fill_parent"
|
||||
a:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
a:id="@+id/playlist_empty"
|
||||
a:layout_width="fill_parent"
|
||||
a:layout_height="wrap_content"
|
||||
a:padding="10dip"
|
||||
a:text="@string/playlist.empty" />
|
||||
<com.google.android.material.progressindicator.CircularProgressIndicator
|
||||
a:id="@+id/progress_indicator"
|
||||
a:layout_width="wrap_content"
|
||||
a:layout_height="0dip"
|
||||
a:indeterminate="true"
|
||||
a:layout_weight="1"
|
||||
a:layout_gravity="center|center_horizontal|center_vertical" />
|
||||
|
||||
<include
|
||||
a:id="@+id/emptyListView"
|
||||
layout="@layout/list_parts_empty_view" />
|
||||
|
||||
<com.simplecityapps.recyclerview_fastscroll.views.FastScrollRecyclerView
|
||||
a:id="@+id/playlist_view"
|
||||
|
@ -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>
|
@ -15,6 +15,13 @@
|
||||
app:showAsAction="ifRoom|withText"
|
||||
a:title="@string/download.menu_star"/>
|
||||
|
||||
<item
|
||||
a:id="@+id/menu_show_artist"
|
||||
a:title="@string/download.menu_show_artist"/>
|
||||
<item
|
||||
a:id="@+id/menu_show_album"
|
||||
a:title="@string/download.menu_show_album"/>
|
||||
|
||||
<item
|
||||
a:id="@+id/menu_item_share_song"
|
||||
a:icon="@drawable/ic_menu_share"
|
||||
|
@ -48,7 +48,6 @@
|
||||
<string name="download.jukebox_offline">Vzdálené ovládání není dostupné v offline módu.</string>
|
||||
<string name="download.jukebox_on">Vzdálené ovládání zapnuto. Hudba přehrávána na serveru.</string>
|
||||
<string name="download.jukebox_server_too_old">Vzdálené ovládání není podporováno. Aktualizujte svůj Subsonic server.</string>
|
||||
<string name="download.jukebox_volume">Hlasitost vzdáleného přístroje</string>
|
||||
<string name="download.menu_equalizer">Ekvalizér</string>
|
||||
<string name="download.menu_jukebox_off">Jukebox vypnut</string>
|
||||
<string name="download.menu_jukebox_on">Jukebox zapnut</string>
|
||||
|
@ -61,7 +61,6 @@
|
||||
<string name="download.jukebox_offline">Fernbedienungs-Modus is Offline nicht verfügbar.</string>
|
||||
<string name="download.jukebox_on">Fernbedienung ausgeschaltet. Musik wird auf dem Server wiedergegeben.</string>
|
||||
<string name="download.jukebox_server_too_old">Fernbedienungs Modus wird nicht unterstützt. Bitte den Subsonic Server aktualisieren.</string>
|
||||
<string name="download.jukebox_volume">Entfernte Lautstärke</string>
|
||||
<string name="download.menu_equalizer">Equalizer</string>
|
||||
<string name="download.menu_jukebox_off">Jukebox Aus</string>
|
||||
<string name="download.menu_jukebox_on">Jukebox An</string>
|
||||
|
@ -62,7 +62,6 @@
|
||||
<string name="download.jukebox_offline">Control remoto no disponible en modo fuera de línea.</string>
|
||||
<string name="download.jukebox_on">Control remoto encendido. La música se reproduce en el servidor.</string>
|
||||
<string name="download.jukebox_server_too_old">Control remoto no soportado. Por favor actualiza tu servidor de Subsonic.</string>
|
||||
<string name="download.jukebox_volume">Volumen remoto</string>
|
||||
<string name="download.menu_equalizer">Ecualizador</string>
|
||||
<string name="download.menu_jukebox_off">Apagar Jukebox</string>
|
||||
<string name="download.menu_jukebox_on">Encender Jukebox</string>
|
||||
@ -70,7 +69,7 @@
|
||||
<string name="download.menu_save">Guardar lista de reproducción</string>
|
||||
<string name="download.menu_screen_off">Pantalla apagada</string>
|
||||
<string name="download.menu_screen_on">Pantalla encendida</string>
|
||||
<string name="download.menu_show_album">Mostrar Álbum</string>
|
||||
<string name="download.menu_show_album">Ir al álbum</string>
|
||||
<string name="download.menu_shuffle">Aleatorio</string>
|
||||
<string name="download.menu_shuffle_on">Modo aleatorio activado</string>
|
||||
<string name="download.menu_shuffle_off">Modo aleatorio desactivado</string>
|
||||
@ -361,7 +360,7 @@
|
||||
<string name="share_default_greeting">Echa un vistazo a esta música que te comparto desde %s</string>
|
||||
<string name="share_via">Compartir canciones vía</string>
|
||||
<string name="menu.share">Compartir</string>
|
||||
<string name="download.menu_show_artist">Mostrar artista</string>
|
||||
<string name="download.menu_show_artist">Ir al artista</string>
|
||||
<string name="albumArt">Portadas de álbumes</string>
|
||||
<string name="common_multiple_years">Múltiples años</string>
|
||||
<string name="settings.show_confirmation_dialog">Mostrar diálogo de confirmación</string>
|
||||
@ -442,7 +441,7 @@
|
||||
<string name="settings.five_star_rating_title">Use cinco estrellas para las canciones</string>
|
||||
<string name="settings.five_star_rating_description">Utilice el sistema de calificación de cinco estrellas para canciones en lugar de simplemente marcar / desmarcar elementos.</string>
|
||||
<string name="settings.use_hw_offload_title">Utilizar la reproducción por hardware (experimental)</string>
|
||||
<string name="settings.use_hw_offload_description">Intenta reproducir los medios utilizando el chip decodificador de medios de tu teléfono. Esto puede mejorar el uso de la batería.</string>
|
||||
<string name="settings.use_hw_offload_description">Intenta reproducir los medios usando el procesador decodificador de los medios en tu teléfono. Esto puede mejorar el uso de la batería. ¡Algunos usuarios informan de fallos en la reproducción cuando activan esta opción!</string>
|
||||
<string name="list_view">Lista</string>
|
||||
<string name="grid_view">Portada</string>
|
||||
<string name="settings.preload_100">100 canciones</string>
|
||||
|
@ -61,7 +61,6 @@
|
||||
<string name="download.jukebox_offline">Le mode jukebox n\'est pas disponible en mode déconnecté.</string>
|
||||
<string name="download.jukebox_on">Mode jukebox activé. La musique est jouée sur le serveur</string>
|
||||
<string name="download.jukebox_server_too_old">Le mode jukebox n\'est pas pris en charge. Mise à jour du serveur Subsonic requise.</string>
|
||||
<string name="download.jukebox_volume">Volume sur serveur distant</string>
|
||||
<string name="download.menu_equalizer">Égaliseur</string>
|
||||
<string name="download.menu_jukebox_off">Désactiver le mode jukebox</string>
|
||||
<string name="download.menu_jukebox_on">Activer le mode jukebox</string>
|
||||
|
27
ultrasonic/src/main/res/values-gl/strings.xml
Normal file
27
ultrasonic/src/main/res/values-gl/strings.xml
Normal file
@ -0,0 +1,27 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="background_task.loading">Cargando…</string>
|
||||
<string name="background_task.network_error">Ocorreu un erro de rede. Por favor comproba a dirección do servidor ou téntao de novo mais tarde.</string>
|
||||
<string name="background_task.unsupported_api">A API do servidor v%1$s non admite esta función.</string>
|
||||
<string name="background_task.no_network">Este programa require acceso á rede. Por favor acende a Wi-Fi ou a rede móbil.</string>
|
||||
<string name="background_task.not_found">Recurso non atopado. Por favor comproba a dirección do servidor.</string>
|
||||
<string name="background_task.parse_error">Non se entende a resposta. Por favor comproba a dirección do servidor.</string>
|
||||
<string name="background_task.ssl_cert_error">Erro do certificado HTTPS: %1$s.</string>
|
||||
<string name="background_task.ssl_error">Excepción de conexión SSL. Comprobe o certificado do servidor.</string>
|
||||
<string name="background_task.wait">Por favor agarde…</string>
|
||||
<string name="button_bar.browse">Biblioteca</string>
|
||||
<string name="button_bar.chat">Chat</string>
|
||||
<string name="button_bar.now_playing">Reproducindo agora</string>
|
||||
<string name="buttons.play">Reproducir</string>
|
||||
<string name="buttons.pause">Pausar</string>
|
||||
<string name="buttons.repeat">Repetir</string>
|
||||
<string name="buttons.shuffle">Mesturar</string>
|
||||
<string name="buttons.stop">Parar</string>
|
||||
<string name="buttons.next">Seguinte</string>
|
||||
<string name="buttons.previous">Anterior</string>
|
||||
<string name="podcasts.label">Podcast</string>
|
||||
<string name="podcasts_channels.empty">Non hai canles de Podcasts rexistrados</string>
|
||||
<string name="button_bar.podcasts">Podcast</string>
|
||||
<string name="button_bar.search">Buscar</string>
|
||||
<string name="chat.send_a_message">Enviar unha mensaxe</string>
|
||||
</resources>
|
@ -54,7 +54,6 @@
|
||||
<string name="download.jukebox_offline">A távvezérlés nem lehetséges kapcsolat nélküli módban!</string>
|
||||
<string name="download.jukebox_on">Távvezérlés bekapcsolása. A zenelejátszás a kiszolgálón történik.</string>
|
||||
<string name="download.jukebox_server_too_old">A távvezérlés nem támogatott. Kérjük, frissítse a Subsonic kiszolgálót!</string>
|
||||
<string name="download.jukebox_volume">Hangerő távvezérlése</string>
|
||||
<string name="download.menu_equalizer">Equalizer</string>
|
||||
<string name="download.menu_jukebox_off">Jukebox ki</string>
|
||||
<string name="download.menu_jukebox_on">Jukebox be</string>
|
||||
|
@ -45,7 +45,6 @@
|
||||
<string name="download.jukebox_offline">Il controllo remoto non è disponibile nella modalità offline. </string>
|
||||
<string name="download.jukebox_on">Controllo remoto abilitato. La musica verrà riprodotta sul server.</string>
|
||||
<string name="download.jukebox_server_too_old">Il controllo remoto non è supportato. Per favore aggiorna la versione del server Airsonic.</string>
|
||||
<string name="download.jukebox_volume">Volume remoto</string>
|
||||
<string name="download.menu_equalizer">Equalizzatore</string>
|
||||
<string name="download.menu_jukebox_off">Jukebox spento</string>
|
||||
<string name="download.menu_jukebox_on">Jukebox acceso</string>
|
||||
|
449
ultrasonic/src/main/res/values-ja/strings.xml
Normal file
449
ultrasonic/src/main/res/values-ja/strings.xml
Normal file
@ -0,0 +1,449 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<string name="background_task.loading">読み込み中…</string>
|
||||
<string name="background_task.network_error">ネットワークエラーが発生しました。サーバーのアドレスを確認してやり直してください。</string>
|
||||
<string name="background_task.parse_error">応答が確認できません。サーバーのアドレスを確認してください。</string>
|
||||
<string name="background_task.ssl_cert_error">HTTPS証明書エラー: %1$s.</string>
|
||||
<string name="background_task.ssl_error">SSL接続が異常です。サーバーの証明書を確認してください。</string>
|
||||
<string name="background_task.wait">お待ち下さい…</string>
|
||||
<string name="button_bar.bookmarks">ブックマーク</string>
|
||||
<string name="button_bar.browse">メディアライブラリ</string>
|
||||
<string name="button_bar.chat">チャット</string>
|
||||
<string name="button_bar.now_playing">再生中</string>
|
||||
<string name="buttons.pause">一時停止</string>
|
||||
<string name="buttons.repeat">リピート</string>
|
||||
<string name="buttons.shuffle">シャッフル</string>
|
||||
<string name="buttons.stop">停止</string>
|
||||
<string name="chat.send_a_message">メッセージ送信</string>
|
||||
<string name="common.appname">Ultrasonic</string>
|
||||
<string name="common.artist">アーティスト</string>
|
||||
<string name="common.cancel">キャンセル</string>
|
||||
<string name="common.comment">コメント</string>
|
||||
<string name="common.confirm">確認</string>
|
||||
<string name="common.delete">削除</string>
|
||||
<string name="common.download">ダウンロード</string>
|
||||
<string name="common.info">詳細</string>
|
||||
<string name="common.multiple_genres">複数ジャンル</string>
|
||||
<string name="common.name">名前</string>
|
||||
<string name="common.ok">OK</string>
|
||||
<string name="common.pin">固定</string>
|
||||
<string name="common.play_last">最後に再生</string>
|
||||
<string name="common.play_next">次に再生</string>
|
||||
<string name="common.play_now">今すぐ再生</string>
|
||||
<string name="common.play_shuffled">シャッフル再生</string>
|
||||
<string name="common.public">公開</string>
|
||||
<string name="common.save">保存</string>
|
||||
<string name="common.select_all">すべて選択</string>
|
||||
<string name="common.title">タイトル</string>
|
||||
<string name="common.delete_selection_confirmation">選択した項目を削除してよろしいですか\?</string>
|
||||
<string name="common.unpin_selection_confirmation">選択した項目を固定解除してよろしいですか\?</string>
|
||||
<string name="download.jukebox_not_authorized">リモートコントロールが許可されていません。Subsonicサーバー上で <b> ユーザ > 設定</b> からジュークボックスモードを有効化してください。</string>
|
||||
<string name="download.jukebox_offline">リモートコントロールはオフラインモードでは利用できません。</string>
|
||||
<string name="download.jukebox_on">リモートコントロールがオンになりました。サーバーで音楽が再生されます。</string>
|
||||
<string name="download.jukebox_off">リモートコントロールがオフになりました。モバイル端末で音楽が再生されます。</string>
|
||||
<string name="download.jukebox_server_too_old">リモートコントロールがサポートされていません。Subsonicサーバーをアップグレードしてください。</string>
|
||||
<string name="download.menu_jukebox_on">ジュークボックス ON</string>
|
||||
<string name="download.menu_lyrics">歌詞</string>
|
||||
<string name="download.menu_show_album">アルバムを表示</string>
|
||||
<string name="download.menu_shuffle">シャッフル</string>
|
||||
<string name="download.menu_shuffle_on">シャッフルモードは有効です</string>
|
||||
<string name="download.playerstate_loading">バッファ中…</string>
|
||||
<string name="download.playerstate_playing_shuffle">シャッフル再生</string>
|
||||
<string name="download.playlist_done">プレイリストが保存されました。</string>
|
||||
<string name="download.playlist_error">プレイリストの保存ができません、やり直してください。</string>
|
||||
<string name="download.playlist_name">プレイリストの名前を入力:</string>
|
||||
<string name="download.playlist_saving">プレイリスト \"%s\" を保存中…</string>
|
||||
<string name="download.playlist_title">プレイリストを保存</string>
|
||||
<string name="download.repeat_all">全曲リピート</string>
|
||||
<string name="download.repeat_off">リピートしない</string>
|
||||
<string name="jukebox.is_default">ジュークボックスをデフォルト化</string>
|
||||
<string name="lyrics.nomatch">歌詞が見つかりません</string>
|
||||
<string name="language.default">システム既定</string>
|
||||
<string name="language.zh_CN">中国語 (中国)</string>
|
||||
<string name="language.zh_TW">中国語 (台湾)</string>
|
||||
<string name="language.cs">チェコ語</string>
|
||||
<string name="language.nl">オランダ語</string>
|
||||
<string name="language.en">英語</string>
|
||||
<string name="language.fr">フランス語</string>
|
||||
<string name="language.de">ドイツ語</string>
|
||||
<string name="language.hu">ハンガリー語</string>
|
||||
<string name="language.it">イタリア語</string>
|
||||
<string name="language.es">スペイン語</string>
|
||||
<string name="language.pl">ポーランド語</string>
|
||||
<string name="language.pt">ポルトガル語</string>
|
||||
<string name="language.pt_BR">ポルトガル語 (ブラジル)</string>
|
||||
<string name="language.ru">ロシア語</string>
|
||||
<string name="main.albums_alphaByArtist">アーティスト別</string>
|
||||
<string name="main.albums_frequent">最多再生回数</string>
|
||||
<string name="main.albums_highest">高評価</string>
|
||||
<string name="main.albums_newest">最近の追加</string>
|
||||
<string name="main.albums_random">ランダム</string>
|
||||
<string name="main.albums_recent">最近の再生</string>
|
||||
<string name="main.albums_starred">スター付き</string>
|
||||
<string name="main.albums_by_year">年代順</string>
|
||||
<string name="main.albums_title">アルバム</string>
|
||||
<string name="main.artists_title">アーティスト</string>
|
||||
<string name="main.genres_title">ジャンル</string>
|
||||
<string name="main.offline">オフライン</string>
|
||||
<string name="main.setup_server">%s - サーバの設定</string>
|
||||
<string name="main.songs_random">ランダム</string>
|
||||
<string name="main.songs_starred">スター付き</string>
|
||||
<string name="main.videos">動画</string>
|
||||
<string name="main.welcome_text_demo">自分の曲をUltrasonicで再生するには <b>自身のサーバー</b> が必要です。
|
||||
\n
|
||||
\n➤ アプリを試したい場合、デモサーバーを追加できます。
|
||||
\n
|
||||
\n➤ それ以外の場合、 <b>設定</b> でサーバーを設定できます。</string>
|
||||
<string name="main.welcome_title">ようこそ、Ultrasonicへ!</string>
|
||||
<string name="menu.about">アプリについて</string>
|
||||
<string name="menu.common">一般</string>
|
||||
<string name="menu.deleted_playlist">プレイリスト %s を削除しました</string>
|
||||
<string name="menu.deleted_playlist_error">プレイリスト %s を削除できません</string>
|
||||
<string name="menu.downloads">ダウンロード</string>
|
||||
<string name="menu.exit">終了</string>
|
||||
<string name="menu.settings">設定</string>
|
||||
<string name="playlist.updated_info">%s のプレイリスト情報をアップデートしました</string>
|
||||
<string name="playlist.updated_info_error">%s のプレイリスト情報をアップデートできません</string>
|
||||
<string name="search.albums">アルバム</string>
|
||||
<string name="search.artists">アーティスト</string>
|
||||
<string name="search.label">検索</string>
|
||||
<string name="search.more">もっと表示</string>
|
||||
<string name="search.no_match">一致するものはありません、やり直してください</string>
|
||||
<string name="search.songs">曲</string>
|
||||
<string name="search.title">検索</string>
|
||||
<string name="select_album.empty">メディアが見つかりません</string>
|
||||
<string name="select_album.n_selected">%dトラックが選択されています</string>
|
||||
<string name="select_album.no_network">警告: 使用可能なネットワークがありません。
|
||||
\n モバイルデータを使用している場合、設定で従量制接続でのダウンロードを許可する必要がある場合があります。</string>
|
||||
<string name="select_album.no_sdcard">エラー: SDカードが利用できません。</string>
|
||||
<string name="select_album.play_all">すべて再生</string>
|
||||
<string name="select_artist.all_folders">すべてのフォルダ</string>
|
||||
<string name="select_artist.folder">フォルダを選択</string>
|
||||
<string name="settings.appearance_title">外観</string>
|
||||
<string name="settings.increment_time_10">10秒</string>
|
||||
<string name="settings.increment_time_12">12秒</string>
|
||||
<string name="settings.increment_time_120">2分</string>
|
||||
<string name="settings.increment_time_15">15秒</string>
|
||||
<string name="settings.increment_time_2">2秒</string>
|
||||
<string name="settings.increment_time_20">20秒</string>
|
||||
<string name="settings.increment_time_30">30秒</string>
|
||||
<string name="settings.increment_time_5">5秒</string>
|
||||
<string name="settings.increment_time_60">1分</string>
|
||||
<string name="settings.increment_time_8">8秒</string>
|
||||
<string name="settings.custom_cache_location">キャッシュの場所をカスタムする</string>
|
||||
<string name="settings.cache_location">キャッシュの場所</string>
|
||||
<string name="settings.cache_location_error">キャッシュの場所が無効です。デフォルトを使用します。</string>
|
||||
<string name="settings.cache_size">キャッシュサイズ</string>
|
||||
<string name="settings.cache_size_100">100 MB</string>
|
||||
<string name="settings.cache_size_1000">1 GB</string>
|
||||
<string name="settings.cache_size_10000">10 GB</string>
|
||||
<string name="settings.cache_size_15000">15 GB</string>
|
||||
<string name="settings.cache_size_200">200 MB</string>
|
||||
<string name="settings.cache_size_30000">30 GB</string>
|
||||
<string name="settings.cache_size_4000">4 GB</string>
|
||||
<string name="settings.cache_size_500">500 MB</string>
|
||||
<string name="settings.cache_size_5000">5 GB</string>
|
||||
<string name="settings.cache_size_6000">6 GB</string>
|
||||
<string name="settings.cache_size_7000">7 GB</string>
|
||||
<string name="settings.cache_size_8000">8 GB</string>
|
||||
<string name="settings.cache_size_9000">9 GB</string>
|
||||
<string name="settings.cache_size_unlimited">無制限</string>
|
||||
<string name="settings.cache_title">音楽キャッシュ</string>
|
||||
<string name="settings.default_artists">デフォルトアーティスト数</string>
|
||||
<string name="settings.default_songs">デフォルト曲数</string>
|
||||
<string name="settings.directory_cache_time">ディレクトリキャッシュ時間</string>
|
||||
<string name="settings.directory_cache_time_0">無効</string>
|
||||
<string name="settings.directory_cache_time_1">1分</string>
|
||||
<string name="settings.directory_cache_time_10">10分</string>
|
||||
<string name="settings.directory_cache_time_2">2分</string>
|
||||
<string name="settings.directory_cache_time_30">30分</string>
|
||||
<string name="settings.directory_cache_time_5">5分</string>
|
||||
<string name="settings.directory_cache_time_60">1時間</string>
|
||||
<string name="settings.disc_sort">ディスクで曲を並べ替え</string>
|
||||
<string name="settings.disc_sort_summary">ディスク番号とトラック番号で曲リストを並び替え</string>
|
||||
<string name="settings.display_bitrate">ビットレートと拡張子の表示</string>
|
||||
<string name="settings.display_bitrate_summary">アーティスト名に加え、ビットレートと拡張子も表示します</string>
|
||||
<string name="settings.download_transition">再生時に、再生中画面を表示</string>
|
||||
<string name="settings.download_transition_summary">メディア一覧表示から再生を開始した場合、自動で再生中画面に切り替えます</string>
|
||||
<string name="settings.hide_media_title">他アプリから隠す</string>
|
||||
<string name="settings.hide_media_toast">Androidが、端末内の音楽を次回スキャンするときに有効になります。</string>
|
||||
<string name="settings.hide_media_summary">他のアプリから音楽ファイルを見えないようにします。</string>
|
||||
<string name="settings.increment_time">シーク間隔</string>
|
||||
<string name="settings.invalid_url">有効なURLを指定してください。</string>
|
||||
<string name="settings.max_albums">最大アルバム数</string>
|
||||
<string name="settings.max_artists">最大アーティスト数</string>
|
||||
<string name="settings.max_bitrate_112">112 Kbps</string>
|
||||
<string name="settings.max_bitrate_128">128 Kbps</string>
|
||||
<string name="settings.max_bitrate_160">160 Kbps</string>
|
||||
<string name="settings.max_bitrate_192">192 Kbps</string>
|
||||
<string name="settings.max_bitrate_256">256 Kbps</string>
|
||||
<string name="settings.max_bitrate_32">32 Kbps</string>
|
||||
<string name="settings.max_bitrate_320">320 Kbps</string>
|
||||
<string name="settings.max_bitrate_64">64 Kbps</string>
|
||||
<string name="settings.max_bitrate_80">80 Kbps</string>
|
||||
<string name="settings.max_bitrate_96">96 Kbps</string>
|
||||
<string name="settings.media_button_summary">端末本体、ヘッドセットやBluetoothの再生コントロールボタンに対応します</string>
|
||||
<string name="settings.media_button_title">メディアボタン</string>
|
||||
<string name="settings.network_timeout_15000">15秒</string>
|
||||
<string name="settings.network_timeout_75000">75秒</string>
|
||||
<string name="settings.network_timeout_90000">90秒</string>
|
||||
<string name="settings.notifications_title">通知</string>
|
||||
<string name="settings.network_title">ネットワーク</string>
|
||||
<string name="settings.other_title">その他の設定</string>
|
||||
<string name="settings.override_language">言語を指定</string>
|
||||
<string name="settings.override_language_summary">言語の変更には、アプリ再起動が必要です</string>
|
||||
<string name="settings.playback_control_title">再生コントロール設定</string>
|
||||
<string name="settings.playback.resume_on_bluetooth_device">Bluetoothデバイスの接続時に再生を再開</string>
|
||||
<string name="settings.playback.pause_on_bluetooth_device">Bluetoothデバイスの切断時に再生を一時停止</string>
|
||||
<string name="settings.playback.bluetooth_all">すべてのBluetoothデバイス</string>
|
||||
<string name="settings.playback.bluetooth_a2dp">オーディオデバイス (A2DP) のみ</string>
|
||||
<string name="settings.playback.bluetooth_disabled">無効</string>
|
||||
<string name="settings.playback.resume_play_on_headphones_plug.title">ヘッドホン装着時に再生を再開</string>
|
||||
<string name="settings.playback.resume_play_on_headphones_plug.summary">デバイスにヘッドホンが差し込まれたとき、アプリが自動的に再生を再開します。</string>
|
||||
<string name="settings.preload">プリロードする曲数</string>
|
||||
<string name="settings.parallel_downloads">並行ダウンロードする曲数</string>
|
||||
<string name="settings.preload_1">1曲</string>
|
||||
<string name="settings.preload_10">10曲</string>
|
||||
<string name="settings.preload_2">2曲</string>
|
||||
<string name="settings.preload_3">3曲</string>
|
||||
<string name="settings.preload_500">500曲</string>
|
||||
<string name="settings.preload_1000">1000曲</string>
|
||||
<string name="settings.preload_unlimited">無制限</string>
|
||||
<string name="settings.scrobble_summary">サーバー上のScrobbleサービスで、ユーザー名とパスワードを忘れずに設定してください</string>
|
||||
<string name="settings.scrobble_title">再生した曲をScrobble</string>
|
||||
<string name="settings.search_1">1</string>
|
||||
<string name="settings.search_10">10</string>
|
||||
<string name="settings.search_100">100</string>
|
||||
<string name="settings.search_15">15</string>
|
||||
<string name="settings.search_20">20</string>
|
||||
<string name="settings.search_25">25</string>
|
||||
<string name="settings.search_250">250</string>
|
||||
<string name="settings.search_3">3</string>
|
||||
<string name="settings.server_name">サーバー名</string>
|
||||
<string name="settings.server_password">パスワード</string>
|
||||
<string name="settings.server_scaling_summary">サーバーから、フルサイズではなく圧縮された画像をダウンロードする (帯域幅を節約できます)</string>
|
||||
<string name="settings.server_scaling_title">サーバー側でアルバムアートの圧縮</string>
|
||||
<string name="settings.server_username">ユーザー名</string>
|
||||
<string name="settings.server_color">サーバーの色</string>
|
||||
<string name="settings.show_now_playing">再生中を表示</string>
|
||||
<string name="settings.show_now_playing_summary">すべてのアクティビティで、現在再生中のトラックを表示します</string>
|
||||
<string name="settings.show_track_number">トラック番号を表示</string>
|
||||
<string name="settings.show_track_number_summary">曲を表示するとき、トラック番号も表示します</string>
|
||||
<string name="settings.test_connection_title">接続テスト</string>
|
||||
<string name="settings.theme_day_night">デイ & ナイト</string>
|
||||
<string name="settings.theme_light">ライト</string>
|
||||
<string name="settings.theme_dark">ダーク</string>
|
||||
<string name="settings.title.allow_self_signed_certificate">自己署名のHTTPS証明書を許可</string>
|
||||
<string name="settings.title.force_plain_text_password">平文パスワード認証を強制する</string>
|
||||
<string name="settings.summary.force_plain_text_password">この機能により、アプリは常にパスワードを暗号化せずに送信するようになります。 Subsonic サーバーがユーザーの新しい認証 API をサポートしていない場合に有用です。</string>
|
||||
<string name="settings.use_folder_for_album_artist">アーティスト名としてフォルダを使用</string>
|
||||
<string name="settings.use_folder_for_album_artist_summary">最上位のフォルダ名を、アルバムアーティスト名として利用します</string>
|
||||
<string name="settings.show_now_playing_details_summary">再生中画面で曲の詳細を表示 (ジャンル、年、ビットレート)</string>
|
||||
<string name="settings.use_id3">ID3タグを利用してブラウズ</string>
|
||||
<string name="settings.use_id3_summary">ファイルシステムベースの方式ではなく、ID3タグ方式を利用します</string>
|
||||
<string name="settings.use_id3_offline">オフライン時もID3方式を利用</string>
|
||||
<string name="settings.show_artist_picture">アーティストリストでアーティスト画像を表示</string>
|
||||
<string name="settings.show_artist_picture_summary">利用可能であれば、アーティストリストでアーティスト画像を表示します</string>
|
||||
<string name="main.video" tools:ignore="UnusedResources">動画</string>
|
||||
<string name="settings.wifi_required_summary">定額制接続でのみメディアをダウンロードします</string>
|
||||
<string name="settings.wifi_required_title">Wi-Fi接続時のみダウンロード</string>
|
||||
<string name="song_details.kbps">%d kbps</string>
|
||||
<string name="util.bytes_format.byte">0 B</string>
|
||||
<string name="util.bytes_format.gigabyte">0.00 GB</string>
|
||||
<string name="download.menu_clear_playlist">プレイリストを消去</string>
|
||||
<string name="util.bytes_format.megabyte">0.00 MB</string>
|
||||
<string name="util.no_time" tools:ignore="TypographyDashes">-:--</string>
|
||||
<string name="util.zero_time">0:00</string>
|
||||
<string name="widget.initial_text">タップして音楽を選択</string>
|
||||
<string name="widget.sdcard_missing">SDカードなし</string>
|
||||
<string name="settings.share_description_default">デフォルトの共有説明文</string>
|
||||
<string name="settings.sharing_title">共有</string>
|
||||
<string name="settings.sharing_always_ask_for_details_summary">サーバー上に共有を作成するとき、常に説明と有効期限を確認します</string>
|
||||
<string name="share_set_share_options">共有オプションの設定</string>
|
||||
<string name="share_on_server">サーバー上に共有を作成</string>
|
||||
<string name="settings.share_on_server_summary">有効の場合、サーバー上に共有が作成され、そのURLが共有されます。無効の場合、曲の詳細のみが共有されます</string>
|
||||
<string name="no_expiration">有効期限なし</string>
|
||||
<string name="download.toggle_playlist">プレイリストの切り替え</string>
|
||||
<string name="download.menu_star">スター</string>
|
||||
<string name="button_bar.shares">共有</string>
|
||||
<string name="select_share.empty">サーバー上で利用可能な共有がありません</string>
|
||||
<string name="menu_deleted_share">共有 %s を削除しました</string>
|
||||
<string name="menu_deleted_share_error">共有 %s を削除できません</string>
|
||||
<string name="settings.share_minutes">分</string>
|
||||
<string name="settings.share_hours">時間</string>
|
||||
<string name="settings.share_days">日</string>
|
||||
<string name="time_span_disable">無効</string>
|
||||
<string name="save_as_defaults">デフォルトとして保存</string>
|
||||
<string name="share_comment">コメント</string>
|
||||
<string name="settings.share_expiration">有効期限までの時間</string>
|
||||
<string name="download_song_removed">\"%s\" をプレイリストから削除しました</string>
|
||||
<string name="download.share_playlist">プレイリストを共有</string>
|
||||
<string name="download.share_song">現在の曲を共有</string>
|
||||
<string name="settings.share_greeting_default">デフォルトの共有時あいさつ文</string>
|
||||
<string name="share_default_greeting">%s から共有した音楽をチェックしてみましょう</string>
|
||||
<string name="share_via">経由で曲を共有</string>
|
||||
<string name="menu.share">共有</string>
|
||||
<string name="download.menu_show_artist">アーティストを表示</string>
|
||||
<string name="settings.show_confirmation_dialog">確認ダイアログを表示</string>
|
||||
<string name="settings.show_confirmation_dialog_summary">曲を削除または固定解除する前、確認ダイアログを表示します</string>
|
||||
<string name="settings.debug.log_path">ログファイルは %1$s/%2$s に保存されています</string>
|
||||
<string name="settings.debug.log_keep">ファイルを保持</string>
|
||||
<string name="settings.debug.log_deleted">ログファイルを削除しました。</string>
|
||||
<string name="notification.permission_required">メディア再生には通知が必要です。Androidの設定からいつでも権限を許可することができます。</string>
|
||||
<string name="server_selector.label">設定済みサーバー</string>
|
||||
<string name="server_editor.new_label">サーバーを追加</string>
|
||||
<string name="server_editor.leave_confirmation">変更を保存せずに離れてよろしいですか\?</string>
|
||||
<string name="server_editor.required">この項目は必須です</string>
|
||||
<string name="server_menu.edit">編集</string>
|
||||
<string name="server_menu.delete">削除</string>
|
||||
<string name="server_menu.move_up">上に移動</string>
|
||||
<string name="server_editor.advanced">詳細設定</string>
|
||||
<string name="server_editor.disabled_feature">サーバーがサポート外のため、いくつかの機能が無効になりました。
|
||||
\nこのテストはいつでも再実行することができます。</string>
|
||||
<string name="server_menu.demo">デモサーバー</string>
|
||||
<string name="about.webpage">Webページにアクセス</string>
|
||||
<string name="about.report">バグを報告</string>
|
||||
<plurals name="select_album_n_songs">
|
||||
<item quantity="other">%d 曲</item>
|
||||
</plurals>
|
||||
<plurals name="select_album_n_songs_downloaded">
|
||||
<item quantity="other">%d 曲がダウンロード選択されました</item>
|
||||
</plurals>
|
||||
<plurals name="select_album_n_songs_unpinned">
|
||||
<item quantity="other">%d 曲が固定解除されました</item>
|
||||
</plurals>
|
||||
<plurals name="select_album_n_songs_pinned">
|
||||
<item quantity="other">%d 曲が固定されるよう選択されました</item>
|
||||
</plurals>
|
||||
<plurals name="select_album_n_songs_deleted">
|
||||
<item quantity="other">%d 曲が削除されました</item>
|
||||
</plurals>
|
||||
<plurals name="select_album_n_songs_added">
|
||||
<item quantity="other">%d 曲が再生キューの末尾に追加されました</item>
|
||||
</plurals>
|
||||
<plurals name="select_album_n_songs_play_next">
|
||||
<item quantity="other">%d 曲が現在再生中の曲の次に追加されました</item>
|
||||
</plurals>
|
||||
<string name="api.subsonic.generic">一般APIエラー: %1$s</string>
|
||||
<string name="api.subsonic.generic.no.message">サーバーからメッセージが応答されていません</string>
|
||||
<string name="api.subsonic.token_auth_not_supported_for_ldap">LDAPユーザーに対してのトークン認証はサポートされていません。</string>
|
||||
<string name="api.subsonic.not_authenticated">ユーザー名またはパスワードが間違っています。</string>
|
||||
<string name="api.subsonic.not_authorized">許可されていません。 Subsonic サーバーのユーザー権限を確認してください。</string>
|
||||
<string name="api.subsonic.param_missing">必要なパラメータが不足しています。</string>
|
||||
<string name="api.subsonic.requested_data_was_not_found">要求されたデータが見つかりませんでした。</string>
|
||||
<string name="api.subsonic.upgrade_client">互換性のないバージョンです。UltrasonicのAndroidアプリをバージョンアップしてください。</string>
|
||||
<string name="api.subsonic.upgrade_server">互換性のないバージョンです。Subsonicサーバーをバージョンアップしてください。</string>
|
||||
<string name="settings.five_star_rating_title">曲に五つ星評価を利用</string>
|
||||
<string name="settings.five_star_rating_description">楽曲の評価を、スターあり/なし ではなく、5つの星を付ける方式にします。</string>
|
||||
<string name="list_view">リスト</string>
|
||||
<string name="grid_view">カバー</string>
|
||||
<string name="supported_server_features">サポートされている機能</string>
|
||||
<string name="jukebox">ジュークボックス</string>
|
||||
<string name="foreground_exception_title">再生を再開できません</string>
|
||||
<string name="background_task.unsupported_api">サーバーAPI v%1$s ではこの機能がサポートされていません。</string>
|
||||
<string name="chat.send_button">送信</string>
|
||||
<string name="background_task.no_network">アプリを利用するにはネットワークアクセスが必要です。Wi-Fiかモバイル回線に接続してください。</string>
|
||||
<string name="buttons.play">再生</string>
|
||||
<string name="background_task.not_found">リソースが見つかりません。サーバーのアドレスを確認してください。</string>
|
||||
<string name="buttons.next">次へ</string>
|
||||
<string name="buttons.previous">前へ</string>
|
||||
<string name="podcasts.label">ポッドキャスト</string>
|
||||
<string name="podcasts_channels.empty">ポッドキャストが何も登録されていません</string>
|
||||
<string name="button_bar.podcasts">ポッドキャスト</string>
|
||||
<string name="chat.user_avatar">アバター画像</string>
|
||||
<string name="common.album">アルバム</string>
|
||||
<string name="button_bar.search">検索</string>
|
||||
<string name="common.unpin">固定解除</string>
|
||||
<string name="common.various_artists">様々なアーティスト</string>
|
||||
<string name="download.bookmark_removed" formatted="false">ブックマークが削除されました。</string>
|
||||
<string name="download.empty">何もダウンロードしていません</string>
|
||||
<string name="delete_playlist">%1$s を削除してよろしいですか</string>
|
||||
<string name="download.bookmark_set_at_position" formatted="false">%s にブックマークされました。</string>
|
||||
<string name="playlist.empty">プレイリストは空です</string>
|
||||
<string name="download.menu_equalizer">イコライザー</string>
|
||||
<string name="download.menu_save">プレイリストを保存</string>
|
||||
<string name="download.menu_screen_off">画面オフ</string>
|
||||
<string name="download.menu_jukebox_off">ジュークボックス OFF</string>
|
||||
<string name="download.menu_screen_on">画面オン</string>
|
||||
<string name="download.menu_shuffle_off">シャッフルモードは無効です</string>
|
||||
<string name="equalizer.preset">プリセットを選択</string>
|
||||
<string name="download.repeat_single">一曲リピート</string>
|
||||
<string name="equalizer.enabled">有効です</string>
|
||||
<string name="equalizer.label">イコライザー</string>
|
||||
<string name="error.label">エラー</string>
|
||||
<string name="main.albums_alphaByName">名前別</string>
|
||||
<string name="main.songs_title">曲</string>
|
||||
<string name="main.welcome_cancel">設定に移動</string>
|
||||
<string name="menu.refresh">再読み込み</string>
|
||||
<string name="music_library.label">メディアライブラリ</string>
|
||||
<string name="music_library.label_offline">オフラインメディア</string>
|
||||
<string name="playlist.label">プレイリスト</string>
|
||||
<string name="playlist.update_info">情報をアップデート</string>
|
||||
<string name="select_genre.empty">ジャンルが見つかりません</string>
|
||||
<string name="select_playlist.empty">サーバーに保存されたプレイリストがありません</string>
|
||||
<string name="settings.increment_time_0">無効</string>
|
||||
<string name="settings.increment_time_1">1秒</string>
|
||||
<string name="settings.cache_size_20000">20 GB</string>
|
||||
<string name="settings.cache_size_25000">25 GB</string>
|
||||
<string name="settings.cache_size_2000">2 GB</string>
|
||||
<string name="settings.cache_size_3000">3 GB</string>
|
||||
<string name="settings.chat_refresh">チャットの更新間隔</string>
|
||||
<string name="settings.clear_bookmark">ブックマークを消去</string>
|
||||
<string name="settings.clear_search_history">検索履歴の消去</string>
|
||||
<string name="settings.clear_bookmark_summary">曲の再生完了時にブックマークを消去</string>
|
||||
<string name="settings.search_history_cleared">検索履歴が消去されました</string>
|
||||
<string name="settings.default_albums">デフォルトアルバム数</string>
|
||||
<string name="settings.server_address">サーバーアドレス</string>
|
||||
<string name="settings.network_timeout_30000">30秒</string>
|
||||
<string name="settings.network_timeout_45000">45秒</string>
|
||||
<string name="settings.max_bitrate_mobile">最高ビットレート - モバイル回線</string>
|
||||
<string name="settings.max_songs">最大曲数</string>
|
||||
<string name="settings.max_bitrate_unlimited">無制限</string>
|
||||
<string name="settings.network_timeout_120000">120秒</string>
|
||||
<string name="settings.max_bitrate_wifi">最高ビットレート - Wi-Fi</string>
|
||||
<string name="settings.network_timeout">ネットワークタイムアウト</string>
|
||||
<string name="settings.network_timeout_105000">105秒</string>
|
||||
<string name="settings.network_timeout_60000">60秒</string>
|
||||
<string name="settings.preload_50">50曲</string>
|
||||
<string name="settings.preload_5">5曲</string>
|
||||
<string name="settings.preload_100">100曲</string>
|
||||
<string name="settings.search_30">30</string>
|
||||
<string name="settings.search_40">40</string>
|
||||
<string name="settings.search_5">5</string>
|
||||
<string name="settings.search_title">検索設定</string>
|
||||
<string name="settings.search_50">50</string>
|
||||
<string name="settings.search_75">75</string>
|
||||
<string name="settings.search_500">500</string>
|
||||
<string name="settings.theme_black">ブラック</string>
|
||||
<string name="settings.theme_title">テーマ</string>
|
||||
<string name="settings.use_id3_offline_summary">この設定を有効にすると、Ultrasonic 4.0以降でダウンロードした音楽のみが表示されます。それ以前のバージョンでダウンロードしたファイルには、必要なメタデータが含まれていません。固定モードと保存モードを切り替えることで、不足メタデータのダウンロードができます。</string>
|
||||
<string name="settings.show_now_playing_details">再生中に詳細を表示</string>
|
||||
<string name="util.bytes_format.kilobyte">0 KB</string>
|
||||
<string name="widget.sdcard_busy">SDカード利用不可</string>
|
||||
<string name="settings.sharing_always_ask_for_details">常に詳細を確認</string>
|
||||
<string name="settings.share_expiration_default">デフォルトの有効期限</string>
|
||||
<string name="do_not_show_dialog_again">ダイアログを再度表示しない</string>
|
||||
<string name="download.bookmark_set">ブックマーク設定</string>
|
||||
<string name="download.bookmark_delete">ブックマーク削除</string>
|
||||
<string name="time_span_disabled">無効</string>
|
||||
<string name="common_multiple_years">複数の年</string>
|
||||
<string name="server_selector.delete_confirmation">サーバーを削除してよろしいですか\?</string>
|
||||
<string name="settings.debug.title">デバッグ用オプション</string>
|
||||
<string name="albumArt">アルバムアートワーク</string>
|
||||
<string name="settings.debug.log_to_file">デバッグログをファイルに書き込み</string>
|
||||
<string name="settings.debug.log_delete">ファイルを削除</string>
|
||||
<string name="settings.debug.log_summary">%1$s 個のログファイルが ディレクトリ %3$s で ~%2$s MBの容量を使用しています。これらを保持しますか\?</string>
|
||||
<string name="notification.downloading_title">バックグラウンドでメディアをダウンロード中…</string>
|
||||
<string name="server_editor.label">サーバーの編集</string>
|
||||
<string name="server_menu.move_down">下に移動</string>
|
||||
<string name="server_editor.authentication">認証</string>
|
||||
<string name="about.text"><b>Ultrasonic</b> はフリーでオープンソースであり、Subsonic API (バージョン 1.7.0 以上) 互換サーバーに対応した音楽ストリーミングAndroidクライアントです。
|
||||
\n
|
||||
\n<b>Ultrasonic</b> はSubsonic互換サーバーに接続することで、自宅コンピューターからAndroid端末へ簡単に音楽をストリーミングしたりダウンロードできます。Subsonicサーバーソフトは、Ultrasonicと別に設定が必要です。
|
||||
\n
|
||||
\nデフォルトでは、Ultrasonicは何も設定されていません。自分用サーバーを構築した後に、そのサーバーに接続するように設定を変更してください。</string>
|
||||
<string name="api.subsonic.trial_period_is_over">試用期間は終了しました。</string>
|
||||
<string name="settings.use_hw_offload_title">ハードウェア再生を使用する (実験的)</string>
|
||||
<string name="settings.use_hw_offload_description">端末のメディアデコーダーチップを使用してメディアを再生するよう試行します。これにより、バッテリー使用量を改善できます。このオプションを有効化することで、再生の不具合が起こる場合も報告されています!</string>
|
||||
<string name="foreground_exception_text">メディア通知の再生ボタンがある場合はそれをタップします。ない場合はアプリを開いて再生を開始し、セッションをコントローラーに再接続します</string>
|
||||
</resources>
|
@ -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_off">Fjernkontroll avskrudd. Musikk spilles på enheten.</string>
|
||||
<string name="download.jukebox_server_too_old">Fjernkontroll støttes ikke. Oppgrader din Subsonic-tjener.</string>
|
||||
<string name="download.jukebox_volume">Fjernkontroll</string>
|
||||
<string name="download.menu_jukebox_off">Jukebox avslått</string>
|
||||
<string name="download.menu_jukebox_on">Jukebox påslått</string>
|
||||
<string name="download.menu_shuffle">Omstokking</string>
|
||||
|
@ -63,7 +63,6 @@
|
||||
<string name="download.jukebox_offline">Afstandsbediening is niet beschikbaar in offline-modus.</string>
|
||||
<string name="download.jukebox_on">Afstandsbediening ingeschakeld; muziek wordt afgespeeld op de server.</string>
|
||||
<string name="download.jukebox_server_too_old">Afstandsbediening wordt niet ondersteund. Werk je Subsonic-server bij.</string>
|
||||
<string name="download.jukebox_volume">Afstandsbedieningvolume</string>
|
||||
<string name="download.menu_equalizer">Equalizer</string>
|
||||
<string name="download.menu_jukebox_off">Jukebox uitgeschakeld</string>
|
||||
<string name="download.menu_jukebox_on">Jukebox ingeschakeld</string>
|
||||
|
@ -1,7 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<string name="background_task.loading">Ładowanie…</string>
|
||||
<string name="background_task.loading">Ładowanie…</string>
|
||||
<string name="background_task.network_error">Wystąpił błąd sieci. Proszę sprawdzić adres serwera i spróbować później.</string>
|
||||
<string name="background_task.unsupported_api">Server api v%1$s does not support this function.</string>
|
||||
<string name="background_task.no_network">Ta aplikacja wymaga dostępu do sieci. Proszę włączyć wi-fi lub dane komórkowe.</string>
|
||||
@ -9,7 +8,7 @@
|
||||
<string name="background_task.parse_error">Brak prawidłowej odpowiedzi. Proszę sprawdzić adres serwera.</string>
|
||||
<string name="background_task.ssl_cert_error">Błąd certyfikatu HTTPS: %1$s.</string>
|
||||
<string name="background_task.ssl_error">Błąd połączenia SSL. Proszę sprawdzić certyfikat serwera.</string>
|
||||
<string name="background_task.wait">Proszę czekać…</string>
|
||||
<string name="background_task.wait">Proszę czekać…</string>
|
||||
<string name="button_bar.bookmarks">Zakładki</string>
|
||||
<string name="button_bar.browse">Biblioteka</string>
|
||||
<string name="button_bar.chat">Czat</string>
|
||||
@ -39,7 +38,7 @@
|
||||
<string name="common.save">Zapisz</string>
|
||||
<string name="common.unpin">Odepnij</string>
|
||||
<string name="common.various_artists">Różni artyści</string>
|
||||
<string name="delete_playlist">Czy chcesz usunąć %1$s?</string>
|
||||
<string name="delete_playlist">Czy chcesz usunąć %1$s</string>
|
||||
<string name="download.bookmark_removed" formatted="false">Zakładka usunięta.</string>
|
||||
<string name="download.bookmark_set_at_position" formatted="false">Zakładka ustawiona na %s.</string>
|
||||
<string name="playlist.empty">Playlista jest pusta</string>
|
||||
@ -48,7 +47,6 @@
|
||||
<string name="download.jukebox_offline">Pilot jest niedostępny w trybie offline.</string>
|
||||
<string name="download.jukebox_on">Tryb pilota jest włączony. Muzyka jest odtwarzana na serwerze.</string>
|
||||
<string name="download.jukebox_server_too_old">Tryb pilota jest niedostępny. Proszę uaktualnić serwer Subsonic.</string>
|
||||
<string name="download.jukebox_volume">Zdalna głośność</string>
|
||||
<string name="download.menu_equalizer">Korektor dźwięku</string>
|
||||
<string name="download.menu_jukebox_off">Jukebox wyłączony</string>
|
||||
<string name="download.menu_jukebox_on">Jukebox włączony</string>
|
||||
@ -62,7 +60,7 @@
|
||||
<string name="download.playlist_done">Playlista została zapisana.</string>
|
||||
<string name="download.playlist_error">Błąd zapisu playlisty. Proszę spróbować później.</string>
|
||||
<string name="download.playlist_name">Wprowadź nazwę playlisty:</string>
|
||||
<string name="download.playlist_saving">Trwa zapis playlisty \"%s\"…</string>
|
||||
<string name="download.playlist_saving">Trwa zapis playlisty \"%s\"…</string>
|
||||
<string name="download.playlist_title">Zapisz playlistę</string>
|
||||
<string name="download.repeat_all">Powtarzaj wszystko</string>
|
||||
<string name="download.repeat_off">Powtarzanie wyłączone</string>
|
||||
@ -96,7 +94,7 @@
|
||||
<string name="menu.deleted_playlist_error">Usunięcie playlisty %s nie powiodło się</string>
|
||||
<string name="menu.exit">Zakończ</string>
|
||||
<string name="menu.settings">Ustawienia</string>
|
||||
<string name="menu.refresh">Refresh</string>
|
||||
<string name="menu.refresh">Odśwież</string>
|
||||
<string name="music_library.label">Biblioteka mediów</string>
|
||||
<string name="music_library.label_offline">Media offline</string>
|
||||
<string name="playlist.label">Playlisty</string>
|
||||
@ -172,8 +170,8 @@
|
||||
<string name="settings.display_bitrate_summary">Dołącza bitrate i typ pliku do nazwy artysty</string>
|
||||
<string name="settings.hide_media_summary">Ukrywa pliki muzyczne przed innymi aplikacjami.</string>
|
||||
<string name="settings.hide_media_title">Ukryj pliki</string>
|
||||
<string name="settings.hide_media_toast">Efekt widoczny będzie po następnym skanowaniu muzyki przez system Android</string>
|
||||
<string name="settings.invalid_url">Proszę wprowadzić prawidłowy URL</string>
|
||||
<string name="settings.hide_media_toast">Efekt widoczny będzie po następnym skanowaniu muzyki przez system Android.</string>
|
||||
<string name="settings.invalid_url">Proszę wprowadzić prawidłowy URL.</string>
|
||||
<string name="settings.max_albums">Maksymalna ilość wyników - albumy</string>
|
||||
<string name="settings.max_artists">Maksymalna ilość wyników - artyści</string>
|
||||
<string name="settings.max_bitrate_112">112 Kbps</string>
|
||||
@ -205,10 +203,10 @@
|
||||
<string name="settings.network_title">Sieć</string>
|
||||
<string name="settings.other_title">Inne ustawienia</string>
|
||||
<string name="settings.playback_control_title">Ustawienia sterowania odtwarzaniem</string>
|
||||
<string name="settings.playback.resume_on_bluetooth_device">Resume when a Bluetooth device is connected</string>
|
||||
<string name="settings.playback.pause_on_bluetooth_device">Pause when a Bluetooth device is disconnected</string>
|
||||
<string name="settings.playback.bluetooth_all">All Bluetooth devices</string>
|
||||
<string name="settings.playback.bluetooth_a2dp">Only audio (A2DP) devices</string>
|
||||
<string name="settings.playback.resume_on_bluetooth_device">Wznów po podłączeniu urządzenia Bluetooth</string>
|
||||
<string name="settings.playback.pause_on_bluetooth_device">Wstrzymaj, gdy urządzenie Bluetooth jest odłączone</string>
|
||||
<string name="settings.playback.bluetooth_all">Wszystkie urządzenia Bluetooth</string>
|
||||
<string name="settings.playback.bluetooth_a2dp">Tylko urządzenia audio (A2DP)</string>
|
||||
<string name="settings.playback.bluetooth_disabled">Wyłączone</string>
|
||||
<string name="settings.playback.resume_play_on_headphones_plug.title">Wznawiaj po podłączeniu słuchawek</string>
|
||||
<string name="settings.playback.resume_play_on_headphones_plug.summary">Aplikacja wznowi zatrzymane odtwarzanie po podpięciu słuchawek.</string>
|
||||
@ -298,18 +296,18 @@
|
||||
<string name="menu.share">Udostępnianie</string>
|
||||
<string name="download.menu_show_artist">Wyświetlaj artystę</string>
|
||||
<string name="common_multiple_years">Z różnych lat</string>
|
||||
<string name="server_selector.label">Configured servers</string>
|
||||
<string name="server_selector.delete_confirmation">Are you sure you want to delete the server?</string>
|
||||
<string name="server_editor.label">Editing server</string>
|
||||
<string name="server_selector.label">Skonfigurowane serwery</string>
|
||||
<string name="server_selector.delete_confirmation">Czy na pewno chcesz usunąć ten serwer\?</string>
|
||||
<string name="server_editor.label">Edycja serwera</string>
|
||||
<string name="server_editor.new_label">Dodaj serwer</string>
|
||||
<string name="server_editor.leave_confirmation">Are you sure you want to leave and lose your changes?</string>
|
||||
<string name="server_editor.required">This field is required</string>
|
||||
<string name="server_menu.edit">Edit</string>
|
||||
<string name="server_editor.leave_confirmation">Czy na pewno chcesz wyjść i utracić dokonane zmiany\?</string>
|
||||
<string name="server_editor.required">To pole jest wymagane</string>
|
||||
<string name="server_menu.edit">Edytuj</string>
|
||||
<string name="server_menu.delete">Usuń</string>
|
||||
<string name="server_menu.move_up">Move up</string>
|
||||
<string name="server_menu.move_down">Move down</string>
|
||||
<string name="server_menu.move_up">Przesuń się w górę</string>
|
||||
<string name="server_menu.move_down">Przesuń się w dół</string>
|
||||
<string name="server_editor.authentication">Authentication</string>
|
||||
<string name="server_editor.advanced">Advanced settings</string>
|
||||
<string name="server_editor.advanced">Ustawienia zaawansowane</string>
|
||||
<plurals name="select_album_n_songs">
|
||||
<item quantity="one">%d utwór</item>
|
||||
<item quantity="few">%d utwory</item>
|
||||
@ -327,7 +325,148 @@
|
||||
<string name="api.subsonic.trial_period_is_over">Okres próbny się zakończył.</string>
|
||||
<string name="api.subsonic.upgrade_client">Brak zgodności wersji. Uaktualnij aplikację Ultrasonic na Androida.</string>
|
||||
<string name="api.subsonic.upgrade_server">Brak zgodności wersji. Uaktualnij serwer Subsonic.</string>
|
||||
|
||||
<!-- Subsonic features -->
|
||||
<string name="settings.five_star_rating_title">Użyj pięciu gwiazdek dla utworów</string>
|
||||
</resources>
|
||||
<string name="settings.show_confirmation_dialog_summary">Pokaż okno potwierdzające usunięcie lub odpięcie utworów</string>
|
||||
<string name="language.en">Angielski</string>
|
||||
<string name="settings.scrobble_summary">Pamiętaj o ustawieniu nazwy użytkownika i hasła do usługi Scrobble na serwerze</string>
|
||||
<string name="settings.use_id3_offline">Użyj metody ID3 także kiedy nie masz połączenia</string>
|
||||
<string name="settings.debug.log_keep">Zatrzymaj pliki</string>
|
||||
<string name="download.menu_shuffle_off">Wyłączony tryb losowy</string>
|
||||
<string name="buttons.stop">Zatrzymaj</string>
|
||||
<string name="language.fr">Francuski</string>
|
||||
<string name="common.unpin_selection_confirmation">Czy na pewno chcesz odpiąć zaznaczone pozycje\?</string>
|
||||
<string name="settings.custom_cache_location">Użyj niestandardowej lokacji pamięci podręcznej</string>
|
||||
<string name="common.select_all">Wybierz wszystko</string>
|
||||
<string name="download.menu_shuffle_on">Włączony tryb losowy</string>
|
||||
<string name="buttons.next">Następne</string>
|
||||
<string name="main.albums_by_year">Chronologicznie</string>
|
||||
<string name="main.welcome_cancel">Otwórz ustawienia</string>
|
||||
<string name="language.cs">Czeski</string>
|
||||
<string name="chat.send_button">Wyślij</string>
|
||||
<string name="select_album.n_selected">Zaznaczono %d utworów</string>
|
||||
<string name="share_on_server">Stwórz udostępnienie na serwerze</string>
|
||||
<string name="language.de">Niemiecki</string>
|
||||
<string name="about.report">Zgłoś błąd</string>
|
||||
<string name="notification.downloading_title">Pobieranie w tle…</string>
|
||||
<string name="settings.preload_1000">1000 piosenek</string>
|
||||
<string name="supported_server_features">Wspierane funkcje</string>
|
||||
<string name="language.pl">Polski</string>
|
||||
<string name="common.artist">Artysta</string>
|
||||
<string name="language.nl">Holenderski</string>
|
||||
<string name="language.hu">Węgierski</string>
|
||||
<string name="settings.debug.log_summary">Zapisanych jest %1$s plików z logami, które zajmują ~%2$s MB miejsca w katalogu %3$s. Czy chcesz je zachować\?</string>
|
||||
<string name="buttons.previous">Poprzednie</string>
|
||||
<plurals name="select_album_n_songs_deleted">
|
||||
<item quantity="one">Usunięto %d utwór</item>
|
||||
<item quantity="few">Usunięto %d utwory</item>
|
||||
<item quantity="many">Usunięto %d utworów</item>
|
||||
<item quantity="other">Usunięto %d utworów</item>
|
||||
</plurals>
|
||||
<string name="buttons.repeat">Powtarzaj</string>
|
||||
<string name="download.empty">Nic nie jest pobierane</string>
|
||||
<string name="language.ru">Rosyjski</string>
|
||||
<string name="download.playerstate_loading">Byforowanie…</string>
|
||||
<string name="main.setup_server">%s - Ustaw serwer</string>
|
||||
<string name="settings.preload_50">50 piosenek</string>
|
||||
<string name="language.zh_CN">Chiński (Chiny)</string>
|
||||
<string name="settings.override_language">Nadpisz język</string>
|
||||
<string name="buttons.play">Odtwórz</string>
|
||||
<string name="language.default">Domyślne systemowe</string>
|
||||
<string name="menu.downloads">Pobrane</string>
|
||||
<string name="settings.display_bitrate">Wyświetlaj bitrate i typ pliku</string>
|
||||
<string name="language.pt_BR">Portugalski (Brazylia)</string>
|
||||
<string name="settings.debug.log_path">Plik z logami jest dostępny w %1$s/%2$s</string>
|
||||
<string name="settings.debug.log_deleted">Usunięte pliki z logami.</string>
|
||||
<string name="foreground_exception_text">Naciśnij przycisk odtwarzania na powiadomieniu o mediach, jeśli jest ono nadal obecne, w przeciwnym razie otwórz aplikację, aby rozpocząć odtwarzanie i ponownie podłącz sesję do kontrolera</string>
|
||||
<string name="language.it">Włoski</string>
|
||||
<string name="language.pt">Portugalski</string>
|
||||
<string name="settings.server_color">Kolor serwera</string>
|
||||
<string name="buttons.pause">Pauza</string>
|
||||
<string name="settings.show_artist_picture">Pokaż obraz wykonawcy na liście</string>
|
||||
<string name="common.title">Tytuł</string>
|
||||
<string name="common.delete_selection_confirmation">Czy na pewno chcesz usunąć zaznaczone pozycje\?</string>
|
||||
<string name="albumArt">Okładka albumu</string>
|
||||
<string name="common.album">Album</string>
|
||||
<string name="settings.preload_500">500 piosenek</string>
|
||||
<string name="settings.share_on_server_summary">Udostępnianie spowoduje utworzenie go na serwerze i udostępnienie jego adresu URL. Jeśli ta opcja jest wyłączona, udostępniane są tylko szczegóły utworu</string>
|
||||
<string name="settings.download_transition">Pokaż Obecnie odtwarzane po kliknięciu przycisku Odtwarzaj</string>
|
||||
<string name="language.es">Hiszpański</string>
|
||||
<string name="settings.override_language_summary">Wymagane jest ponowne uruchomienie aplikacji po zmianie języka</string>
|
||||
<string name="foreground_exception_title">Nie można wznowić odtwarzania</string>
|
||||
<string name="chat.user_avatar">Awatar</string>
|
||||
<string name="language.zh_TW">Chiński (Tajwan)</string>
|
||||
<string name="settings.theme_day_night">Dzień i noc</string>
|
||||
<string name="settings.theme_black">Czarny</string>
|
||||
<string name="settings.summary.force_plain_text_password">Zmusza to aplikację do wysyłania hasła w postaci niezaszyfrowanej. Przydatne, jeśli serwer Subsonic nie obsługuje nowego interfejsu API uwierzytelniania dla użytkowników.</string>
|
||||
<string name="settings.show_now_playing_details_summary">Pokaż więcej informacji o utworze w sekcji Obecnie odtwarzane (gatunek, rok, przepustowość)</string>
|
||||
<string name="settings.show_now_playing_details">Pokaż szczegóły w sekcji Obecnie odtwarzane</string>
|
||||
<string name="settings.wifi_required_title">Pobieraj tylko przez Wi-Fi</string>
|
||||
<string name="settings.sharing_always_ask_for_details_summary">Zawsze pytaj o opis i czas wygaśnięcia podczas tworzenia udostępnienia na serwerze</string>
|
||||
<string name="settings.debug.log_delete">Usuń pliki</string>
|
||||
<plurals name="select_album_n_songs_pinned">
|
||||
<item quantity="one">%d utwór zaznaczony do przypięcia</item>
|
||||
<item quantity="few">%d utwory zaznaczone do przypięcia</item>
|
||||
<item quantity="many">%d utworów zaznaczonych do przypięcia</item>
|
||||
<item quantity="other">%d utworów zaznaczonych do przypięcia</item>
|
||||
</plurals>
|
||||
<plurals name="select_album_n_songs_downloaded">
|
||||
<item quantity="one">%d utworów zaznaczonych do pobrania</item>
|
||||
<item quantity="few">%d utwory zaznaczone do pobrania</item>
|
||||
<item quantity="many">%d utworów zaznaczonych do pobrania</item>
|
||||
<item quantity="other">%d utworów zaznaczonych do pobrania</item>
|
||||
</plurals>
|
||||
<string name="about.webpage">Odwiedź stronę internetową</string>
|
||||
<plurals name="select_album_n_songs_unpinned">
|
||||
<item quantity="one">Odpięto %d utwór</item>
|
||||
<item quantity="few">Odpięto %d utwory</item>
|
||||
<item quantity="many">Odpięto %d utworów</item>
|
||||
<item quantity="other">Odpięto %d utworów</item>
|
||||
</plurals>
|
||||
<string name="settings.use_hw_offload_title">Użyj odtwarzania sprzętowwego (eksperymentalne)</string>
|
||||
<string name="jukebox">Jukebox</string>
|
||||
<string name="select_album.no_network">Uwaga: Brak dostępnych sieci do użycia.
|
||||
\n Jeżeli używasz danych mobilnych, potrzebne może być włączenie płatnych połączeń w ustawieniach.</string>
|
||||
<string name="settings.download_transition_summary">Przełącz na Obecnie odtwarzane po rozpoczęciu odtwarzania w widoku multimediów</string>
|
||||
<string name="settings.increment_time">Odstępy między wyszukaniami</string>
|
||||
<string name="settings.parallel_downloads">Ilość równocześnie pobieranych piosenek</string>
|
||||
<string name="settings.preload_100">100 piosenek</string>
|
||||
<string name="settings.scrobble_title">Scrobbluj moje odtworzenia</string>
|
||||
<string name="settings.use_id3_offline_summary">Jeśli włączysz to ustawienie, będzie ono wyświetlać tylko muzykę pobraną za pomocą Ultrasonic w wersji 4.0 lub nowszej. Wcześniejsze pobrane pliki nie zawierają wymaganych metadanych. Możesz przełączać się między trybami Przypinania i Zapisywania, aby wyzwolić pobieranie brakujących metadanych.</string>
|
||||
<string name="settings.show_artist_picture_summary">Wyświetla obraz wykonawcy na liście wykonawców, jeśli jest dostępny</string>
|
||||
<string name="settings.wifi_required_summary">Pobieraj tylko podczas połączeń niepłatnuch</string>
|
||||
<string name="download.share_song">Udostępnij obecnie odtwarzaną piosenkę</string>
|
||||
<string name="settings.show_confirmation_dialog">Pokaż okno potwierdzające</string>
|
||||
<string name="settings.debug.title">Opcje debugowania</string>
|
||||
<string name="settings.debug.log_to_file">Zapisz logi debugowania do pliku</string>
|
||||
<string name="notification.permission_required">Powiadomienia są wymagane do odtwarzania multimediów. Możesz przyznać uprawnienie do nich w dowolnym momencie w ustawieniach Androida.</string>
|
||||
<string name="server_editor.disabled_feature">Jedna lub więcej funkcji zostało wyłączonych ponieważ serwer ich nie obsługiwał.
|
||||
\nMożesz uruchomić ten test ponownie kiedykolwiek.</string>
|
||||
<string name="server_menu.demo">Serwer demonstracyjny</string>
|
||||
<string name="settings.five_star_rating_description">Użyj systemu pięciu gwiazdek do oceniania utworów zamiast po prostu dodawać lub usuwać utwory z ulubionych.</string>
|
||||
<string name="list_view">Lista</string>
|
||||
<string name="grid_view">Okładka</string>
|
||||
<string name="settings.use_hw_offload_description">Spróbuj odtworzyć pliki multimedialne za pomocą układu dekodującego w telefonie. Może to zmniejszyć zużycie baterii!</string>
|
||||
<plurals name="select_album_n_songs_added">
|
||||
<item quantity="one">Dodano %d utwór na koniec kolejki odtwarzania</item>
|
||||
<item quantity="few">Dodano %d utwory na koniec kolejki odtwarzania</item>
|
||||
<item quantity="many">Dodano %d utworów na koniec kolejki odtwarzania</item>
|
||||
<item quantity="other">Dodano %d utworów na koniec kolejki odtwarzania</item>
|
||||
</plurals>
|
||||
<plurals name="select_album_n_songs_play_next">
|
||||
<item quantity="one">Wstawiono %d utwór po bieżącym utworze</item>
|
||||
<item quantity="few">Wstawiono %d utwory po bieżącym utworze</item>
|
||||
<item quantity="many">Wstawiono %d utworów po bieżącym utworze</item>
|
||||
<item quantity="other">Wstawiono %d utworów po bieżącym utworze</item>
|
||||
</plurals>
|
||||
<string name="about.text"><b>Ultrasonic</b> to darmowy i otwarty klient strumieniowego przesyłania muzyki dla serwerów kompatybilnych z API Subsonic (wersja 1.7.0 lub nowsza).
|
||||
\n
|
||||
\nDzięki <b>Ultrasonic</b> możesz łatwo przesyłać strumieniowo lub pobierać muzykę z komputera domowego na telefon za pomocą serwera multimediów kompatybilnego z Subsonic. Oprogramowanie serwera Subsonic wymaga oddzielnej konfiguracji od Ultrasonic.
|
||||
\n
|
||||
\nDomyślnie Ultrasonic nie jest skonfigurowane. Po skonfigurowaniu własnego serwera zmień ustawienia serwera, aby połączyć się z komputerem.</string>
|
||||
<string name="main.welcome_text_demo">Aby używać Ultrasonic z własną muzyką, potrzebujesz <b>własnego serwera</b>.
|
||||
\n
|
||||
\n➤ Jeśli chcesz najpierw wypróbować aplikację, możesz teraz dodać serwer demonstracyjny.
|
||||
\n
|
||||
\n➤ W przeciwnym razie możesz skonfigurować serwer w <b>ustawieniach</b>.</string>
|
||||
</resources>
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user