diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 7b3bdb7f..3f9d9721 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -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: diff --git a/README.md b/README.md index 27ded22e..7a7579cb 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/detekt-config.yml b/config/detekt/detekt.yml similarity index 85% rename from detekt-config.yml rename to config/detekt/detekt.yml index ef02adeb..0bd0b7cb 100644 --- a/detekt-config.yml +++ b/config/detekt/detekt.yml @@ -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: diff --git a/core/domain/build.gradle b/core/domain/build.gradle index b462ae9c..75d3d4a5 100644 --- a/core/domain/build.gradle +++ b/core/domain/build.gradle @@ -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 diff --git a/fastlane/metadata/android/en-US/changelogs/122.txt b/fastlane/metadata/android/en-US/changelogs/122.txt new file mode 100644 index 00000000..e691579a --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/122.txt @@ -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) \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt index 18d5f968..cdec6bb2 100644 --- a/fastlane/metadata/android/en-US/full_description.txt +++ b/fastlane/metadata/android/en-US/full_description.txt @@ -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 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index dc011630..057f57a7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" } diff --git a/gradle_scripts/android-module-bootstrap.gradle b/gradle_scripts/android-module-bootstrap.gradle index eeb4fe6d..f84d8fe5 100644 --- a/gradle_scripts/android-module-bootstrap.gradle +++ b/gradle_scripts/android-module-bootstrap.gradle @@ -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 diff --git a/gradle_scripts/code_quality.gradle b/gradle_scripts/code_quality.gradle index 8738567e..f5880485 100644 --- a/gradle_scripts/code_quality.gradle +++ b/gradle_scripts/code_quality.gradle @@ -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" diff --git a/gradle_scripts/kotlin-module-bootstrap.gradle b/gradle_scripts/kotlin-module-bootstrap.gradle index d440dec5..c71044e9 100644 --- a/gradle_scripts/kotlin-module-bootstrap.gradle +++ b/gradle_scripts/kotlin-module-bootstrap.gradle @@ -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 { diff --git a/ultrasonic/build.gradle b/ultrasonic/build.gradle index 8213981d..bc68203f 100644 --- a/ultrasonic/build.gradle +++ b/ultrasonic/build.gradle @@ -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 diff --git a/detekt-baseline.xml b/ultrasonic/detekt-baseline.xml similarity index 86% rename from detekt-baseline.xml rename to ultrasonic/detekt-baseline.xml index 3acd3952..a8789e05 100644 --- a/detekt-baseline.xml +++ b/ultrasonic/detekt-baseline.xml @@ -1,9 +1,9 @@ - + TooManyFunctions:PlaybackService.kt$PlaybackService : MediaLibraryServiceKoinComponentCoroutineScope UnusedPrivateMember:UApp.kt$private fun VmPolicy.Builder.detectAllExceptSocket(): VmPolicy.Builder - ImplicitDefaultLocale:EditServerFragment.kt$EditServerFragment.<no name provided>$String.format( "%s %s", resources.getString(R.string.settings_connection_failure), getErrorMessage(error) ) + ImplicitDefaultLocale:EditServerFragment.kt$EditServerFragment.<no name provided>$String.format( "%s %s", resources.getString(R.string.settings_connection_failure), getErrorMessage(error) ) ImplicitDefaultLocale:FileLoggerTree.kt$FileLoggerTree$String.format("Failed to write log to %s", file) ImplicitDefaultLocale:FileLoggerTree.kt$FileLoggerTree$String.format("Log file rotated, logging into file %s", file?.name) ImplicitDefaultLocale:FileLoggerTree.kt$FileLoggerTree$String.format("Logging into file %s", file?.name) @@ -13,7 +13,7 @@ LongMethod:PlaylistsFragment.kt$PlaylistsFragment$override fun onContextItemSelected(menuItem: MenuItem): Boolean LongMethod:ShareHandler.kt$ShareHandler$private fun showDialog( fragment: Fragment, shareDetails: ShareDetails, swipe: SwipeRefreshLayout?, cancellationToken: CancellationToken, additionalId: String? ) LongMethod:SharesFragment.kt$SharesFragment$override fun onContextItemSelected(menuItem: MenuItem): Boolean - 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) ) + 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) ) MagicNumber:ActiveServerProvider.kt$ActiveServerProvider$8192 MagicNumber:JukeboxMediaPlayer.kt$JukeboxMediaPlayer$0.05f MagicNumber:JukeboxMediaPlayer.kt$JukeboxMediaPlayer$50 diff --git a/ultrasonic/lint-baseline.xml b/ultrasonic/lint-baseline.xml index 2458dc9d..f77f8b2f 100644 --- a/ultrasonic/lint-baseline.xml +++ b/ultrasonic/lint-baseline.xml @@ -1,27 +1,5 @@ - - - - - - - - - + @@ -48,50 +26,6 @@ file="../core/subsonic-api/build/libs/subsonic-api.jar"/> - - - - - - - - - - - - - - - - - - - - + @@ -21,6 +22,7 @@ - - - . - - 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())); - } - } -} diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/receiver/BluetoothIntentReceiver.kt b/ultrasonic/src/main/java/org/moire/ultrasonic/receiver/BluetoothIntentReceiver.kt new file mode 100644 index 00000000..727a7ff5 --- /dev/null +++ b/ultrasonic/src/main/java/org/moire/ultrasonic/receiver/BluetoothIntentReceiver.kt @@ -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) + } +} diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/util/BackgroundTask.java b/ultrasonic/src/main/java/org/moire/ultrasonic/util/BackgroundTask.java index 8ae51dee..bf59e1c8 100644 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/util/BackgroundTask.java +++ b/ultrasonic/src/main/java/org/moire/ultrasonic/util/BackgroundTask.java @@ -58,7 +58,7 @@ public abstract class BackgroundTask implements ProgressListener protected String getErrorMessage(Throwable error) { - return CommunicationError.getErrorMessage(error, activity); + return CommunicationError.getErrorMessage(error); } @Override diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/util/SimpleServiceBinder.java b/ultrasonic/src/main/java/org/moire/ultrasonic/util/SimpleServiceBinder.java deleted file mode 100644 index 47360a1b..00000000 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/util/SimpleServiceBinder.java +++ /dev/null @@ -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 . - - Copyright 2009 (C) Sindre Mehus - */ -package org.moire.ultrasonic.util; - -import android.os.Binder; - -/** - * @author Sindre Mehus - */ -public class SimpleServiceBinder extends Binder -{ - private final S service; - - public SimpleServiceBinder(S service) - { - this.service = service; - } - - public S getService() - { - return service; - } -} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/activity/NavigationActivity.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/activity/NavigationActivity.kt index 6ba4f89d..8ed8c8bc 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/activity/NavigationActivity.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/activity/NavigationActivity.kt @@ -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 } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/ArtistRowBinder.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/ArtistRowBinder.kt index 7e73e0b6..a50f082f 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/ArtistRowBinder.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/ArtistRowBinder.kt @@ -115,7 +115,7 @@ class ArtistRowBinder( } private fun showArtistPicture(): Boolean { - return ActiveServerProvider.isID3Enabled() && Settings.shouldShowArtistPicture + return ActiveServerProvider.shouldUseId3Tags() && Settings.shouldShowArtistPicture } /** diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/TrackViewHolder.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/TrackViewHolder.kt index 79b74069..2efea271 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/TrackViewHolder.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/TrackViewHolder.kt @@ -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 diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/data/ActiveServerProvider.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/data/ActiveServerProvider.kt index 04ff6b8e..06672dc1 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/data/ActiveServerProvider.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/data/ActiveServerProvider.kt @@ -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) } /** diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/MediaPlayerModule.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/MediaPlayerModule.kt index 6ec150c6..823a84fa 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/MediaPlayerModule.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/MediaPlayerModule.kt @@ -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()) } } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/AlbumListFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/AlbumListFragment.kt index 7fb227be..9e7cb6c5 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/AlbumListFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/AlbumListFragment.kt @@ -138,8 +138,8 @@ class AlbumListFragment( ) private fun getListOfSortOrders(): List { - val useId3 = Settings.shouldUseId3Tags - val useId3Offline = Settings.useId3TagsOffline + val useId3 = Settings.id3TagsEnabledOnline + val useId3Offline = Settings.id3TagsEnabledOffline val isOnline = !ActiveServerProvider.isOffline() val supported = mutableListOf() diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/BookmarksFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/BookmarksFragment.kt index ed644e72..5f0ef215 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/BookmarksFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/BookmarksFragment.kt @@ -72,7 +72,7 @@ class BookmarksFragment : TrackCollectionFragment() { currentPlayingPosition = songs[0].bookmarkPosition ) - mediaPlayerController.restore( + mediaPlayerManager.restore( state = state, autoPlay = true, newPlaylist = true diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/EditServerFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/EditServerFragment.kt index e16e0d53..73d3a0ba 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/EditServerFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/EditServerFragment.kt @@ -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() } } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/EntryListFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/EntryListFragment.kt index 80a78416..dca68e30 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/EntryListFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/EntryListFragment.kt @@ -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 : MultiListFragment() { */ private fun showFolderHeader(): Boolean { return listModel.showSelectFolderHeader() && !listModel.isOffline() && - !Settings.shouldUseId3Tags + !ActiveServerProvider.shouldUseId3Tags() } override fun onContextMenuItemSelected(menuItem: MenuItem, item: T): Boolean { diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/NowPlayingFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/NowPlayingFragment.kt index eb192af7..70eaf028 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/NowPlayingFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/NowPlayingFragment.kt @@ -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) { diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/PlayerFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/PlayerFragment.kt index 2dd60683..cc46a670 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/PlayerFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/PlayerFragment.kt @@ -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 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(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 = 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 { diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/SearchFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/SearchFragment.kt index 9dfcc1ef..bd70f431 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/SearchFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/SearchFragment.kt @@ -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(), 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(), 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(), 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(), 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(), 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)) } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/SettingsFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/SettingsFragment.kt index 4c25e30c..700bde05 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/SettingsFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/SettingsFragment.kt @@ -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 diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/TrackCollectionFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/TrackCollectionFragment.kt index 7d38ba24..5491bca0 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/TrackCollectionFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/TrackCollectionFragment.kt @@ -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( diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/legacy/PlaylistsFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/legacy/PlaylistsFragment.kt index 3a077b45..ebffc3cc 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/legacy/PlaylistsFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/legacy/PlaylistsFragment.kt @@ -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? = null - private val downloadHandler = inject( - DownloadHandler::class.java - ) + private val downloadHandler by inject() 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, diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/model/AlbumListModel.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/model/AlbumListModel.kt index 56f82ab3..4fda6e60 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/model/AlbumListModel.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/model/AlbumListModel.kt @@ -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 { diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/model/ArtistListModel.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/model/ArtistListModel.kt index 6dd042ce..801f6feb 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/model/ArtistListModel.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/model/ArtistListModel.kt @@ -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) diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/model/EditServerModel.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/model/EditServerModel.kt index c7e51723..622f7b99 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/model/EditServerModel.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/model/EditServerModel.kt @@ -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 { val client = buildTestClient(currentServerSetting) // One line of magic: diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/model/GenericListModel.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/model/GenericListModel.kt index 813dcce8..b0ab5481 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/model/GenericListModel.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/model/GenericListModel.kt @@ -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) diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/model/TrackCollectionModel.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/model/TrackCollectionModel.kt index 8c2e65f9..df0cb4aa 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/model/TrackCollectionModel.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/model/TrackCollectionModel.kt @@ -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) } } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/AutoMediaBrowserCallback.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/AutoMediaBrowserCallback.kt index 8921bb31..8ca449af 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/AutoMediaBrowserCallback.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/AutoMediaBrowserCallback.kt @@ -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() + private val mediaPlayerManager by inject() 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 + internal var customLayout = ImmutableList.of() + + 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? = 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 { - 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 { - 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 { + // 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) + } } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/CustomNotificationProvider.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/CustomNotificationProvider.kt index 65674111..50fa4c4f 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/CustomNotificationProvider.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/CustomNotificationProvider.kt @@ -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() - - override fun addNotificationActions( - mediaSession: MediaSession, - mediaButtons: ImmutableList, - builder: NotificationCompat.Builder, - actionFactory: MediaNotification.ActionFactory - ): IntArray { - val tmp: MutableList = 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, diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/PlaybackService.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/PlaybackService.kt index 7603f94f..6dd656a3 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/PlaybackService.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/PlaybackService.kt @@ -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 } } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadService.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadService.kt index 946ce471..808c3bbe 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadService.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadService.kt @@ -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(val service: S) : Binder() diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadTask.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadTask.kt index 4cd4c7df..e36975b8 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadTask.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadTask.kt @@ -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 diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/JukeboxMediaPlayer.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/JukeboxMediaPlayer.kt index d8550f09..dfc06cd5 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/JukeboxMediaPlayer.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/JukeboxMediaPlayer.kt @@ -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 = mutableListOf() + private var listeners: ListenerSet private val playlist: MutableList = 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(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? = null - - @JvmStatic - fun requestStart(): ListenableFuture? { - 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) { @@ -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(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? { diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/JukeboxNotificationActionFactory.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/JukeboxNotificationActionFactory.kt deleted file mode 100644 index 73bad2cb..00000000 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/JukeboxNotificationActionFactory.kt +++ /dev/null @@ -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 - } - } -} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/JukeboxUnimplementedFunctions.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/JukeboxUnimplementedFunctions.kt index eeba3b52..a22111be 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/JukeboxUnimplementedFunctions.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/JukeboxUnimplementedFunctions.kt @@ -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) { 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") - } } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerLifecycleSupport.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerLifecycleSupport.kt index 3c5d4df3..8c64d9a0 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerLifecycleSupport.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerLifecycleSupport.kt @@ -32,7 +32,7 @@ import timber.log.Timber class MediaPlayerLifecycleSupport : KoinComponent { private lateinit var ratingManager: RatingManager private val playbackStateSerializer by inject() - private val mediaPlayerController by inject() + private val mediaPlayerManager by inject() 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() } } } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerController.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerManager.kt similarity index 87% rename from ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerController.kt rename to ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerManager.kt index 2be0f26f..a580b3d3 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerController.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerManager.kt @@ -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 } } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/PlaybackState.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/PlaybackState.kt index a40e7b05..d57a5b86 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/PlaybackState.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/PlaybackState.kt @@ -14,6 +14,6 @@ data class PlaybackState( var repeatMode: Int = 0 ) : Serializable { companion object { - const val serialVersionUID = -293487987L + private const val serialVersionUID = -293487987L } } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/RESTMusicService.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/RESTMusicService.kt index eddf1d9f..a31ea7c2 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/RESTMusicService.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/RESTMusicService.kt @@ -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) diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/subsonic/DownloadHandler.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/subsonic/DownloadHandler.kt index c327207b..1a653a50 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/subsonic/DownloadHandler.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/subsonic/DownloadHandler.kt @@ -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 = 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 = 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 = 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 - ) { - if (songs.size > maxSongs) { - return - } + id: String + ): MutableList { + val songs: MutableList = LinkedList() val musicService = getMusicService() val artist = musicService.getAlbumsOfArtist(id, "", false) for ((id1) in artist) { @@ -250,6 +246,7 @@ class DownloadHandler( } } } + return songs } } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/CacheCleaner.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/CacheCleaner.kt index 675979f7..3ec2cee6 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/CacheCleaner.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/CacheCleaner.kt @@ -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 { val filesToNotDelete: MutableSet = HashSet(5) - val mediaController = inject( - MediaPlayerController::class.java + val mediaController = inject( + MediaPlayerManager::class.java ) val playlist = mainScope.future { mediaController.value.playlist }.get() diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/CommunicationError.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/CommunicationError.kt index f81e7b92..acdd03da 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/CommunicationError.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/CommunicationError.kt @@ -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) { diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/CoroutinePatterns.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/CoroutinePatterns.kt index c0a95bd9..8e0d1154 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/CoroutinePatterns.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/CoroutinePatterns.kt @@ -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()) diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/SelectCacheActivityContract.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/SelectCacheActivityContract.kt new file mode 100644 index 00000000..88437aca --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/SelectCacheActivityContract.kt @@ -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() { + 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 + } +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Settings.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Settings.kt index 65dbd92b..5b179ec1 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Settings.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Settings.kt @@ -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 { - val prefs = PreferenceManager.getDefaultSharedPreferences(UApp.applicationContext()) - return prefs.all.keys.toList() + return preferences.all.keys.toList() } private val appContext: Context diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Util.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Util.kt index 40739ee7..83ecb11b 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Util.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Util.kt @@ -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}") } diff --git a/ultrasonic/src/main/res/layout-land/current_playing.xml b/ultrasonic/src/main/res/layout-land/current_playing.xml index 923ec7f2..575f62c2 100644 --- a/ultrasonic/src/main/res/layout-land/current_playing.xml +++ b/ultrasonic/src/main/res/layout-land/current_playing.xml @@ -119,7 +119,7 @@ - + diff --git a/ultrasonic/src/main/res/layout/current_playing.xml b/ultrasonic/src/main/res/layout/current_playing.xml index 4ed71bce..c73d42ce 100644 --- a/ultrasonic/src/main/res/layout/current_playing.xml +++ b/ultrasonic/src/main/res/layout/current_playing.xml @@ -112,7 +112,7 @@ - + diff --git a/ultrasonic/src/main/res/layout/current_playlist.xml b/ultrasonic/src/main/res/layout/current_playlist.xml index 1d95e539..e3168e2c 100644 --- a/ultrasonic/src/main/res/layout/current_playlist.xml +++ b/ultrasonic/src/main/res/layout/current_playlist.xml @@ -5,13 +5,17 @@ a:layout_height="fill_parent" a:orientation="vertical"> - + + - - - - - - - \ No newline at end of file diff --git a/ultrasonic/src/main/res/values-cs/strings.xml b/ultrasonic/src/main/res/values-cs/strings.xml index 0d215da8..c06ae0b5 100644 --- a/ultrasonic/src/main/res/values-cs/strings.xml +++ b/ultrasonic/src/main/res/values-cs/strings.xml @@ -48,7 +48,6 @@ Vzdálené ovládání není dostupné v offline módu. Vzdálené ovládání zapnuto. Hudba přehrávána na serveru. Vzdálené ovládání není podporováno. Aktualizujte svůj Subsonic server. - Hlasitost vzdáleného přístroje Ekvalizér Jukebox vypnut Jukebox zapnut diff --git a/ultrasonic/src/main/res/values-de/strings.xml b/ultrasonic/src/main/res/values-de/strings.xml index bb3b8559..728688b9 100644 --- a/ultrasonic/src/main/res/values-de/strings.xml +++ b/ultrasonic/src/main/res/values-de/strings.xml @@ -61,7 +61,6 @@ Fernbedienungs-Modus is Offline nicht verfügbar. Fernbedienung ausgeschaltet. Musik wird auf dem Server wiedergegeben. Fernbedienungs Modus wird nicht unterstützt. Bitte den Subsonic Server aktualisieren. - Entfernte Lautstärke Equalizer Jukebox Aus Jukebox An diff --git a/ultrasonic/src/main/res/values-es/strings.xml b/ultrasonic/src/main/res/values-es/strings.xml index 04ac76bd..39adfa95 100644 --- a/ultrasonic/src/main/res/values-es/strings.xml +++ b/ultrasonic/src/main/res/values-es/strings.xml @@ -62,7 +62,6 @@ Control remoto no disponible en modo fuera de línea. Control remoto encendido. La música se reproduce en el servidor. Control remoto no soportado. Por favor actualiza tu servidor de Subsonic. - Volumen remoto Ecualizador Apagar Jukebox Encender Jukebox @@ -453,4 +452,5 @@ Funciones soportadas No se puede reanudar la reproducción 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 + Tasa de bits máxima: al fijar una canción de forma permanente \ No newline at end of file diff --git a/ultrasonic/src/main/res/values-fr/strings.xml b/ultrasonic/src/main/res/values-fr/strings.xml index b538dd39..735457b5 100644 --- a/ultrasonic/src/main/res/values-fr/strings.xml +++ b/ultrasonic/src/main/res/values-fr/strings.xml @@ -61,7 +61,6 @@ Le mode jukebox n\'est pas disponible en mode déconnecté. Mode jukebox activé. La musique est jouée sur le serveur Le mode jukebox n\'est pas pris en charge. Mise à jour du serveur Subsonic requise. - Volume sur serveur distant Égaliseur Désactiver le mode jukebox Activer le mode jukebox diff --git a/ultrasonic/src/main/res/values-hu/strings.xml b/ultrasonic/src/main/res/values-hu/strings.xml index ed40d385..f85bf6b3 100644 --- a/ultrasonic/src/main/res/values-hu/strings.xml +++ b/ultrasonic/src/main/res/values-hu/strings.xml @@ -54,7 +54,6 @@ A távvezérlés nem lehetséges kapcsolat nélküli módban! Távvezérlés bekapcsolása. A zenelejátszás a kiszolgálón történik. A távvezérlés nem támogatott. Kérjük, frissítse a Subsonic kiszolgálót! - Hangerő távvezérlése Equalizer Jukebox ki Jukebox be diff --git a/ultrasonic/src/main/res/values-it/strings.xml b/ultrasonic/src/main/res/values-it/strings.xml index c5e1dd60..0b778491 100644 --- a/ultrasonic/src/main/res/values-it/strings.xml +++ b/ultrasonic/src/main/res/values-it/strings.xml @@ -45,7 +45,6 @@ Il controllo remoto non è disponibile nella modalità offline. Controllo remoto abilitato. La musica verrà riprodotta sul server. Il controllo remoto non è supportato. Per favore aggiorna la versione del server Airsonic. - Volume remoto Equalizzatore Jukebox spento Jukebox acceso diff --git a/ultrasonic/src/main/res/values-ja/strings.xml b/ultrasonic/src/main/res/values-ja/strings.xml index afe2dcca..b1ee02f1 100644 --- a/ultrasonic/src/main/res/values-ja/strings.xml +++ b/ultrasonic/src/main/res/values-ja/strings.xml @@ -42,7 +42,6 @@ リモートコントロールがオンになりました。サーバーで音楽が再生されます。 リモートコントロールがオフになりました。モバイル端末で音楽が再生されます。 リモートコントロールがサポートされていません。Subsonicサーバーをアップグレードしてください。 - リモート音量 ジュークボックス ON 歌詞 アルバムを表示 diff --git a/ultrasonic/src/main/res/values-nb-rNO/strings.xml b/ultrasonic/src/main/res/values-nb-rNO/strings.xml index 2d0addec..5d530829 100644 --- a/ultrasonic/src/main/res/values-nb-rNO/strings.xml +++ b/ultrasonic/src/main/res/values-nb-rNO/strings.xml @@ -339,7 +339,6 @@ Fjernkontroll er avskrudd. Skru på jukebox-modus i Brukere > Innstillinger på din Subsonic-tjener. Fjernkontroll avskrudd. Musikk spilles på enheten. Fjernkontroll støttes ikke. Oppgrader din Subsonic-tjener. - Fjernkontroll Jukebox avslått Jukebox påslått Omstokking diff --git a/ultrasonic/src/main/res/values-nl/strings.xml b/ultrasonic/src/main/res/values-nl/strings.xml index b92aab0f..882ba982 100644 --- a/ultrasonic/src/main/res/values-nl/strings.xml +++ b/ultrasonic/src/main/res/values-nl/strings.xml @@ -63,7 +63,6 @@ Afstandsbediening is niet beschikbaar in offline-modus. Afstandsbediening ingeschakeld; muziek wordt afgespeeld op de server. Afstandsbediening wordt niet ondersteund. Werk je Subsonic-server bij. - Afstandsbedieningvolume Equalizer Jukebox uitgeschakeld Jukebox ingeschakeld diff --git a/ultrasonic/src/main/res/values-pl/strings.xml b/ultrasonic/src/main/res/values-pl/strings.xml index c75f5e26..c5f5cef2 100644 --- a/ultrasonic/src/main/res/values-pl/strings.xml +++ b/ultrasonic/src/main/res/values-pl/strings.xml @@ -47,7 +47,6 @@ Pilot jest niedostępny w trybie offline. Tryb pilota jest włączony. Muzyka jest odtwarzana na serwerze. Tryb pilota jest niedostępny. Proszę uaktualnić serwer Subsonic. - Zdalna głośność Korektor dźwięku Jukebox wyłączony Jukebox włączony diff --git a/ultrasonic/src/main/res/values-pt-rBR/strings.xml b/ultrasonic/src/main/res/values-pt-rBR/strings.xml index a6bf5731..b61c8b15 100644 --- a/ultrasonic/src/main/res/values-pt-rBR/strings.xml +++ b/ultrasonic/src/main/res/values-pt-rBR/strings.xml @@ -62,7 +62,6 @@ Controle remoto não está disponível no modo offline. Controle remoto ligado. Música tocada no servidor. Controle remoto não suportado. Atualize seu servidor Subsonic. - Volume Remoto Equalizador Jukebox Desligado Jukebox Ligado diff --git a/ultrasonic/src/main/res/values-pt/strings.xml b/ultrasonic/src/main/res/values-pt/strings.xml index d7c7f4b9..682ecc02 100644 --- a/ultrasonic/src/main/res/values-pt/strings.xml +++ b/ultrasonic/src/main/res/values-pt/strings.xml @@ -47,7 +47,6 @@ Controle remoto não está disponível no modo offline. Controle remoto ligado. Música tocada no servidor. Controle remoto não suportado. Atualize seu servidor Subsonic. - Volume Remoto Equalizador Jukebox Desligado Jukebox Ligado diff --git a/ultrasonic/src/main/res/values-ru/strings.xml b/ultrasonic/src/main/res/values-ru/strings.xml index 05cb3e20..93eb988a 100644 --- a/ultrasonic/src/main/res/values-ru/strings.xml +++ b/ultrasonic/src/main/res/values-ru/strings.xml @@ -59,7 +59,6 @@ Пульт дистанционного управления недоступен в автономном режиме. Включен пульт управления. Музыка играет на сервере. Пульт дистанционного управления не поддерживается. Пожалуйста, обновите ваш дозвуковой сервер. - Удаленная громкость Эквалайзер Jukebox выключен Jukebox включен diff --git a/ultrasonic/src/main/res/values-zh-rCN/strings.xml b/ultrasonic/src/main/res/values-zh-rCN/strings.xml index fea8126c..769c22ae 100644 --- a/ultrasonic/src/main/res/values-zh-rCN/strings.xml +++ b/ultrasonic/src/main/res/values-zh-rCN/strings.xml @@ -60,7 +60,6 @@ 离线模式不支持远程控制。 已打开远程控制,音乐将在服务端播放。 远程控制不支持,请升级您的 Subsonic 服务器。 - 远程音量 均衡器 关闭点唱机 开启点唱机 @@ -68,7 +67,7 @@ 保存播放列表 关闭屏幕常亮 开启屏幕常亮 - 显示专辑 + 转到专辑 随机 随机播放模式已启用 随机播放模式已禁用 @@ -281,7 +280,7 @@ 测试连接 亮色 暗色 - Black + 黑色 主题 允许自签名 HTTPS 证书 强制原始密码认证 @@ -338,7 +337,7 @@ 看看我从 %s 分享的这首音乐 分享歌曲通过 分享 - 显示艺术家 + 转到艺术家 数年 调试选项 将调试日志写入文件 @@ -449,4 +448,5 @@ 捷克语 德语 葡萄牙语(巴西) + 最大比特率 - 永久固定歌曲时 \ No newline at end of file diff --git a/ultrasonic/src/main/res/values-zh-rTW/strings.xml b/ultrasonic/src/main/res/values-zh-rTW/strings.xml index 8fdfccfd..0610cc46 100644 --- a/ultrasonic/src/main/res/values-zh-rTW/strings.xml +++ b/ultrasonic/src/main/res/values-zh-rTW/strings.xml @@ -84,7 +84,7 @@ 8 秒 使用自訂緩衝路徑 緩衝路径 - 錯誤緩衝路徑,使用預設緩衝路徑 + 錯誤緩衝路徑,使用預設緩衝路徑。 緩衝大小 100 MB 1 GB @@ -129,7 +129,7 @@ 已停用 註記 刪除 - 顯示專輯 + 轉至專輯 簡體中文(中國) 儲存播放清單 書籤設置在 %s。 @@ -141,7 +141,6 @@ 固定 傳送 聊天 - 遠端音量 頭像 您真的要刪除目前選取的項目嗎? 無法理解答覆,請檢查伺服器位址。 @@ -233,4 +232,68 @@ 在 Android 系統下次掃描裝置內音樂時生效。 播放時顯示正在播放介面 在媒體庫介面開始播放後切換到正在播放介面 + 50 + 3 首歌 + 1 + 20 + 75 + 分鐘 + 500 首歌 + 112 Kbps + 1 首歌 + 3 + 40 + 500 + 160 Kbps + 0:00 + 120 秒 + 小時 + 黑色 + 認證 + 2 首歌 + 10 + 伺服器地址 + 60 秒 + 5 + 100 首歌 + 明色 + 96 Kbps + 0 KB + 覆寫當前語言 + 5 首歌 + 250 + 192 Kbps + 80 Kbps + 25 + 30 + 100 + 影片 + 禁用 + 50 首歌 + %d kbps + 10 首歌 + 256 Kbps + 32 Kbps + 320 Kbps + 64 Kbps + 網路延時 + 105 秒 + 45 秒 + 75 秒 + 90 秒 + 通知 + 網路 + 其他設定 + 15 + 伺服器顏色 + 0 B + 0.00 GB + 用戶名 + 暗色 + -:-- + 0.00 MB + 1000 首歌 + 密碼 + + 128 Kbps \ No newline at end of file diff --git a/ultrasonic/src/main/res/values/setting_keys.xml b/ultrasonic/src/main/res/values/setting_keys.xml index c18a9a92..0e135e5e 100644 --- a/ultrasonic/src/main/res/values/setting_keys.xml +++ b/ultrasonic/src/main/res/values/setting_keys.xml @@ -12,6 +12,7 @@ showTrackNumber maxBitrateWifi maxBitrateMobile + maxBitratePinning cacheSize customCacheLocation cacheLocation diff --git a/ultrasonic/src/main/res/values/strings.xml b/ultrasonic/src/main/res/values/strings.xml index 12c534f1..4f888cbb 100644 --- a/ultrasonic/src/main/res/values/strings.xml +++ b/ultrasonic/src/main/res/values/strings.xml @@ -63,7 +63,6 @@ Remote control is not available in offline mode. Turned on remote control. Music is played on server. Remote control is not supported. Please upgrade your Subsonic server. - Remote Volume Equalizer Jukebox Off Jukebox On @@ -234,6 +233,7 @@ Max Bitrate - Mobile Unlimited Max Bitrate - Wi-Fi + Max Bitrate - When pinning a song permanently Max Songs Respond to phone, headset and Bluetooth media buttons Media Buttons diff --git a/ultrasonic/src/main/res/xml/settings.xml b/ultrasonic/src/main/res/xml/settings.xml index ee11bda0..692117c5 100644 --- a/ultrasonic/src/main/res/xml/settings.xml +++ b/ultrasonic/src/main/res/xml/settings.xml @@ -167,18 +167,25 @@ a:title="@string/settings.network_title" app:iconSpaceReserved="false"> +