Merge branch '450' into 4502

This commit is contained in:
tzugen 2023-06-03 11:31:38 +02:00
commit 365067f1a0
No known key found for this signature in database
GPG Key ID: 61E9C34BC10EC930
79 changed files with 1128 additions and 1289 deletions

View File

@ -1,5 +1,5 @@
default:
image: registry.gitlab.com/ultrasonic/ci-android:latest
image: registry.gitlab.com/ultrasonic/ci-android:1.1.0
cache: &global_cache
key:
files:
@ -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:

View File

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

View File

@ -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:

View File

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

View File

@ -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)

View File

@ -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

View File

@ -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" }

View File

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

View File

@ -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"

View File

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

View File

@ -1,6 +1,6 @@
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-kapt'
apply plugin: 'org.jetbrains.kotlin.android'
apply plugin: 'org.jetbrains.kotlin.kapt'
apply plugin: "androidx.navigation.safeargs.kotlin"
apply from: "../gradle_scripts/code_quality.gradle"
@ -9,12 +9,12 @@ android {
defaultConfig {
applicationId "org.moire.ultrasonic"
versionCode 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -58,7 +58,7 @@ public abstract class BackgroundTask<T> implements ProgressListener
protected String getErrorMessage(Throwable error)
{
return CommunicationError.getErrorMessage(error, activity);
return CommunicationError.getErrorMessage(error);
}
@Override

View File

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

View File

@ -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
}

View File

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

View File

@ -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

View File

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

View File

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

View File

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

View File

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

View File

@ -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()
}
}

View File

@ -16,6 +16,7 @@ import androidx.navigation.fragment.findNavController
import io.reactivex.rxjava3.disposables.CompositeDisposable
import org.moire.ultrasonic.R
import org.moire.ultrasonic.adapters.FolderSelectorBinder
import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.domain.Artist
import org.moire.ultrasonic.domain.GenericEntry
import org.moire.ultrasonic.domain.Identifiable
@ -23,7 +24,6 @@ import org.moire.ultrasonic.service.RxBus
import org.moire.ultrasonic.service.plusAssign
import org.moire.ultrasonic.subsonic.DownloadAction
import org.moire.ultrasonic.subsonic.DownloadHandler
import org.moire.ultrasonic.util.Settings
/**
* An extension of the MultiListFragment, with a few helper functions geared
@ -39,7 +39,7 @@ abstract class EntryListFragment<T : GenericEntry> : MultiListFragment<T>() {
*/
private fun showFolderHeader(): Boolean {
return listModel.showSelectFolderHeader() && !listModel.isOffline() &&
!Settings.shouldUseId3Tags
!ActiveServerProvider.shouldUseId3Tags()
}
override fun onContextMenuItemSelected(menuItem: MenuItem, item: T): Boolean {

View File

@ -25,7 +25,7 @@ import kotlin.math.abs
import org.koin.android.ext.android.inject
import org.moire.ultrasonic.NavigationGraphDirections
import org.moire.ultrasonic.R
import org.moire.ultrasonic.service.MediaPlayerController
import org.moire.ultrasonic.service.MediaPlayerManager
import org.moire.ultrasonic.service.RxBus
import org.moire.ultrasonic.subsonic.ImageLoaderProvider
import org.moire.ultrasonic.util.Settings
@ -48,7 +48,7 @@ class NowPlayingFragment : Fragment() {
private var nowPlayingArtist: TextView? = null
private var rxBusSubscription: Disposable? = null
private val mediaPlayerController: MediaPlayerController by inject()
private val mediaPlayerManager: MediaPlayerManager by inject()
private val imageLoaderProvider: ImageLoaderProvider by inject()
override fun onCreate(savedInstanceState: Bundle?) {
@ -85,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) {

View File

@ -34,6 +34,7 @@ import android.widget.SeekBar.OnSeekBarChangeListener
import android.widget.TextView
import android.widget.Toast
import android.widget.ViewFlipper
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.content.res.ResourcesCompat
import androidx.core.view.MenuHost
import androidx.core.view.MenuProvider
@ -53,6 +54,7 @@ import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.LinearSmoothScroller
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.button.MaterialButton
import com.google.android.material.progressindicator.CircularProgressIndicator
import io.reactivex.rxjava3.disposables.CompositeDisposable
import java.text.DateFormat
import java.text.SimpleDateFormat
@ -76,14 +78,17 @@ import org.moire.ultrasonic.R
import org.moire.ultrasonic.adapters.BaseAdapter
import org.moire.ultrasonic.adapters.TrackViewBinder
import org.moire.ultrasonic.api.subsonic.models.AlbumListType
import org.moire.ultrasonic.app.UApp
import org.moire.ultrasonic.audiofx.EqualizerController
import org.moire.ultrasonic.data.ActiveServerProvider.Companion.isOffline
import org.moire.ultrasonic.data.ActiveServerProvider.Companion.shouldUseId3Tags
import org.moire.ultrasonic.data.RatingUpdate
import org.moire.ultrasonic.databinding.CurrentPlayingBinding
import org.moire.ultrasonic.domain.Identifiable
import org.moire.ultrasonic.domain.MusicDirectory
import org.moire.ultrasonic.domain.Track
import org.moire.ultrasonic.fragment.FragmentTitle.Companion.setTitle
import org.moire.ultrasonic.service.MediaPlayerController
import org.moire.ultrasonic.service.MediaPlayerManager
import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService
import org.moire.ultrasonic.service.RxBus
import org.moire.ultrasonic.service.plusAssign
@ -124,7 +129,7 @@ class PlayerFragment :
// Data & Services
private val networkAndStorageChecker: NetworkAndStorageChecker by inject()
private val mediaPlayerController: MediaPlayerController by inject()
private val mediaPlayerManager: MediaPlayerManager by inject()
private val shareHandler: ShareHandler by inject()
private val imageLoaderProvider: ImageLoaderProvider by inject()
private var currentSong: Track? = null
@ -142,6 +147,7 @@ class PlayerFragment :
private lateinit var fiveStar5ImageView: ImageView
private lateinit var playlistFlipper: ViewFlipper
private lateinit var emptyTextView: TextView
private lateinit var emptyView: ConstraintLayout
private lateinit var songTitleTextView: TextView
private lateinit var artistTextView: TextView
private lateinit var albumTextView: TextView
@ -161,9 +167,15 @@ class PlayerFragment :
private lateinit var shuffleButton: View
private lateinit var repeatButton: MaterialButton
private lateinit var progressBar: SeekBar
private lateinit var progressIndicator: CircularProgressIndicator
private val hollowStar = R.drawable.ic_star_hollow
private val fullStar = R.drawable.ic_star_full
private var _binding: CurrentPlayingBinding? = null
// This property is only valid between onCreateView and
// onDestroyView.
private val binding get() = _binding!!
private val viewAdapter: BaseAdapter<Identifiable> by lazy {
BaseAdapter()
}
@ -177,13 +189,17 @@ class PlayerFragment :
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return inflater.inflate(R.layout.current_playing, container, false)
): View {
_binding = CurrentPlayingBinding.inflate(layoutInflater, container, false)
return binding.root
}
// TODO: Switch them all over to use the view binding
private fun findViews(view: View) {
playlistFlipper = view.findViewById(R.id.current_playing_playlist_flipper)
emptyTextView = view.findViewById(R.id.playlist_empty)
emptyTextView = view.findViewById(R.id.empty_list_text)
emptyView = view.findViewById(R.id.emptyListView)
progressIndicator = view.findViewById(R.id.progress_indicator)
songTitleTextView = view.findViewById(R.id.current_playing_song)
artistTextView = view.findViewById(R.id.current_playing_artist)
albumTextView = view.findViewById(R.id.current_playing_album)
@ -210,7 +226,7 @@ class PlayerFragment :
fiveStar5ImageView = view.findViewById(R.id.song_five_star_5)
}
@Suppress("LongMethod", "DEPRECATION")
@Suppress("LongMethod")
@SuppressLint("ClickableViewAccessibility")
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
cancellationToken = CancellationToken()
@ -220,6 +236,7 @@ class PlayerFragment :
val width: Int
val height: Int
@Suppress("DEPRECATION")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val bounds = windowManager.currentWindowMetrics.bounds
width = bounds.width()
@ -248,8 +265,8 @@ class PlayerFragment :
val previousButton: AutoRepeatButton = view.findViewById(R.id.button_previous)
val nextButton: AutoRepeatButton = view.findViewById(R.id.button_next)
shuffleButton = view.findViewById(R.id.button_shuffle)
updateShuffleButtonState(mediaPlayerController.isShufflePlayEnabled)
updateRepeatButtonState(mediaPlayerController.repeatMode)
updateShuffleButtonState(mediaPlayerManager.isShufflePlayEnabled)
updateRepeatButtonState(mediaPlayerManager.repeatMode)
val ratingLinearLayout = view.findViewById<LinearLayout>(R.id.song_rating)
if (!useFiveStarRating) ratingLinearLayout.isVisible = false
@ -271,7 +288,7 @@ class PlayerFragment :
previousButton.setOnClickListener {
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
launch(CommunicationError.getHandler(context)) {
mediaPlayerController.seekToPrevious()
mediaPlayerManager.seekToPrevious()
}
}
@ -282,7 +299,7 @@ class PlayerFragment :
nextButton.setOnClickListener {
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
launch(CommunicationError.getHandler(context)) {
mediaPlayerController.seekToNext()
mediaPlayerManager.seekToNext()
}
}
@ -292,22 +309,22 @@ class PlayerFragment :
pauseButton.setOnClickListener {
launch(CommunicationError.getHandler(context)) {
mediaPlayerController.pause()
mediaPlayerManager.pause()
}
}
stopButton.setOnClickListener {
launch(CommunicationError.getHandler(context)) {
mediaPlayerController.reset()
mediaPlayerManager.reset()
}
}
playButton.setOnClickListener {
if (!mediaPlayerController.isJukeboxEnabled)
if (!mediaPlayerManager.isJukeboxEnabled)
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
launch(CommunicationError.getHandler(context)) {
mediaPlayerController.play()
mediaPlayerManager.play()
}
}
@ -316,12 +333,12 @@ class PlayerFragment :
}
repeatButton.setOnClickListener {
var newRepeat = mediaPlayerController.repeatMode + 1
var newRepeat = mediaPlayerManager.repeatMode + 1
if (newRepeat == 3) {
newRepeat = 0
}
mediaPlayerController.repeatMode = newRepeat
mediaPlayerManager.repeatMode = newRepeat
onPlaylistChanged()
@ -343,7 +360,7 @@ class PlayerFragment :
progressBar.setOnSeekBarChangeListener(object : OnSeekBarChangeListener {
override fun onStopTrackingTouch(seekBar: SeekBar) {
launch(CommunicationError.getHandler(context)) {
mediaPlayerController.seekTo(progressBar.progress)
mediaPlayerManager.seekTo(progressBar.progress)
}
}
@ -380,12 +397,19 @@ class PlayerFragment :
// Query the Jukebox state in an IO Context
ioScope.launch(CommunicationError.getHandler(context)) {
try {
jukeboxAvailable = mediaPlayerController.isJukeboxAvailable
jukeboxAvailable = mediaPlayerManager.isJukeboxAvailable
} catch (all: Exception) {
Timber.e(all)
}
}
// Subscribe to change in command availability
mediaPlayerManager.addListener(object : Player.Listener {
override fun onAvailableCommandsChanged(availableCommands: Player.Commands) {
updateMediaButtonActivationState()
}
})
view.setOnTouchListener { _, event -> gestureScanner.onTouchEvent(event) }
}
@ -417,7 +441,7 @@ class PlayerFragment :
}
private fun toggleShuffle() {
val isEnabled = mediaPlayerController.toggleShuffle()
val isEnabled = mediaPlayerManager.toggleShuffle()
if (isEnabled) {
Util.toast(activity, R.string.download_menu_shuffle_on)
@ -430,7 +454,7 @@ class PlayerFragment :
override fun onResume() {
super.onResume()
if (mediaPlayerController.currentMediaItem == null) {
if (mediaPlayerManager.currentMediaItem == null) {
playlistFlipper.displayedChild = 1
} else {
// Download list and Album art must be updated when resumed
@ -443,7 +467,7 @@ class PlayerFragment :
executorService = Executors.newSingleThreadScheduledExecutor()
executorService.scheduleWithFixedDelay(runnable, 0L, 500L, TimeUnit.MILLISECONDS)
if (mediaPlayerController.keepScreenOn) {
if (mediaPlayerManager.keepScreenOn) {
requireActivity().window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
} else {
requireActivity().window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
@ -454,7 +478,7 @@ class PlayerFragment :
// Scroll to current playing.
private fun scrollToCurrent() {
val index = mediaPlayerController.currentMediaItemIndex
val index = mediaPlayerManager.currentMediaItemIndex
if (index != -1) {
val smoothScroller = LinearSmoothScroller(context)
@ -472,6 +496,7 @@ class PlayerFragment :
rxBusSubscription.dispose()
cancel("CoroutineScope cancelled because the view was destroyed")
cancellationToken.cancel()
_binding = null
super.onDestroyView()
}
@ -541,7 +566,7 @@ class PlayerFragment :
equalizerMenuItem.isVisible = isEqualizerAvailable
}
val mediaPlayerController = mediaPlayerController
val mediaPlayerController = mediaPlayerManager
val track = mediaPlayerController.currentMediaItem?.toTrack()
if (track != null) {
@ -591,10 +616,10 @@ class PlayerFragment :
}
}
if (isOffline() || !Settings.shouldUseId3Tags) {
popup.menu.findItem(R.id.menu_show_artist)?.isVisible = false
}
// Only show the menu if the ID3 tags are available
popup.menu.findItem(R.id.menu_show_artist)?.isVisible = shouldUseId3Tags()
// Only show the lyrics when the user is online
popup.menu.findItem(R.id.menu_lyrics)?.isVisible = !isOffline()
popup.show()
return popup
@ -614,7 +639,7 @@ class PlayerFragment :
R.id.menu_show_artist -> {
if (track == null) return false
if (Settings.shouldUseId3Tags) {
if (Settings.id3TagsEnabledOnline) {
val action = PlayerFragmentDirections.playerToAlbumsList(
type = AlbumListType.SORTED_BY_NAME,
byArtist = true,
@ -630,7 +655,7 @@ class PlayerFragment :
R.id.menu_show_album -> {
if (track == null) return false
val albumId = if (Settings.shouldUseId3Tags) track.albumId else track.parent
val albumId = if (shouldUseId3Tags()) track.albumId else track.parent
val action = PlayerFragmentDirections.playerToSelectAlbum(
id = albumId,
@ -638,7 +663,6 @@ class PlayerFragment :
parentId = track.parent,
isAlbum = true
)
findNavController().navigate(action)
return true
}
@ -650,12 +674,12 @@ class PlayerFragment :
}
R.id.menu_item_screen_on_off -> {
val window = requireActivity().window
if (mediaPlayerController.keepScreenOn) {
if (mediaPlayerManager.keepScreenOn) {
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
mediaPlayerController.keepScreenOn = false
mediaPlayerManager.keepScreenOn = false
} else {
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
mediaPlayerController.keepScreenOn = true
mediaPlayerManager.keepScreenOn = true
}
return true
}
@ -668,8 +692,8 @@ class PlayerFragment :
return true
}
R.id.menu_item_jukebox -> {
val jukeboxEnabled = !mediaPlayerController.isJukeboxEnabled
mediaPlayerController.isJukeboxEnabled = jukeboxEnabled
val jukeboxEnabled = !mediaPlayerManager.isJukeboxEnabled
mediaPlayerManager.isJukeboxEnabled = jukeboxEnabled
Util.toast(
context,
if (jukeboxEnabled) R.string.download_jukebox_on
@ -683,13 +707,13 @@ class PlayerFragment :
return true
}
R.id.menu_item_clear_playlist -> {
mediaPlayerController.isShufflePlayEnabled = false
mediaPlayerController.clear()
mediaPlayerManager.isShufflePlayEnabled = false
mediaPlayerManager.clear()
onPlaylistChanged()
return true
}
R.id.menu_item_save_playlist -> {
if (mediaPlayerController.playlistSize > 0) {
if (mediaPlayerManager.playlistSize > 0) {
showSavePlaylistDialog()
}
return true
@ -708,7 +732,7 @@ class PlayerFragment :
if (track == null) return true
val songId = track.id
val playerPosition = mediaPlayerController.playerPosition
val playerPosition = mediaPlayerManager.playerPosition
track.bookmarkPosition = playerPosition
val bookmarkTime = Util.formatTotalDuration(playerPosition.toLong(), true)
Thread {
@ -743,7 +767,7 @@ class PlayerFragment :
return true
}
R.id.menu_item_share -> {
val mediaPlayerController = mediaPlayerController
val mediaPlayerController = mediaPlayerManager
val tracks: MutableList<Track?> = ArrayList()
val playlist = mediaPlayerController.playlist
for (item in playlist) {
@ -778,8 +802,7 @@ class PlayerFragment :
private fun update(cancel: CancellationToken? = null) {
if (cancel?.isCancellationRequested == true) return
val mediaPlayerController = mediaPlayerController
if (currentSong?.id != mediaPlayerController.currentMediaItem?.mediaId) {
if (currentSong?.id != mediaPlayerManager.currentMediaItem?.mediaId) {
onTrackChanged()
}
updateSeekBar()
@ -787,10 +810,10 @@ class PlayerFragment :
private fun savePlaylistInBackground(playlistName: String) {
Util.toast(context, resources.getString(R.string.download_playlist_saving, playlistName))
mediaPlayerController.suggestedPlaylistName = playlistName
mediaPlayerManager.suggestedPlaylistName = playlistName
// The playlist can be acquired only from the main thread
val entries = mediaPlayerController.playlist.map {
val entries = mediaPlayerManager.playlist.map {
it.toTrack()
}
@ -799,16 +822,16 @@ class PlayerFragment :
musicService.createPlaylist(null, playlistName, entries)
}.invokeOnCompletion {
if (it == null || it is CancellationException) {
Util.toast(context, R.string.download_playlist_done)
Util.toast(UApp.applicationContext(), R.string.download_playlist_done)
} else {
Timber.e(it, "Exception has occurred in savePlaylistInBackground")
val msg = String.format(
Locale.ROOT,
"%s %s",
resources.getString(R.string.download_playlist_error),
CommunicationError.getErrorMessage(it, context)
CommunicationError.getErrorMessage(it)
)
Util.toast(context, msg)
Util.toast(UApp.applicationContext(), msg)
}
}
}
@ -843,8 +866,8 @@ class PlayerFragment :
// Create listener
val clickHandler: ((Track, Int) -> Unit) = { _, listPos ->
val mediaIndex = mediaPlayerController.getUnshuffledIndexOf(listPos)
mediaPlayerController.play(mediaIndex)
val mediaIndex = mediaPlayerManager.getUnshuffledIndexOf(listPos)
mediaPlayerManager.play(mediaIndex)
}
viewAdapter.register(
@ -908,7 +931,7 @@ class PlayerFragment :
@SuppressLint("NotifyDataSetChanged")
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
val pos = viewHolder.bindingAdapterPosition
val item = mediaPlayerController.getMediaItemAt(pos)
val item = mediaPlayerManager.getMediaItemAt(pos)
// Remove the item from the list quickly
val items = viewAdapter.getCurrentList().toMutableList()
@ -924,7 +947,7 @@ class PlayerFragment :
Util.toast(context, songRemoved)
// Remove the item from the playlist
mediaPlayerController.removeFromPlaylist(pos)
mediaPlayerManager.removeFromPlaylist(pos)
}
override fun onSelectedChanged(
@ -944,7 +967,7 @@ class PlayerFragment :
dragging = false
// Move the item in the playlist separately
Timber.i("Moving item %s to %s", startPosition, endPosition)
mediaPlayerController.moveItemInPlaylist(startPosition, endPosition)
mediaPlayerManager.moveItemInPlaylist(startPosition, endPosition)
}
}
@ -1022,25 +1045,24 @@ class PlayerFragment :
}
private fun onPlaylistChanged() {
val mediaPlayerController = mediaPlayerController
val mediaPlayerController = mediaPlayerManager
// Try to display playlist in play order
val list = mediaPlayerController.playlistInPlayOrder
emptyTextView.setText(R.string.playlist_empty)
viewAdapter.submitList(list.map(MediaItem::toTrack))
emptyTextView.isVisible = list.isEmpty()
progressIndicator.isVisible = false
emptyView.isVisible = list.isEmpty()
updateRepeatButtonState(mediaPlayerController.repeatMode)
}
private fun onTrackChanged() {
currentSong = mediaPlayerController.currentMediaItem?.toTrack()
currentSong = mediaPlayerManager.currentMediaItem?.toTrack()
scrollToCurrent()
val totalDuration = mediaPlayerController.playListDuration
val totalSongs = mediaPlayerController.playlistSize
val currentSongIndex = mediaPlayerController.currentMediaItemIndex + 1
val totalDuration = mediaPlayerManager.playListDuration
val totalSongs = mediaPlayerManager.playlistSize
val currentSongIndex = mediaPlayerManager.currentMediaItemIndex + 1
val duration = Util.formatTotalDuration(totalDuration)
val trackFormat =
String.format(Locale.getDefault(), "%d / %d", currentSongIndex, totalSongs)
@ -1095,23 +1117,27 @@ class PlayerFragment :
updateSongRating()
nextButton.isEnabled = mediaPlayerController.canSeekToNext()
previousButton.isEnabled = mediaPlayerController.canSeekToPrevious()
updateMediaButtonActivationState()
}
private fun updateMediaButtonActivationState() {
nextButton.isEnabled = mediaPlayerManager.canSeekToNext()
previousButton.isEnabled = mediaPlayerManager.canSeekToPrevious()
}
@Synchronized
private fun updateSeekBar() {
val isJukeboxEnabled: Boolean = mediaPlayerController.isJukeboxEnabled
val millisPlayed: Int = max(0, mediaPlayerController.playerPosition)
val duration: Int = mediaPlayerController.playerDuration
val playbackState: Int = mediaPlayerController.playbackState
val isJukeboxEnabled: Boolean = mediaPlayerManager.isJukeboxEnabled
val millisPlayed: Int = max(0, mediaPlayerManager.playerPosition)
val duration: Int = mediaPlayerManager.playerDuration
val playbackState: Int = mediaPlayerManager.playbackState
if (currentSong != null) {
positionTextView.text = Util.formatTotalDuration(millisPlayed.toLong(), true)
durationTextView.text = Util.formatTotalDuration(duration.toLong(), true)
progressBar.max = if (duration == 0) 100 else duration // Work-around for apparent bug.
progressBar.progress = millisPlayed
progressBar.isEnabled = mediaPlayerController.isPlaying || isJukeboxEnabled
progressBar.isEnabled = mediaPlayerManager.isPlaying || isJukeboxEnabled
} else {
positionTextView.setText(R.string.util_zero_time)
durationTextView.setText(R.string.util_no_time)
@ -1120,7 +1146,7 @@ class PlayerFragment :
progressBar.isEnabled = false
}
val progress = mediaPlayerController.bufferedPercentage
val progress = mediaPlayerManager.bufferedPercentage
updateBufferProgress(playbackState, progress)
}
@ -1133,7 +1159,7 @@ class PlayerFragment :
setTitle(this@PlayerFragment, downloadStatus)
}
Player.STATE_READY -> {
if (mediaPlayerController.isShufflePlayEnabled) {
if (mediaPlayerManager.isShufflePlayEnabled) {
setTitle(
this@PlayerFragment,
R.string.download_playerstate_playing_shuffle
@ -1157,7 +1183,7 @@ class PlayerFragment :
}
private fun updateButtonStates(playbackState: Int) {
val isPlaying = mediaPlayerController.isPlaying
val isPlaying = mediaPlayerManager.isPlaying
when (playbackState) {
Player.STATE_READY -> {
pauseButton.isVisible = isPlaying
@ -1180,9 +1206,9 @@ class PlayerFragment :
private fun seek(forward: Boolean) {
launch(CommunicationError.getHandler(context)) {
if (forward) {
mediaPlayerController.seekForward()
mediaPlayerManager.seekForward()
} else {
mediaPlayerController.seekBack()
mediaPlayerManager.seekBack()
}
}
}
@ -1208,28 +1234,28 @@ class PlayerFragment :
// Right to Left swipe
if (e1X - e2X > swipeDistance && absX > swipeVelocity) {
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
mediaPlayerController.seekToNext()
mediaPlayerManager.seekToNext()
return true
}
// Left to Right swipe
if (e2X - e1X > swipeDistance && absX > swipeVelocity) {
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
mediaPlayerController.seekToPrevious()
mediaPlayerManager.seekToPrevious()
return true
}
// Top to Bottom swipe
if (e2Y - e1Y > swipeDistance && absY > swipeVelocity) {
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
mediaPlayerController.seekTo(mediaPlayerController.playerPosition + 30000)
mediaPlayerManager.seekTo(mediaPlayerManager.playerPosition + 30000)
return true
}
// Bottom to Top swipe
if (e1Y - e2Y > swipeDistance && absY > swipeVelocity) {
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
mediaPlayerController.seekTo(mediaPlayerController.playerPosition - 8000)
mediaPlayerManager.seekTo(mediaPlayerManager.playerPosition - 8000)
return true
}
return false
@ -1294,7 +1320,7 @@ class PlayerFragment :
builder.setView(layout)
builder.setCancelable(true)
val dialog = builder.create()
val playlistName = mediaPlayerController.suggestedPlaylistName
val playlistName = mediaPlayerManager.suggestedPlaylistName
if (playlistName != null) {
playlistNameView.setText(playlistName)
} else {

View File

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

View File

@ -1,14 +1,11 @@
package org.moire.ultrasonic.fragment
import android.app.Activity
import android.content.DialogInterface
import android.content.Intent
import android.content.SharedPreferences
import android.content.SharedPreferences.OnSharedPreferenceChangeListener
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.provider.DocumentsContract
import android.provider.SearchRecentSuggestions
import android.view.View
import androidx.annotation.StringRes
@ -31,16 +28,17 @@ import org.moire.ultrasonic.log.FileLoggerTree.Companion.getLogFileSizes
import org.moire.ultrasonic.log.FileLoggerTree.Companion.plantToTimberForest
import org.moire.ultrasonic.log.FileLoggerTree.Companion.uprootFromTimberForest
import org.moire.ultrasonic.provider.SearchSuggestionProvider
import org.moire.ultrasonic.service.MediaPlayerController
import org.moire.ultrasonic.service.MediaPlayerManager
import org.moire.ultrasonic.service.RxBus
import org.moire.ultrasonic.util.ConfirmationDialog
import org.moire.ultrasonic.util.Constants
import org.moire.ultrasonic.util.ErrorDialog
import org.moire.ultrasonic.util.FileUtil.ultrasonicDirectory
import org.moire.ultrasonic.util.InfoDialog
import org.moire.ultrasonic.util.SelectCacheActivityContract
import org.moire.ultrasonic.util.Settings
import org.moire.ultrasonic.util.Settings.id3TagsEnabledOnline
import org.moire.ultrasonic.util.Settings.preferences
import org.moire.ultrasonic.util.Settings.shouldUseId3Tags
import org.moire.ultrasonic.util.Storage
import org.moire.ultrasonic.util.TimeSpanPreference
import org.moire.ultrasonic.util.TimeSpanPreferenceDialogFragmentCompat
@ -64,7 +62,7 @@ class SettingsFragment :
private var debugLogToFile: CheckBoxPreference? = null
private var customCacheLocation: CheckBoxPreference? = null
private val mediaPlayerController: MediaPlayerController by inject()
private val mediaPlayerManager: MediaPlayerManager by inject()
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
setPreferencesFromResource(R.xml.settings, rootKey)
@ -100,64 +98,14 @@ class SettingsFragment :
updateCustomPreferences()
}
/**
* This function will be called when we return from the file picker
* with a new custom cache location
*
* TODO: This method has been deprecated in favor of using the Activity Result API
* which brings increased type safety via an ActivityResultContract and the prebuilt
* contracts for common intents available in
* androidx.activity.result.contract.ActivityResultContracts,
* provides hooks for testing, and allow receiving results in separate,
* testable classes independent from your fragment.
* Use registerForActivityResult(ActivityResultContract, ActivityResultCallback) with the
* appropriate ActivityResultContract and handling the result in the callback.
*/
override fun onActivityResult(requestCode: Int, resultCode: Int, resultData: Intent?) {
if (
requestCode == SELECT_CACHE_ACTIVITY &&
resultCode == Activity.RESULT_OK &&
resultData != null
) {
val read = (resultData.flags and Intent.FLAG_GRANT_READ_URI_PERMISSION) != 0
val write = (resultData.flags and Intent.FLAG_GRANT_WRITE_URI_PERMISSION) != 0
val persist = (resultData.flags and Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION) != 0
if (read && write && persist) {
if (resultData.data != null) {
// The result data contains a URI for the document or directory that
// the user selected.
val uri = resultData.data!!
val contentResolver = UApp.applicationContext().contentResolver
contentResolver.takePersistableUriPermission(uri, RW_FLAG)
setCacheLocation(uri.toString())
setupCacheLocationPreference()
return
}
}
ErrorDialog.Builder(requireContext())
.setMessage(R.string.settings_cache_location_error)
.show()
}
if (Settings.cacheLocationUri == "") {
Settings.customCacheLocation = false
customCacheLocation?.isChecked = false
setupCacheLocationPreference()
}
}
override fun onResume() {
super.onResume()
val preferences = preferences
preferences.registerOnSharedPreferenceChangeListener(this)
}
override fun onPause() {
super.onPause()
val prefs = preferences
prefs.unregisterOnSharedPreferenceChangeListener(this)
preferences.unregisterOnSharedPreferenceChangeListener(this)
}
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
@ -249,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

View File

@ -12,8 +12,11 @@ import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import androidx.core.view.MenuHost
import androidx.core.view.MenuProvider
import androidx.core.view.isVisible
import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LiveData
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.viewModelScope
@ -40,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(

View File

@ -29,7 +29,8 @@ import androidx.fragment.app.Fragment
import androidx.navigation.fragment.findNavController
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import java.util.Locale
import org.koin.java.KoinJavaComponent.inject
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.moire.ultrasonic.NavigationGraphDirections
import org.moire.ultrasonic.R
import org.moire.ultrasonic.api.subsonic.ApiNotSupportedException
@ -55,15 +56,13 @@ import org.moire.ultrasonic.util.Util.toast
*
* TODO: This file has been converted from Java, but not modernized yet.
*/
class PlaylistsFragment : Fragment() {
class PlaylistsFragment : Fragment(), KoinComponent {
private var refreshPlaylistsListView: SwipeRefreshLayout? = null
private var playlistsListView: ListView? = null
private var emptyTextView: View? = null
private var playlistAdapter: ArrayAdapter<Playlist>? = null
private val downloadHandler = inject<DownloadHandler>(
DownloadHandler::class.java
)
private val downloadHandler by inject<DownloadHandler>()
private var cancellationToken: CancellationToken? = null
@ -148,7 +147,7 @@ class PlaylistsFragment : Fragment() {
val playlist = playlistsListView!!.getItemAtPosition(info.position) as Playlist
when (menuItem.itemId) {
R.id.playlist_menu_pin -> {
downloadHandler.value.justDownload(
downloadHandler.justDownload(
DownloadAction.PIN,
fragment = this,
id = playlist.id,
@ -158,7 +157,7 @@ class PlaylistsFragment : Fragment() {
)
}
R.id.playlist_menu_unpin -> {
downloadHandler.value.justDownload(
downloadHandler.justDownload(
DownloadAction.UNPIN,
fragment = this,
id = playlist.id,
@ -168,7 +167,7 @@ class PlaylistsFragment : Fragment() {
)
}
R.id.playlist_menu_download -> {
downloadHandler.value.justDownload(
downloadHandler.justDownload(
DownloadAction.DOWNLOAD,
fragment = this,
id = playlist.id,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -26,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
}
}

View File

@ -11,6 +11,7 @@ import android.app.Notification
import android.app.Service
import android.content.Intent
import android.net.wifi.WifiManager
import android.os.Binder
import android.os.Build
import android.os.Handler
import android.os.IBinder
@ -39,7 +40,6 @@ import org.moire.ultrasonic.util.FileUtil.getCompleteFile
import org.moire.ultrasonic.util.FileUtil.getPartialFile
import org.moire.ultrasonic.util.FileUtil.getPinnedFile
import org.moire.ultrasonic.util.Settings
import org.moire.ultrasonic.util.SimpleServiceBinder
import org.moire.ultrasonic.util.Storage
import org.moire.ultrasonic.util.Util
import org.moire.ultrasonic.util.Util.stopForegroundRemoveNotification
@ -452,3 +452,5 @@ class DownloadService : Service(), KoinComponent {
}
}
}
class SimpleServiceBinder<S>(val service: S) : Binder()

View File

@ -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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 }
}

View File

@ -14,6 +14,6 @@ data class PlaybackState(
var repeatMode: Int = 0
) : Serializable {
companion object {
const val serialVersionUID = -293487987L
private const val serialVersionUID = -293487987L
}
}

View File

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

View File

@ -14,11 +14,11 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.moire.ultrasonic.R
import org.moire.ultrasonic.data.ActiveServerProvider.Companion.isOffline
import org.moire.ultrasonic.data.ActiveServerProvider.Companion.shouldUseId3Tags
import org.moire.ultrasonic.domain.MusicDirectory
import org.moire.ultrasonic.domain.Track
import org.moire.ultrasonic.service.DownloadService
import org.moire.ultrasonic.service.MediaPlayerController
import org.moire.ultrasonic.service.MediaPlayerManager
import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService
import org.moire.ultrasonic.util.Settings
import org.moire.ultrasonic.util.executeTaskWithToast
@ -28,7 +28,7 @@ import org.moire.ultrasonic.util.executeTaskWithToast
*/
@Suppress("LongParameterList")
class DownloadHandler(
val mediaPlayerController: MediaPlayerController,
val mediaPlayerManager: MediaPlayerManager,
private val networkAndStorageChecker: NetworkAndStorageChecker
) : CoroutineScope by CoroutineScope(Dispatchers.IO) {
private val maxSongs = 500
@ -46,7 +46,7 @@ class DownloadHandler(
var successString: String? = null
// Launch the Job
executeTaskWithToast(fragment, {
executeTaskWithToast({
val tracksToDownload: List<Track> = tracks
?: getTracksFromServer(isArtist, id!!, isDirectory, name, isShare)
@ -104,7 +104,7 @@ class DownloadHandler(
) {
var successString: String? = null
// Launch the Job
executeTaskWithToast(fragment, {
executeTaskWithToast({
val songs: MutableList<Track> =
getTracksFromServer(isArtist, id, isDirectory, name, isShare)
@ -150,16 +150,16 @@ class DownloadHandler(
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
val insertionMode = when {
append -> MediaPlayerController.InsertionMode.APPEND
playNext -> MediaPlayerController.InsertionMode.AFTER_CURRENT
else -> MediaPlayerController.InsertionMode.CLEAR
append -> MediaPlayerManager.InsertionMode.APPEND
playNext -> MediaPlayerManager.InsertionMode.AFTER_CURRENT
else -> MediaPlayerManager.InsertionMode.CLEAR
}
if (playlistName != null) {
mediaPlayerController.suggestedPlaylistName = playlistName
mediaPlayerManager.suggestedPlaylistName = playlistName
}
mediaPlayerController.addToPlaylist(
mediaPlayerManager.addToPlaylist(
songs,
autoPlay,
shuffle,
@ -181,11 +181,11 @@ class DownloadHandler(
val musicService = getMusicService()
val songs: MutableList<Track> = LinkedList()
val root: MusicDirectory
if (!isOffline() && isArtist && Settings.shouldUseId3Tags) {
getSongsForArtist(id, songs)
if (shouldUseId3Tags() && isArtist) {
return getSongsForArtist(id)
} else {
if (isDirectory) {
root = if (!isOffline() && Settings.shouldUseId3Tags)
root = if (shouldUseId3Tags())
musicService.getAlbumAsDir(id, name, false)
else
musicService.getMusicDirectory(id, name, false)
@ -219,23 +219,19 @@ class DownloadHandler(
}
val musicService = getMusicService()
for ((id1, _, _, title) in parent.getAlbums()) {
val root: MusicDirectory = if (
!isOffline() &&
Settings.shouldUseId3Tags
) musicService.getAlbumAsDir(id1, title, false)
else musicService.getMusicDirectory(id1, title, false)
val root: MusicDirectory = if (shouldUseId3Tags())
musicService.getAlbumAsDir(id1, title, false)
else
musicService.getMusicDirectory(id1, title, false)
getSongsRecursively(root, songs)
}
}
@Throws(Exception::class)
private fun getSongsForArtist(
id: String,
songs: MutableCollection<Track>
) {
if (songs.size > maxSongs) {
return
}
id: String
): MutableList<Track> {
val songs: MutableList<Track> = LinkedList()
val musicService = getMusicService()
val artist = musicService.getAlbumsOfArtist(id, "", false)
for ((id1) in artist) {
@ -250,6 +246,7 @@ class DownloadHandler(
}
}
}
return songs
}
}

View File

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

View File

@ -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) {

View File

@ -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())

View File

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

View File

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

View File

@ -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}")
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -62,7 +62,6 @@
<string name="download.jukebox_offline">Control remoto no disponible en modo fuera de línea.</string>
<string name="download.jukebox_on">Control remoto encendido. La música se reproduce en el servidor.</string>
<string name="download.jukebox_server_too_old">Control remoto no soportado. Por favor actualiza tu servidor de Subsonic.</string>
<string name="download.jukebox_volume">Volumen remoto</string>
<string name="download.menu_equalizer">Ecualizador</string>
<string name="download.menu_jukebox_off">Apagar Jukebox</string>
<string name="download.menu_jukebox_on">Encender Jukebox</string>
@ -453,4 +452,5 @@
<string name="supported_server_features">Funciones soportadas</string>
<string name="foreground_exception_title">No se puede reanudar la reproducción</string>
<string name="foreground_exception_text">Presione el botón de reproducción en la notificación de medios si aún está presente; de lo contrario, abra la aplicación para iniciar la reproducción y vuelva a conectar la sesión al controlador</string>
<string name="settings.max_bitrate_pinning">Tasa de bits máxima: al fijar una canción de forma permanente</string>
</resources>

View File

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

View File

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

View File

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

View File

@ -42,7 +42,6 @@
<string name="download.jukebox_on">リモートコントロールがオンになりました。サーバーで音楽が再生されます。</string>
<string name="download.jukebox_off">リモートコントロールがオフになりました。モバイル端末で音楽が再生されます。</string>
<string name="download.jukebox_server_too_old">リモートコントロールがサポートされていません。Subsonicサーバーをアップグレードしてください。</string>
<string name="download.jukebox_volume">リモート音量</string>
<string name="download.menu_jukebox_on">ジュークボックス ON</string>
<string name="download.menu_lyrics">歌詞</string>
<string name="download.menu_show_album">アルバムを表示</string>

View File

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

View File

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

View File

@ -47,7 +47,6 @@
<string name="download.jukebox_offline">Pilot jest niedostępny w trybie offline.</string>
<string name="download.jukebox_on">Tryb pilota jest włączony. Muzyka jest odtwarzana na serwerze.</string>
<string name="download.jukebox_server_too_old">Tryb pilota jest niedostępny. Proszę uaktualnić serwer Subsonic.</string>
<string name="download.jukebox_volume">Zdalna głośność</string>
<string name="download.menu_equalizer">Korektor dźwięku</string>
<string name="download.menu_jukebox_off">Jukebox wyłączony</string>
<string name="download.menu_jukebox_on">Jukebox włączony</string>

View File

@ -62,7 +62,6 @@
<string name="download.jukebox_offline">Controle remoto não está disponível no modo offline.</string>
<string name="download.jukebox_on">Controle remoto ligado. Música tocada no servidor.</string>
<string name="download.jukebox_server_too_old">Controle remoto não suportado. Atualize seu servidor Subsonic.</string>
<string name="download.jukebox_volume">Volume Remoto</string>
<string name="download.menu_equalizer">Equalizador</string>
<string name="download.menu_jukebox_off">Jukebox Desligado</string>
<string name="download.menu_jukebox_on">Jukebox Ligado</string>

View File

@ -47,7 +47,6 @@
<string name="download.jukebox_offline">Controle remoto não está disponível no modo offline.</string>
<string name="download.jukebox_on">Controle remoto ligado. Música tocada no servidor.</string>
<string name="download.jukebox_server_too_old">Controle remoto não suportado. Atualize seu servidor Subsonic.</string>
<string name="download.jukebox_volume">Volume Remoto</string>
<string name="download.menu_equalizer">Equalizador</string>
<string name="download.menu_jukebox_off">Jukebox Desligado</string>
<string name="download.menu_jukebox_on">Jukebox Ligado</string>

View File

@ -59,7 +59,6 @@
<string name="download.jukebox_offline">Пульт дистанционного управления недоступен в автономном режиме.</string>
<string name="download.jukebox_on">Включен пульт управления. Музыка играет на сервере.</string>
<string name="download.jukebox_server_too_old">Пульт дистанционного управления не поддерживается. Пожалуйста, обновите ваш дозвуковой сервер.</string>
<string name="download.jukebox_volume">Удаленная громкость</string>
<string name="download.menu_equalizer">Эквалайзер</string>
<string name="download.menu_jukebox_off">Jukebox выключен</string>
<string name="download.menu_jukebox_on">Jukebox включен</string>

View File

@ -60,7 +60,6 @@
<string name="download.jukebox_offline">离线模式不支持远程控制。</string>
<string name="download.jukebox_on">已打开远程控制,音乐将在服务端播放。</string>
<string name="download.jukebox_server_too_old">远程控制不支持,请升级您的 Subsonic 服务器。</string>
<string name="download.jukebox_volume">远程音量</string>
<string name="download.menu_equalizer">均衡器</string>
<string name="download.menu_jukebox_off">关闭点唱机</string>
<string name="download.menu_jukebox_on">开启点唱机</string>
@ -68,7 +67,7 @@
<string name="download.menu_save">保存播放列表</string>
<string name="download.menu_screen_off">关闭屏幕常亮</string>
<string name="download.menu_screen_on">开启屏幕常亮</string>
<string name="download.menu_show_album">显示专辑</string>
<string name="download.menu_show_album">转到专辑</string>
<string name="download.menu_shuffle">随机</string>
<string name="download.menu_shuffle_on">随机播放模式已启用</string>
<string name="download.menu_shuffle_off">随机播放模式已禁用</string>
@ -281,7 +280,7 @@
<string name="settings.test_connection_title">测试连接</string>
<string name="settings.theme_light">亮色</string>
<string name="settings.theme_dark">暗色</string>
<string name="settings.theme_black">Black</string>
<string name="settings.theme_black">黑色</string>
<string name="settings.theme_title">主题</string>
<string name="settings.title.allow_self_signed_certificate">允许自签名 HTTPS 证书</string>
<string name="settings.title.force_plain_text_password">强制原始密码认证</string>
@ -338,7 +337,7 @@
<string name="share_default_greeting">看看我从 %s 分享的这首音乐</string>
<string name="share_via">分享歌曲通过</string>
<string name="menu.share">分享</string>
<string name="download.menu_show_artist">显示艺术家</string>
<string name="download.menu_show_artist">转到艺术家</string>
<string name="common_multiple_years">数年</string>
<string name="settings.debug.title">调试选项</string>
<string name="settings.debug.log_to_file">将调试日志写入文件</string>
@ -449,4 +448,5 @@
<string name="language.cs">捷克语</string>
<string name="language.de">德语</string>
<string name="language.pt_BR">葡萄牙语(巴西)</string>
<string name="settings.max_bitrate_pinning">最大比特率 - 永久固定歌曲时</string>
</resources>

View File

@ -84,7 +84,7 @@
<string name="settings.increment_time_8">8 秒</string>
<string name="settings.custom_cache_location">使用自訂緩衝路徑</string>
<string name="settings.cache_location">緩衝路径</string>
<string name="settings.cache_location_error">錯誤緩衝路徑,使用預設緩衝路徑</string>
<string name="settings.cache_location_error">錯誤緩衝路徑,使用預設緩衝路徑</string>
<string name="settings.cache_size">緩衝大小</string>
<string name="settings.cache_size_100">100 MB</string>
<string name="settings.cache_size_1000">1 GB</string>
@ -129,7 +129,7 @@
<string name="time_span_disabled">已停用</string>
<string name="share_comment">註記</string>
<string name="server_menu.delete">刪除</string>
<string name="download.menu_show_album">顯示專輯</string>
<string name="download.menu_show_album">轉至專輯</string>
<string name="language.zh_CN">簡體中文(中國)</string>
<string name="download.menu_save">儲存播放清單</string>
<string name="download.bookmark_set_at_position" formatted="false">書籤設置在 %s。</string>
@ -141,7 +141,6 @@
<string name="common.pin">固定</string>
<string name="chat.send_button">傳送</string>
<string name="button_bar.chat">聊天</string>
<string name="download.jukebox_volume">遠端音量</string>
<string name="chat.user_avatar">頭像</string>
<string name="common.delete_selection_confirmation">您真的要刪除目前選取的項目嗎?</string>
<string name="background_task.parse_error">無法理解答覆,請檢查伺服器位址。</string>
@ -233,4 +232,68 @@
<string name="settings.hide_media_toast">在 Android 系統下次掃描裝置內音樂時生效。</string>
<string name="settings.download_transition">播放時顯示正在播放介面</string>
<string name="settings.download_transition_summary">在媒體庫介面開始播放後切換到正在播放介面</string>
<string name="settings.search_50">50</string>
<string name="settings.preload_3">3 首歌</string>
<string name="settings.search_1">1</string>
<string name="settings.search_20">20</string>
<string name="settings.search_75">75</string>
<string name="settings.share_minutes">分鐘</string>
<string name="settings.preload_500">500 首歌</string>
<string name="settings.max_bitrate_112">112 Kbps</string>
<string name="settings.preload_1">1 首歌</string>
<string name="settings.search_3">3</string>
<string name="settings.search_40">40</string>
<string name="settings.search_500">500</string>
<string name="settings.max_bitrate_160">160 Kbps</string>
<string name="util.zero_time">0:00</string>
<string name="settings.network_timeout_120000">120 秒</string>
<string name="settings.share_hours">小時</string>
<string name="settings.theme_black">黑色</string>
<string name="server_editor.authentication">認證</string>
<string name="settings.preload_2">2 首歌</string>
<string name="settings.search_10">10</string>
<string name="settings.server_address">伺服器地址</string>
<string name="settings.network_timeout_60000">60 秒</string>
<string name="settings.search_5">5</string>
<string name="settings.preload_100">100 首歌</string>
<string name="settings.theme_light">明色</string>
<string name="settings.max_bitrate_96">96 Kbps</string>
<string name="util.bytes_format.kilobyte">0 KB</string>
<string name="settings.override_language">覆寫當前語言</string>
<string name="settings.preload_5">5 首歌</string>
<string name="settings.search_250">250</string>
<string name="settings.max_bitrate_192">192 Kbps</string>
<string name="settings.max_bitrate_80">80 Kbps</string>
<string name="settings.search_25">25</string>
<string name="settings.search_30">30</string>
<string name="settings.search_100">100</string>
<string name="main.video" tools:ignore="UnusedResources">影片</string>
<string name="time_span_disable">禁用</string>
<string name="settings.preload_50">50 首歌</string>
<string name="song_details.kbps">%d kbps</string>
<string name="settings.preload_10">10 首歌</string>
<string name="settings.max_bitrate_256">256 Kbps</string>
<string name="settings.max_bitrate_32">32 Kbps</string>
<string name="settings.max_bitrate_320">320 Kbps</string>
<string name="settings.max_bitrate_64">64 Kbps</string>
<string name="settings.network_timeout">網路延時</string>
<string name="settings.network_timeout_105000">105 秒</string>
<string name="settings.network_timeout_45000">45 秒</string>
<string name="settings.network_timeout_75000">75 秒</string>
<string name="settings.network_timeout_90000">90 秒</string>
<string name="settings.notifications_title">通知</string>
<string name="settings.network_title">網路</string>
<string name="settings.other_title">其他設定</string>
<string name="settings.search_15">15</string>
<string name="settings.server_color">伺服器顏色</string>
<string name="util.bytes_format.byte">0 B</string>
<string name="util.bytes_format.gigabyte">0.00 GB</string>
<string name="settings.server_username">用戶名</string>
<string name="settings.theme_dark">暗色</string>
<string name="util.no_time" tools:ignore="TypographyDashes">-:--</string>
<string name="util.bytes_format.megabyte">0.00 MB</string>
<string name="settings.preload_1000">1000 首歌</string>
<string name="settings.server_password">密碼</string>
<string name="settings.share_days"></string>
<string name="settings.max_bitrate_128">128 Kbps</string>
</resources>

View File

@ -12,6 +12,7 @@
<string name="setting_key.show_track_number" translatable="false">showTrackNumber</string>
<string name="setting_key.max_bitrate_wifi" translatable="false">maxBitrateWifi</string>
<string name="setting_key.max_bitrate_mobile" translatable="false">maxBitrateMobile</string>
<string name="setting_key.max_bitrate_pinning" translatable="false">maxBitratePinning</string>
<string name="setting_key.cache_size" translatable="false">cacheSize</string>
<string name="setting_key.custom_cache_location" translatable="false">customCacheLocation</string>
<string name="setting_key.cache_location" translatable="false">cacheLocation</string>

View File

@ -63,7 +63,6 @@
<string name="download.jukebox_offline">Remote control is not available in offline mode.</string>
<string name="download.jukebox_on">Turned on remote control. Music is played on server.</string>
<string name="download.jukebox_server_too_old">Remote control is not supported. Please upgrade your Subsonic server.</string>
<string name="download.jukebox_volume">Remote Volume</string>
<string name="download.menu_equalizer">Equalizer</string>
<string name="download.menu_jukebox_off">Jukebox Off</string>
<string name="download.menu_jukebox_on">Jukebox On</string>
@ -234,6 +233,7 @@
<string name="settings.max_bitrate_mobile">Max Bitrate - Mobile</string>
<string name="settings.max_bitrate_unlimited">Unlimited</string>
<string name="settings.max_bitrate_wifi">Max Bitrate - Wi-Fi</string>
<string name="settings.max_bitrate_pinning">Max Bitrate - When pinning a song permanently</string>
<string name="settings.max_songs">Max Songs</string>
<string name="settings.media_button_summary">Respond to phone, headset and Bluetooth media buttons</string>
<string name="settings.media_button_title">Media Buttons</string>

View File

@ -167,18 +167,25 @@
a:title="@string/settings.network_title"
app:iconSpaceReserved="false">
<ListPreference
a:defaultValue="0"
a:defaultValue="256"
a:entries="@array/maxBitrateNames"
a:entryValues="@array/maxBitrateValues"
a:key="@string/setting_key.max_bitrate_mobile"
a:title="@string/settings.max_bitrate_mobile"
app:iconSpaceReserved="false"/>
<ListPreference
a:defaultValue="320"
a:entries="@array/maxBitrateNames"
a:entryValues="@array/maxBitrateValues"
a:key="@string/setting_key.max_bitrate_wifi"
a:title="@string/settings.max_bitrate_wifi"
app:iconSpaceReserved="false"/>
<ListPreference
a:defaultValue="0"
a:defaultValue="320"
a:entries="@array/maxBitrateNames"
a:entryValues="@array/maxBitrateValues"
a:key="@string/setting_key.max_bitrate_mobile"
a:title="@string/settings.max_bitrate_mobile"
a:key="@string/setting_key.max_bitrate_pinning"
a:title="@string/settings.max_bitrate_pinning"
app:iconSpaceReserved="false"/>
<CheckBoxPreference
a:defaultValue="false"