mirror of
https://gitlab.com/ultrasonic/ultrasonic.git
synced 2025-04-13 16:07:16 +03:00
Merge branch '450' into 4502
This commit is contained in:
commit
365067f1a0
@ -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:
|
||||
@ -74,7 +74,9 @@ Unit Tests:
|
||||
|
||||
Assemble Release:
|
||||
stage: Build
|
||||
script: ./gradlew assembleRelease
|
||||
script:
|
||||
- sed -i 's/applicationId \"org.moire.ultrasonic\"/applicationId "org.moire.ultrasonic.gitlab"/' ultrasonic/build.gradle
|
||||
- ./gradlew assembleRelease
|
||||
artifacts:
|
||||
name: ultrasonic-release-unsigned-${CI_COMMIT_SHA}
|
||||
paths:
|
||||
|
@ -31,12 +31,6 @@ If you want to use the version downloaded from F-Droid or from GitLab with
|
||||
First, see if your issue haven’t been yet reported [here][issues], otherwise
|
||||
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
|
||||
|
@ -52,7 +52,11 @@ style:
|
||||
active: true
|
||||
ForbiddenComment:
|
||||
active: true
|
||||
values: ['FIXME:', 'STOPSHIP:']
|
||||
comments:
|
||||
- reason: 'Forbidden FIXME todo marker in comment, please fix the problem.'
|
||||
value: 'FIXME:'
|
||||
- reason: 'Forbidden STOPSHIP todo marker in comment, please address the problem before shipping the code.'
|
||||
value: 'STOPSHIP:'
|
||||
WildcardImport:
|
||||
active: true
|
||||
MaxLineLength:
|
@ -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
|
||||
|
10
fastlane/metadata/android/en-US/changelogs/122.txt
Normal file
10
fastlane/metadata/android/en-US/changelogs/122.txt
Normal file
@ -0,0 +1,10 @@
|
||||
Features:
|
||||
- Revamp management of ratings. Tracks can be starred from the notification in Android 13, and the changes will show up everywhere immediately.
|
||||
- Add a setting to control the maximum bitrate when pinning music (can be used to avoid downloading lossless files like flac).
|
||||
- Modernize the Jukebox player.
|
||||
- The hardware keys can be used to set the Jukebox volume.
|
||||
- The current playlist shows a spinner when loading takes some time
|
||||
|
||||
Bug fixes:
|
||||
- Request correct bluetooth permission on Android 13 (needed to pause/play on connect)
|
||||
- Update dependencies (OkHttp, Material)
|
@ -1,17 +1,17 @@
|
||||
Ultrasonic is a Subsonic (and compatible servers) client to Android. You can use Ultrasonic to connect with your server and listen music.
|
||||
|
||||
Main features:
|
||||
* Thin
|
||||
* Fast
|
||||
* Material theme with dark and light variants
|
||||
* Small size & fast
|
||||
* Material You theme with dark and light variants
|
||||
* Multiple server support
|
||||
* Offline Mode
|
||||
* Download tracks for offline playback
|
||||
* Bookmarks
|
||||
* Playlists on server
|
||||
* Random play
|
||||
* Shuffled playback
|
||||
* Jukebox mode
|
||||
* Server chat
|
||||
* And much more!!!
|
||||
* And much more!!
|
||||
|
||||
Note: Ultrasonic uses semantic release versions. Releases with a zero in the last digit introduce new features or significant changes, all other releases focus on fixing bugs.
|
||||
|
||||
The source code is available with GPL license in GitLab: https://gitlab.com/ultrasonic/ultrasonic
|
||||
If you have any issue, please post in: https://gitlab.com/ultrasonic/ultrasonic/issues
|
||||
|
@ -3,13 +3,13 @@
|
||||
gradle = "8.1.1"
|
||||
|
||||
navigation = "2.5.3"
|
||||
gradlePlugin = "8.0.1"
|
||||
androidxcore = "1.10.0"
|
||||
gradlePlugin = "8.0.2"
|
||||
androidxcore = "1.10.1"
|
||||
ktlint = "0.43.2"
|
||||
ktlintGradle = "11.3.2"
|
||||
detekt = "1.22.0"
|
||||
detekt = "1.23.0"
|
||||
preferences = "1.2.0"
|
||||
media3 = "1.0.1"
|
||||
media3 = "1.0.2"
|
||||
|
||||
androidSupport = "1.6.0"
|
||||
materialDesign = "1.8.0"
|
||||
@ -17,25 +17,24 @@ constraintLayout = "2.1.4"
|
||||
multidex = "2.0.1"
|
||||
room = "2.5.1"
|
||||
kotlin = "1.8.21"
|
||||
kotlinxCoroutines = "1.7.0"
|
||||
kotlinxGuava = "1.7.0"
|
||||
kotlinxCoroutines = "1.7.1"
|
||||
viewModelKtx = "2.6.1"
|
||||
swipeRefresh = "1.1.0"
|
||||
|
||||
retrofit = "2.9.0"
|
||||
## KEEP ON 2.13 branch (https://github.com/FasterXML/jackson-databind/issues/3658#issuecomment-1312633064) for compatibility with API 24
|
||||
jackson = "2.13.5"
|
||||
okhttp = "4.10.0"
|
||||
okhttp = "4.11.0"
|
||||
koin = "3.3.2"
|
||||
picasso = "2.8"
|
||||
|
||||
junit4 = "4.13.2"
|
||||
junit5 = "5.9.3"
|
||||
mockito = "5.3.1"
|
||||
mockitoKotlin = "4.1.0"
|
||||
mockitoKotlin = "5.0.0"
|
||||
kluent = "1.73"
|
||||
apacheCodecs = "1.15"
|
||||
robolectric = "4.10.2"
|
||||
robolectric = "4.10.3"
|
||||
timber = "5.0.1"
|
||||
fastScroll = "2.0.1"
|
||||
colorPicker = "2.2.4"
|
||||
@ -74,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" }
|
||||
|
@ -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
|
||||
|
@ -25,8 +25,6 @@ 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 = "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 {
|
||||
|
@ -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 120
|
||||
versionName "4.4.1"
|
||||
versionCode 122
|
||||
versionName "4.5.0"
|
||||
|
||||
minSdkVersion versions.minSdk
|
||||
targetSdkVersion versions.targetSdk
|
||||
resConfigs 'cs', 'de', 'en', 'es', 'fr', 'gl', 'hu', 'it', 'ja', 'nb-rNO', 'nl', 'pl', 'pt', 'pt-rBR', 'ru', 'zh-rCN', 'zh-rTW'
|
||||
resourceConfigurations += ['cs', 'de', 'en', 'es', 'fr', 'gl', 'hu', 'it', 'ja', 'nb-rNO', 'nl', 'pl', 'pt', 'pt-rBR', 'ru', 'zh-rCN', 'zh-rTW']
|
||||
}
|
||||
|
||||
bundle.language.enableSplit = false
|
||||
|
@ -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"
|
||||
|
@ -3,6 +3,7 @@
|
||||
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" />
|
||||
@ -21,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"
|
||||
@ -65,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)
|
||||
}
|
||||
}
|
@ -58,7 +58,7 @@ public abstract class BackgroundTask<T> implements ProgressListener
|
||||
|
||||
protected String getErrorMessage(Throwable error)
|
||||
{
|
||||
return CommunicationError.getErrorMessage(error, activity);
|
||||
return CommunicationError.getErrorMessage(error);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -1,39 +0,0 @@
|
||||
/*
|
||||
This file is part of Subsonic.
|
||||
|
||||
Subsonic is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
Subsonic is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
Copyright 2009 (C) Sindre Mehus
|
||||
*/
|
||||
package org.moire.ultrasonic.util;
|
||||
|
||||
import android.os.Binder;
|
||||
|
||||
/**
|
||||
* @author Sindre Mehus
|
||||
*/
|
||||
public class SimpleServiceBinder<S> extends Binder
|
||||
{
|
||||
private final S service;
|
||||
|
||||
public SimpleServiceBinder(S service)
|
||||
{
|
||||
this.service = service;
|
||||
}
|
||||
|
||||
public S getService()
|
||||
{
|
||||
return service;
|
||||
}
|
||||
}
|
@ -17,7 +17,6 @@ import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.MediaStore
|
||||
import android.provider.SearchRecentSuggestions
|
||||
import android.view.KeyEvent
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
@ -55,8 +54,8 @@ 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.MediaPlayerManager
|
||||
import org.moire.ultrasonic.service.RxBus
|
||||
import org.moire.ultrasonic.service.plusAssign
|
||||
import org.moire.ultrasonic.util.Constants
|
||||
@ -98,7 +97,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()
|
||||
|
||||
@ -274,18 +273,6 @@ class NavigationActivity : AppCompatActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
|
||||
val isVolumeDown = keyCode == KeyEvent.KEYCODE_VOLUME_DOWN
|
||||
val isVolumeUp = keyCode == KeyEvent.KEYCODE_VOLUME_UP
|
||||
val isVolumeAdjust = isVolumeDown || isVolumeUp
|
||||
val isJukebox = mediaPlayerController.isJukeboxEnabled
|
||||
if (isVolumeAdjust && isJukebox) {
|
||||
mediaPlayerController.adjustVolume(isVolumeUp)
|
||||
return true
|
||||
}
|
||||
return super.onKeyDown(keyCode, event)
|
||||
}
|
||||
|
||||
private fun setupNavigationMenu(navController: NavController) {
|
||||
navigationView?.setupWithNavController(navController)
|
||||
|
||||
@ -308,7 +295,7 @@ class NavigationActivity : AppCompatActivity() {
|
||||
}
|
||||
R.id.menu_exit -> {
|
||||
setResult(Constants.RESULT_CLOSE_ALL)
|
||||
mediaPlayerController.onDestroy()
|
||||
mediaPlayerManager.onDestroy()
|
||||
finish()
|
||||
exit()
|
||||
}
|
||||
@ -475,9 +462,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
|
||||
}
|
||||
|
@ -115,7 +115,7 @@ class ArtistRowBinder(
|
||||
}
|
||||
|
||||
private fun showArtistPicture(): Boolean {
|
||||
return ActiveServerProvider.isID3Enabled() && Settings.shouldShowArtistPicture
|
||||
return ActiveServerProvider.shouldUseId3Tags() && Settings.shouldShowArtistPicture
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -11,6 +11,7 @@ import androidx.core.view.isGone
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.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
|
||||
@ -139,7 +140,19 @@ class TrackViewHolder(val view: View) :
|
||||
updateStatus(it.state, it.progress)
|
||||
}
|
||||
|
||||
// Timber.v("Setting song done")
|
||||
// Listen for rating updates
|
||||
rxBusSubscription!! += RxBus.ratingPublishedObservable.subscribe {
|
||||
launch(Dispatchers.Main) {
|
||||
// Ignore updates which are not for the current song
|
||||
if (it.id != song.id) return@launch
|
||||
|
||||
if (it.rating is HeartRating) {
|
||||
updateSingleStar(it.rating.isHeart)
|
||||
} else if (it.rating is StarRating) {
|
||||
updateFiveStars(it.rating.starRating.toInt())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// This is called when the Holder is recycled and receives a new Song
|
||||
|
@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -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
|
||||
|
@ -401,7 +401,7 @@ class EditServerFragment : Fragment(), OnBackPressedHandler {
|
||||
Timber.w(exception)
|
||||
ErrorDialog.Builder(requireContext())
|
||||
.setTitle(R.string.error_label)
|
||||
.setMessage(getErrorMessage(exception, context))
|
||||
.setMessage(getErrorMessage(exception))
|
||||
.show()
|
||||
}
|
||||
}
|
||||
|
@ -16,6 +16,7 @@ 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
|
||||
@ -23,7 +24,6 @@ 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
|
||||
@ -39,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 {
|
||||
|
@ -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,13 +85,13 @@ 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
|
||||
@ -111,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,
|
||||
@ -127,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")
|
||||
}
|
||||
@ -149,10 +149,10 @@ class NowPlayingFragment : Fragment() {
|
||||
if (abs(deltaX) > MIN_DISTANCE) {
|
||||
// left or right
|
||||
if (deltaX < 0) {
|
||||
mediaPlayerController.seekToPrevious()
|
||||
mediaPlayerManager.seekToPrevious()
|
||||
}
|
||||
if (deltaX > 0) {
|
||||
mediaPlayerController.seekToNext()
|
||||
mediaPlayerManager.seekToNext()
|
||||
}
|
||||
} else if (abs(deltaY) > MIN_DISTANCE) {
|
||||
if (deltaY < 0) {
|
||||
|
@ -34,6 +34,7 @@ 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
|
||||
@ -53,6 +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.android.material.progressindicator.CircularProgressIndicator
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import java.text.DateFormat
|
||||
import java.text.SimpleDateFormat
|
||||
@ -76,14 +78,17 @@ import org.moire.ultrasonic.R
|
||||
import org.moire.ultrasonic.adapters.BaseAdapter
|
||||
import org.moire.ultrasonic.adapters.TrackViewBinder
|
||||
import org.moire.ultrasonic.api.subsonic.models.AlbumListType
|
||||
import org.moire.ultrasonic.app.UApp
|
||||
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
|
||||
@ -124,7 +129,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
|
||||
@ -142,6 +147,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
|
||||
@ -161,9 +167,15 @@ class PlayerFragment :
|
||||
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()
|
||||
}
|
||||
@ -177,13 +189,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)
|
||||
@ -210,7 +226,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()
|
||||
@ -220,6 +236,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()
|
||||
@ -248,8 +265,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
|
||||
@ -271,7 +288,7 @@ class PlayerFragment :
|
||||
previousButton.setOnClickListener {
|
||||
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
|
||||
launch(CommunicationError.getHandler(context)) {
|
||||
mediaPlayerController.seekToPrevious()
|
||||
mediaPlayerManager.seekToPrevious()
|
||||
}
|
||||
}
|
||||
|
||||
@ -282,7 +299,7 @@ class PlayerFragment :
|
||||
nextButton.setOnClickListener {
|
||||
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
|
||||
launch(CommunicationError.getHandler(context)) {
|
||||
mediaPlayerController.seekToNext()
|
||||
mediaPlayerManager.seekToNext()
|
||||
}
|
||||
}
|
||||
|
||||
@ -292,22 +309,22 @@ class PlayerFragment :
|
||||
|
||||
pauseButton.setOnClickListener {
|
||||
launch(CommunicationError.getHandler(context)) {
|
||||
mediaPlayerController.pause()
|
||||
mediaPlayerManager.pause()
|
||||
}
|
||||
}
|
||||
|
||||
stopButton.setOnClickListener {
|
||||
launch(CommunicationError.getHandler(context)) {
|
||||
mediaPlayerController.reset()
|
||||
mediaPlayerManager.reset()
|
||||
}
|
||||
}
|
||||
|
||||
playButton.setOnClickListener {
|
||||
if (!mediaPlayerController.isJukeboxEnabled)
|
||||
if (!mediaPlayerManager.isJukeboxEnabled)
|
||||
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
|
||||
|
||||
launch(CommunicationError.getHandler(context)) {
|
||||
mediaPlayerController.play()
|
||||
mediaPlayerManager.play()
|
||||
}
|
||||
}
|
||||
|
||||
@ -316,12 +333,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()
|
||||
|
||||
@ -343,7 +360,7 @@ class PlayerFragment :
|
||||
progressBar.setOnSeekBarChangeListener(object : OnSeekBarChangeListener {
|
||||
override fun onStopTrackingTouch(seekBar: SeekBar) {
|
||||
launch(CommunicationError.getHandler(context)) {
|
||||
mediaPlayerController.seekTo(progressBar.progress)
|
||||
mediaPlayerManager.seekTo(progressBar.progress)
|
||||
}
|
||||
}
|
||||
|
||||
@ -380,12 +397,19 @@ class PlayerFragment :
|
||||
// Query the Jukebox state in an IO Context
|
||||
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) }
|
||||
}
|
||||
|
||||
@ -417,7 +441,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)
|
||||
@ -430,7 +454,7 @@ 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
|
||||
@ -443,7 +467,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)
|
||||
@ -454,7 +478,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)
|
||||
@ -472,6 +496,7 @@ class PlayerFragment :
|
||||
rxBusSubscription.dispose()
|
||||
cancel("CoroutineScope cancelled because the view was destroyed")
|
||||
cancellationToken.cancel()
|
||||
_binding = null
|
||||
super.onDestroyView()
|
||||
}
|
||||
|
||||
@ -541,7 +566,7 @@ class PlayerFragment :
|
||||
equalizerMenuItem.isVisible = isEqualizerAvailable
|
||||
}
|
||||
|
||||
val mediaPlayerController = mediaPlayerController
|
||||
val mediaPlayerController = mediaPlayerManager
|
||||
val track = mediaPlayerController.currentMediaItem?.toTrack()
|
||||
|
||||
if (track != null) {
|
||||
@ -591,10 +616,10 @@ 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
|
||||
@ -614,7 +639,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,
|
||||
@ -630,7 +655,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,
|
||||
@ -638,7 +663,6 @@ class PlayerFragment :
|
||||
parentId = track.parent,
|
||||
isAlbum = true
|
||||
)
|
||||
|
||||
findNavController().navigate(action)
|
||||
return true
|
||||
}
|
||||
@ -650,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
|
||||
}
|
||||
@ -668,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
|
||||
@ -683,13 +707,13 @@ 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
|
||||
@ -708,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 {
|
||||
@ -743,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) {
|
||||
@ -778,8 +802,7 @@ class PlayerFragment :
|
||||
|
||||
private fun update(cancel: CancellationToken? = null) {
|
||||
if (cancel?.isCancellationRequested == true) return
|
||||
val mediaPlayerController = mediaPlayerController
|
||||
if (currentSong?.id != mediaPlayerController.currentMediaItem?.mediaId) {
|
||||
if (currentSong?.id != mediaPlayerManager.currentMediaItem?.mediaId) {
|
||||
onTrackChanged()
|
||||
}
|
||||
updateSeekBar()
|
||||
@ -787,10 +810,10 @@ class PlayerFragment :
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
@ -799,16 +822,16 @@ class PlayerFragment :
|
||||
musicService.createPlaylist(null, playlistName, entries)
|
||||
}.invokeOnCompletion {
|
||||
if (it == null || it is CancellationException) {
|
||||
Util.toast(context, R.string.download_playlist_done)
|
||||
Util.toast(UApp.applicationContext(), R.string.download_playlist_done)
|
||||
} else {
|
||||
Timber.e(it, "Exception has occurred in savePlaylistInBackground")
|
||||
val msg = String.format(
|
||||
Locale.ROOT,
|
||||
"%s %s",
|
||||
resources.getString(R.string.download_playlist_error),
|
||||
CommunicationError.getErrorMessage(it, context)
|
||||
CommunicationError.getErrorMessage(it)
|
||||
)
|
||||
Util.toast(context, msg)
|
||||
Util.toast(UApp.applicationContext(), msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -843,8 +866,8 @@ class PlayerFragment :
|
||||
|
||||
// Create listener
|
||||
val clickHandler: ((Track, Int) -> Unit) = { _, listPos ->
|
||||
val mediaIndex = mediaPlayerController.getUnshuffledIndexOf(listPos)
|
||||
mediaPlayerController.play(mediaIndex)
|
||||
val mediaIndex = mediaPlayerManager.getUnshuffledIndexOf(listPos)
|
||||
mediaPlayerManager.play(mediaIndex)
|
||||
}
|
||||
|
||||
viewAdapter.register(
|
||||
@ -908,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()
|
||||
@ -924,7 +947,7 @@ class PlayerFragment :
|
||||
Util.toast(context, songRemoved)
|
||||
|
||||
// Remove the item from the playlist
|
||||
mediaPlayerController.removeFromPlaylist(pos)
|
||||
mediaPlayerManager.removeFromPlaylist(pos)
|
||||
}
|
||||
|
||||
override fun onSelectedChanged(
|
||||
@ -944,7 +967,7 @@ class PlayerFragment :
|
||||
dragging = false
|
||||
// Move the item in the playlist separately
|
||||
Timber.i("Moving item %s to %s", startPosition, endPosition)
|
||||
mediaPlayerController.moveItemInPlaylist(startPosition, endPosition)
|
||||
mediaPlayerManager.moveItemInPlaylist(startPosition, endPosition)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1022,25 +1045,24 @@ class PlayerFragment :
|
||||
}
|
||||
|
||||
private fun onPlaylistChanged() {
|
||||
val mediaPlayerController = mediaPlayerController
|
||||
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 onTrackChanged() {
|
||||
currentSong = mediaPlayerController.currentMediaItem?.toTrack()
|
||||
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)
|
||||
@ -1095,23 +1117,27 @@ class PlayerFragment :
|
||||
|
||||
updateSongRating()
|
||||
|
||||
nextButton.isEnabled = mediaPlayerController.canSeekToNext()
|
||||
previousButton.isEnabled = mediaPlayerController.canSeekToPrevious()
|
||||
updateMediaButtonActivationState()
|
||||
}
|
||||
|
||||
private fun updateMediaButtonActivationState() {
|
||||
nextButton.isEnabled = mediaPlayerManager.canSeekToNext()
|
||||
previousButton.isEnabled = mediaPlayerManager.canSeekToPrevious()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun updateSeekBar() {
|
||||
val isJukeboxEnabled: Boolean = mediaPlayerController.isJukeboxEnabled
|
||||
val millisPlayed: Int = max(0, mediaPlayerController.playerPosition)
|
||||
val duration: Int = mediaPlayerController.playerDuration
|
||||
val playbackState: Int = mediaPlayerController.playbackState
|
||||
val isJukeboxEnabled: Boolean = mediaPlayerManager.isJukeboxEnabled
|
||||
val millisPlayed: Int = max(0, mediaPlayerManager.playerPosition)
|
||||
val duration: Int = mediaPlayerManager.playerDuration
|
||||
val playbackState: Int = mediaPlayerManager.playbackState
|
||||
|
||||
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.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)
|
||||
@ -1120,7 +1146,7 @@ class PlayerFragment :
|
||||
progressBar.isEnabled = false
|
||||
}
|
||||
|
||||
val progress = mediaPlayerController.bufferedPercentage
|
||||
val progress = mediaPlayerManager.bufferedPercentage
|
||||
updateBufferProgress(playbackState, progress)
|
||||
}
|
||||
|
||||
@ -1133,7 +1159,7 @@ class PlayerFragment :
|
||||
setTitle(this@PlayerFragment, downloadStatus)
|
||||
}
|
||||
Player.STATE_READY -> {
|
||||
if (mediaPlayerController.isShufflePlayEnabled) {
|
||||
if (mediaPlayerManager.isShufflePlayEnabled) {
|
||||
setTitle(
|
||||
this@PlayerFragment,
|
||||
R.string.download_playerstate_playing_shuffle
|
||||
@ -1157,7 +1183,7 @@ class PlayerFragment :
|
||||
}
|
||||
|
||||
private fun updateButtonStates(playbackState: Int) {
|
||||
val isPlaying = mediaPlayerController.isPlaying
|
||||
val isPlaying = mediaPlayerManager.isPlaying
|
||||
when (playbackState) {
|
||||
Player.STATE_READY -> {
|
||||
pauseButton.isVisible = isPlaying
|
||||
@ -1180,9 +1206,9 @@ class PlayerFragment :
|
||||
private fun seek(forward: Boolean) {
|
||||
launch(CommunicationError.getHandler(context)) {
|
||||
if (forward) {
|
||||
mediaPlayerController.seekForward()
|
||||
mediaPlayerManager.seekForward()
|
||||
} else {
|
||||
mediaPlayerController.seekBack()
|
||||
mediaPlayerManager.seekBack()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1208,28 +1234,28 @@ class PlayerFragment :
|
||||
// Right to Left swipe
|
||||
if (e1X - e2X > swipeDistance && absX > swipeVelocity) {
|
||||
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
|
||||
mediaPlayerController.seekToNext()
|
||||
mediaPlayerManager.seekToNext()
|
||||
return true
|
||||
}
|
||||
|
||||
// Left to Right swipe
|
||||
if (e2X - e1X > swipeDistance && absX > swipeVelocity) {
|
||||
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
|
||||
mediaPlayerController.seekToPrevious()
|
||||
mediaPlayerManager.seekToPrevious()
|
||||
return true
|
||||
}
|
||||
|
||||
// Top to Bottom swipe
|
||||
if (e2Y - e1Y > swipeDistance && absY > swipeVelocity) {
|
||||
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
|
||||
mediaPlayerController.seekTo(mediaPlayerController.playerPosition + 30000)
|
||||
mediaPlayerManager.seekTo(mediaPlayerManager.playerPosition + 30000)
|
||||
return true
|
||||
}
|
||||
|
||||
// Bottom to Top swipe
|
||||
if (e1Y - e2Y > swipeDistance && absY > swipeVelocity) {
|
||||
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
|
||||
mediaPlayerController.seekTo(mediaPlayerController.playerPosition - 8000)
|
||||
mediaPlayerManager.seekTo(mediaPlayerManager.playerPosition - 8000)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
@ -1294,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
|
||||
@ -42,7 +45,7 @@ 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.DownloadService
|
||||
import org.moire.ultrasonic.service.MediaPlayerController
|
||||
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
|
||||
@ -55,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()
|
||||
@ -80,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
|
||||
@ -141,12 +149,24 @@ class SearchFragment : MultiListFragment<Identifiable>(), KoinComponent {
|
||||
}
|
||||
|
||||
/**
|
||||
* This method creates the search bar above the recycler view
|
||||
* This provide creates the search bar above the recycler view
|
||||
*/
|
||||
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
|
||||
private val menuProvider: MenuProvider = object : MenuProvider {
|
||||
override fun onPrepareMenu(menu: Menu) {
|
||||
setupOptionsMenu(menu)
|
||||
}
|
||||
|
||||
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
|
||||
menuInflater.inflate(R.menu.search, menu)
|
||||
}
|
||||
|
||||
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
|
||||
return true
|
||||
}
|
||||
}
|
||||
fun setupOptionsMenu(menu: Menu) {
|
||||
val activity = activity ?: return
|
||||
val 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)
|
||||
@ -275,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(
|
||||
@ -305,15 +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),
|
||||
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))
|
||||
}
|
||||
|
||||
|
@ -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,7 +43,7 @@ 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.DownloadAction
|
||||
@ -82,7 +85,7 @@ open class TrackCollectionFragment(
|
||||
private var playAllButton: MenuItem? = null
|
||||
private var shareButton: MenuItem? = null
|
||||
|
||||
internal val mediaPlayerController: MediaPlayerController by inject()
|
||||
internal val mediaPlayerManager: MediaPlayerManager by inject()
|
||||
private val shareHandler: ShareHandler by inject()
|
||||
internal var cancellationToken: CancellationToken? = null
|
||||
|
||||
@ -114,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)
|
||||
@ -257,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() {
|
||||
@ -344,13 +351,11 @@ open class TrackCollectionFragment(
|
||||
|
||||
val isArtist = navArgs.isArtist
|
||||
|
||||
// Need a valid id to download stuff
|
||||
val id = navArgs.id ?: return
|
||||
|
||||
if (hasSubFolders) {
|
||||
// Need a valid id to recurse sub directories stuff
|
||||
if (hasSubFolders && navArgs.id != null) {
|
||||
downloadHandler.fetchTracksAndAddToController(
|
||||
fragment = this,
|
||||
id = id,
|
||||
id = navArgs.id!!,
|
||||
append = append,
|
||||
autoPlay = !append,
|
||||
shuffle = shuffle,
|
||||
@ -379,20 +384,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
|
||||
@ -575,14 +577,14 @@ open class TrackCollectionFragment(
|
||||
setTitle(R.string.main_videos)
|
||||
listModel.getVideos(refresh2)
|
||||
} else if (id == null || getRandomTracks) {
|
||||
// There seems to be a bug in ViewPager when resuming the Actitivy that subfragments
|
||||
// There seems to be a bug in ViewPager when resuming the Activity that sub-fragments
|
||||
// arguments are empty. If we have no id, just show some random tracks
|
||||
setTitle(R.string.main_songs_random)
|
||||
listModel.getRandom(size, append)
|
||||
} else {
|
||||
setTitle(name)
|
||||
|
||||
if (ActiveServerProvider.isID3Enabled()) {
|
||||
if (ActiveServerProvider.shouldUseId3Tags()) {
|
||||
if (isAlbum) {
|
||||
listModel.getAlbum(refresh2, id, name)
|
||||
} else {
|
||||
@ -636,10 +638,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
|
||||
@ -55,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
|
||||
|
||||
@ -148,7 +147,7 @@ class PlaylistsFragment : Fragment() {
|
||||
val playlist = playlistsListView!!.getItemAtPosition(info.position) as Playlist
|
||||
when (menuItem.itemId) {
|
||||
R.id.playlist_menu_pin -> {
|
||||
downloadHandler.value.justDownload(
|
||||
downloadHandler.justDownload(
|
||||
DownloadAction.PIN,
|
||||
fragment = this,
|
||||
id = playlist.id,
|
||||
@ -158,7 +157,7 @@ class PlaylistsFragment : Fragment() {
|
||||
)
|
||||
}
|
||||
R.id.playlist_menu_unpin -> {
|
||||
downloadHandler.value.justDownload(
|
||||
downloadHandler.justDownload(
|
||||
DownloadAction.UNPIN,
|
||||
fragment = this,
|
||||
id = playlist.id,
|
||||
@ -168,7 +167,7 @@ class PlaylistsFragment : Fragment() {
|
||||
)
|
||||
}
|
||||
R.id.playlist_menu_download -> {
|
||||
downloadHandler.value.justDownload(
|
||||
downloadHandler.justDownload(
|
||||
DownloadAction.DOWNLOAD,
|
||||
fragment = this,
|
||||
id = playlist.id,
|
||||
|
@ -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,8 +17,9 @@ import androidx.media3.common.MediaMetadata.FOLDER_TYPE_ARTISTS
|
||||
import androidx.media3.common.MediaMetadata.FOLDER_TYPE_MIXED
|
||||
import androidx.media3.common.MediaMetadata.FOLDER_TYPE_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
|
||||
@ -28,7 +27,6 @@ import androidx.media3.session.SessionCommand
|
||||
import androidx.media3.session.SessionResult
|
||||
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
|
||||
@ -47,11 +45,9 @@ 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.service.RatingManager
|
||||
import org.moire.ultrasonic.util.MainThreadExecutor
|
||||
import org.moire.ultrasonic.util.Settings
|
||||
import org.moire.ultrasonic.util.Util
|
||||
import org.moire.ultrasonic.util.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,6 +318,9 @@ 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 {
|
||||
Timber.i(controller.packageName)
|
||||
// This function even though its declared in AutoMediaBrowserCallback.kt is
|
||||
@ -326,7 +342,6 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
|
||||
* and thereby customarily it is required to rebuild it..
|
||||
* 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,
|
||||
@ -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,8 +26,7 @@ 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.DefaultShuffleOrder
|
||||
import androidx.media3.exoplayer.source.ShuffleOrder.UnshuffledShuffleOrder
|
||||
import androidx.media3.exoplayer.source.ShuffleOrder
|
||||
import androidx.media3.session.MediaLibraryService
|
||||
import androidx.media3.session.MediaSession
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
@ -37,6 +36,7 @@ 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
|
||||
@ -46,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
|
||||
@ -61,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()
|
||||
|
||||
@ -76,6 +79,7 @@ class PlaybackService :
|
||||
super.onCreate()
|
||||
initializeSessionAndPlayer()
|
||||
setListener(MediaSessionServiceListener())
|
||||
instance = this
|
||||
}
|
||||
|
||||
private fun getWakeModeFlag(): Int {
|
||||
@ -99,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)
|
||||
|
||||
@ -127,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()
|
||||
@ -147,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)
|
||||
@ -157,59 +262,17 @@ class PlaybackService :
|
||||
.setSeekForwardIncrementMs(Settings.seekInterval.toLong())
|
||||
.build()
|
||||
|
||||
// Setup Equalizer
|
||||
equalizer = EqualizerController.create(player.audioSessionId)
|
||||
|
||||
// Enable audio offload
|
||||
if (Settings.useHwOffload)
|
||||
player.experimentalSetOffloadSchedulingEnabled(true)
|
||||
|
||||
// Create browser interface
|
||||
librarySessionCallback = AutoMediaBrowserCallback(player, this)
|
||||
|
||||
// This will need to use the AutoCalls
|
||||
mediaLibrarySession = MediaLibrarySession.Builder(this, player, librarySessionCallback)
|
||||
.setSessionActivity(getPendingIntentForContent())
|
||||
.setBitmapLoader(ArtworkBitmapLoader())
|
||||
.build()
|
||||
|
||||
// Set a listener to update the API client when the active server has changed
|
||||
rxBusSubscription += RxBus.activeServerChangedObservable.subscribe {
|
||||
// Set the player wake mode
|
||||
player.setWakeMode(getWakeModeFlag())
|
||||
}
|
||||
|
||||
// Set a listener to reset the ShuffleOrder
|
||||
rxBusSubscription += RxBus.shufflePlayObservable.subscribe { shuffle ->
|
||||
val len = player.currentTimeline.windowCount
|
||||
|
||||
Timber.i("Resetting shuffle order, isShuffled: %s", shuffle)
|
||||
|
||||
// If disabling Shuffle return early
|
||||
if (!shuffle) {
|
||||
return@subscribe player.setShuffleOrder(UnshuffledShuffleOrder(len))
|
||||
}
|
||||
|
||||
// Get the position of the current track in the unshuffled order
|
||||
val cur = player.currentMediaItemIndex
|
||||
val seed = System.currentTimeMillis()
|
||||
val random = Random(seed)
|
||||
|
||||
val list = createShuffleListFromCurrentIndex(cur, len, random)
|
||||
Timber.i("New Shuffle order: %s", list.joinToString { it.toString() })
|
||||
player.setShuffleOrder(DefaultShuffleOrder(list, seed))
|
||||
}
|
||||
|
||||
// Listen to the shutdown command
|
||||
rxBusSubscription += RxBus.shutdownCommandObservable.subscribe {
|
||||
Timber.i("Received destroy command via Rx")
|
||||
onDestroy()
|
||||
}
|
||||
|
||||
player.addListener(listener)
|
||||
isStarted = true
|
||||
return player
|
||||
}
|
||||
|
||||
fun createShuffleListFromCurrentIndex(
|
||||
private fun createShuffleListFromCurrentIndex(
|
||||
currentIndex: Int,
|
||||
length: Int,
|
||||
random: Random
|
||||
@ -233,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()
|
||||
}
|
||||
|
||||
@ -243,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,
|
||||
@ -333,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
|
||||
@ -39,7 +40,6 @@ 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
|
||||
@ -452,3 +452,5 @@ class DownloadService : Service(), KoinComponent {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class SimpleServiceBinder<S>(val service: S) : Binder()
|
||||
|
@ -92,8 +92,8 @@ class DownloadTask(
|
||||
// Attempt partial HTTP GET, appending to the file if it exists.
|
||||
val (inStream, isPartial) = musicService.getDownloadInputStream(
|
||||
downloadTrack.track, fileLength,
|
||||
Settings.maxBitRate,
|
||||
downloadTrack.pinned
|
||||
if (downloadTrack.pinned) Settings.maxBitRatePinning else Settings.maxBitRate,
|
||||
downloadTrack.pinned && Settings.pinWithHighestQuality
|
||||
)
|
||||
|
||||
inputStream = inStream
|
||||
|
@ -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,97 +446,104 @@ 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
|
||||
Player.MEDIA_ITEM_TRANSITION_REASON_AUTO
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
@ -32,7 +32,7 @@ import timber.log.Timber
|
||||
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
|
||||
@ -64,7 +64,7 @@ class MediaPlayerLifecycleSupport : KoinComponent {
|
||||
return
|
||||
}
|
||||
|
||||
mediaPlayerController.onCreate {
|
||||
mediaPlayerManager.onCreate {
|
||||
restoreLastSession(autoPlay, afterRestore)
|
||||
}
|
||||
|
||||
@ -81,7 +81,7 @@ class MediaPlayerLifecycleSupport : KoinComponent {
|
||||
|
||||
Timber.i("Restoring %s songs", it!!.songs.size)
|
||||
|
||||
mediaPlayerController.restore(
|
||||
mediaPlayerManager.restore(
|
||||
it,
|
||||
autoPlay,
|
||||
false
|
||||
@ -110,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)
|
||||
|
||||
@ -146,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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -183,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.seekToPrevious()
|
||||
KeyEvent.KEYCODE_MEDIA_NEXT -> mediaPlayerController.seekToNext()
|
||||
KeyEvent.KEYCODE_MEDIA_STOP -> mediaPlayerController.stop()
|
||||
KeyEvent.KEYCODE_MEDIA_PLAY -> mediaPlayerController.play()
|
||||
KeyEvent.KEYCODE_MEDIA_PAUSE -> mediaPlayerController.pause()
|
||||
KeyEvent.KEYCODE_1 -> mediaPlayerController.legacySetRating(1)
|
||||
KeyEvent.KEYCODE_2 -> mediaPlayerController.legacySetRating(2)
|
||||
KeyEvent.KEYCODE_3 -> mediaPlayerController.legacySetRating(3)
|
||||
KeyEvent.KEYCODE_4 -> mediaPlayerController.legacySetRating(4)
|
||||
KeyEvent.KEYCODE_5 -> mediaPlayerController.legacySetRating(5)
|
||||
KeyEvent.KEYCODE_STAR -> mediaPlayerController.legacyToggleStar()
|
||||
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 -> {
|
||||
}
|
||||
}
|
||||
@ -222,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.seekToNext()
|
||||
Constants.CMD_PREVIOUS -> mediaPlayerController.seekToPrevious()
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -16,8 +16,6 @@ import androidx.media3.common.HeartRating
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.PlaybackException
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.Player.COMMAND_SEEK_TO_NEXT
|
||||
import androidx.media3.common.Player.COMMAND_SEEK_TO_PREVIOUS
|
||||
import androidx.media3.common.Player.MEDIA_ITEM_TRANSITION_REASON_AUTO
|
||||
import androidx.media3.common.Player.REPEAT_MODE_OFF
|
||||
import androidx.media3.common.Rating
|
||||
@ -50,12 +48,13 @@ 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
|
||||
@ -97,15 +96,15 @@ class MediaPlayerController(
|
||||
* We run the event through RxBus in order to throttle them
|
||||
*/
|
||||
override fun onTimelineChanged(timeline: Timeline, reason: Int) {
|
||||
val start = controller?.currentTimeline?.getFirstWindowIndex(isShufflePlayEnabled)
|
||||
val start = timeline.getFirstWindowIndex(isShufflePlayEnabled)
|
||||
Timber.w("On timeline changed. First shuffle play at index: %s", start)
|
||||
deferredPlay?.let {
|
||||
Timber.w("Executing deferred shuffle play")
|
||||
it()
|
||||
deferredPlay = null
|
||||
}
|
||||
|
||||
RxBus.playlistPublisher.onNext(playlist.map(MediaItem::toTrack))
|
||||
val playlist = Util.getPlayListFromTimeline(timeline, false).map(MediaItem::toTrack)
|
||||
RxBus.playlistPublisher.onNext(playlist)
|
||||
}
|
||||
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
@ -179,11 +178,8 @@ class MediaPlayerController(
|
||||
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) {
|
||||
@ -195,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()
|
||||
}
|
||||
}
|
||||
|
||||
@ -246,6 +241,22 @@ class MediaPlayerController(
|
||||
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
|
||||
|
||||
@ -262,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.
|
||||
@ -336,7 +351,6 @@ class MediaPlayerController(
|
||||
@Synchronized
|
||||
fun play(index: Int) {
|
||||
controller?.seekTo(index, 0L)
|
||||
// FIXME CHECK ITS NOT MAKING PROBLEMS
|
||||
controller?.prepare()
|
||||
controller?.play()
|
||||
}
|
||||
@ -538,7 +552,7 @@ class MediaPlayerController(
|
||||
|
||||
@Synchronized
|
||||
fun canSeekToPrevious(): Boolean {
|
||||
return controller?.isCommandAvailable(COMMAND_SEEK_TO_PREVIOUS) == true
|
||||
return controller?.isCommandAvailable(Player.COMMAND_SEEK_TO_PREVIOUS) == true
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
@ -548,7 +562,7 @@ class MediaPlayerController(
|
||||
|
||||
@Synchronized
|
||||
fun canSeekToNext(): Boolean {
|
||||
return controller?.isCommandAvailable(COMMAND_SEEK_TO_NEXT) == true
|
||||
return controller?.isCommandAvailable(Player.COMMAND_SEEK_TO_NEXT) == true
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
@ -580,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
|
||||
@ -700,10 +661,6 @@ class MediaPlayerController(
|
||||
controller?.volume = gain
|
||||
}
|
||||
|
||||
fun setVolume(volume: Float) {
|
||||
controller?.volume = volume
|
||||
}
|
||||
|
||||
/*
|
||||
* Sets the rating of the current track
|
||||
*/
|
||||
@ -841,4 +798,6 @@ class MediaPlayerController(
|
||||
enum class InsertionMode {
|
||||
CLEAR, APPEND, AFTER_CURRENT
|
||||
}
|
||||
|
||||
enum class PlayerBackend { JUKEBOX, LOCAL }
|
||||
}
|
@ -14,6 +14,6 @@ data class PlaybackState(
|
||||
var repeatMode: Int = 0
|
||||
) : Serializable {
|
||||
companion object {
|
||||
const val serialVersionUID = -293487987L
|
||||
private const val serialVersionUID = -293487987L
|
||||
}
|
||||
}
|
||||
|
@ -18,7 +18,7 @@ import org.moire.ultrasonic.api.subsonic.models.JukeboxAction
|
||||
import org.moire.ultrasonic.api.subsonic.throwOnFailure
|
||||
import org.moire.ultrasonic.api.subsonic.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)
|
||||
|
@ -14,11 +14,11 @@ import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
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.DownloadService
|
||||
import org.moire.ultrasonic.service.MediaPlayerController
|
||||
import org.moire.ultrasonic.service.MediaPlayerManager
|
||||
import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService
|
||||
import org.moire.ultrasonic.util.Settings
|
||||
import org.moire.ultrasonic.util.executeTaskWithToast
|
||||
@ -28,7 +28,7 @@ import org.moire.ultrasonic.util.executeTaskWithToast
|
||||
*/
|
||||
@Suppress("LongParameterList")
|
||||
class DownloadHandler(
|
||||
val mediaPlayerController: MediaPlayerController,
|
||||
val mediaPlayerManager: MediaPlayerManager,
|
||||
private val networkAndStorageChecker: NetworkAndStorageChecker
|
||||
) : CoroutineScope by CoroutineScope(Dispatchers.IO) {
|
||||
private val maxSongs = 500
|
||||
@ -46,7 +46,7 @@ class DownloadHandler(
|
||||
var successString: String? = null
|
||||
|
||||
// Launch the Job
|
||||
executeTaskWithToast(fragment, {
|
||||
executeTaskWithToast({
|
||||
val tracksToDownload: List<Track> = tracks
|
||||
?: getTracksFromServer(isArtist, id!!, isDirectory, name, isShare)
|
||||
|
||||
@ -104,7 +104,7 @@ class DownloadHandler(
|
||||
) {
|
||||
var successString: String? = null
|
||||
// Launch the Job
|
||||
executeTaskWithToast(fragment, {
|
||||
executeTaskWithToast({
|
||||
val songs: MutableList<Track> =
|
||||
getTracksFromServer(isArtist, id, isDirectory, name, isShare)
|
||||
|
||||
@ -150,16 +150,16 @@ class DownloadHandler(
|
||||
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
|
||||
|
||||
val insertionMode = when {
|
||||
append -> MediaPlayerController.InsertionMode.APPEND
|
||||
playNext -> MediaPlayerController.InsertionMode.AFTER_CURRENT
|
||||
else -> MediaPlayerController.InsertionMode.CLEAR
|
||||
append -> MediaPlayerManager.InsertionMode.APPEND
|
||||
playNext -> MediaPlayerManager.InsertionMode.AFTER_CURRENT
|
||||
else -> MediaPlayerManager.InsertionMode.CLEAR
|
||||
}
|
||||
|
||||
if (playlistName != null) {
|
||||
mediaPlayerController.suggestedPlaylistName = playlistName
|
||||
mediaPlayerManager.suggestedPlaylistName = playlistName
|
||||
}
|
||||
|
||||
mediaPlayerController.addToPlaylist(
|
||||
mediaPlayerManager.addToPlaylist(
|
||||
songs,
|
||||
autoPlay,
|
||||
shuffle,
|
||||
@ -181,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)
|
||||
@ -219,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) {
|
||||
@ -250,6 +246,7 @@ class DownloadHandler(
|
||||
}
|
||||
}
|
||||
}
|
||||
return songs
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -19,7 +19,7 @@ import org.koin.java.KoinJavaComponent.inject
|
||||
import org.moire.ultrasonic.data.ActiveServerProvider
|
||||
import org.moire.ultrasonic.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()
|
||||
|
@ -20,6 +20,7 @@ import kotlinx.coroutines.CoroutineExceptionHandler
|
||||
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
|
||||
import org.moire.ultrasonic.subsonic.getLocalizedErrorMessage
|
||||
import timber.log.Timber
|
||||
|
||||
@ -46,14 +47,14 @@ object CommunicationError {
|
||||
|
||||
ErrorDialog(
|
||||
context = context,
|
||||
message = getErrorMessage(error, context)
|
||||
message = getErrorMessage(error)
|
||||
).show()
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
@Suppress("ReturnCount")
|
||||
fun getErrorMessage(error: Throwable, context: Context?): String {
|
||||
if (context == null) return "Couldn't get Error message, Context is null"
|
||||
fun getErrorMessage(error: Throwable): String {
|
||||
val context = UApp.applicationContext()
|
||||
if (error is IOException && !Util.hasUsableNetwork()) {
|
||||
return context.resources.getString(R.string.background_task_no_network)
|
||||
} else if (error is FileNotFoundException) {
|
||||
|
@ -17,6 +17,7 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import org.moire.ultrasonic.R
|
||||
import org.moire.ultrasonic.app.UApp
|
||||
import timber.log.Timber
|
||||
|
||||
object CoroutinePatterns {
|
||||
@ -30,7 +31,6 @@ object CoroutinePatterns {
|
||||
}
|
||||
|
||||
fun CoroutineScope.executeTaskWithToast(
|
||||
fragment: Fragment,
|
||||
task: suspend CoroutineScope.() -> Unit,
|
||||
successString: () -> String?
|
||||
): Job {
|
||||
@ -40,7 +40,7 @@ fun CoroutineScope.executeTaskWithToast(
|
||||
// Setup a handler when the job is done
|
||||
job.invokeOnCompletion {
|
||||
val toastString = if (it != null && it !is CancellationException) {
|
||||
CommunicationError.getErrorMessage(it, fragment.context)
|
||||
CommunicationError.getErrorMessage(it)
|
||||
} else {
|
||||
successString()
|
||||
}
|
||||
@ -49,7 +49,7 @@ fun CoroutineScope.executeTaskWithToast(
|
||||
if (toastString == null) return@invokeOnCompletion
|
||||
|
||||
launch(Dispatchers.Main) {
|
||||
Util.toast(fragment.context, toastString)
|
||||
Util.toast(UApp.applicationContext(), toastString)
|
||||
}
|
||||
}
|
||||
|
||||
@ -62,7 +62,7 @@ fun CoroutineScope.executeTaskWithModalDialog(
|
||||
successString: () -> String
|
||||
) {
|
||||
// Create the job
|
||||
val job = executeTaskWithToast(fragment, task, successString)
|
||||
val job = executeTaskWithToast(task, successString)
|
||||
|
||||
// Create the dialog
|
||||
val builder = InfoDialog.Builder(fragment.requireContext())
|
||||
|
@ -0,0 +1,49 @@
|
||||
/*
|
||||
* SelectCacheActivityContract.kt
|
||||
* Copyright (C) 2009-2023 Ultrasonic developers
|
||||
*
|
||||
* Distributed under terms of the GNU GPLv3 license.
|
||||
*/
|
||||
|
||||
package org.moire.ultrasonic.util
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.DocumentsContract
|
||||
import androidx.activity.result.contract.ActivityResultContract
|
||||
import org.moire.ultrasonic.fragment.SettingsFragment
|
||||
|
||||
class SelectCacheActivityContract : ActivityResultContract<String?, Uri?>() {
|
||||
override fun createIntent(context: Context, input: String?): Intent {
|
||||
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
|
||||
if (Settings.cacheLocationUri != "" && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, input)
|
||||
}
|
||||
intent.addFlags(SettingsFragment.RW_FLAG)
|
||||
intent.addFlags(SettingsFragment.PERSISTABLE_FLAG)
|
||||
return intent
|
||||
}
|
||||
|
||||
override fun parseResult(resultCode: Int, intent: Intent?): Uri? {
|
||||
if (
|
||||
resultCode == Activity.RESULT_OK &&
|
||||
intent != null
|
||||
) {
|
||||
val read = (intent.flags and Intent.FLAG_GRANT_READ_URI_PERMISSION) != 0
|
||||
val write = (intent.flags and Intent.FLAG_GRANT_WRITE_URI_PERMISSION) != 0
|
||||
val persist = (intent.flags and Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION) != 0
|
||||
|
||||
if (read && write && persist) {
|
||||
if (intent.data != null) {
|
||||
// The result data contains a URI for the document or directory that
|
||||
// the user selected.
|
||||
return intent.data!!
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
@ -33,22 +33,26 @@ object Settings {
|
||||
val maxBitRate: Int
|
||||
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)
|
||||
|
||||
@ -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
|
||||
|
@ -833,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>
|
@ -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>
|
||||
@ -453,4 +452,5 @@
|
||||
<string name="supported_server_features">Funciones soportadas</string>
|
||||
<string name="foreground_exception_title">No se puede reanudar la reproducción</string>
|
||||
<string name="foreground_exception_text">Presione el botón de reproducción en la notificación de medios si aún está presente; de lo contrario, abra la aplicación para iniciar la reproducción y vuelva a conectar la sesión al controlador</string>
|
||||
<string name="settings.max_bitrate_pinning">Tasa de bits máxima: al fijar una canción de forma permanente</string>
|
||||
</resources>
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -42,7 +42,6 @@
|
||||
<string name="download.jukebox_on">リモートコントロールがオンになりました。サーバーで音楽が再生されます。</string>
|
||||
<string name="download.jukebox_off">リモートコントロールがオフになりました。モバイル端末で音楽が再生されます。</string>
|
||||
<string name="download.jukebox_server_too_old">リモートコントロールがサポートされていません。Subsonicサーバーをアップグレードしてください。</string>
|
||||
<string name="download.jukebox_volume">リモート音量</string>
|
||||
<string name="download.menu_jukebox_on">ジュークボックス ON</string>
|
||||
<string name="download.menu_lyrics">歌詞</string>
|
||||
<string name="download.menu_show_album">アルバムを表示</string>
|
||||
|
@ -339,7 +339,6 @@
|
||||
<string name="download.jukebox_not_authorized">Fjernkontroll er avskrudd. Skru på jukebox-modus i <b>Brukere > Innstillinger</b> på din Subsonic-tjener.</string>
|
||||
<string name="download.jukebox_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>
|
||||
|
@ -47,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 +62,6 @@
|
||||
<string name="download.jukebox_offline">Controle remoto não está disponível no modo offline.</string>
|
||||
<string name="download.jukebox_on">Controle remoto ligado. Música tocada no servidor.</string>
|
||||
<string name="download.jukebox_server_too_old">Controle remoto não suportado. Atualize seu servidor Subsonic.</string>
|
||||
<string name="download.jukebox_volume">Volume Remoto</string>
|
||||
<string name="download.menu_equalizer">Equalizador</string>
|
||||
<string name="download.menu_jukebox_off">Jukebox Desligado</string>
|
||||
<string name="download.menu_jukebox_on">Jukebox Ligado</string>
|
||||
|
@ -47,7 +47,6 @@
|
||||
<string name="download.jukebox_offline">Controle remoto não está disponível no modo offline.</string>
|
||||
<string name="download.jukebox_on">Controle remoto ligado. Música tocada no servidor.</string>
|
||||
<string name="download.jukebox_server_too_old">Controle remoto não suportado. Atualize seu servidor Subsonic.</string>
|
||||
<string name="download.jukebox_volume">Volume Remoto</string>
|
||||
<string name="download.menu_equalizer">Equalizador</string>
|
||||
<string name="download.menu_jukebox_off">Jukebox Desligado</string>
|
||||
<string name="download.menu_jukebox_on">Jukebox Ligado</string>
|
||||
|
@ -59,7 +59,6 @@
|
||||
<string name="download.jukebox_offline">Пульт дистанционного управления недоступен в автономном режиме.</string>
|
||||
<string name="download.jukebox_on">Включен пульт управления. Музыка играет на сервере.</string>
|
||||
<string name="download.jukebox_server_too_old">Пульт дистанционного управления не поддерживается. Пожалуйста, обновите ваш дозвуковой сервер.</string>
|
||||
<string name="download.jukebox_volume">Удаленная громкость</string>
|
||||
<string name="download.menu_equalizer">Эквалайзер</string>
|
||||
<string name="download.menu_jukebox_off">Jukebox выключен</string>
|
||||
<string name="download.menu_jukebox_on">Jukebox включен</string>
|
||||
|
@ -60,7 +60,6 @@
|
||||
<string name="download.jukebox_offline">离线模式不支持远程控制。</string>
|
||||
<string name="download.jukebox_on">已打开远程控制,音乐将在服务端播放。</string>
|
||||
<string name="download.jukebox_server_too_old">远程控制不支持,请升级您的 Subsonic 服务器。</string>
|
||||
<string name="download.jukebox_volume">远程音量</string>
|
||||
<string name="download.menu_equalizer">均衡器</string>
|
||||
<string name="download.menu_jukebox_off">关闭点唱机</string>
|
||||
<string name="download.menu_jukebox_on">开启点唱机</string>
|
||||
@ -68,7 +67,7 @@
|
||||
<string name="download.menu_save">保存播放列表</string>
|
||||
<string name="download.menu_screen_off">关闭屏幕常亮</string>
|
||||
<string name="download.menu_screen_on">开启屏幕常亮</string>
|
||||
<string name="download.menu_show_album">显示专辑</string>
|
||||
<string name="download.menu_show_album">转到专辑</string>
|
||||
<string name="download.menu_shuffle">随机</string>
|
||||
<string name="download.menu_shuffle_on">随机播放模式已启用</string>
|
||||
<string name="download.menu_shuffle_off">随机播放模式已禁用</string>
|
||||
@ -281,7 +280,7 @@
|
||||
<string name="settings.test_connection_title">测试连接</string>
|
||||
<string name="settings.theme_light">亮色</string>
|
||||
<string name="settings.theme_dark">暗色</string>
|
||||
<string name="settings.theme_black">Black</string>
|
||||
<string name="settings.theme_black">黑色</string>
|
||||
<string name="settings.theme_title">主题</string>
|
||||
<string name="settings.title.allow_self_signed_certificate">允许自签名 HTTPS 证书</string>
|
||||
<string name="settings.title.force_plain_text_password">强制原始密码认证</string>
|
||||
@ -338,7 +337,7 @@
|
||||
<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="download.menu_show_artist">转到艺术家</string>
|
||||
<string name="common_multiple_years">数年</string>
|
||||
<string name="settings.debug.title">调试选项</string>
|
||||
<string name="settings.debug.log_to_file">将调试日志写入文件</string>
|
||||
@ -449,4 +448,5 @@
|
||||
<string name="language.cs">捷克语</string>
|
||||
<string name="language.de">德语</string>
|
||||
<string name="language.pt_BR">葡萄牙语(巴西)</string>
|
||||
<string name="settings.max_bitrate_pinning">最大比特率 - 永久固定歌曲时</string>
|
||||
</resources>
|
@ -84,7 +84,7 @@
|
||||
<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_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>
|
||||
@ -129,7 +129,7 @@
|
||||
<string name="time_span_disabled">已停用</string>
|
||||
<string name="share_comment">註記</string>
|
||||
<string name="server_menu.delete">刪除</string>
|
||||
<string name="download.menu_show_album">顯示專輯</string>
|
||||
<string name="download.menu_show_album">轉至專輯</string>
|
||||
<string name="language.zh_CN">簡體中文(中國)</string>
|
||||
<string name="download.menu_save">儲存播放清單</string>
|
||||
<string name="download.bookmark_set_at_position" formatted="false">書籤設置在 %s。</string>
|
||||
@ -141,7 +141,6 @@
|
||||
<string name="common.pin">固定</string>
|
||||
<string name="chat.send_button">傳送</string>
|
||||
<string name="button_bar.chat">聊天</string>
|
||||
<string name="download.jukebox_volume">遠端音量</string>
|
||||
<string name="chat.user_avatar">頭像</string>
|
||||
<string name="common.delete_selection_confirmation">您真的要刪除目前選取的項目嗎?</string>
|
||||
<string name="background_task.parse_error">無法理解答覆,請檢查伺服器位址。</string>
|
||||
@ -233,4 +232,68 @@
|
||||
<string name="settings.hide_media_toast">在 Android 系統下次掃描裝置內音樂時生效。</string>
|
||||
<string name="settings.download_transition">播放時顯示正在播放介面</string>
|
||||
<string name="settings.download_transition_summary">在媒體庫介面開始播放後切換到正在播放介面</string>
|
||||
<string name="settings.search_50">50</string>
|
||||
<string name="settings.preload_3">3 首歌</string>
|
||||
<string name="settings.search_1">1</string>
|
||||
<string name="settings.search_20">20</string>
|
||||
<string name="settings.search_75">75</string>
|
||||
<string name="settings.share_minutes">分鐘</string>
|
||||
<string name="settings.preload_500">500 首歌</string>
|
||||
<string name="settings.max_bitrate_112">112 Kbps</string>
|
||||
<string name="settings.preload_1">1 首歌</string>
|
||||
<string name="settings.search_3">3</string>
|
||||
<string name="settings.search_40">40</string>
|
||||
<string name="settings.search_500">500</string>
|
||||
<string name="settings.max_bitrate_160">160 Kbps</string>
|
||||
<string name="util.zero_time">0:00</string>
|
||||
<string name="settings.network_timeout_120000">120 秒</string>
|
||||
<string name="settings.share_hours">小時</string>
|
||||
<string name="settings.theme_black">黑色</string>
|
||||
<string name="server_editor.authentication">認證</string>
|
||||
<string name="settings.preload_2">2 首歌</string>
|
||||
<string name="settings.search_10">10</string>
|
||||
<string name="settings.server_address">伺服器地址</string>
|
||||
<string name="settings.network_timeout_60000">60 秒</string>
|
||||
<string name="settings.search_5">5</string>
|
||||
<string name="settings.preload_100">100 首歌</string>
|
||||
<string name="settings.theme_light">明色</string>
|
||||
<string name="settings.max_bitrate_96">96 Kbps</string>
|
||||
<string name="util.bytes_format.kilobyte">0 KB</string>
|
||||
<string name="settings.override_language">覆寫當前語言</string>
|
||||
<string name="settings.preload_5">5 首歌</string>
|
||||
<string name="settings.search_250">250</string>
|
||||
<string name="settings.max_bitrate_192">192 Kbps</string>
|
||||
<string name="settings.max_bitrate_80">80 Kbps</string>
|
||||
<string name="settings.search_25">25</string>
|
||||
<string name="settings.search_30">30</string>
|
||||
<string name="settings.search_100">100</string>
|
||||
<string name="main.video" tools:ignore="UnusedResources">影片</string>
|
||||
<string name="time_span_disable">禁用</string>
|
||||
<string name="settings.preload_50">50 首歌</string>
|
||||
<string name="song_details.kbps">%d kbps</string>
|
||||
<string name="settings.preload_10">10 首歌</string>
|
||||
<string name="settings.max_bitrate_256">256 Kbps</string>
|
||||
<string name="settings.max_bitrate_32">32 Kbps</string>
|
||||
<string name="settings.max_bitrate_320">320 Kbps</string>
|
||||
<string name="settings.max_bitrate_64">64 Kbps</string>
|
||||
<string name="settings.network_timeout">網路延時</string>
|
||||
<string name="settings.network_timeout_105000">105 秒</string>
|
||||
<string name="settings.network_timeout_45000">45 秒</string>
|
||||
<string name="settings.network_timeout_75000">75 秒</string>
|
||||
<string name="settings.network_timeout_90000">90 秒</string>
|
||||
<string name="settings.notifications_title">通知</string>
|
||||
<string name="settings.network_title">網路</string>
|
||||
<string name="settings.other_title">其他設定</string>
|
||||
<string name="settings.search_15">15</string>
|
||||
<string name="settings.server_color">伺服器顏色</string>
|
||||
<string name="util.bytes_format.byte">0 B</string>
|
||||
<string name="util.bytes_format.gigabyte">0.00 GB</string>
|
||||
<string name="settings.server_username">用戶名</string>
|
||||
<string name="settings.theme_dark">暗色</string>
|
||||
<string name="util.no_time" tools:ignore="TypographyDashes">-:--</string>
|
||||
<string name="util.bytes_format.megabyte">0.00 MB</string>
|
||||
<string name="settings.preload_1000">1000 首歌</string>
|
||||
<string name="settings.server_password">密碼</string>
|
||||
<string name="settings.share_days">天</string>
|
||||
<string name="settings.max_bitrate_128">128 Kbps</string>
|
||||
</resources>
|
@ -12,6 +12,7 @@
|
||||
<string name="setting_key.show_track_number" translatable="false">showTrackNumber</string>
|
||||
<string name="setting_key.max_bitrate_wifi" translatable="false">maxBitrateWifi</string>
|
||||
<string name="setting_key.max_bitrate_mobile" translatable="false">maxBitrateMobile</string>
|
||||
<string name="setting_key.max_bitrate_pinning" translatable="false">maxBitratePinning</string>
|
||||
<string name="setting_key.cache_size" translatable="false">cacheSize</string>
|
||||
<string name="setting_key.custom_cache_location" translatable="false">customCacheLocation</string>
|
||||
<string name="setting_key.cache_location" translatable="false">cacheLocation</string>
|
||||
|
@ -63,7 +63,6 @@
|
||||
<string name="download.jukebox_offline">Remote control is not available in offline mode.</string>
|
||||
<string name="download.jukebox_on">Turned on remote control. Music is played on server.</string>
|
||||
<string name="download.jukebox_server_too_old">Remote control is not supported. Please upgrade your Subsonic server.</string>
|
||||
<string name="download.jukebox_volume">Remote Volume</string>
|
||||
<string name="download.menu_equalizer">Equalizer</string>
|
||||
<string name="download.menu_jukebox_off">Jukebox Off</string>
|
||||
<string name="download.menu_jukebox_on">Jukebox On</string>
|
||||
@ -234,6 +233,7 @@
|
||||
<string name="settings.max_bitrate_mobile">Max Bitrate - Mobile</string>
|
||||
<string name="settings.max_bitrate_unlimited">Unlimited</string>
|
||||
<string name="settings.max_bitrate_wifi">Max Bitrate - Wi-Fi</string>
|
||||
<string name="settings.max_bitrate_pinning">Max Bitrate - When pinning a song permanently</string>
|
||||
<string name="settings.max_songs">Max Songs</string>
|
||||
<string name="settings.media_button_summary">Respond to phone, headset and Bluetooth media buttons</string>
|
||||
<string name="settings.media_button_title">Media Buttons</string>
|
||||
|
@ -167,18 +167,25 @@
|
||||
a:title="@string/settings.network_title"
|
||||
app:iconSpaceReserved="false">
|
||||
<ListPreference
|
||||
a:defaultValue="0"
|
||||
a:defaultValue="256"
|
||||
a:entries="@array/maxBitrateNames"
|
||||
a:entryValues="@array/maxBitrateValues"
|
||||
a:key="@string/setting_key.max_bitrate_mobile"
|
||||
a:title="@string/settings.max_bitrate_mobile"
|
||||
app:iconSpaceReserved="false"/>
|
||||
<ListPreference
|
||||
a:defaultValue="320"
|
||||
a:entries="@array/maxBitrateNames"
|
||||
a:entryValues="@array/maxBitrateValues"
|
||||
a:key="@string/setting_key.max_bitrate_wifi"
|
||||
a:title="@string/settings.max_bitrate_wifi"
|
||||
app:iconSpaceReserved="false"/>
|
||||
<ListPreference
|
||||
a:defaultValue="0"
|
||||
a:defaultValue="320"
|
||||
a:entries="@array/maxBitrateNames"
|
||||
a:entryValues="@array/maxBitrateValues"
|
||||
a:key="@string/setting_key.max_bitrate_mobile"
|
||||
a:title="@string/settings.max_bitrate_mobile"
|
||||
a:key="@string/setting_key.max_bitrate_pinning"
|
||||
a:title="@string/settings.max_bitrate_pinning"
|
||||
app:iconSpaceReserved="false"/>
|
||||
<CheckBoxPreference
|
||||
a:defaultValue="false"
|
||||
|
Loading…
x
Reference in New Issue
Block a user