diff --git a/core/domain/src/main/kotlin/org/moire/ultrasonic/domain/PlayerState.kt b/core/domain/src/main/kotlin/org/moire/ultrasonic/domain/PlayerState.kt deleted file mode 100644 index 7ae5dd66..00000000 --- a/core/domain/src/main/kotlin/org/moire/ultrasonic/domain/PlayerState.kt +++ /dev/null @@ -1,12 +0,0 @@ -package org.moire.ultrasonic.domain - -enum class PlayerState { - IDLE, - DOWNLOADING, - PREPARING, - PREPARED, - STARTED, - STOPPED, - PAUSED, - COMPLETED -} diff --git a/core/domain/src/main/kotlin/org/moire/ultrasonic/domain/RepeatMode.kt b/core/domain/src/main/kotlin/org/moire/ultrasonic/domain/RepeatMode.kt deleted file mode 100644 index f9005eb9..00000000 --- a/core/domain/src/main/kotlin/org/moire/ultrasonic/domain/RepeatMode.kt +++ /dev/null @@ -1,15 +0,0 @@ -package org.moire.ultrasonic.domain - -enum class RepeatMode { - OFF { - override operator fun next(): RepeatMode = ALL - }, - ALL { - override operator fun next(): RepeatMode = SINGLE - }, - SINGLE { - override operator fun next(): RepeatMode = OFF - }; - - abstract operator fun next(): RepeatMode -} diff --git a/core/subsonic-api/src/main/kotlin/org/moire/ultrasonic/api/subsonic/SubsonicAPIClient.kt b/core/subsonic-api/src/main/kotlin/org/moire/ultrasonic/api/subsonic/SubsonicAPIClient.kt index 6cb54300..b445c246 100644 --- a/core/subsonic-api/src/main/kotlin/org/moire/ultrasonic/api/subsonic/SubsonicAPIClient.kt +++ b/core/subsonic-api/src/main/kotlin/org/moire/ultrasonic/api/subsonic/SubsonicAPIClient.kt @@ -122,7 +122,7 @@ class SubsonicAPIClient( private fun OkHttpClient.Builder.addLogging() { val loggingInterceptor = HttpLoggingInterceptor(okLogger) - loggingInterceptor.level = HttpLoggingInterceptor.Level.BODY + loggingInterceptor.level = HttpLoggingInterceptor.Level.HEADERS this.addInterceptor(loggingInterceptor) } diff --git a/detekt-baseline.xml b/detekt-baseline.xml index bfcdeea0..205ff394 100644 --- a/detekt-baseline.xml +++ b/detekt-baseline.xml @@ -2,41 +2,24 @@ - ComplexCondition:DownloadHandler.kt$DownloadHandler.<no name provided>$!append && !playNext && !unpin && !background - ComplexMethod:DownloadFile.kt$DownloadFile.DownloadTask$override fun execute() ImplicitDefaultLocale:EditServerFragment.kt$EditServerFragment.<no name provided>$String.format( "%s %s", resources.getString(R.string.settings_connection_failure), getErrorMessage(error) ) ImplicitDefaultLocale:FileLoggerTree.kt$FileLoggerTree$String.format("Failed to write log to %s", file) ImplicitDefaultLocale:FileLoggerTree.kt$FileLoggerTree$String.format("Log file rotated, logging into file %s", file?.name) ImplicitDefaultLocale:FileLoggerTree.kt$FileLoggerTree$String.format("Logging into file %s", file?.name) - ImplicitDefaultLocale:LocalMediaPlayer.kt$LocalMediaPlayer.BufferTask$String.format("BufferTask (%s)", downloadFile) - ImplicitDefaultLocale:LocalMediaPlayer.kt$LocalMediaPlayer.CheckCompletionTask$String.format("CheckCompletionTask (%s)", downloadFile) ImplicitDefaultLocale:ShareHandler.kt$ShareHandler$String.format("%d:%s", timeSpanAmount, timeSpanType) - LongMethod:DownloadFile.kt$DownloadFile.DownloadTask$override fun execute() LongMethod:EditServerFragment.kt$EditServerFragment$override fun onViewCreated(view: View, savedInstanceState: Bundle?) - LongMethod:LocalMediaPlayer.kt$LocalMediaPlayer$@Synchronized private fun doPlay(downloadFile: DownloadFile, position: Int, start: Boolean) LongMethod:NavigationActivity.kt$NavigationActivity$override fun onCreate(savedInstanceState: Bundle?) LongMethod:ShareHandler.kt$ShareHandler$private fun showDialog( fragment: Fragment, shareDetails: ShareDetails, swipe: SwipeRefreshLayout?, cancellationToken: CancellationToken ) - LongParameterList:ServerRowAdapter.kt$ServerRowAdapter$( private var context: Context, private var data: Array<ServerSetting>, private val model: ServerSettingsModel, private val activeServerProvider: ActiveServerProvider, private val manageMode: Boolean, private val serverDeletedCallback: ((Int) -> Unit), private val serverEditRequestedCallback: ((Int) -> Unit) ) + LongParameterList:ServerRowAdapter.kt$ServerRowAdapter$( private var context: Context, passedData: Array<ServerSetting>, private val model: ServerSettingsModel, private val activeServerProvider: ActiveServerProvider, private val manageMode: Boolean, private val serverDeletedCallback: ((Int) -> Unit), private val serverEditRequestedCallback: ((Int) -> Unit) ) MagicNumber:ActiveServerProvider.kt$ActiveServerProvider$8192 - MagicNumber:LocalMediaPlayer.kt$LocalMediaPlayer.<no name provided>$60000 - MagicNumber:LocalMediaPlayer.kt$LocalMediaPlayer.BufferTask$100000 - MagicNumber:LocalMediaPlayer.kt$LocalMediaPlayer.BufferTask$8 - MagicNumber:LocalMediaPlayer.kt$LocalMediaPlayer.BufferTask$86400L - MagicNumber:LocalMediaPlayer.kt$LocalMediaPlayer.BufferTask$8L - MagicNumber:LocalMediaPlayer.kt$LocalMediaPlayer.CheckCompletionTask$5000L - MagicNumber:MediaPlayerService.kt$MediaPlayerService$3 - MagicNumber:MediaPlayerService.kt$MediaPlayerService$4 + MagicNumber:JukeboxMediaPlayer.kt$JukeboxMediaPlayer$0.05f + MagicNumber:JukeboxMediaPlayer.kt$JukeboxMediaPlayer$50 MagicNumber:RESTMusicService.kt$RESTMusicService$206 - NestedBlockDepth:DownloadFile.kt$DownloadFile.DownloadTask$override fun execute() NestedBlockDepth:DownloadHandler.kt$DownloadHandler$private fun downloadRecursively( fragment: Fragment, id: String, name: String?, isShare: Boolean, isDirectory: Boolean, save: Boolean, append: Boolean, autoPlay: Boolean, shuffle: Boolean, background: Boolean, playNext: Boolean, unpin: Boolean, isArtist: Boolean ) - NestedBlockDepth:MediaPlayerService.kt$MediaPlayerService$private fun setupOnSongCompletedHandler() TooGenericExceptionCaught:FileLoggerTree.kt$FileLoggerTree$x: Throwable - TooGenericExceptionCaught:LocalMediaPlayer.kt$LocalMediaPlayer$ex: Exception - TooGenericExceptionCaught:LocalMediaPlayer.kt$LocalMediaPlayer$exception: Throwable - TooGenericExceptionCaught:LocalMediaPlayer.kt$LocalMediaPlayer$x: Exception - TooGenericExceptionCaught:LocalMediaPlayer.kt$LocalMediaPlayer.PositionCache$e: Exception - TooGenericExceptionThrown:DownloadFile.kt$DownloadFile.DownloadTask$throw RuntimeException( String.format(Locale.ROOT, "Download of '%s' was cancelled", track) ) - TooManyFunctions:MediaPlayerService.kt$MediaPlayerService : Service + TooGenericExceptionCaught:JukeboxMediaPlayer.kt$JukeboxMediaPlayer$x: Throwable + TooGenericExceptionCaught:JukeboxMediaPlayer.kt$JukeboxMediaPlayer.TaskQueue$x: Throwable + TooGenericExceptionThrown:Downloader.kt$Downloader.DownloadTask$throw RuntimeException( String.format( Locale.ROOT, "Download of '%s' was cancelled", downloadFile.track ) ) TooManyFunctions:RESTMusicService.kt$RESTMusicService : MusicService UtilityClassWithPublicConstructor:FragmentTitle.kt$FragmentTitle diff --git a/detekt-config.yml b/detekt-config.yml index cdebb21a..b59e4ec3 100644 --- a/detekt-config.yml +++ b/detekt-config.yml @@ -70,7 +70,7 @@ style: excludeImportStatements: false MagicNumber: # 100 common in percentage, 1000 in milliseconds - ignoreNumbers: ['-1', '0', '1', '2', '5', '10', '100', '256', '512', '1000', '1024'] + ignoreNumbers: ['-1', '0', '1', '2', '5', '10', '100', '256', '512', '1000', '1024', '4096'] ignoreEnums: true ignorePropertyDeclaration: true UnnecessaryAbstractClass: diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 064dd182..59ea094a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -11,6 +11,7 @@ detekt = "1.19.0" jacoco = "0.8.7" preferences = "1.1.1" media = "1.3.1" +media3 = "1.0.0-alpha03" androidSupport = "28.0.0" androidLegacySupport = "1.0.0" @@ -20,6 +21,7 @@ multidex = "2.0.1" room = "2.4.0" kotlin = "1.6.10" kotlinxCoroutines = "1.6.0-native-mt" +kotlinxGuava = "1.6.0" viewModelKtx = "2.3.0" retrofit = "2.9.0" @@ -66,10 +68,14 @@ navigationUiKtx = { module = "androidx.navigation:navigation-ui-ktx", ve navigationFeature = { module = "androidx.navigation:navigation-dynamic-features-fragment", version.ref = "navigation" } preferences = { module = "androidx.preference:preference", version.ref = "preferences" } media = { module = "androidx.media:media", version.ref = "media" } +media3exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3" } +media3okhttp = { module = "androidx.media3:media3-datasource-okhttp", version.ref = "media3" } +media3session = { module = "androidx.media3:media3-session", version.ref = "media3" } 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"} retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" } gsonConverter = { module = "com.squareup.retrofit2:converter-gson", version.ref = "retrofit" } jacksonConverter = { module = "com.squareup.retrofit2:converter-jackson", version.ref = "retrofit" } diff --git a/ultrasonic/build.gradle b/ultrasonic/build.gradle index 31ff5368..95196a9c 100644 --- a/ultrasonic/build.gradle +++ b/ultrasonic/build.gradle @@ -100,6 +100,9 @@ dependencies { implementation libs.constraintLayout implementation libs.preferences implementation libs.media + implementation libs.media3exoplayer + implementation libs.media3session + implementation libs.media3okhttp implementation libs.navigationFragment implementation libs.navigationUi @@ -109,6 +112,7 @@ dependencies { implementation libs.kotlinStdlib implementation libs.kotlinxCoroutines + implementation libs.kotlinxGuava implementation libs.koinAndroid implementation libs.okhttpLogging implementation libs.fastScroll diff --git a/ultrasonic/lint-baseline.xml b/ultrasonic/lint-baseline.xml index cd999750..521d62a7 100644 --- a/ultrasonic/lint-baseline.xml +++ b/ultrasonic/lint-baseline.xml @@ -1,37 +1,15 @@ - + + errorLine1=" val view = inflater.inflate(R.layout.jukebox_volume, null)" + errorLine2=" ~~~~"> - - - - - - - - + file="src/main/kotlin/org/moire/ultrasonic/service/JukeboxMediaPlayer.kt" + line="331" + column="66"/> @@ -56,17 +34,6 @@ column="73"/> - - - - @@ -88,18 +55,29 @@ errorLine2=" ~~~~~~~~"> + + + + @@ -114,171 +92,6 @@ column="10"/> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -838,39 +475,6 @@ column="6"/> - - - - - - - - - - - - - - - - - + + - - - - - - + @@ -89,7 +85,8 @@ - + @@ -99,7 +96,8 @@ + android:label="Ultrasonic (4x1)" + android:exported="false"> @@ -110,7 +108,8 @@ + android:label="Ultrasonic (4x2)" + android:exported="false"> @@ -121,7 +120,8 @@ + android:label="Ultrasonic (4x3)" + android:exported="false"> @@ -132,7 +132,8 @@ + android:label="Ultrasonic (4x4)" + android:exported="false"> @@ -141,18 +142,16 @@ android:name="android.appwidget.provider" android:resource="@xml/appwidget_info_4x4"/> - + + + + + - - - - - diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/provider/UltrasonicAppWidgetProvider.java b/ultrasonic/src/main/java/org/moire/ultrasonic/provider/UltrasonicAppWidgetProvider.java deleted file mode 100644 index 29cd9bcc..00000000 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/provider/UltrasonicAppWidgetProvider.java +++ /dev/null @@ -1,221 +0,0 @@ -package org.moire.ultrasonic.provider; - -import android.app.PendingIntent; -import android.appwidget.AppWidgetManager; -import android.appwidget.AppWidgetProvider; -import android.content.ComponentName; -import android.content.Context; -import android.content.Intent; -import android.content.res.Resources; -import android.graphics.Bitmap; -import android.os.Environment; -import android.view.KeyEvent; -import android.widget.RemoteViews; - -import org.moire.ultrasonic.R; -import org.moire.ultrasonic.activity.NavigationActivity; -import org.moire.ultrasonic.domain.Track; -import org.moire.ultrasonic.imageloader.BitmapUtils; -import org.moire.ultrasonic.receiver.MediaButtonIntentReceiver; -import org.moire.ultrasonic.service.MediaPlayerController; -import org.moire.ultrasonic.util.Constants; - -import timber.log.Timber; - -/** - * Widget Provider for the Ultrasonic Widgets - */ -public class UltrasonicAppWidgetProvider extends AppWidgetProvider -{ - protected int layoutId; - - @Override - public void onUpdate(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds) - { - defaultAppWidget(context, appWidgetIds); - } - - /** - * Initialize given widgets to default state, where we launch Ultrasonic on default click - * and hide actions if service not running. - */ - private void defaultAppWidget(Context context, int[] appWidgetIds) - { - final Resources res = context.getResources(); - final RemoteViews views = new RemoteViews(context.getPackageName(), this.layoutId); - - views.setTextViewText(R.id.title, null); - views.setTextViewText(R.id.album, null); - views.setTextViewText(R.id.artist, res.getText(R.string.widget_initial_text)); - - linkButtons(context, views, false); - pushUpdate(context, appWidgetIds, views); - } - - private void pushUpdate(Context context, int[] appWidgetIds, RemoteViews views) - { - // Update specific list of appWidgetIds if given, otherwise default to all - final AppWidgetManager manager = AppWidgetManager.getInstance(context); - - if (manager != null) - { - if (appWidgetIds != null) - { - manager.updateAppWidget(appWidgetIds, views); - } - else - { - manager.updateAppWidget(new ComponentName(context, this.getClass()), views); - } - } - } - - /** - * Handle a change notification coming over from {@link MediaPlayerController} - */ - public void notifyChange(Context context, Track currentSong, boolean playing, boolean setAlbum) - { - if (hasInstances(context)) - { - performUpdate(context, currentSong, playing, setAlbum); - } - } - - /** - * Check against {@link AppWidgetManager} if there are any instances of this widget. - */ - private boolean hasInstances(Context context) - { - AppWidgetManager manager = AppWidgetManager.getInstance(context); - - if (manager != null) - { - int[] appWidgetIds = manager.getAppWidgetIds(new ComponentName(context, getClass())); - return (appWidgetIds.length > 0); - } - - return false; - } - - /** - * Update all active widget instances by pushing changes - */ - private void performUpdate(Context context, Track currentSong, boolean playing, boolean setAlbum) - { - final Resources res = context.getResources(); - final RemoteViews views = new RemoteViews(context.getPackageName(), this.layoutId); - - String title = currentSong == null ? null : currentSong.getTitle(); - String artist = currentSong == null ? null : currentSong.getArtist(); - String album = currentSong == null ? null : currentSong.getAlbum(); - CharSequence errorState = null; - - // Show error message? - String status = Environment.getExternalStorageState(); - if (status.equals(Environment.MEDIA_SHARED) || status.equals(Environment.MEDIA_UNMOUNTED)) - { - errorState = res.getText(R.string.widget_sdcard_busy); - } - else if (status.equals(Environment.MEDIA_REMOVED)) - { - errorState = res.getText(R.string.widget_sdcard_missing); - } - else if (currentSong == null) - { - errorState = res.getText(R.string.widget_initial_text); - } - - if (errorState != null) - { - // Show error state to user - views.setTextViewText(R.id.title, null); - views.setTextViewText(R.id.artist, errorState); - if (setAlbum) - { - views.setTextViewText(R.id.album, null); - } - views.setImageViewResource(R.id.appwidget_coverart, R.drawable.unknown_album); - } - else - { - // No error, so show normal titles - views.setTextViewText(R.id.title, title); - views.setTextViewText(R.id.artist, artist); - if (setAlbum) - { - views.setTextViewText(R.id.album, album); - } - } - - // Set correct drawable for pause state - if (playing) - { - views.setImageViewResource(R.id.control_play, R.drawable.media_pause_normal_dark); - } - else - { - views.setImageViewResource(R.id.control_play, R.drawable.media_start_normal_dark); - } - - // Set the cover art - try - { - Bitmap bitmap = currentSong == null ? null : BitmapUtils.Companion.getAlbumArtBitmapFromDisk(currentSong, 240); - - if (bitmap == null) - { - // Set default cover art - views.setImageViewResource(R.id.appwidget_coverart, R.drawable.unknown_album); - } - else - { - views.setImageViewBitmap(R.id.appwidget_coverart, bitmap); - } - } - catch (Exception x) - { - Timber.e(x, "Failed to load cover art"); - views.setImageViewResource(R.id.appwidget_coverart, R.drawable.unknown_album); - } - - // Link actions buttons to intents - linkButtons(context, views, currentSong != null); - - pushUpdate(context, null, views); - } - - /** - * Link up various button actions using {@link PendingIntent}. - */ - private static void linkButtons(Context context, RemoteViews views, boolean playerActive) - { - Intent intent = new Intent(context, NavigationActivity.class).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP); - if (playerActive) - intent.putExtra(Constants.INTENT_SHOW_PLAYER, true); - - intent.setAction("android.intent.action.MAIN"); - intent.addCategory("android.intent.category.LAUNCHER"); - PendingIntent pendingIntent = PendingIntent.getActivity(context, 10, intent, PendingIntent.FLAG_UPDATE_CURRENT); - views.setOnClickPendingIntent(R.id.appwidget_coverart, pendingIntent); - views.setOnClickPendingIntent(R.id.appwidget_top, pendingIntent); - - // Emulate media button clicks. - intent = new Intent(Constants.CMD_PROCESS_KEYCODE); - intent.setComponent(new ComponentName(context, MediaButtonIntentReceiver.class)); - intent.putExtra(Intent.EXTRA_KEY_EVENT, new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE)); - pendingIntent = PendingIntent.getBroadcast(context, 11, intent, 0); - views.setOnClickPendingIntent(R.id.control_play, pendingIntent); - - intent = new Intent(Constants.CMD_PROCESS_KEYCODE); - intent.setComponent(new ComponentName(context, MediaButtonIntentReceiver.class)); - intent.putExtra(Intent.EXTRA_KEY_EVENT, new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_NEXT)); - pendingIntent = PendingIntent.getBroadcast(context, 12, intent, 0); - views.setOnClickPendingIntent(R.id.control_next, pendingIntent); - - intent = new Intent(Constants.CMD_PROCESS_KEYCODE); - intent.setComponent(new ComponentName(context, MediaButtonIntentReceiver.class)); - intent.putExtra(Intent.EXTRA_KEY_EVENT, new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_PREVIOUS)); - pendingIntent = PendingIntent.getBroadcast(context, 13, intent, 0); - views.setOnClickPendingIntent(R.id.control_previous, pendingIntent); - } -} diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/provider/UltrasonicAppWidgetProvider4X1.java b/ultrasonic/src/main/java/org/moire/ultrasonic/provider/UltrasonicAppWidgetProvider4X1.java deleted file mode 100644 index 0c8a8ca8..00000000 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/provider/UltrasonicAppWidgetProvider4X1.java +++ /dev/null @@ -1,42 +0,0 @@ -/* - This file is part of Subsonic. - - Subsonic is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - Subsonic is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with Subsonic. If not, see . - - Copyright 2010 (C) Sindre Mehus - */ -package org.moire.ultrasonic.provider; - -import org.moire.ultrasonic.R; - -public class UltrasonicAppWidgetProvider4X1 extends UltrasonicAppWidgetProvider -{ - - public UltrasonicAppWidgetProvider4X1() - { - super(); - this.layoutId = R.layout.appwidget4x1; - } - - private static UltrasonicAppWidgetProvider4X1 instance; - - public static synchronized UltrasonicAppWidgetProvider4X1 getInstance() - { - if (instance == null) - { - instance = new UltrasonicAppWidgetProvider4X1(); - } - return instance; - } -} diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/provider/UltrasonicAppWidgetProvider4X2.java b/ultrasonic/src/main/java/org/moire/ultrasonic/provider/UltrasonicAppWidgetProvider4X2.java deleted file mode 100644 index 3ba12ae6..00000000 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/provider/UltrasonicAppWidgetProvider4X2.java +++ /dev/null @@ -1,42 +0,0 @@ -/* - This file is part of Subsonic. - - Subsonic is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - Subsonic is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with Subsonic. If not, see . - - Copyright 2010 (C) Sindre Mehus - */ -package org.moire.ultrasonic.provider; - -import org.moire.ultrasonic.R; - -public class UltrasonicAppWidgetProvider4X2 extends UltrasonicAppWidgetProvider -{ - - public UltrasonicAppWidgetProvider4X2() - { - super(); - this.layoutId = R.layout.appwidget4x2; - } - - private static UltrasonicAppWidgetProvider4X2 instance; - - public static synchronized UltrasonicAppWidgetProvider4X2 getInstance() - { - if (instance == null) - { - instance = new UltrasonicAppWidgetProvider4X2(); - } - return instance; - } -} diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/provider/UltrasonicAppWidgetProvider4X3.java b/ultrasonic/src/main/java/org/moire/ultrasonic/provider/UltrasonicAppWidgetProvider4X3.java deleted file mode 100644 index 15b2a561..00000000 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/provider/UltrasonicAppWidgetProvider4X3.java +++ /dev/null @@ -1,42 +0,0 @@ -/* - This file is part of Subsonic. - - Subsonic is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - Subsonic is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with Subsonic. If not, see . - - Copyright 2010 (C) Sindre Mehus - */ -package org.moire.ultrasonic.provider; - -import org.moire.ultrasonic.R; - -public class UltrasonicAppWidgetProvider4X3 extends UltrasonicAppWidgetProvider -{ - - public UltrasonicAppWidgetProvider4X3() - { - super(); - this.layoutId = R.layout.appwidget4x3; - } - - private static UltrasonicAppWidgetProvider4X3 instance; - - public static synchronized UltrasonicAppWidgetProvider4X3 getInstance() - { - if (instance == null) - { - instance = new UltrasonicAppWidgetProvider4X3(); - } - return instance; - } -} diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/provider/UltrasonicAppWidgetProvider4X4.java b/ultrasonic/src/main/java/org/moire/ultrasonic/provider/UltrasonicAppWidgetProvider4X4.java deleted file mode 100644 index c28c55ab..00000000 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/provider/UltrasonicAppWidgetProvider4X4.java +++ /dev/null @@ -1,42 +0,0 @@ -/* - This file is part of Subsonic. - - Subsonic is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - Subsonic is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with Subsonic. If not, see . - - Copyright 2010 (C) Sindre Mehus - */ -package org.moire.ultrasonic.provider; - -import org.moire.ultrasonic.R; - -public class UltrasonicAppWidgetProvider4X4 extends UltrasonicAppWidgetProvider -{ - - public UltrasonicAppWidgetProvider4X4() - { - super(); - this.layoutId = R.layout.appwidget4x4; - } - - private static UltrasonicAppWidgetProvider4X4 instance; - - public static synchronized UltrasonicAppWidgetProvider4X4 getInstance() - { - if (instance == null) - { - instance = new UltrasonicAppWidgetProvider4X4(); - } - return instance; - } -} diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/receiver/A2dpIntentReceiver.java b/ultrasonic/src/main/java/org/moire/ultrasonic/receiver/A2dpIntentReceiver.java deleted file mode 100644 index e122ca31..00000000 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/receiver/A2dpIntentReceiver.java +++ /dev/null @@ -1,57 +0,0 @@ -package org.moire.ultrasonic.receiver; - -import static org.koin.java.KoinJavaComponent.inject; - -import android.content.BroadcastReceiver; -import android.content.Context; -import android.content.Intent; - -import org.moire.ultrasonic.domain.Track; -import org.moire.ultrasonic.service.MediaPlayerController; - -import kotlin.Lazy; - -public class A2dpIntentReceiver extends BroadcastReceiver -{ - private static final String PLAYSTATUS_RESPONSE = "com.android.music.playstatusresponse"; - private Lazy mediaPlayerControllerLazy = inject(MediaPlayerController.class); - - @Override - public void onReceive(Context context, Intent intent) - { - if (mediaPlayerControllerLazy.getValue().getCurrentPlaying() == null) return; - - Track song = mediaPlayerControllerLazy.getValue().getCurrentPlaying().getTrack(); - if (song == null) return; - - Intent avrcpIntent = new Intent(PLAYSTATUS_RESPONSE); - - Integer duration = song.getDuration(); - int playerPosition = mediaPlayerControllerLazy.getValue().getPlayerPosition(); - int listSize = mediaPlayerControllerLazy.getValue().getPlaylistSize(); - - if (duration != null) - { - avrcpIntent.putExtra("duration", (long) duration); - } - - avrcpIntent.putExtra("position", (long) playerPosition); - avrcpIntent.putExtra("ListSize", (long) listSize); - - switch (mediaPlayerControllerLazy.getValue().getPlayerState()) - { - case STARTED: - avrcpIntent.putExtra("playing", true); - break; - case STOPPED: - case PAUSED: - case COMPLETED: - avrcpIntent.putExtra("playing", false); - break; - default: - return; - } - - context.sendBroadcast(avrcpIntent); - } -} \ No newline at end of file diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/receiver/MediaButtonIntentReceiver.java b/ultrasonic/src/main/java/org/moire/ultrasonic/receiver/MediaButtonIntentReceiver.java deleted file mode 100644 index 229da68d..00000000 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/receiver/MediaButtonIntentReceiver.java +++ /dev/null @@ -1,83 +0,0 @@ -/* - This file is part of Subsonic. - - Subsonic is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - Subsonic is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with Subsonic. If not, see . - - Copyright 2010 (C) Sindre Mehus - */ -package org.moire.ultrasonic.receiver; - -import android.content.BroadcastReceiver; -import android.content.Context; -import android.content.Intent; -import android.os.Bundle; -import android.os.Parcelable; -import timber.log.Timber; - -import org.moire.ultrasonic.service.MediaPlayerLifecycleSupport; -import org.moire.ultrasonic.util.Constants; -import org.moire.ultrasonic.util.Settings; -import org.moire.ultrasonic.util.Util; - -import kotlin.Lazy; - -import static org.koin.java.KoinJavaComponent.inject; - -/** - * @author Sindre Mehus - */ -public class MediaButtonIntentReceiver extends BroadcastReceiver -{ - private Lazy lifecycleSupport = inject(MediaPlayerLifecycleSupport.class); - - @Override - public void onReceive(Context context, Intent intent) - { - String intentAction = intent.getAction(); - - // If media button are turned off and we received a media button, exit - if (!Settings.getMediaButtonsEnabled() && Intent.ACTION_MEDIA_BUTTON.equals(intentAction)) - return; - - // Only process media buttons and CMD_PROCESS_KEYCODE, which is received from the widgets - if (!Intent.ACTION_MEDIA_BUTTON.equals(intentAction) && - !Constants.CMD_PROCESS_KEYCODE.equals(intentAction)) return; - - Bundle extras = intent.getExtras(); - - if (extras == null) - { - return; - } - - Parcelable event = (Parcelable) extras.get(Intent.EXTRA_KEY_EVENT); - Timber.i("Got MEDIA_BUTTON key event: %s", event); - - try - { - Intent serviceIntent = new Intent(Constants.CMD_PROCESS_KEYCODE); - serviceIntent.putExtra(Intent.EXTRA_KEY_EVENT, event); - lifecycleSupport.getValue().receiveIntent(serviceIntent); - - if (isOrderedBroadcast()) - { - abortBroadcast(); - } - } - catch (Exception x) - { - // Ignored. - } - } -} diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/service/JukeboxMediaPlayer.java b/ultrasonic/src/main/java/org/moire/ultrasonic/service/JukeboxMediaPlayer.java deleted file mode 100644 index 46e62147..00000000 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/service/JukeboxMediaPlayer.java +++ /dev/null @@ -1,489 +0,0 @@ -/* - This file is part of Subsonic. - - Subsonic is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - Subsonic is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with Subsonic. If not, see . - - Copyright 2009 (C) Sindre Mehus - */ -package org.moire.ultrasonic.service; - -import android.content.Context; -import android.os.Handler; -import timber.log.Timber; -import android.view.Gravity; -import android.view.LayoutInflater; -import android.view.View; -import android.widget.ProgressBar; -import android.widget.Toast; - -import org.jetbrains.annotations.NotNull; -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.data.ActiveServerProvider; -import org.moire.ultrasonic.domain.JukeboxStatus; -import org.moire.ultrasonic.domain.PlayerState; -import org.moire.ultrasonic.util.Util; - -import java.util.ArrayList; -import java.util.Iterator; -import java.util.List; -import java.util.concurrent.Executors; -import java.util.concurrent.LinkedBlockingQueue; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.ScheduledFuture; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicLong; - -import kotlin.Lazy; - -import static org.koin.java.KoinJavaComponent.inject; - -/** - * Provides an asynchronous interface to the remote jukebox on the Subsonic server. - * - * @author Sindre Mehus - * @version $Id$ - */ -public class JukeboxMediaPlayer -{ - private static final long STATUS_UPDATE_INTERVAL_SECONDS = 5L; - - private final TaskQueue tasks = new TaskQueue(); - private final ScheduledExecutorService executorService = Executors.newSingleThreadScheduledExecutor(); - private ScheduledFuture statusUpdateFuture; - private final AtomicLong timeOfLastUpdate = new AtomicLong(); - private JukeboxStatus jukeboxStatus; - private float gain = 0.5f; - private VolumeToast volumeToast; - private final AtomicBoolean running = new AtomicBoolean(); - private Thread serviceThread; - private boolean enabled = false; - - // TODO: These create circular references, try to refactor - private final Lazy mediaPlayerControllerLazy = inject(MediaPlayerController.class); - private final Downloader downloader; - - // TODO: Report warning if queue fills up. - // TODO: Create shutdown method? - // TODO: Disable repeat. - // TODO: Persist RC state? - // TODO: Minimize status updates. - - public JukeboxMediaPlayer(Downloader downloader) - { - this.downloader = downloader; - } - - public void startJukeboxService() - { - if (running.get()) - { - return; - } - - running.set(true); - startProcessTasks(); - Timber.d("Started Jukebox Service"); - } - - public void stopJukeboxService() - { - running.set(false); - Util.sleepQuietly(1000); - - if (serviceThread != null) - { - serviceThread.interrupt(); - } - Timber.d("Stopped Jukebox Service"); - } - - private void startProcessTasks() - { - serviceThread = new Thread() - { - @Override - public void run() - { - processTasks(); - } - }; - - serviceThread.start(); - } - - private synchronized void startStatusUpdate() - { - stopStatusUpdate(); - - Runnable updateTask = new Runnable() - { - @Override - public void run() - { - tasks.remove(GetStatus.class); - tasks.add(new GetStatus()); - } - }; - - statusUpdateFuture = executorService.scheduleWithFixedDelay(updateTask, STATUS_UPDATE_INTERVAL_SECONDS, STATUS_UPDATE_INTERVAL_SECONDS, TimeUnit.SECONDS); - } - - private synchronized void stopStatusUpdate() - { - if (statusUpdateFuture != null) - { - statusUpdateFuture.cancel(false); - statusUpdateFuture = null; - } - } - - private void processTasks() - { - while (running.get()) - { - JukeboxTask task = null; - - try - { - if (!ActiveServerProvider.Companion.isOffline()) - { - task = tasks.take(); - JukeboxStatus status = task.execute(); - onStatusUpdate(status); - } - } - catch (InterruptedException ignored) - { - - } - catch (Throwable x) - { - onError(task, x); - } - - Util.sleepQuietly(1); - } - } - - private void onStatusUpdate(JukeboxStatus jukeboxStatus) - { - timeOfLastUpdate.set(System.currentTimeMillis()); - this.jukeboxStatus = jukeboxStatus; - - // Track change? - Integer index = jukeboxStatus.getCurrentPlayingIndex(); - - if (index != null && index != -1 && index != downloader.getCurrentPlayingIndex()) - { - mediaPlayerControllerLazy.getValue().setCurrentPlaying(index); - } - } - - private void onError(JukeboxTask task, Throwable x) - { - if (x instanceof ApiNotSupportedException && !(task instanceof Stop)) - { - disableJukeboxOnError(x, R.string.download_jukebox_server_too_old); - } - else if (x instanceof OfflineException && !(task instanceof Stop)) - { - disableJukeboxOnError(x, R.string.download_jukebox_offline); - } - else if (x instanceof SubsonicRESTException && ((SubsonicRESTException) x).getCode() == 50 && !(task instanceof Stop)) - { - disableJukeboxOnError(x, R.string.download_jukebox_not_authorized); - } - else - { - Timber.e(x, "Failed to process jukebox task"); - } - } - - private void disableJukeboxOnError(Throwable x, final int resourceId) - { - Timber.w(x.toString()); - Context context = UApp.Companion.applicationContext(); - new Handler().post(() -> Util.toast(context, resourceId, false)); - - mediaPlayerControllerLazy.getValue().setJukeboxEnabled(false); - } - - public void updatePlaylist() - { - if (!enabled) return; - - tasks.remove(Skip.class); - tasks.remove(Stop.class); - tasks.remove(Start.class); - - List ids = new ArrayList<>(); - for (DownloadFile file : downloader.getAll()) - { - ids.add(file.getTrack().getId()); - } - - tasks.add(new SetPlaylist(ids)); - } - - public void skip(final int index, final int offsetSeconds) - { - tasks.remove(Skip.class); - tasks.remove(Stop.class); - tasks.remove(Start.class); - - startStatusUpdate(); - - if (jukeboxStatus != null) - { - jukeboxStatus.setPositionSeconds(offsetSeconds); - } - - tasks.add(new Skip(index, offsetSeconds)); - mediaPlayerControllerLazy.getValue().setPlayerState(PlayerState.STARTED); - } - - public void stop() - { - tasks.remove(Stop.class); - tasks.remove(Start.class); - - stopStatusUpdate(); - - tasks.add(new Stop()); - } - - public void start() - { - tasks.remove(Stop.class); - tasks.remove(Start.class); - - startStatusUpdate(); - tasks.add(new Start()); - } - - public synchronized void adjustVolume(boolean up) - { - float delta = up ? 0.05f : -0.05f; - gain += delta; - gain = Math.max(gain, 0.0f); - gain = Math.min(gain, 1.0f); - - tasks.remove(SetGain.class); - tasks.add(new SetGain(gain)); - - Context context = UApp.Companion.applicationContext(); - if (volumeToast == null) volumeToast = new VolumeToast(context); - - volumeToast.setVolume(gain); - } - - private MusicService getMusicService() - { - return MusicServiceFactory.getMusicService(); - } - - public int getPositionSeconds() - { - if (jukeboxStatus == null || jukeboxStatus.getPositionSeconds() == null || timeOfLastUpdate.get() == 0) - { - return 0; - } - - if (jukeboxStatus.isPlaying()) - { - int secondsSinceLastUpdate = (int) ((System.currentTimeMillis() - timeOfLastUpdate.get()) / 1000L); - return jukeboxStatus.getPositionSeconds() + secondsSinceLastUpdate; - } - - return jukeboxStatus.getPositionSeconds(); - } - - public void setEnabled(boolean enabled) - { - Timber.d("Jukebox Service setting enabled to %b", enabled); - this.enabled = enabled; - - tasks.clear(); - if (enabled) - { - updatePlaylist(); - } - - stop(); - } - - public boolean isEnabled() - { - return enabled; - } - - private static class TaskQueue - { - private final LinkedBlockingQueue queue = new LinkedBlockingQueue<>(); - - void add(JukeboxTask jukeboxTask) - { - queue.add(jukeboxTask); - } - - JukeboxTask take() throws InterruptedException - { - return queue.take(); - } - - void remove(Class taskClass) - { - try - { - Iterator iterator = queue.iterator(); - - while (iterator.hasNext()) - { - JukeboxTask task = iterator.next(); - - if (taskClass.equals(task.getClass())) - { - iterator.remove(); - } - } - } - catch (Throwable x) - { - Timber.w(x, "Failed to clean-up task queue."); - } - } - - void clear() - { - queue.clear(); - } - } - - private abstract static class JukeboxTask - { - abstract JukeboxStatus execute() throws Exception; - - @NotNull - @Override - public String toString() - { - return getClass().getSimpleName(); - } - } - - private class GetStatus extends JukeboxTask - { - @Override - JukeboxStatus execute() throws Exception - { - return getMusicService().getJukeboxStatus(); - } - } - - private class SetPlaylist extends JukeboxTask - { - private final List ids; - - SetPlaylist(List ids) - { - this.ids = ids; - } - - @Override - JukeboxStatus execute() throws Exception - { - return getMusicService().updateJukeboxPlaylist(ids); - } - } - - private class Skip extends JukeboxTask - { - private final int index; - private final int offsetSeconds; - - Skip(int index, int offsetSeconds) - { - this.index = index; - this.offsetSeconds = offsetSeconds; - } - - @Override - JukeboxStatus execute() throws Exception - { - return getMusicService().skipJukebox(index, offsetSeconds); - } - } - - private class Stop extends JukeboxTask - { - @Override - JukeboxStatus execute() throws Exception - { - return getMusicService().stopJukebox(); - } - } - - private class Start extends JukeboxTask - { - @Override - JukeboxStatus execute() throws Exception - { - return getMusicService().startJukebox(); - } - } - - private class SetGain extends JukeboxTask - { - - private final float gain; - - private SetGain(float gain) - { - this.gain = gain; - } - - @Override - JukeboxStatus execute() throws Exception - { - return getMusicService().setJukeboxGain(gain); - } - } - - private static class VolumeToast extends Toast - { - - private final ProgressBar progressBar; - - public VolumeToast(Context context) - { - super(context); - setDuration(Toast.LENGTH_SHORT); - LayoutInflater inflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE); - View view = inflater.inflate(R.layout.jukebox_volume, null); - progressBar = (ProgressBar) view.findViewById(R.id.jukebox_volume_progress_bar); - setView(view); - setGravity(Gravity.TOP, 0, 0); - } - - public void setVolume(float volume) - { - progressBar.setProgress(Math.round(100 * volume)); - show(); - } - } -} diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/util/ShufflePlayBuffer.java b/ultrasonic/src/main/java/org/moire/ultrasonic/util/ShufflePlayBuffer.java deleted file mode 100644 index 23b013af..00000000 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/util/ShufflePlayBuffer.java +++ /dev/null @@ -1,125 +0,0 @@ -/* - This file is part of Subsonic. - - Subsonic is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - Subsonic is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with Subsonic. If not, see . - - Copyright 2009 (C) Sindre Mehus - */ -package org.moire.ultrasonic.util; - -import org.moire.ultrasonic.data.ActiveServerProvider; -import org.moire.ultrasonic.domain.MusicDirectory; -import org.moire.ultrasonic.domain.Track; -import org.moire.ultrasonic.service.MusicService; -import org.moire.ultrasonic.service.MusicServiceFactory; - -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.TimeUnit; - -import timber.log.Timber; - -/** - * @author Sindre Mehus - * @version $Id$ - */ -public class ShufflePlayBuffer -{ - private static final int CAPACITY = 50; - private static final int REFILL_THRESHOLD = 40; - - private final List buffer = new ArrayList<>(); - private ScheduledExecutorService executorService; - private int currentServer; - - public boolean isEnabled = false; - - public ShufflePlayBuffer() - { - } - - public void onCreate() - { - executorService = Executors.newSingleThreadScheduledExecutor(); - Runnable runnable = this::refill; - executorService.scheduleWithFixedDelay(runnable, 1, 10, TimeUnit.SECONDS); - Timber.i("ShufflePlayBuffer created"); - } - - public void onDestroy() - { - executorService.shutdown(); - Timber.i("ShufflePlayBuffer destroyed"); - } - - public List get(int size) - { - clearBufferIfNecessary(); - - List result = new ArrayList<>(size); - synchronized (buffer) - { - while (!buffer.isEmpty() && result.size() < size) - { - result.add(buffer.remove(buffer.size() - 1)); - } - } - Timber.i("Taking %d songs from shuffle play buffer. %d remaining.", result.size(), buffer.size()); - return result; - } - - private void refill() - { - if (!isEnabled) return; - - // Check if active server has changed. - clearBufferIfNecessary(); - - if (buffer.size() > REFILL_THRESHOLD || (!Util.isNetworkConnected() && !ActiveServerProvider.Companion.isOffline())) - { - return; - } - - try - { - MusicService service = MusicServiceFactory.getMusicService(); - int n = CAPACITY - buffer.size(); - MusicDirectory songs = service.getRandomSongs(n); - - synchronized (buffer) - { - buffer.addAll(songs.getTracks()); - Timber.i("Refilled shuffle play buffer with %d songs.", songs.getTracks().size()); - } - } - catch (Exception x) - { - Timber.w(x, "Failed to refill shuffle play buffer."); - } - } - - private void clearBufferIfNecessary() - { - synchronized (buffer) - { - if (currentServer != ActiveServerProvider.Companion.getActiveServerId()) - { - currentServer = ActiveServerProvider.Companion.getActiveServerId(); - buffer.clear(); - } - } - } -} \ No newline at end of file diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/util/StreamProxy.java b/ultrasonic/src/main/java/org/moire/ultrasonic/util/StreamProxy.java deleted file mode 100644 index 227e3d2c..00000000 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/util/StreamProxy.java +++ /dev/null @@ -1,290 +0,0 @@ -package org.moire.ultrasonic.util; - -import org.moire.ultrasonic.domain.Track; -import org.moire.ultrasonic.service.DownloadFile; -import org.moire.ultrasonic.service.Supplier; - -import java.io.BufferedOutputStream; -import java.io.BufferedReader; -import java.io.IOException; -import java.io.InputStream; -import java.io.InputStreamReader; -import java.io.OutputStream; -import java.io.UnsupportedEncodingException; -import java.net.InetAddress; -import java.net.ServerSocket; -import java.net.Socket; -import java.net.SocketException; -import java.net.SocketTimeoutException; -import java.net.URLDecoder; -import java.net.UnknownHostException; -import java.util.StringTokenizer; - -import timber.log.Timber; - -public class StreamProxy implements Runnable -{ - private Thread thread; - private boolean isRunning; - private ServerSocket socket; - private int port; - private Supplier currentPlaying; - - public StreamProxy(Supplier currentPlaying) - { - - // Create listening socket - try - { - socket = new ServerSocket(0, 0, InetAddress.getByAddress(new byte[]{127, 0, 0, 1})); - socket.setSoTimeout(5000); - port = socket.getLocalPort(); - this.currentPlaying = currentPlaying; - } - catch (UnknownHostException e) - { // impossible - } - catch (IOException e) - { - Timber.e(e, "IOException initializing server"); - } - } - - public int getPort() - { - return port; - } - - public void start() - { - thread = new Thread(this); - thread.start(); - } - - public void stop() - { - isRunning = false; - thread.interrupt(); - } - - @Override - public void run() - { - isRunning = true; - while (isRunning) - { - try - { - Socket client = socket.accept(); - if (client == null) - { - continue; - } - Timber.i("Client connected"); - - StreamToMediaPlayerTask task = new StreamToMediaPlayerTask(client); - if (task.processRequest()) - { - new Thread(task).start(); - } - - } - catch (SocketTimeoutException e) - { - // Do nothing - } - catch (IOException e) - { - Timber.e(e, "Error connecting to client"); - } - } - Timber.i("Proxy interrupted. Shutting down."); - } - - private class StreamToMediaPlayerTask implements Runnable { - String localPath; - Socket client; - int cbSkip; - - StreamToMediaPlayerTask(Socket client) { - this.client = client; - } - - private String readRequest() { - InputStream is; - String firstLine; - try { - is = client.getInputStream(); - BufferedReader reader = new BufferedReader(new InputStreamReader(is), 8192); - firstLine = reader.readLine(); - } catch (IOException e) { - Timber.e(e, "Error parsing request"); - return null; - } - - if (firstLine == null) { - Timber.i("Proxy client closed connection without a request."); - return null; - } - - StringTokenizer st = new StringTokenizer(firstLine); - st.nextToken(); // method - String uri = st.nextToken(); - String realUri = uri.substring(1); - Timber.i(realUri); - - return realUri; - } - - boolean processRequest() { - final String uri = readRequest(); - if (uri == null || uri.isEmpty()) { - return false; - } - - // Read HTTP headers - Timber.i("Processing request: %s", uri); - - try { - localPath = URLDecoder.decode(uri, Constants.UTF_8); - } catch (UnsupportedEncodingException e) { - Timber.e(e, "Unsupported encoding"); - return false; - } - - Timber.i("Processing request for file %s", localPath); - if (Storage.INSTANCE.isPathExists(localPath)) return true; - - // Usually the .partial file will be requested here, but sometimes it has already - // been renamed, so check if it is completed since - String saveFileName = FileUtil.INSTANCE.getSaveFile(localPath); - String completeFileName = FileUtil.INSTANCE.getCompleteFile(saveFileName); - - if (Storage.INSTANCE.isPathExists(saveFileName)) { - localPath = saveFileName; - return true; - } - - if (Storage.INSTANCE.isPathExists(completeFileName)) { - localPath = completeFileName; - return true; - } - - Timber.e("File %s does not exist", localPath); - return false; - - } - - @Override - public void run() - { - Timber.i("Streaming song in background"); - DownloadFile downloadFile = currentPlaying == null? null : currentPlaying.get(); - Track song = downloadFile.getTrack(); - long fileSize = downloadFile.getBitRate() * ((song.getDuration() != null) ? song.getDuration() : 0) * 1000 / 8; - Timber.i("Streaming fileSize: %d", fileSize); - - // Create HTTP header - String headers = "HTTP/1.0 200 OK\r\n"; - headers += "Content-Type: application/octet-stream\r\n"; - headers += "Connection: close\r\n"; - headers += "\r\n"; - - long cbToSend = fileSize - cbSkip; - OutputStream output = null; - byte[] buff = new byte[64 * 1024]; - - try - { - output = new BufferedOutputStream(client.getOutputStream(), 32 * 1024); - output.write(headers.getBytes()); - - if (!downloadFile.isWorkDone()) - { - // Loop as long as there's stuff to send - while (isRunning && !client.isClosed()) - { - // See if there's more to send - String file = downloadFile.isCompleteFileAvailable() ? downloadFile.getCompleteOrSaveFile() : downloadFile.getPartialFile(); - int cbSentThisBatch = 0; - - AbstractFile storageFile = Storage.INSTANCE.getFromPath(file); - if (storageFile != null) - { - InputStream input = storageFile.getFileInputStream(); - - try - { - long skip = input.skip(cbSkip); - int cbToSendThisBatch = input.available(); - - while (cbToSendThisBatch > 0) - { - int cbToRead = Math.min(cbToSendThisBatch, buff.length); - int cbRead = input.read(buff, 0, cbToRead); - - if (cbRead == -1) - { - break; - } - - cbToSendThisBatch -= cbRead; - cbToSend -= cbRead; - output.write(buff, 0, cbRead); - output.flush(); - cbSkip += cbRead; - cbSentThisBatch += cbRead; - } - } - finally - { - input.close(); - } - - // Done regardless of whether or not it thinks it is - if (downloadFile.isWorkDone() && cbSkip >= file.length()) - { - break; - } - } - - // If we did nothing this batch, block for a second - if (cbSentThisBatch == 0) - { - Timber.d("Blocking until more data appears (%d)", cbToSend); - Util.sleepQuietly(1000L); - } - } - } - else - { - Timber.w("Requesting data for completely downloaded file"); - } - } - catch (SocketException socketException) - { - Timber.e("SocketException() thrown, proxy client has probably closed. This can exit harmlessly"); - } - catch (Exception e) - { - Timber.e("Exception thrown from streaming task:"); - Timber.e("%s : %s", e.getClass().getName(), e.getLocalizedMessage()); - } - - // Cleanup - try - { - if (output != null) - { - output.close(); - } - client.close(); - } - catch (IOException e) - { - Timber.e("IOException while cleaning up streaming task:"); - Timber.e("%s : %s", e.getClass().getName(), e.getLocalizedMessage()); - } - } - } -} \ No newline at end of file diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/view/VisualizerView.java b/ultrasonic/src/main/java/org/moire/ultrasonic/view/VisualizerView.java index 1de45b44..331b8903 100644 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/view/VisualizerView.java +++ b/ultrasonic/src/main/java/org/moire/ultrasonic/view/VisualizerView.java @@ -18,6 +18,8 @@ */ package org.moire.ultrasonic.view; +import static org.koin.java.KoinJavaComponent.inject; + import android.content.Context; import android.graphics.Canvas; import android.graphics.Color; @@ -29,14 +31,11 @@ import androidx.lifecycle.LifecycleOwner; import androidx.lifecycle.Observer; import org.moire.ultrasonic.audiofx.VisualizerController; -import org.moire.ultrasonic.domain.PlayerState; import org.moire.ultrasonic.service.MediaPlayerController; import kotlin.Lazy; import timber.log.Timber; -import static org.koin.java.KoinJavaComponent.inject; - /** * A simple class that draws waveform data received from a * {@link Visualizer.OnDataCaptureListener#onWaveFormDataCapture} @@ -130,7 +129,7 @@ public class VisualizerView extends View return; } - if (mediaPlayerControllerLazy.getValue().getPlayerState() != PlayerState.STARTED) + if (!mediaPlayerControllerLazy.getValue().isPlaying()) { return; } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/activity/NavigationActivity.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/activity/NavigationActivity.kt index a82d93d1..7c399d6c 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/activity/NavigationActivity.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/activity/NavigationActivity.kt @@ -1,6 +1,6 @@ /* * NavigationActivity.kt - * Copyright (C) 2009-2021 Ultrasonic developers + * Copyright (C) 2009-2022 Ultrasonic developers * * Distributed under terms of the GNU GPLv3 license. */ @@ -27,6 +27,9 @@ import androidx.core.content.ContextCompat import androidx.core.view.GravityCompat import androidx.drawerlayout.widget.DrawerLayout import androidx.fragment.app.FragmentContainerView +import androidx.media3.common.MediaItem +import androidx.media3.common.Player.STATE_BUFFERING +import androidx.media3.common.Player.STATE_READY import androidx.navigation.NavController import androidx.navigation.findNavController import androidx.navigation.fragment.NavHostFragment @@ -38,20 +41,20 @@ import androidx.navigation.ui.setupWithNavController import androidx.preference.PreferenceManager import com.google.android.material.button.MaterialButton import com.google.android.material.navigation.NavigationView -import io.reactivex.rxjava3.disposables.Disposable +import io.reactivex.rxjava3.disposables.CompositeDisposable import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel import org.moire.ultrasonic.R +import org.moire.ultrasonic.app.UApp import org.moire.ultrasonic.data.ActiveServerProvider import org.moire.ultrasonic.data.ServerSettingDao -import org.moire.ultrasonic.domain.PlayerState import org.moire.ultrasonic.fragment.OnBackPressedHandler import org.moire.ultrasonic.model.ServerSettingsModel import org.moire.ultrasonic.provider.SearchSuggestionProvider -import org.moire.ultrasonic.service.DownloadFile import org.moire.ultrasonic.service.MediaPlayerController import org.moire.ultrasonic.service.MediaPlayerLifecycleSupport import org.moire.ultrasonic.service.RxBus +import org.moire.ultrasonic.service.plusAssign import org.moire.ultrasonic.subsonic.ImageLoaderProvider import org.moire.ultrasonic.util.Constants import org.moire.ultrasonic.util.InfoDialog @@ -64,7 +67,7 @@ import org.moire.ultrasonic.util.Util import timber.log.Timber /** - * The main Activity of Ultrasonic which loads all other screens as Fragments + * The main (and only) Activity of Ultrasonic which loads all other screens as Fragments */ @Suppress("TooManyFunctions") class NavigationActivity : AppCompatActivity() { @@ -81,8 +84,8 @@ class NavigationActivity : AppCompatActivity() { private var headerBackgroundImage: ImageView? = null private lateinit var appBarConfiguration: AppBarConfiguration - private var themeChangedEventSubscription: Disposable? = null - private var playerStateSubscription: Disposable? = null + + private var rxBusSubscription: CompositeDisposable = CompositeDisposable() private val serverSettingsModel: ServerSettingsModel by viewModel() private val lifecycleSupport: MediaPlayerLifecycleSupport by inject() @@ -96,6 +99,16 @@ class NavigationActivity : AppCompatActivity() { private var cachedServerCount: Int = 0 override fun onCreate(savedInstanceState: Bundle?) { + Timber.d("onCreate called") + + // First check if Koin has been started + if (UApp.instance != null && !UApp.instance!!.initiated) { + Timber.d("Starting Koin") + UApp.instance!!.startKoin() + } else { + Timber.d("No need to start Koin") + } + setUncaughtExceptionHandler() Util.applyTheme(this) @@ -179,25 +192,25 @@ class NavigationActivity : AppCompatActivity() { hideNowPlaying() } - playerStateSubscription = RxBus.playerStateObservable.subscribe { - if (it.state === PlayerState.STARTED || it.state === PlayerState.PAUSED) + rxBusSubscription += RxBus.playerStateObservable.subscribe { + if (it.state == STATE_READY) showNowPlaying() else hideNowPlaying() } - themeChangedEventSubscription = RxBus.themeChangedEventObservable.subscribe { + rxBusSubscription += RxBus.themeChangedEventObservable.subscribe { recreate() } + rxBusSubscription += RxBus.activeServerChangeObservable.subscribe { + updateNavigationHeaderForServer() + } + serverRepository.liveServerCount().observe(this) { count -> cachedServerCount = count ?: 0 updateNavigationHeaderForServer() } - - ActiveServerProvider.liveActiveServerId.observe(this) { - updateNavigationHeaderForServer() - } } private fun updateNavigationHeaderForServer() { @@ -223,6 +236,7 @@ class NavigationActivity : AppCompatActivity() { } override fun onResume() { + Timber.d("onResume called") super.onResume() Storage.reset() @@ -236,10 +250,11 @@ class NavigationActivity : AppCompatActivity() { } override fun onDestroy() { - super.onDestroy() - themeChangedEventSubscription?.dispose() - playerStateSubscription?.dispose() + Timber.d("onDestroy called") + rxBusSubscription.dispose() imageLoaderProvider.clearImageLoader() + UApp.instance!!.shutdownKoin() + super.onDestroy() } override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean { @@ -364,8 +379,13 @@ class NavigationActivity : AppCompatActivity() { } private fun exit() { + Timber.d("User choose to exit the app") + + // Broadcast that the service is being shutdown + RxBus.stopCommandPublisher.onNext(Unit) + lifecycleSupport.onDestroy() - finish() + finishAndRemoveTask() } private fun showWelcomeDialog() { @@ -414,10 +434,10 @@ class NavigationActivity : AppCompatActivity() { } if (nowPlayingView != null) { - val playerState: PlayerState = mediaPlayerController.playerState - if (playerState == PlayerState.PAUSED || playerState == PlayerState.STARTED) { - val file: DownloadFile? = mediaPlayerController.currentPlaying - if (file != null) { + val playerState: Int = mediaPlayerController.playbackState + if (playerState == STATE_BUFFERING || playerState == STATE_READY) { + val item: MediaItem? = mediaPlayerController.currentMediaItem + if (item != null) { nowPlayingView?.visibility = View.VISIBLE } } else { diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/ServerRowAdapter.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/ServerRowAdapter.kt index 8790bf7c..1ec4fbe7 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/ServerRowAdapter.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/ServerRowAdapter.kt @@ -30,7 +30,7 @@ import org.moire.ultrasonic.util.Util */ internal class ServerRowAdapter( private var context: Context, - private var data: Array, + passedData: Array, private val model: ServerSettingsModel, private val activeServerProvider: ActiveServerProvider, private val manageMode: Boolean, @@ -38,6 +38,12 @@ internal class ServerRowAdapter( private val serverEditRequestedCallback: ((Int) -> Unit) ) : BaseAdapter() { + private var data: MutableList = mutableListOf() + + init { + setData(passedData) + } + companion object { private const val MENU_ID_EDIT = 1 private const val MENU_ID_DELETE = 2 @@ -49,12 +55,19 @@ internal class ServerRowAdapter( context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater fun setData(data: Array) { - this.data = data + this.data.clear() + + // In read mode show the offline server as well + if (!manageMode) { + this.data.add(ActiveServerProvider.OFFLINE_DB) + } + + this.data.addAll(data) notifyDataSetChanged() } override fun getCount(): Int { - return if (manageMode) data.size else data.size + 1 + return data.size } override fun getItem(position: Int): Any { @@ -69,11 +82,11 @@ internal class ServerRowAdapter( * Creates the Row representation of a Server Setting */ @Suppress("LongMethod") - override fun getView(position: Int, convertView: View?, parent: ViewGroup?): View? { - var index = position + override fun getView(pos: Int, convertView: View?, parent: ViewGroup?): View? { + var position = pos // Skip "Offline" in manage mode - if (manageMode) index++ + if (manageMode) position++ var vi: View? = convertView if (vi == null) vi = inflater.inflate(R.layout.server_row, parent, false) @@ -83,22 +96,17 @@ internal class ServerRowAdapter( val layout = vi?.findViewById(R.id.server_layout) val image = vi?.findViewById(R.id.server_image) val serverMenu = vi?.findViewById(R.id.server_menu) - val setting = data.singleOrNull { t -> t.index == index } + val setting = data.singleOrNull { t -> t.index == position } - if (index == 0) { - text?.text = context.getString(R.string.main_offline) - description?.text = "" - } else { - text?.text = setting?.name ?: "" - description?.text = setting?.url ?: "" - if (setting == null) serverMenu?.visibility = View.INVISIBLE - } + text?.text = setting?.name ?: "" + description?.text = setting?.url ?: "" + if (setting == null) serverMenu?.visibility = View.INVISIBLE val icon: Drawable? val background: Drawable? // Configure icons for the row - if (index == 0) { + if (setting?.id == ActiveServerProvider.OFFLINE_DB_ID) { serverMenu?.visibility = View.INVISIBLE icon = Util.getDrawableFromAttribute(context, R.attr.screen_on_off) background = ContextCompat.getDrawable(context, R.drawable.circle) @@ -116,7 +124,7 @@ internal class ServerRowAdapter( image?.background = background // Highlight the Active Server's row by changing its background - if (index == activeServerProvider.getActiveServer().index) { + if (position == activeServerProvider.getActiveServer().index) { layout?.background = ContextCompat.getDrawable(context, R.drawable.select_ripple) } else { layout?.background = ContextCompat.getDrawable(context, R.drawable.default_ripple) @@ -128,7 +136,7 @@ internal class ServerRowAdapter( R.drawable.select_ripple_circle ) - serverMenu?.setOnClickListener { view -> serverMenuClick(view, index) } + serverMenu?.setOnClickListener { view -> serverMenuClick(view, position) } return vi } @@ -192,7 +200,8 @@ internal class ServerRowAdapter( return true } MENU_ID_DELETE -> { - serverDeletedCallback.invoke(position) + val server = getItem(position) as ServerSetting + serverDeletedCallback.invoke(server.id) return true } MENU_ID_UP -> { diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/TrackViewBinder.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/TrackViewBinder.kt index 0c9028f0..4efe0338 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/TrackViewBinder.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/TrackViewBinder.kt @@ -17,7 +17,7 @@ import org.moire.ultrasonic.service.DownloadFile import org.moire.ultrasonic.service.Downloader class TrackViewBinder( - val onItemClick: (DownloadFile) -> Unit, + val onItemClick: (DownloadFile, Int) -> Unit, val onContextMenuClick: ((MenuItem, DownloadFile) -> Boolean)? = null, val checkable: Boolean, val draggable: Boolean, @@ -29,7 +29,7 @@ class TrackViewBinder( // Set our layout files val layout = R.layout.list_item_track - val contextMenuLayout = R.menu.context_menu_track + private val contextMenuLayout = R.menu.context_menu_track private val downloader: Downloader by inject() private val imageHelper: Utils.ImageHelper = Utils.ImageHelper(context) @@ -41,15 +41,14 @@ class TrackViewBinder( @SuppressLint("ClickableViewAccessibility") @Suppress("LongMethod") override fun onBindViewHolder(holder: TrackViewHolder, item: Identifiable) { - val downloadFile: DownloadFile? val diffAdapter = adapter as BaseAdapter<*> - when (item) { + val downloadFile: DownloadFile = when (item) { is Track -> { - downloadFile = downloader.getDownloadFileForSong(item) + downloader.getDownloadFileForSong(item) } is DownloadFile -> { - downloadFile = item + item } else -> { return @@ -90,7 +89,7 @@ class TrackViewBinder( val nowChecked = !holder.check.isChecked holder.isChecked = nowChecked } else { - onItemClick(downloadFile) + onItemClick(downloadFile, holder.bindingAdapterPosition) } } @@ -103,41 +102,37 @@ class TrackViewBinder( // Notify the adapter of selection changes holder.observableChecked.observe( - lifecycleOwner, - { isCheckedNow -> - if (isCheckedNow) { - diffAdapter.notifySelected(holder.entry!!.longId) - } else { - diffAdapter.notifyUnselected(holder.entry!!.longId) - } + lifecycleOwner + ) { isCheckedNow -> + if (isCheckedNow) { + diffAdapter.notifySelected(holder.entry!!.longId) + } else { + diffAdapter.notifyUnselected(holder.entry!!.longId) } - ) + } // Listen to changes in selection status and update ourselves diffAdapter.selectionRevision.observe( - lifecycleOwner, - { - val newStatus = diffAdapter.isSelected(item.longId) + lifecycleOwner + ) { + val newStatus = diffAdapter.isSelected(item.longId) - if (newStatus != holder.check.isChecked) holder.check.isChecked = newStatus - } - ) + if (newStatus != holder.check.isChecked) holder.check.isChecked = newStatus + } // Observe download status downloadFile.status.observe( - lifecycleOwner, - { - holder.updateStatus(it) - diffAdapter.notifyChanged() - } - ) + lifecycleOwner + ) { + holder.updateStatus(it) + diffAdapter.notifyChanged() + } downloadFile.progress.observe( - lifecycleOwner, - { - holder.updateProgress(it) - } - ) + lifecycleOwner + ) { + holder.updateProgress(it) + } } override fun onViewRecycled(holder: TrackViewHolder) { diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/TrackViewHolder.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/TrackViewHolder.kt index 23a885d2..2b9c2ec5 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/TrackViewHolder.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/TrackViewHolder.kt @@ -109,7 +109,7 @@ class TrackViewHolder(val view: View) : RecyclerView.ViewHolder(view), Checkable } rxSubscription = RxBus.playerStateObservable.subscribe { - setPlayIcon(it.track == downloadFile) + setPlayIcon(it.index == bindingAdapterPosition && it.track == downloadFile) } } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/app/UApp.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/app/UApp.kt index fb83096f..6d642063 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/app/UApp.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/app/UApp.kt @@ -2,8 +2,12 @@ package org.moire.ultrasonic.app import android.content.Context import androidx.multidex.MultiDexApplication +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import org.koin.android.ext.koin.androidContext import org.koin.core.context.startKoin +import org.koin.core.context.stopKoin import org.koin.core.logger.Level import org.moire.ultrasonic.BuildConfig import org.moire.ultrasonic.di.appPermanentStorage @@ -23,22 +27,39 @@ import timber.log.Timber.DebugTree class UApp : MultiDexApplication() { + private var ioScope = CoroutineScope(Dispatchers.IO) + init { instance = this +// if (BuildConfig.DEBUG) +// StrictMode.enableDefaults() } + var initiated = false + override fun onCreate() { + initiated = true super.onCreate() if (BuildConfig.DEBUG) { Timber.plant(DebugTree()) } - if (Settings.debugLogToFile) { - FileLoggerTree.plantToTimberForest() + + Timber.d("onCreate called") + + // In general we should not access the settings from the main thread to avoid blocking... + ioScope.launch { + if (Settings.debugLogToFile) { + FileLoggerTree.plantToTimberForest() + } } + startKoin() + } + + internal fun startKoin() { startKoin { - // TODO Currently there is a bug in Koin which makes necessary to set the loglevel to ERROR + // TODO Currently there is a bug in Koin which makes necessary to set the log level to ERROR logger(TimberKoinLogger(Level.ERROR)) // logger(TimberKoinLogger(Level.INFO)) @@ -55,8 +76,13 @@ class UApp : MultiDexApplication() { } } + internal fun shutdownKoin() { + stopKoin() + initiated = false + } + companion object { - private var instance: UApp? = null + var instance: UApp? = null fun applicationContext(): Context { return instance!!.applicationContext diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/data/ActiveServerProvider.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/data/ActiveServerProvider.kt index e132c67f..1526dd7e 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/data/ActiveServerProvider.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/data/ActiveServerProvider.kt @@ -1,6 +1,5 @@ package org.moire.ultrasonic.data -import androidx.lifecycle.MutableLiveData import androidx.room.Room import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -11,6 +10,7 @@ import org.moire.ultrasonic.R import org.moire.ultrasonic.app.UApp import org.moire.ultrasonic.di.DB_FILENAME import org.moire.ultrasonic.service.MusicServiceFactory.resetMusicService +import org.moire.ultrasonic.service.RxBus import org.moire.ultrasonic.util.Constants import org.moire.ultrasonic.util.Settings import org.moire.ultrasonic.util.Util @@ -52,12 +52,32 @@ class ActiveServerProvider( } // Fallback to Offline - setActiveServerId(OFFLINE_DB_ID) + setActiveServerById(OFFLINE_DB_ID) } return OFFLINE_DB } + /** + * Resolves the index (sort order) of a server to its id (unique) + * @param index: The index of the server in the server selector + * @return id: The unique id of the server + */ + fun getServerIdFromIndex(index: Int): Int { + if (index <= OFFLINE_DB_INDEX) { + // Offline mode is selected + return OFFLINE_DB_ID + } + + var id: Int + + runBlocking { + id = repository.findByIndex(index)?.id ?: 0 + } + + return id + } + /** * Sets the Active Server by the Server Index in the Server Selector List * @param index: The index of the Active Server in the Server Selector List @@ -66,13 +86,13 @@ class ActiveServerProvider( Timber.d("setActiveServerByIndex $index") if (index <= OFFLINE_DB_INDEX) { // Offline mode is selected - setActiveServerId(OFFLINE_DB_ID) + setActiveServerById(OFFLINE_DB_ID) return } launch { val serverId = repository.findByIndex(index)?.id ?: 0 - setActiveServerId(serverId) + setActiveServerById(serverId) } } @@ -180,8 +200,6 @@ class ActiveServerProvider( minimumApiVersion = null ) - val liveActiveServerId: MutableLiveData = MutableLiveData(getActiveServerId()) - /** * Queries if the Active Server is the "Offline" mode of Ultrasonic * @return True, if the "Offline" mode is selected @@ -198,13 +216,16 @@ class ActiveServerProvider( } /** - * Sets the Id of the Active Server + * Sets the Active Server by its unique id + * @param serverId: The id of the desired server */ - fun setActiveServerId(serverId: Int) { + fun setActiveServerById(serverId: Int) { resetMusicService() Settings.activeServer = serverId - liveActiveServerId.postValue(serverId) + + Timber.i("setActiveServerById done, new id: %s", serverId) + RxBus.activeServerChangePublisher.onNext(serverId) } /** diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/data/AppDatabase.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/data/AppDatabase.kt index 125f9a31..da6932ef 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/data/AppDatabase.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/data/AppDatabase.kt @@ -11,7 +11,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase */ @Database( entities = [ServerSetting::class], - version = 4, + version = 5, exportSchema = true ) abstract class AppDatabase : RoomDatabase() { diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/data/ServerSetting.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/data/ServerSetting.kt index e3bf722c..05e2ff1d 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/data/ServerSetting.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/data/ServerSetting.kt @@ -19,7 +19,8 @@ import androidx.room.PrimaryKey */ @Entity data class ServerSetting( - @PrimaryKey var id: Int, + // Default ID is 0, which will trigger SQLite to generate a unique ID. + @PrimaryKey(autoGenerate = true) var id: Int = 0, @ColumnInfo(name = "index") var index: Int, @ColumnInfo(name = "name") var name: String, @ColumnInfo(name = "url") var url: String, @@ -37,6 +38,6 @@ data class ServerSetting( @ColumnInfo(name = "podcastSupport") var podcastSupport: Boolean? = null ) { constructor() : this ( - -1, 0, "", "", null, "", "", false, false, false, null, null + 0, 0, "", "", null, "", "", false, false, false, null, null ) } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/data/ServerSettingDao.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/data/ServerSettingDao.kt index b09149cc..660c6c2f 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/data/ServerSettingDao.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/data/ServerSettingDao.kt @@ -69,12 +69,6 @@ interface ServerSettingDao { @Query("SELECT COUNT(*) FROM serverSetting") fun liveServerCount(): LiveData - /** - * Retrieves the greatest value of the Id column in the table - */ - @Query("SELECT MAX([id]) FROM serverSetting") - suspend fun getMaxId(): Int? - /** * Retrieves the greatest value of the Index column in the table */ diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/ApplicationModule.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/ApplicationModule.kt index 3d0298e5..9bffd3f8 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/ApplicationModule.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/ApplicationModule.kt @@ -4,7 +4,6 @@ import org.koin.android.ext.koin.androidContext import org.koin.dsl.module import org.moire.ultrasonic.data.ActiveServerProvider import org.moire.ultrasonic.subsonic.ImageLoaderProvider -import org.moire.ultrasonic.util.MediaSessionHandler /** * This Koin module contains the registration of general classes needed for Ultrasonic @@ -12,5 +11,4 @@ import org.moire.ultrasonic.util.MediaSessionHandler val applicationModule = module { single { ActiveServerProvider(get()) } single { ImageLoaderProvider(androidContext()) } - single { MediaSessionHandler() } } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/MediaPlayerModule.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/MediaPlayerModule.kt index d08c8f20..658893ea 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/MediaPlayerModule.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/MediaPlayerModule.kt @@ -1,15 +1,13 @@ package org.moire.ultrasonic.di import org.koin.dsl.module -import org.moire.ultrasonic.service.AudioFocusHandler +import org.moire.ultrasonic.playback.LegacyPlaylistManager import org.moire.ultrasonic.service.Downloader import org.moire.ultrasonic.service.ExternalStorageMonitor import org.moire.ultrasonic.service.JukeboxMediaPlayer -import org.moire.ultrasonic.service.LocalMediaPlayer import org.moire.ultrasonic.service.MediaPlayerController import org.moire.ultrasonic.service.MediaPlayerLifecycleSupport import org.moire.ultrasonic.service.PlaybackStateSerializer -import org.moire.ultrasonic.util.ShufflePlayBuffer /** * This Koin module contains the registration of classes related to the media player @@ -19,10 +17,8 @@ val mediaPlayerModule = module { single { MediaPlayerLifecycleSupport() } single { PlaybackStateSerializer() } single { ExternalStorageMonitor() } - single { ShufflePlayBuffer() } - single { Downloader(get(), get(), get()) } - single { LocalMediaPlayer() } - single { AudioFocusHandler(get()) } + single { LegacyPlaylistManager() } + single { Downloader(get(), get()) } // TODO Ideally this can be cleaned up when all circular references are removed. single { MediaPlayerController(get(), get(), get(), get(), get()) } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/DownloadsFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/DownloadsFragment.kt index 86847435..913154b4 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/DownloadsFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/DownloadsFragment.kt @@ -54,7 +54,7 @@ class DownloadsFragment : MultiListFragment() { viewAdapter.register( TrackViewBinder( - { }, + { _, _ -> }, { _, _ -> true }, checkable = false, draggable = false, diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/NowPlayingFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/NowPlayingFragment.kt index 95724b59..b34d30ee 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/NowPlayingFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/NowPlayingFragment.kt @@ -22,7 +22,6 @@ import java.lang.Exception import kotlin.math.abs import org.koin.android.ext.android.inject import org.moire.ultrasonic.R -import org.moire.ultrasonic.domain.PlayerState import org.moire.ultrasonic.service.MediaPlayerController import org.moire.ultrasonic.service.RxBus import org.moire.ultrasonic.subsonic.ImageLoaderProvider @@ -47,7 +46,7 @@ class NowPlayingFragment : Fragment() { private var nowPlayingTrack: TextView? = null private var nowPlayingArtist: TextView? = null - private var playerStateSubscription: Disposable? = null + private var rxBusSubscription: Disposable? = null private val mediaPlayerController: MediaPlayerController by inject() private val imageLoader: ImageLoaderProvider by inject() @@ -69,8 +68,7 @@ class NowPlayingFragment : Fragment() { nowPlayingAlbumArtImage = view.findViewById(R.id.now_playing_image) nowPlayingTrack = view.findViewById(R.id.now_playing_trackname) nowPlayingArtist = view.findViewById(R.id.now_playing_artist) - playerStateSubscription = - RxBus.playerStateObservable.subscribe { update() } + rxBusSubscription = RxBus.playerStateObservable.subscribe { update() } } override fun onResume() { @@ -80,29 +78,27 @@ class NowPlayingFragment : Fragment() { override fun onDestroy() { super.onDestroy() - playerStateSubscription!!.dispose() + rxBusSubscription!!.dispose() } @SuppressLint("ClickableViewAccessibility") private fun update() { try { - val playerState = mediaPlayerController.playerState - - if (playerState === PlayerState.PAUSED) { - playButton!!.setImageDrawable( - getDrawableFromAttribute( - requireContext(), R.attr.media_play - ) - ) - } else if (playerState === PlayerState.STARTED) { + if (mediaPlayerController.isPlaying) { playButton!!.setImageDrawable( getDrawableFromAttribute( requireContext(), R.attr.media_pause ) ) + } else { + playButton!!.setImageDrawable( + getDrawableFromAttribute( + requireContext(), R.attr.media_play + ) + ) } - val file = mediaPlayerController.currentPlaying + val file = mediaPlayerController.currentPlayingLegacy if (file != null) { val song = file.track @@ -137,6 +133,7 @@ class NowPlayingFragment : Fragment() { .navigate(R.id.trackCollectionFragment, bundle) } } + requireView().setOnTouchListener { _: View?, event: MotionEvent -> handleOnTouch(event) } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/PlayerFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/PlayerFragment.kt index 53913818..bb06c722 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/PlayerFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/PlayerFragment.kt @@ -13,6 +13,7 @@ import android.graphics.Point import android.graphics.drawable.Drawable import android.os.Bundle import android.os.Handler +import android.os.Looper import android.view.ContextMenu import android.view.ContextMenu.ContextMenuInfo import android.view.GestureDetector @@ -35,22 +36,23 @@ import android.widget.TextView import android.widget.ViewFlipper import androidx.core.view.isVisible import androidx.fragment.app.Fragment +import androidx.media3.common.Player +import androidx.media3.common.Timeline import androidx.navigation.Navigation import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.ItemTouchHelper.ACTION_STATE_DRAG import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearSmoothScroller import androidx.recyclerview.widget.RecyclerView -import io.reactivex.rxjava3.disposables.Disposable +import io.reactivex.rxjava3.disposables.CompositeDisposable import java.text.DateFormat import java.text.SimpleDateFormat -import java.util.ArrayList import java.util.Date import java.util.Locale -import java.util.concurrent.CancellationException import java.util.concurrent.Executors import java.util.concurrent.ScheduledExecutorService import java.util.concurrent.TimeUnit +import kotlin.coroutines.cancellation.CancellationException import kotlin.math.abs import kotlin.math.max import kotlinx.coroutines.CoroutineScope @@ -66,15 +68,13 @@ import org.moire.ultrasonic.audiofx.EqualizerController import org.moire.ultrasonic.audiofx.VisualizerController import org.moire.ultrasonic.data.ActiveServerProvider.Companion.isOffline import org.moire.ultrasonic.domain.Identifiable -import org.moire.ultrasonic.domain.PlayerState -import org.moire.ultrasonic.domain.RepeatMode import org.moire.ultrasonic.domain.Track import org.moire.ultrasonic.fragment.FragmentTitle.Companion.setTitle import org.moire.ultrasonic.service.DownloadFile -import org.moire.ultrasonic.service.LocalMediaPlayer import org.moire.ultrasonic.service.MediaPlayerController import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService import org.moire.ultrasonic.service.RxBus +import org.moire.ultrasonic.service.plusAssign import org.moire.ultrasonic.subsonic.ImageLoaderProvider import org.moire.ultrasonic.subsonic.NetworkAndStorageChecker import org.moire.ultrasonic.subsonic.ShareHandler @@ -89,6 +89,7 @@ import timber.log.Timber /** * Contains the Music Player screen of Ultrasonic with playback controls and the playlist + * TODO: Add timeline lister -> updateProgressBar(). */ @Suppress("LargeClass", "TooManyFunctions", "MagicNumber") class PlayerFragment : @@ -113,14 +114,13 @@ class PlayerFragment : // Data & Services private val networkAndStorageChecker: NetworkAndStorageChecker by inject() private val mediaPlayerController: MediaPlayerController by inject() - private val localMediaPlayer: LocalMediaPlayer by inject() private val shareHandler: ShareHandler by inject() private val imageLoaderProvider: ImageLoaderProvider by inject() - private lateinit var executorService: ScheduledExecutorService private var currentPlaying: DownloadFile? = null private var currentSong: Track? = null private lateinit var viewManager: LinearLayoutManager - private var rxBusSubscription: Disposable? = null + private var rxBusSubscription: CompositeDisposable = CompositeDisposable() + private lateinit var executorService: ScheduledExecutorService private var ioScope = CoroutineScope(Dispatchers.IO) // Views and UI Elements @@ -148,7 +148,8 @@ class PlayerFragment : private lateinit var durationTextView: TextView private lateinit var pauseButton: View private lateinit var stopButton: View - private lateinit var startButton: View + private lateinit var playButton: View + private lateinit var shuffleButton: View private lateinit var repeatButton: ImageView private lateinit var hollowStar: Drawable private lateinit var fullStar: Drawable @@ -189,7 +190,7 @@ class PlayerFragment : pauseButton = view.findViewById(R.id.button_pause) stopButton = view.findViewById(R.id.button_stop) - startButton = view.findViewById(R.id.button_start) + playButton = view.findViewById(R.id.button_start) repeatButton = view.findViewById(R.id.button_repeat) visualizerViewLayout = view.findViewById(R.id.current_playing_visualizer_layout) fiveStar1ImageView = view.findViewById(R.id.song_five_star_1) @@ -216,18 +217,13 @@ class PlayerFragment : swipeVelocity = swipeDistance gestureScanner = GestureDetector(context, this) - // The secondary progress is an indicator of how far the song is cached. - localMediaPlayer.secondaryProgress.observe( - viewLifecycleOwner, - { - progressBar.secondaryProgress = it - } - ) - findViews(view) val previousButton: AutoRepeatButton = view.findViewById(R.id.button_previous) val nextButton: AutoRepeatButton = view.findViewById(R.id.button_next) - val shuffleButton = view.findViewById(R.id.button_shuffle) + shuffleButton = view.findViewById(R.id.button_shuffle) + updateShuffleButtonState(mediaPlayerController.isShufflePlayEnabled) + updateRepeatButtonState(mediaPlayerController.repeatMode) + val ratingLinearLayout = view.findViewById(R.id.song_rating) if (!useFiveStarRating) ratingLinearLayout.isVisible = false hollowStar = Util.getDrawableFromAttribute(view.context, R.attr.star_hollow) @@ -291,34 +287,39 @@ class PlayerFragment : } } - startButton.setOnClickListener { + playButton.setOnClickListener { networkAndStorageChecker.warnIfNetworkOrStorageUnavailable() launch(CommunicationError.getHandler(context)) { - start() + mediaPlayerController.play() onCurrentChanged() onSliderProgressChanged() } } shuffleButton.setOnClickListener { - mediaPlayerController.shuffle() - Util.toast(activity, R.string.download_menu_shuffle_notification) + toggleShuffle() } repeatButton.setOnClickListener { - val repeatMode = mediaPlayerController.repeatMode.next() - mediaPlayerController.repeatMode = repeatMode + var newRepeat = mediaPlayerController.repeatMode + 1 + if (newRepeat == 3) { + newRepeat = 0 + } + + mediaPlayerController.repeatMode = newRepeat + onPlaylistChanged() - when (repeatMode) { - RepeatMode.OFF -> Util.toast( + + when (newRepeat) { + 0 -> Util.toast( context, R.string.download_repeat_off ) - RepeatMode.ALL -> Util.toast( - context, R.string.download_repeat_all - ) - RepeatMode.SINGLE -> Util.toast( + 1 -> Util.toast( context, R.string.download_repeat_single ) + 2 -> Util.toast( + context, R.string.download_repeat_all + ) else -> { } } @@ -351,53 +352,67 @@ class PlayerFragment : visualizerViewLayout.isVisible = false VisualizerController.get().observe( - requireActivity(), - { visualizerController -> - if (visualizerController != null) { - Timber.d("VisualizerController Observer.onChanged received controller") - visualizerView = VisualizerView(context) - visualizerViewLayout.addView( - visualizerView, - LinearLayout.LayoutParams( - LinearLayout.LayoutParams.MATCH_PARENT, - LinearLayout.LayoutParams.MATCH_PARENT - ) + requireActivity() + ) { visualizerController -> + if (visualizerController != null) { + Timber.d("VisualizerController Observer.onChanged received controller") + visualizerView = VisualizerView(context) + visualizerViewLayout.addView( + visualizerView, + LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.MATCH_PARENT ) + ) - visualizerViewLayout.isVisible = visualizerView.isActive + visualizerViewLayout.isVisible = visualizerView.isActive - visualizerView.setOnTouchListener { _, _ -> - visualizerView.isActive = !visualizerView.isActive - mediaPlayerController.showVisualization = visualizerView.isActive - true - } - isVisualizerAvailable = true - } else { - Timber.d("VisualizerController Observer.onChanged has no controller") - visualizerViewLayout.isVisible = false - isVisualizerAvailable = false + visualizerView.setOnTouchListener { _, _ -> + visualizerView.isActive = !visualizerView.isActive + mediaPlayerController.showVisualization = visualizerView.isActive + true } + isVisualizerAvailable = true + } else { + Timber.d("VisualizerController Observer.onChanged has no controller") + visualizerViewLayout.isVisible = false + isVisualizerAvailable = false } - ) + } EqualizerController.get().observe( - requireActivity(), - { equalizerController -> - isEqualizerAvailable = if (equalizerController != null) { - Timber.d("EqualizerController Observer.onChanged received controller") - true - } else { - Timber.d("EqualizerController Observer.onChanged has no controller") - false - } + requireActivity() + ) { equalizerController -> + isEqualizerAvailable = if (equalizerController != null) { + Timber.d("EqualizerController Observer.onChanged received controller") + true + } else { + Timber.d("EqualizerController Observer.onChanged has no controller") + false } - ) + } // Observe playlist changes and update the UI - rxBusSubscription = RxBus.playlistObservable.subscribe { - onPlaylistChanged() + rxBusSubscription += RxBus.playlistObservable.subscribe { + // Use launch to ensure running it in the main thread + launch { + onPlaylistChanged() + } } + rxBusSubscription += RxBus.playerStateObservable.subscribe { + // Use launch to ensure running it in the main thread + launch { + update() + } + } + + mediaPlayerController.controller?.addListener(object : Player.Listener { + override fun onTimelineChanged(timeline: Timeline, reason: Int) { + onSliderProgressChanged() + } + }) + // Query the Jukebox state in an IO Context ioScope.launch(CommunicationError.getHandler(context)) { try { @@ -410,18 +425,68 @@ class PlayerFragment : view.setOnTouchListener { _, event -> gestureScanner.onTouchEvent(event) } } + private fun updateShuffleButtonState(isEnabled: Boolean) { + if (isEnabled) { + shuffleButton.alpha = ALPHA_ACTIVATED + } else { + shuffleButton.alpha = ALPHA_DEACTIVATED + } + } + + private fun updateRepeatButtonState(repeatMode: Int) { + when (repeatMode) { + 0 -> { + repeatButton.setImageDrawable( + Util.getDrawableFromAttribute( + requireContext(), R.attr.media_repeat_off + ) + ) + repeatButton.alpha = ALPHA_DEACTIVATED + } + 1 -> { + repeatButton.setImageDrawable( + Util.getDrawableFromAttribute( + requireContext(), R.attr.media_repeat_single + ) + ) + repeatButton.alpha = ALPHA_ACTIVATED + } + 2 -> { + repeatButton.setImageDrawable( + Util.getDrawableFromAttribute( + requireContext(), R.attr.media_repeat_all + ) + ) + repeatButton.alpha = ALPHA_ACTIVATED + } + else -> { + } + } + } + + private fun toggleShuffle() { + val isEnabled = mediaPlayerController.toggleShuffle() + + if (isEnabled) { + Util.toast(activity, R.string.download_menu_shuffle_on) + } else { + Util.toast(activity, R.string.download_menu_shuffle_off) + } + + updateShuffleButtonState(isEnabled) + } + override fun onResume() { super.onResume() - if (mediaPlayerController.currentPlaying == null) { + if (mediaPlayerController.currentPlayingLegacy == null) { playlistFlipper.displayedChild = 1 } else { - // Download list and Album art must be updated when Resumed + // Download list and Album art must be updated when resumed onPlaylistChanged() onCurrentChanged() } - val handler = Handler() - // TODO Use Rx for Update instead of polling! + val handler = Handler(Looper.getMainLooper()) val runnable = Runnable { handler.post { update(cancellationToken) } } executorService = Executors.newSingleThreadScheduledExecutor() executorService.scheduleWithFixedDelay(runnable, 0L, 500L, TimeUnit.MILLISECONDS) @@ -441,7 +506,7 @@ class PlayerFragment : // Scroll to current playing. private fun scrollToCurrent() { - val index = mediaPlayerController.playList.indexOf(currentPlaying) + val index = mediaPlayerController.currentMediaItemIndex if (index != -1) { val smoothScroller = LinearSmoothScroller(context) @@ -459,7 +524,7 @@ class PlayerFragment : } override fun onDestroyView() { - rxBusSubscription?.dispose() + rxBusSubscription.dispose() cancel("CoroutineScope cancelled because the view was destroyed") cancellationToken.cancel() super.onDestroyView() @@ -504,7 +569,7 @@ class PlayerFragment : visualizerMenuItem.isVisible = isVisualizerAvailable } val mediaPlayerController = mediaPlayerController - val downloadFile = mediaPlayerController.currentPlaying + val downloadFile = mediaPlayerController.currentPlayingLegacy if (downloadFile != null) { currentSong = downloadFile.track @@ -615,7 +680,6 @@ class PlayerFragment : return true } R.id.menu_remove -> { - mediaPlayerController.removeFromPlaylist(song!!) onPlaylistChanged() return true } @@ -631,8 +695,7 @@ class PlayerFragment : return true } R.id.menu_shuffle -> { - mediaPlayerController.shuffle() - Util.toast(context, R.string.download_menu_shuffle_notification) + toggleShuffle() return true } R.id.menu_item_equalizer -> { @@ -768,10 +831,10 @@ class PlayerFragment : } } - private fun update(cancel: CancellationToken?) { - if (cancel!!.isCancellationRequested) return + private fun update(cancel: CancellationToken? = null) { + if (cancel?.isCancellationRequested == true) return val mediaPlayerController = mediaPlayerController - if (currentPlaying != mediaPlayerController.currentPlaying) { + if (currentPlaying != mediaPlayerController.currentPlayingLegacy) { onCurrentChanged() } onSliderProgressChanged() @@ -822,24 +885,6 @@ class PlayerFragment : scrollToCurrent() } - private fun start() { - val service = mediaPlayerController - val state = service.playerState - if (state === PlayerState.PAUSED || - state === PlayerState.COMPLETED || state === PlayerState.STOPPED - ) { - service.start() - } else if (state === PlayerState.IDLE) { - networkAndStorageChecker.warnIfNetworkOrStorageUnavailable() - val current = mediaPlayerController.currentPlayingNumberOnPlaylist - if (current == -1) { - service.play(0) - } else { - service.play(current) - } - } - } - private fun initPlaylistDisplay() { // Create a View Manager viewManager = LinearLayoutManager(this.context) @@ -852,17 +897,17 @@ class PlayerFragment : } // Create listener - val listener: ((DownloadFile) -> Unit) = { file -> - val list = mediaPlayerController.playList - val index = list.indexOf(file) - mediaPlayerController.play(index) + val clickHandler: ((DownloadFile, Int) -> Unit) = { _, pos -> + mediaPlayerController.seekTo(pos, 0) + mediaPlayerController.prepare() + mediaPlayerController.play() onCurrentChanged() onSliderProgressChanged() } viewAdapter.register( TrackViewBinder( - onItemClick = listener, + onItemClick = clickHandler, checkable = false, draggable = true, context = requireContext(), @@ -874,68 +919,65 @@ class PlayerFragment : } ) - dragTouchHelper = ItemTouchHelper(object : ItemTouchHelper.SimpleCallback( + val callback = object : ItemTouchHelper.SimpleCallback( ItemTouchHelper.UP or ItemTouchHelper.DOWN, ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT ) { + override fun onMove( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder, + target: RecyclerView.ViewHolder + ): Boolean { - override fun onMove( - recyclerView: RecyclerView, - viewHolder: RecyclerView.ViewHolder, - target: RecyclerView.ViewHolder - ): Boolean { + val from = viewHolder.bindingAdapterPosition + val to = target.bindingAdapterPosition - val from = viewHolder.bindingAdapterPosition - val to = target.bindingAdapterPosition + // Move it in the data set + mediaPlayerController.moveItemInPlaylist(from, to) + return true + } - // Move it in the data set - mediaPlayerController.moveItemInPlaylist(from, to) - viewAdapter.submitList(mediaPlayerController.playList) + // Swipe to delete from playlist + @SuppressLint("NotifyDataSetChanged") + override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { + val pos = viewHolder.bindingAdapterPosition + val item = mediaPlayerController.controller?.getMediaItemAt(pos) + mediaPlayerController.removeFromPlaylist(pos) - return true - } + val songRemoved = String.format( + resources.getString(R.string.download_song_removed), + item?.mediaMetadata?.title + ) - // Swipe to delete from playlist - override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { - val pos = viewHolder.bindingAdapterPosition - val file = mediaPlayerController.playList[pos] - mediaPlayerController.removeFromPlaylist(file) + Util.toast(context, songRemoved) + } - val songRemoved = String.format( - resources.getString(R.string.download_song_removed), - file.track.title - ) - Util.toast(context, songRemoved) + override fun onSelectedChanged( + viewHolder: RecyclerView.ViewHolder?, + actionState: Int + ) { + super.onSelectedChanged(viewHolder, actionState) - viewAdapter.submitList(mediaPlayerController.playList) - viewAdapter.notifyDataSetChanged() - } - - override fun onSelectedChanged( - viewHolder: RecyclerView.ViewHolder?, - actionState: Int - ) { - super.onSelectedChanged(viewHolder, actionState) - - if (actionState == ACTION_STATE_DRAG) { - viewHolder?.itemView?.alpha = 0.6f - } - } - - override fun clearView( - recyclerView: RecyclerView, - viewHolder: RecyclerView.ViewHolder - ) { - super.clearView(recyclerView, viewHolder) - - viewHolder.itemView.alpha = 1.0f - } - - override fun isLongPressDragEnabled(): Boolean { - return false + if (actionState == ACTION_STATE_DRAG) { + viewHolder?.itemView?.alpha = ALPHA_DEACTIVATED } } - ) + + override fun clearView( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder + ) { + super.clearView(recyclerView, viewHolder) + + viewHolder.itemView.alpha = 1.0f + } + + override fun isLongPressDragEnabled(): Boolean { + return false + } + } + + dragTouchHelper = ItemTouchHelper(callback) dragTouchHelper.attachToRecyclerView(playlistView) } @@ -949,33 +991,16 @@ class PlayerFragment : emptyTextView.isVisible = list.isEmpty() - when (mediaPlayerController.repeatMode) { - RepeatMode.OFF -> repeatButton.setImageDrawable( - Util.getDrawableFromAttribute( - requireContext(), R.attr.media_repeat_off - ) - ) - RepeatMode.ALL -> repeatButton.setImageDrawable( - Util.getDrawableFromAttribute( - requireContext(), R.attr.media_repeat_all - ) - ) - RepeatMode.SINGLE -> repeatButton.setImageDrawable( - Util.getDrawableFromAttribute( - requireContext(), R.attr.media_repeat_single - ) - ) - else -> { - } - } + updateRepeatButtonState(mediaPlayerController.repeatMode) } private fun onCurrentChanged() { - currentPlaying = mediaPlayerController.currentPlaying + currentPlaying = mediaPlayerController.currentPlayingLegacy + scrollToCurrent() val totalDuration = mediaPlayerController.playListDuration val totalSongs = mediaPlayerController.playlistSize.toLong() - val currentSongIndex = mediaPlayerController.currentPlayingNumberOnPlaylist + 1 + val currentSongIndex = mediaPlayerController.currentMediaItemIndex + 1 val duration = Util.formatTotalDuration(totalDuration) val trackFormat = String.format(Locale.getDefault(), "%d / %d", currentSongIndex, totalSongs) @@ -992,7 +1017,7 @@ class PlayerFragment : genreTextView.isVisible = (currentSong!!.genre != null && currentSong!!.genre!!.isNotBlank()) - var bitRate: String = "" + var bitRate = "" if (currentSong!!.bitRate != null && currentSong!!.bitRate!! > 0) bitRate = String.format( Util.appContext().getString(R.string.song_details_kbps), @@ -1027,14 +1052,15 @@ class PlayerFragment : } } - @Suppress("LongMethod", "ComplexMethod") + @Suppress("LongMethod") @Synchronized private fun onSliderProgressChanged() { val isJukeboxEnabled: Boolean = mediaPlayerController.isJukeboxEnabled val millisPlayed: Int = max(0, mediaPlayerController.playerPosition) val duration: Int = mediaPlayerController.playerDuration - val playerState: PlayerState = mediaPlayerController.playerState + val playbackState: Int = mediaPlayerController.playbackState + val isPlaying = mediaPlayerController.isPlaying if (cancellationToken.isCancellationRequested) return if (currentPlaying != null) { @@ -1043,7 +1069,7 @@ class PlayerFragment : progressBar.max = if (duration == 0) 100 else duration // Work-around for apparent bug. progressBar.progress = millisPlayed - progressBar.isEnabled = currentPlaying!!.isWorkDone || isJukeboxEnabled + progressBar.isEnabled = mediaPlayerController.isPlaying || isJukeboxEnabled } else { positionTextView.setText(R.string.util_zero_time) durationTextView.setText(R.string.util_no_time) @@ -1052,21 +1078,19 @@ class PlayerFragment : progressBar.isEnabled = false } - when (playerState) { - PlayerState.DOWNLOADING -> { - val progress = - if (currentPlaying != null) currentPlaying!!.progress.value!! else 0 + val progress = mediaPlayerController.bufferedPercentage + + when (playbackState) { + Player.STATE_BUFFERING -> { + val downloadStatus = resources.getString( - R.string.download_playerstate_downloading, - Util.formatPercentage(progress) + R.string.download_playerstate_loading ) + progressBar.secondaryProgress = progress setTitle(this@PlayerFragment, downloadStatus) } - PlayerState.PREPARING -> setTitle( - this@PlayerFragment, - R.string.download_playerstate_buffering - ) - PlayerState.STARTED -> { + Player.STATE_READY -> { + progressBar.secondaryProgress = progress if (mediaPlayerController.isShufflePlayEnabled) { setTitle( this@PlayerFragment, @@ -1076,30 +1100,28 @@ class PlayerFragment : setTitle(this@PlayerFragment, R.string.common_appname) } } - PlayerState.IDLE, - PlayerState.PREPARED, - PlayerState.STOPPED, - PlayerState.PAUSED, - PlayerState.COMPLETED -> { + Player.STATE_IDLE, + Player.STATE_ENDED, + -> { } else -> setTitle(this@PlayerFragment, R.string.common_appname) } - when (playerState) { - PlayerState.STARTED -> { - pauseButton.isVisible = true + when (playbackState) { + Player.STATE_READY -> { + pauseButton.isVisible = isPlaying stopButton.isVisible = false - startButton.isVisible = false + playButton.isVisible = !isPlaying } - PlayerState.DOWNLOADING, PlayerState.PREPARING -> { + Player.STATE_BUFFERING -> { pauseButton.isVisible = false stopButton.isVisible = true - startButton.isVisible = false + playButton.isVisible = false } else -> { pauseButton.isVisible = false stopButton.isVisible = false - startButton.isVisible = true + playButton.isVisible = true } } @@ -1238,5 +1260,7 @@ class PlayerFragment : companion object { private const val PERCENTAGE_OF_SCREEN_FOR_SWIPE = 5 + private const val ALPHA_ACTIVATED = 1f + private const val ALPHA_DEACTIVATED = 0.4f } } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/SearchFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/SearchFragment.kt index bfca7917..6df86761 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/SearchFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/SearchFragment.kt @@ -109,7 +109,7 @@ class SearchFragment : MultiListFragment(), KoinComponent { viewAdapter.register( TrackViewBinder( - onItemClick = ::onItemClick, + onItemClick = { file, _ -> onItemClick(file) }, onContextMenuClick = ::onContextMenuItemSelected, checkable = false, draggable = false, @@ -303,13 +303,12 @@ class SearchFragment : MultiListFragment(), KoinComponent { } mediaPlayerController.addToPlaylist( listOf(song), - save = false, + cachePermanently = false, autoPlay = false, - playNext = false, shuffle = false, - newPlaylist = false + insertionMode = MediaPlayerController.InsertionMode.APPEND ) - mediaPlayerController.play(mediaPlayerController.playlistSize - 1) + mediaPlayerController.play(mediaPlayerController.mediaItemCount - 1) toast(context, resources.getQuantityString(R.plurals.select_album_n_songs_added, 1, 1)) } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/ServerSelectorFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/ServerSelectorFragment.kt index d676e0ee..0798950b 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/ServerSelectorFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/ServerSelectorFragment.kt @@ -9,14 +9,12 @@ import android.widget.ListView import androidx.fragment.app.Fragment import androidx.navigation.fragment.findNavController import com.google.android.material.floatingactionbutton.FloatingActionButton -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.withContext import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel import org.moire.ultrasonic.R import org.moire.ultrasonic.adapters.ServerRowAdapter import org.moire.ultrasonic.data.ActiveServerProvider +import org.moire.ultrasonic.data.ServerSetting import org.moire.ultrasonic.fragment.EditServerFragment.Companion.EDIT_SERVER_INTENT_INDEX import org.moire.ultrasonic.model.ServerSettingsModel import org.moire.ultrasonic.service.MediaPlayerController @@ -26,6 +24,8 @@ import timber.log.Timber /** * Displays the list of configured servers, they can be selected or edited + * + * TODO: Manage mode is unused. Remove it... */ class ServerSelectorFragment : Fragment() { companion object { @@ -34,7 +34,7 @@ class ServerSelectorFragment : Fragment() { private var listView: ListView? = null private val serverSettingsModel: ServerSettingsModel by viewModel() - private val service: MediaPlayerController by inject() + private val controller: MediaPlayerController by inject() private val activeServerProvider: ActiveServerProvider by inject() private var serverRowAdapter: ServerRowAdapter? = null @@ -59,6 +59,7 @@ class ServerSelectorFragment : Fragment() { SERVER_SELECTOR_MANAGE_MODE, false ) ?: false + if (manageMode) { FragmentTitle.setTitle(this, R.string.settings_server_manage_servers) } else { @@ -72,31 +73,26 @@ class ServerSelectorFragment : Fragment() { serverSettingsModel, activeServerProvider, manageMode, - { - i -> - onServerDeleted(i) - }, - { - i -> - editServer(i) - } + ::deleteServerById, + ::editServerByIndex ) listView?.adapter = serverRowAdapter - listView?.onItemClickListener = AdapterView.OnItemClickListener { - _, _, position, _ -> + listView?.onItemClickListener = AdapterView.OnItemClickListener { parent, _, position, _ -> + + val server = parent.getItemAtPosition(position) as ServerSetting if (manageMode) { - editServer(position + 1) + editServerByIndex(position + 1) } else { - setActiveServer(position) + setActiveServerById(server.id) findNavController().popBackStack(R.id.mainFragment, false) } } val fab = view.findViewById(R.id.server_add_fab) fab.setOnClickListener { - editServer(-1) + editServerByIndex(-1) } } @@ -113,44 +109,37 @@ class ServerSelectorFragment : Fragment() { /** * Sets the active server when a list item is clicked */ - private fun setActiveServer(index: Int) { - // TODO this is still a blocking call - we shouldn't leave this activity before the active server is updated. - // Maybe this can be refactored by using LiveData, or this can be made more user friendly with a ProgressDialog - runBlocking { - withContext(Dispatchers.IO) { - if (activeServerProvider.getActiveServer().index != index) { - service.clearIncomplete() - activeServerProvider.setActiveServerByIndex(index) - service.isJukeboxEnabled = - activeServerProvider.getActiveServer().jukeboxByDefault - } - } + private fun setActiveServerById(id: Int) { + + controller.clearIncomplete() + + if (activeServerProvider.getActiveServer().id != id) { + ActiveServerProvider.setActiveServerById(id) } - Timber.i("Active server was set to: $index") } /** * This Callback handles the deletion of a Server Setting */ - private fun onServerDeleted(index: Int) { + private fun deleteServerById(id: Int) { ErrorDialog.Builder(context) .setTitle(R.string.server_menu_delete) .setMessage(R.string.server_selector_delete_confirmation) .setPositiveButton(R.string.common_delete) { dialog, _ -> dialog.dismiss() - val activeServerIndex = activeServerProvider.getActiveServer().index - val id = ActiveServerProvider.getActiveServerId() + // Get the id of the current active server + val activeServerId = ActiveServerProvider.getActiveServerId() // If the currently active server is deleted, go offline - if (index == activeServerIndex) setActiveServer(-1) + if (id == activeServerId) setActiveServerById(ActiveServerProvider.OFFLINE_DB_ID) - serverSettingsModel.deleteItem(index) + serverSettingsModel.deleteItemById(id) // Clear the metadata cache - activeServerProvider.deleteMetaDatabase(id) + activeServerProvider.deleteMetaDatabase(activeServerId) - Timber.i("Server deleted: $index") + Timber.i("Server deleted, id: $id") } .setNegativeButton(R.string.common_cancel) { dialog, _ -> dialog.dismiss() @@ -161,7 +150,7 @@ class ServerSelectorFragment : Fragment() { /** * Starts the Edit Server Fragment to edit the details of a server */ - private fun editServer(index: Int) { + private fun editServerByIndex(index: Int) { val bundle = Bundle() bundle.putInt(EDIT_SERVER_INTENT_INDEX, index) findNavController().navigate(R.id.serverSelectorToEditServer, bundle) diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/SettingsFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/SettingsFragment.kt index b140e701..70bc45d0 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/SettingsFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/SettingsFragment.kt @@ -18,12 +18,11 @@ import androidx.preference.CheckBoxPreference import androidx.preference.EditTextPreference import androidx.preference.ListPreference import androidx.preference.Preference -import androidx.preference.PreferenceCategory import androidx.preference.PreferenceFragmentCompat import java.io.File import kotlin.math.ceil import org.koin.core.component.KoinComponent -import org.koin.java.KoinJavaComponent.inject +import org.koin.core.component.inject import org.moire.ultrasonic.R import org.moire.ultrasonic.app.UApp import org.moire.ultrasonic.fragment.FragmentTitle.Companion.setTitle @@ -40,7 +39,6 @@ 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.MediaSessionHandler import org.moire.ultrasonic.util.Settings import org.moire.ultrasonic.util.Settings.preferences import org.moire.ultrasonic.util.Settings.shareGreeting @@ -77,9 +75,6 @@ class SettingsFragment : private var chatRefreshInterval: ListPreference? = null private var directoryCacheTime: ListPreference? = null private var mediaButtonsEnabled: CheckBoxPreference? = null - private var lockScreenEnabled: CheckBoxPreference? = null - private var sendBluetoothNotifications: CheckBoxPreference? = null - private var sendBluetoothAlbumArt: CheckBoxPreference? = null private var showArtistPicture: CheckBoxPreference? = null private var sharingDefaultDescription: EditTextPreference? = null private var sharingDefaultGreeting: EditTextPreference? = null @@ -89,12 +84,7 @@ class SettingsFragment : private var debugLogToFile: CheckBoxPreference? = null private var customCacheLocation: CheckBoxPreference? = null - private val mediaPlayerControllerLazy = inject( - MediaPlayerController::class.java - ) - private val mediaSessionHandler = inject( - MediaSessionHandler::class.java - ) + private val mediaPlayerController: MediaPlayerController by inject() override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { setPreferencesFromResource(R.xml.settings, rootKey) @@ -121,10 +111,6 @@ class SettingsFragment : chatRefreshInterval = findPreference(Constants.PREFERENCES_KEY_CHAT_REFRESH_INTERVAL) directoryCacheTime = findPreference(Constants.PREFERENCES_KEY_DIRECTORY_CACHE_TIME) mediaButtonsEnabled = findPreference(Constants.PREFERENCES_KEY_MEDIA_BUTTONS) - lockScreenEnabled = findPreference(Constants.PREFERENCES_KEY_SHOW_LOCK_SCREEN_CONTROLS) - sendBluetoothAlbumArt = findPreference(Constants.PREFERENCES_KEY_SEND_BLUETOOTH_ALBUM_ART) - sendBluetoothNotifications = - findPreference(Constants.PREFERENCES_KEY_SEND_BLUETOOTH_NOTIFICATIONS) sharingDefaultDescription = findPreference(Constants.PREFERENCES_KEY_DEFAULT_SHARE_DESCRIPTION) sharingDefaultGreeting = findPreference(Constants.PREFERENCES_KEY_DEFAULT_SHARE_GREETING) @@ -137,25 +123,10 @@ class SettingsFragment : showArtistPicture = findPreference(Constants.PREFERENCES_KEY_SHOW_ARTIST_PICTURE) customCacheLocation = findPreference(Constants.PREFERENCES_KEY_CUSTOM_CACHE_LOCATION) - sharingDefaultGreeting!!.text = shareGreeting + sharingDefaultGreeting?.text = shareGreeting setupClearSearchPreference() setupCacheLocationPreference() setupBluetoothDevicePreferences() - - // After API26 foreground services must be used for music playback, and they must have a notification - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val notificationsCategory = - findPreference(Constants.PREFERENCES_KEY_CATEGORY_NOTIFICATIONS) - var preferenceToRemove = - findPreference(Constants.PREFERENCES_KEY_SHOW_NOTIFICATION) - if (preferenceToRemove != null) notificationsCategory!!.removePreference( - preferenceToRemove - ) - preferenceToRemove = findPreference(Constants.PREFERENCES_KEY_ALWAYS_SHOW_NOTIFICATION) - if (preferenceToRemove != null) notificationsCategory!!.removePreference( - preferenceToRemove - ) - } } override fun onActivityCreated(savedInstanceState: Bundle?) { @@ -221,12 +192,6 @@ class SettingsFragment : Constants.PREFERENCES_KEY_HIDE_MEDIA -> { setHideMedia(sharedPreferences.getBoolean(key, false)) } - Constants.PREFERENCES_KEY_MEDIA_BUTTONS -> { - setMediaButtonsEnabled(sharedPreferences.getBoolean(key, true)) - } - Constants.PREFERENCES_KEY_SEND_BLUETOOTH_NOTIFICATIONS -> { - setBluetoothPreferences(sharedPreferences.getBoolean(key, true)) - } Constants.PREFERENCES_KEY_DEBUG_LOG_TO_FILE -> { setDebugLogToFile(sharedPreferences.getBoolean(key, false)) } @@ -306,9 +271,7 @@ class SettingsFragment : R.string.settings_playback_resume_on_bluetooth_device, Settings.resumeOnBluetoothDevice ) { choice: Int -> - val editor = resumeOnBluetoothDevice!!.sharedPreferences.edit() - editor.putInt(Constants.PREFERENCES_KEY_RESUME_ON_BLUETOOTH_DEVICE, choice) - editor.apply() + Settings.resumeOnBluetoothDevice = choice resumeOnBluetoothDevice!!.summary = bluetoothDevicePreferenceToString(choice) } true @@ -399,23 +362,16 @@ class SettingsFragment : sharingDefaultExpiration!!.summary = sharingDefaultExpiration!!.text sharingDefaultDescription!!.summary = sharingDefaultDescription!!.text sharingDefaultGreeting!!.summary = sharingDefaultGreeting!!.text - if (!mediaButtonsEnabled!!.isChecked) { - lockScreenEnabled!!.isChecked = false - lockScreenEnabled!!.isEnabled = false - } - if (!sendBluetoothNotifications!!.isChecked) { - sendBluetoothAlbumArt!!.isChecked = false - sendBluetoothAlbumArt!!.isEnabled = false - } - if (debugLogToFile!!.isChecked) { - debugLogToFile!!.summary = getString( + + if (debugLogToFile?.isChecked == true) { + debugLogToFile?.summary = getString( R.string.settings_debug_log_path, ultrasonicDirectory, FileLoggerTree.FILENAME ) } else { - debugLogToFile!!.summary = "" + debugLogToFile?.summary = "" } - showArtistPicture!!.isEnabled = shouldUseId3Tags + showArtistPicture?.isEnabled = shouldUseId3Tags } private fun setHideMedia(hide: Boolean) { @@ -433,15 +389,6 @@ class SettingsFragment : toast(activity, R.string.settings_hide_media_toast, false) } - private fun setMediaButtonsEnabled(enabled: Boolean) { - lockScreenEnabled!!.isEnabled = enabled - mediaSessionHandler.value.updateMediaButtonReceiver() - } - - private fun setBluetoothPreferences(enabled: Boolean) { - sendBluetoothAlbumArt!!.isEnabled = enabled - } - private fun setCacheLocation(path: String) { if (path != "") { val uri = Uri.parse(path) @@ -451,8 +398,8 @@ class SettingsFragment : Settings.cacheLocationUri = path // Clear download queue. - mediaPlayerControllerLazy.value.clear() - mediaPlayerControllerLazy.value.clearCaches() + mediaPlayerController.clear() + mediaPlayerController.clearCaches() Storage.reset() } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/TrackCollectionFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/TrackCollectionFragment.kt index e0e990a9..3c9fcb39 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/TrackCollectionFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/TrackCollectionFragment.kt @@ -122,7 +122,7 @@ open class TrackCollectionFragment : MultiListFragment() { viewAdapter.register( TrackViewBinder( - onItemClick = { onItemClick(it.track) }, + onItemClick = { file, _ -> onItemClick(file.track) }, onContextMenuClick = { menu, id -> onContextMenuItemSelected(menu, id.track) }, checkable = true, draggable = false, diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/model/ServerSettingsModel.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/model/ServerSettingsModel.kt index 99ea5890..3e62541d 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/model/ServerSettingsModel.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/model/ServerSettingsModel.kt @@ -12,6 +12,7 @@ import kotlinx.coroutines.runBlocking import org.moire.ultrasonic.R import org.moire.ultrasonic.app.UApp import org.moire.ultrasonic.data.ActiveServerProvider +import org.moire.ultrasonic.data.ActiveServerProvider.Companion.OFFLINE_DB_ID import org.moire.ultrasonic.data.ServerSetting import org.moire.ultrasonic.data.ServerSettingDao import timber.log.Timber @@ -30,6 +31,8 @@ class ServerSettingsModel( /** * Retrieves the list of the configured servers from the database. * This function is asynchronous, uses LiveData to provide the Setting. + * + * It does not include the Offline "server". */ fun getServerList(): LiveData> { // This check should run before returning any result @@ -92,14 +95,14 @@ class ServerSettingsModel( /** * Removes a Setting from the database */ - fun deleteItem(index: Int) { - if (index == 0) return + fun deleteItemById(id: Int) { + if (id == OFFLINE_DB_ID) return viewModelScope.launch { - val itemToBeDeleted = repository.findByIndex(index) + val itemToBeDeleted = repository.findById(id) if (itemToBeDeleted != null) { repository.delete(itemToBeDeleted) - Timber.d("deleteItem deleted index: $index") + Timber.d("deleteItem deleted id: $id") reindexSettings() activeServerProvider.invalidateCache() } @@ -127,7 +130,6 @@ class ServerSettingsModel( appScope.launch { serverSetting.index = (repository.count() ?: 0) + 1 - serverSetting.id = (repository.getMaxId() ?: 0) + 1 repository.insert(serverSetting) Timber.d("saveNewItem saved server setting: $serverSetting") } @@ -142,12 +144,11 @@ class ServerSettingsModel( runBlocking { demo.index = (repository.count() ?: 0) + 1 - demo.id = (repository.getMaxId() ?: 0) + 1 repository.insert(demo) Timber.d("Added demo server") } - return demo.id + return demo.index } /** diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/APIDataSource.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/APIDataSource.kt new file mode 100644 index 00000000..469e1f30 --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/APIDataSource.kt @@ -0,0 +1,336 @@ +/* + * APIDataSource.kt + * Copyright (C) 2009-2022 Ultrasonic developers + * + * Distributed under terms of the GNU GPLv3 license. + */ + +package org.moire.ultrasonic.playback + +import android.annotation.SuppressLint +import android.net.Uri +import androidx.core.net.toUri +import androidx.media3.common.C +import androidx.media3.common.PlaybackException +import androidx.media3.common.util.Assertions +import androidx.media3.common.util.Util +import androidx.media3.datasource.BaseDataSource +import androidx.media3.datasource.DataSourceException +import androidx.media3.datasource.DataSpec +import androidx.media3.datasource.HttpDataSource +import androidx.media3.datasource.HttpDataSource.HttpDataSourceException +import androidx.media3.datasource.HttpDataSource.InvalidResponseCodeException +import androidx.media3.datasource.HttpDataSource.RequestProperties +import androidx.media3.datasource.HttpUtil +import androidx.media3.datasource.TransferListener +import com.google.common.net.HttpHeaders +import java.io.IOException +import java.io.InputStream +import java.io.InterruptedIOException +import okhttp3.Call +import okhttp3.ResponseBody +import org.moire.ultrasonic.api.subsonic.SubsonicAPIClient +import org.moire.ultrasonic.api.subsonic.response.StreamResponse +import org.moire.ultrasonic.api.subsonic.throwOnFailure +import org.moire.ultrasonic.api.subsonic.toStreamResponse +import timber.log.Timber + +/** + * An [HttpDataSource] that delegates to Square's [Call.Factory]. + * + * + * Note: HTTP request headers will be set using all parameters passed via (in order of decreasing + * priority) the `dataSpec`, [.setRequestProperty] and the default parameters used to + * construct the instance. + */ +@SuppressLint("UnsafeOptInUsageError") +@Suppress("MagicNumber") +open class APIDataSource private constructor( + subsonicAPIClient: SubsonicAPIClient +) : BaseDataSource(true), + HttpDataSource { + + /** [DataSource.Factory] for [APIDataSource] instances. */ + class Factory(private var subsonicAPIClient: SubsonicAPIClient) : HttpDataSource.Factory { + private val defaultRequestProperties: RequestProperties = RequestProperties() + private var transferListener: TransferListener? = null + + override fun setDefaultRequestProperties( + defaultRequestProperties: Map + ): Factory { + this.defaultRequestProperties.clearAndSet(defaultRequestProperties) + return this + } + + /** + * Sets the [TransferListener] that will be used. + * + * See [DataSource.addTransferListener]. + * + * @param transferListener The listener that will be used. + * @return This factory. + */ + fun setTransferListener(transferListener: TransferListener?): Factory { + this.transferListener = transferListener + return this + } + + fun setAPIClient(newClient: SubsonicAPIClient) { + this.subsonicAPIClient = newClient + } + + override fun createDataSource(): APIDataSource { + val dataSource = APIDataSource( + subsonicAPIClient + ) + if (transferListener != null) { + dataSource.addTransferListener(transferListener!!) + } + return dataSource + } + } + + private val subsonicAPIClient: SubsonicAPIClient = Assertions.checkNotNull(subsonicAPIClient) + private val requestProperties: RequestProperties = RequestProperties() + private var dataSpec: DataSpec? = null + private var response: retrofit2.Response? = null + private var responseByteStream: InputStream? = null + private var openedNetwork = false + private var bytesToRead: Long = 0 + private var bytesRead: Long = 0 + + override fun getUri(): Uri? { + return when (response) { + null -> null + else -> response!!.raw().request.url.toString().toUri() + } + } + + override fun getResponseCode(): Int { + return if (response == null) -1 else response!!.code() + } + + override fun getResponseHeaders(): Map> { + return if (response == null) emptyMap() else response!!.headers().toMultimap() + } + + override fun setRequestProperty(name: String, value: String) { + Assertions.checkNotNull(name) + Assertions.checkNotNull(value) + requestProperties[name] = value + } + + override fun clearRequestProperty(name: String) { + Assertions.checkNotNull(name) + requestProperties.remove(name) + } + + override fun clearAllRequestProperties() { + requestProperties.clear() + } + + @Suppress("LongMethod", "NestedBlockDepth") + @Throws(HttpDataSourceException::class) + override fun open(dataSpec: DataSpec): Long { + Timber.i( + "APIDatasource: Open: %s %s %s", + dataSpec.uri, + dataSpec.position, + dataSpec.toString() + ) + + this.dataSpec = dataSpec + bytesRead = 0 + bytesToRead = 0 + + transferInitializing(dataSpec) + val components = dataSpec.uri.toString().split('|') + val id = components[0] + val bitrate = components[1].toInt() + val request = subsonicAPIClient.api.stream(id, bitrate, offset = dataSpec.position) + val response: retrofit2.Response? + val streamResponse: StreamResponse + + try { + this.response = request.execute() + response = this.response + streamResponse = response!!.toStreamResponse() + responseByteStream = streamResponse.stream + } catch (e: IOException) { + throw HttpDataSourceException.createForIOException( + e, dataSpec, HttpDataSourceException.TYPE_OPEN + ) + } + + streamResponse.throwOnFailure() + + val responseCode = response.code() + + // Check for a valid response code. + if (!response.isSuccessful) { + if (responseCode == 416) { + val documentSize = + HttpUtil.getDocumentSize(response.headers()[HttpHeaders.CONTENT_RANGE]) + if (dataSpec.position == documentSize) { + openedNetwork = true + transferStarted(dataSpec) + return if (dataSpec.length != C.LENGTH_UNSET.toLong()) dataSpec.length else 0 + } + } + val errorResponseBody: ByteArray = try { + Util.toByteArray(Assertions.checkNotNull(responseByteStream)) + } catch (ignore: IOException) { + Util.EMPTY_BYTE_ARRAY + } + val headers = response.headers().toMultimap() + closeConnectionQuietly() + val cause: IOException? = + if (responseCode == 416) DataSourceException( + PlaybackException.ERROR_CODE_IO_READ_POSITION_OUT_OF_RANGE + ) else null + throw InvalidResponseCodeException( + responseCode, response.message(), cause, headers, dataSpec, errorResponseBody + ) + } + + // If we requested a range starting from a non-zero position and received a 200 rather than a + // 206, then the server does not support partial requests. We'll need to manually skip to the + // requested position. + val bytesToSkip = + if (responseCode == 200 && dataSpec.position != 0L) dataSpec.position else 0 + + // Determine the length of the data to be read, after skipping. + bytesToRead = if (dataSpec.length != C.LENGTH_UNSET.toLong()) { + dataSpec.length + } else { + val contentLength = response.body()!!.contentLength() + if (contentLength != -1L) contentLength - bytesToSkip else C.LENGTH_UNSET.toLong() + } + openedNetwork = true + transferStarted(dataSpec) + try { + skipFully(bytesToSkip, dataSpec) + } catch (e: HttpDataSourceException) { + closeConnectionQuietly() + throw e + } + + return bytesToRead + } + + @Throws(HttpDataSourceException::class) + override fun read(buffer: ByteArray, offset: Int, length: Int): Int { + // Timber.d("APIDatasource: Read: %s %s", offset, length) + return try { + readInternal(buffer, offset, length) + } catch (e: IOException) { + throw HttpDataSourceException.createForIOException( + e, Util.castNonNull(dataSpec), HttpDataSourceException.TYPE_READ + ) + } + } + + override fun close() { + Timber.i("APIDatasource: Close") + if (openedNetwork) { + openedNetwork = false + transferEnded() + closeConnectionQuietly() + } + } + + /** + * Attempts to skip the specified number of bytes in full. + * + * @param bytesToSkip The number of bytes to skip. + * @param dataSpec The [DataSpec]. + * @throws HttpDataSourceException If the thread is interrupted during the operation, or an error + * occurs while reading from the source, or if the data ended before skipping the specified + * number of bytes. + */ + @Suppress("ThrowsCount") + @Throws(HttpDataSourceException::class) + private fun skipFully(bytesToSkip: Long, dataSpec: DataSpec) { + var bytesToSkip = bytesToSkip + if (bytesToSkip == 0L) { + return + } + val skipBuffer = ByteArray(4096) + try { + while (bytesToSkip > 0) { + val readLength = + bytesToSkip.coerceAtMost(skipBuffer.size.toLong()).toInt() + val read = Util.castNonNull(responseByteStream).read(skipBuffer, 0, readLength) + if (Thread.currentThread().isInterrupted) { + throw InterruptedIOException() + } + if (read == -1) { + throw HttpDataSourceException( + dataSpec, + PlaybackException.ERROR_CODE_IO_READ_POSITION_OUT_OF_RANGE, + HttpDataSourceException.TYPE_OPEN + ) + } + bytesToSkip -= read.toLong() + bytesTransferred(read) + } + return + } catch (e: IOException) { + if (e is HttpDataSourceException) { + throw e + } else { + throw HttpDataSourceException( + dataSpec, + PlaybackException.ERROR_CODE_IO_UNSPECIFIED, + HttpDataSourceException.TYPE_OPEN + ) + } + } + } + + /** + * Reads up to `length` bytes of data and stores them into `buffer`, starting at index + * `offset`. + * + * + * This method blocks until at least one byte of data can be read, the end of the opened range + * is detected, or an exception is thrown. + * + * @param buffer The buffer into which the read data should be stored. + * @param offset The start offset into `buffer` at which data should be written. + * @param readLength The maximum number of bytes to read. + * @return The number of bytes read, or [C.RESULT_END_OF_INPUT] if the end of the opened + * range is reached. + * @throws IOException If an error occurs reading from the source. + */ + @Throws(IOException::class) + private fun readInternal(buffer: ByteArray, offset: Int, readLength: Int): Int { + var readLength = readLength + if (readLength == 0) { + return 0 + } + if (bytesToRead != C.LENGTH_UNSET.toLong()) { + val bytesRemaining = bytesToRead - bytesRead + if (bytesRemaining == 0L) { + return C.RESULT_END_OF_INPUT + } + readLength = readLength.toLong().coerceAtMost(bytesRemaining).toInt() + } + val read = Util.castNonNull(responseByteStream).read(buffer, offset, readLength) + if (read == -1) { + return C.RESULT_END_OF_INPUT + } + bytesRead += read.toLong() + bytesTransferred(read) + return read + } + + /** Closes the current connection quietly, if there is one. */ + private fun closeConnectionQuietly() { + if (response != null) { + Assertions.checkNotNull(response!!.body()).close() + response = null + } + responseByteStream = null + } +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/AutoMediaBrowserService.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/AutoMediaBrowserCallback.kt similarity index 61% rename from ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/AutoMediaBrowserService.kt rename to ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/AutoMediaBrowserCallback.kt index b0c5c6e1..e2abb257 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/AutoMediaBrowserService.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/AutoMediaBrowserCallback.kt @@ -1,32 +1,47 @@ /* - * AutoMediaBrowserService.kt - * Copyright (C) 2009-2021 Ultrasonic developers + * CustomMediaLibrarySessionCallback.kt + * Copyright (C) 2009-2022 Ultrasonic developers * * Distributed under terms of the GNU GPLv3 license. */ -package org.moire.ultrasonic.service +package org.moire.ultrasonic.playback +import android.net.Uri import android.os.Bundle -import android.os.Handler -import android.support.v4.media.MediaBrowserCompat -import android.support.v4.media.MediaDescriptionCompat -import androidx.media.MediaBrowserServiceCompat -import androidx.media.utils.MediaConstants -import io.reactivex.rxjava3.disposables.CompositeDisposable +import androidx.media3.common.MediaItem +import androidx.media3.common.MediaMetadata +import androidx.media3.common.MediaMetadata.FOLDER_TYPE_ALBUMS +import androidx.media3.common.MediaMetadata.FOLDER_TYPE_ARTISTS +import androidx.media3.common.MediaMetadata.FOLDER_TYPE_MIXED +import androidx.media3.common.MediaMetadata.FOLDER_TYPE_NONE +import androidx.media3.common.MediaMetadata.FOLDER_TYPE_PLAYLISTS +import androidx.media3.common.MediaMetadata.FOLDER_TYPE_TITLES +import androidx.media3.common.Player +import androidx.media3.session.LibraryResult +import androidx.media3.session.MediaLibraryService +import androidx.media3.session.MediaSession +import androidx.media3.session.SessionResult +import com.google.common.collect.ImmutableList +import com.google.common.util.concurrent.Futures +import com.google.common.util.concurrent.ListenableFuture import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.guava.future import kotlinx.coroutines.launch -import org.koin.android.ext.android.inject +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject import org.moire.ultrasonic.R import org.moire.ultrasonic.api.subsonic.models.AlbumListType +import org.moire.ultrasonic.app.UApp import org.moire.ultrasonic.data.ActiveServerProvider import org.moire.ultrasonic.domain.MusicDirectory import org.moire.ultrasonic.domain.SearchCriteria import org.moire.ultrasonic.domain.SearchResult import org.moire.ultrasonic.domain.Track -import org.moire.ultrasonic.util.MediaSessionHandler +import org.moire.ultrasonic.service.MediaPlayerController +import org.moire.ultrasonic.service.MusicServiceFactory import org.moire.ultrasonic.util.Settings import org.moire.ultrasonic.util.Util import timber.log.Timber @@ -66,14 +81,16 @@ private const val MEDIA_SEARCH_SONG_ITEM = "MEDIA_SEARCH_SONG_ITEM" private const val DISPLAY_LIMIT = 100 private const val SEARCH_LIMIT = 10 +private const val SEARCH_QUERY_PREFIX_COMPAT = "androidx://media3-session/playFromSearch" +private const val SEARCH_QUERY_PREFIX = "androidx://media3-session/setMediaUri" + /** * MediaBrowserService implementation for e.g. Android Auto */ -@Suppress("TooManyFunctions", "LargeClass") -class AutoMediaBrowserService : MediaBrowserServiceCompat() { +@Suppress("TooManyFunctions", "LargeClass", "UnusedPrivateMember") +class AutoMediaBrowserCallback(var player: Player) : + MediaLibraryService.MediaLibrarySession.MediaLibrarySessionCallback, KoinComponent { - private val lifecycleSupport by inject() - private val mediaSessionHandler by inject() private val mediaPlayerController by inject() private val activeServerProvider: ActiveServerProvider by inject() private val musicService = MusicServiceFactory.getMusicService() @@ -90,40 +107,197 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() { private val useId3Tags get() = Settings.shouldUseId3Tags private val musicFolderId get() = activeServerProvider.getActiveServer().musicFolderId - private var rxBusSubscription: CompositeDisposable = CompositeDisposable() - - @Suppress("MagicNumber") - override fun onCreate() { - super.onCreate() - - rxBusSubscription += RxBus.mediaSessionTokenObservable.subscribe { - if (sessionToken == null) sessionToken = it - } - - rxBusSubscription += RxBus.playFromMediaIdCommandObservable.subscribe { - playFromMediaId(it.first) - } - - rxBusSubscription += RxBus.playFromSearchCommandObservable.subscribe { - playFromSearchCommand(it.first) - } - - mediaSessionHandler.initialize() - - val handler = Handler() - handler.postDelayed( - { - // Ultrasonic may be started from Android Auto. This boots up the necessary components. - Timber.d( - "AutoMediaBrowserService starting lifecycleSupport and MediaPlayerService..." - ) - lifecycleSupport.onCreate() - MediaPlayerService.getInstance() - }, - 100 + /** + * Called when a {@link MediaBrowser} requests the root {@link MediaItem} by {@link + * MediaBrowser#getLibraryRoot(LibraryParams)}. + * + *

Return a {@link ListenableFuture} to send a {@link LibraryResult} back to the browser + * asynchronously. You can also return a {@link LibraryResult} directly by using Guava's + * {@link Futures#immediateFuture(Object)}. + * + *

The {@link LibraryResult#params} may differ from the given {@link LibraryParams params} + * if the session can't provide a root that matches with the {@code params}. + * + *

To allow browsing the media library, return a {@link LibraryResult} with {@link + * LibraryResult#RESULT_SUCCESS} and a root {@link MediaItem} with a valid {@link + * MediaItem#mediaId}. The media id is required for the browser to get the children under the + * root. + * + *

Interoperability: If this callback is called because a legacy {@link + * android.support.v4.media.MediaBrowserCompat} has requested a {@link + * androidx.media.MediaBrowserServiceCompat.BrowserRoot}, then the main thread may be blocked + * until the returned future is done. If your service may be queried by a legacy {@link + * android.support.v4.media.MediaBrowserCompat}, you should ensure that the future completes + * quickly to avoid blocking the main thread for a long period of time. + * + * @param session The session for this event. + * @param browser The browser information. + * @param params The optional parameters passed by the browser. + * @return A pending result that will be resolved with a root media item. + * @see SessionCommand#COMMAND_CODE_LIBRARY_GET_LIBRARY_ROOT + */ + override fun onGetLibraryRoot( + session: MediaLibraryService.MediaLibrarySession, + browser: MediaSession.ControllerInfo, + params: MediaLibraryService.LibraryParams? + ): ListenableFuture> { + return Futures.immediateFuture( + LibraryResult.ofItem( + buildMediaItem( + "Root Folder", + MEDIA_ROOT_ID, + isPlayable = false, + folderType = FOLDER_TYPE_MIXED + ), + params + ) ) + } - Timber.i("AutoMediaBrowserService onCreate finished") + override fun onGetItem( + session: MediaLibraryService.MediaLibrarySession, + browser: MediaSession.ControllerInfo, + mediaId: String + ): ListenableFuture> { + playFromMediaId(mediaId) + + // TODO: + // Create LRU Cache of MediaItems, fill it in the other calls + // and retrieve it here. + return Futures.immediateFuture( + LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE) + ) + } + + override fun onGetChildren( + session: MediaLibraryService.MediaLibrarySession, + browser: MediaSession.ControllerInfo, + parentId: String, + page: Int, + pageSize: Int, + params: MediaLibraryService.LibraryParams? + ): ListenableFuture>> { + // TODO: params??? + return onLoadChildren(parentId) + } + + private fun setMediaItemFromSearchQuery(query: String) { + // Only accept query with pattern "play [Title]" or "[Title]" + // Where [Title]: must be exactly matched + // If no media with exact name found, play a random media instead + val mediaTitle = + if (query.startsWith("play ", ignoreCase = true)) { + query.drop(5) + } else { + query + } + + playFromMediaId(mediaTitle) + } + + override fun onSetMediaUri( + session: MediaSession, + controller: MediaSession.ControllerInfo, + uri: Uri, + extras: Bundle + ): Int { + + if (uri.toString().startsWith(SEARCH_QUERY_PREFIX) || + uri.toString().startsWith(SEARCH_QUERY_PREFIX_COMPAT) + ) { + val searchQuery = + uri.getQueryParameter("query") + ?: return SessionResult.RESULT_ERROR_NOT_SUPPORTED + setMediaItemFromSearchQuery(searchQuery) + + return SessionResult.RESULT_SUCCESS + } else { + return SessionResult.RESULT_ERROR_NOT_SUPPORTED + } + } + + @Suppress("ReturnCount", "ComplexMethod") + fun onLoadChildren( + parentId: String, + ): ListenableFuture>> { + Timber.d("AutoMediaBrowserService onLoadChildren called. ParentId: %s", parentId) + + val parentIdParts = parentId.split('|') + + when (parentIdParts.first()) { + MEDIA_ROOT_ID -> return getRootItems() + MEDIA_LIBRARY_ID -> return getLibrary() + MEDIA_ARTIST_ID -> return getArtists() + MEDIA_ARTIST_SECTION -> return getArtists(parentIdParts[1]) + MEDIA_ALBUM_ID -> return getAlbums(AlbumListType.SORTED_BY_NAME) + MEDIA_ALBUM_PAGE_ID -> return getAlbums( + AlbumListType.fromName(parentIdParts[1]), parentIdParts[2].toInt() + ) + MEDIA_PLAYLIST_ID -> return getPlaylists() + MEDIA_ALBUM_FREQUENT_ID -> return getAlbums(AlbumListType.FREQUENT) + MEDIA_ALBUM_NEWEST_ID -> return getAlbums(AlbumListType.NEWEST) + MEDIA_ALBUM_RECENT_ID -> return getAlbums(AlbumListType.RECENT) + MEDIA_ALBUM_RANDOM_ID -> return getAlbums(AlbumListType.RANDOM) + MEDIA_ALBUM_STARRED_ID -> return getAlbums(AlbumListType.STARRED) + MEDIA_SONG_RANDOM_ID -> return getRandomSongs() + MEDIA_SONG_STARRED_ID -> return getStarredSongs() + MEDIA_SHARE_ID -> return getShares() + MEDIA_BOOKMARK_ID -> return getBookmarks() + MEDIA_PODCAST_ID -> return getPodcasts() + MEDIA_PLAYLIST_ITEM -> return getPlaylist(parentIdParts[1], parentIdParts[2]) + MEDIA_ARTIST_ITEM -> return getAlbumsForArtist( + parentIdParts[1], parentIdParts[2] + ) + MEDIA_ALBUM_ITEM -> return getSongsForAlbum(parentIdParts[1], parentIdParts[2]) + MEDIA_SHARE_ITEM -> return getSongsForShare(parentIdParts[1]) + MEDIA_PODCAST_ITEM -> return getPodcastEpisodes(parentIdParts[1]) + else -> return Futures.immediateFuture(LibraryResult.ofItemList(listOf(), null)) + } + } + + fun onSearch( + query: String, + extras: Bundle?, + ): ListenableFuture>> { + Timber.d("AutoMediaBrowserService onSearch query: %s", query) + val mediaItems: MutableList = ArrayList() + + return serviceScope.future { + val criteria = SearchCriteria(query, SEARCH_LIMIT, SEARCH_LIMIT, SEARCH_LIMIT) + val searchResult = callWithErrorHandling { musicService.search(criteria) } + + // TODO Add More... button to categories + if (searchResult != null) { + searchResult.artists.map { artist -> + mediaItems.add( + artist.name ?: "", + listOf(MEDIA_ARTIST_ITEM, artist.id, artist.name).joinToString("|"), + FOLDER_TYPE_ARTISTS + ) + } + + searchResult.albums.map { album -> + mediaItems.add( + album.title ?: "", + listOf(MEDIA_ALBUM_ITEM, album.id, album.name) + .joinToString("|"), + FOLDER_TYPE_ALBUMS + ) + } + + searchSongsCache = searchResult.songs + searchResult.songs.map { song -> + mediaItems.add( + buildMediaItemFromTrack( + song, + listOf(MEDIA_SEARCH_SONG_ITEM, song.id).joinToString("|"), + isPlayable = true + ) + ) + } + } + return@future LibraryResult.ofItemList(mediaItems, null) + } } @Suppress("MagicNumber", "ComplexMethod") @@ -183,133 +357,6 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() { } } - override fun onDestroy() { - super.onDestroy() - rxBusSubscription.dispose() - mediaSessionHandler.release() - serviceJob.cancel() - - Timber.i("AutoMediaBrowserService onDestroy finished") - } - - override fun onGetRoot( - clientPackageName: String, - clientUid: Int, - rootHints: Bundle? - ): BrowserRoot { - Timber.d( - "AutoMediaBrowserService onGetRoot called. clientPackageName: %s; clientUid: %d", - clientPackageName, clientUid - ) - - val extras = Bundle() - extras.putInt( - MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_BROWSABLE, - MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_LIST_ITEM - ) - extras.putInt( - MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_PLAYABLE, - MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_LIST_ITEM - ) - extras.putBoolean( - MediaConstants.BROWSER_SERVICE_EXTRAS_KEY_SEARCH_SUPPORTED, true - ) - - return BrowserRoot(MEDIA_ROOT_ID, extras) - } - - @Suppress("ReturnCount", "ComplexMethod") - override fun onLoadChildren( - parentId: String, - result: Result> - ) { - Timber.d("AutoMediaBrowserService onLoadChildren called. ParentId: %s", parentId) - - val parentIdParts = parentId.split('|') - - when (parentIdParts.first()) { - MEDIA_ROOT_ID -> return getRootItems(result) - MEDIA_LIBRARY_ID -> return getLibrary(result) - MEDIA_ARTIST_ID -> return getArtists(result) - MEDIA_ARTIST_SECTION -> return getArtists(result, parentIdParts[1]) - MEDIA_ALBUM_ID -> return getAlbums(result, AlbumListType.SORTED_BY_NAME) - MEDIA_ALBUM_PAGE_ID -> return getAlbums( - result, AlbumListType.fromName(parentIdParts[1]), parentIdParts[2].toInt() - ) - MEDIA_PLAYLIST_ID -> return getPlaylists(result) - MEDIA_ALBUM_FREQUENT_ID -> return getAlbums(result, AlbumListType.FREQUENT) - MEDIA_ALBUM_NEWEST_ID -> return getAlbums(result, AlbumListType.NEWEST) - MEDIA_ALBUM_RECENT_ID -> return getAlbums(result, AlbumListType.RECENT) - MEDIA_ALBUM_RANDOM_ID -> return getAlbums(result, AlbumListType.RANDOM) - MEDIA_ALBUM_STARRED_ID -> return getAlbums(result, AlbumListType.STARRED) - MEDIA_SONG_RANDOM_ID -> return getRandomSongs(result) - MEDIA_SONG_STARRED_ID -> return getStarredSongs(result) - MEDIA_SHARE_ID -> return getShares(result) - MEDIA_BOOKMARK_ID -> return getBookmarks(result) - MEDIA_PODCAST_ID -> return getPodcasts(result) - MEDIA_PLAYLIST_ITEM -> return getPlaylist(parentIdParts[1], parentIdParts[2], result) - MEDIA_ARTIST_ITEM -> return getAlbumsForArtist( - result, parentIdParts[1], parentIdParts[2] - ) - MEDIA_ALBUM_ITEM -> return getSongsForAlbum(result, parentIdParts[1], parentIdParts[2]) - MEDIA_SHARE_ITEM -> return getSongsForShare(result, parentIdParts[1]) - MEDIA_PODCAST_ITEM -> return getPodcastEpisodes(result, parentIdParts[1]) - else -> result.sendResult(mutableListOf()) - } - } - - override fun onSearch( - query: String, - extras: Bundle?, - result: Result> - ) { - Timber.d("AutoMediaBrowserService onSearch query: %s", query) - val mediaItems: MutableList = ArrayList() - result.detach() - - serviceScope.launch { - val criteria = SearchCriteria(query, SEARCH_LIMIT, SEARCH_LIMIT, SEARCH_LIMIT) - val searchResult = callWithErrorHandling { musicService.search(criteria) } - - // TODO Add More... button to categories - if (searchResult != null) { - searchResult.artists.map { artist -> - mediaItems.add( - artist.name ?: "", - listOf(MEDIA_ARTIST_ITEM, artist.id, artist.name).joinToString("|"), - null, - R.string.search_artists - ) - } - - searchResult.albums.map { album -> - mediaItems.add( - album.title ?: "", - listOf(MEDIA_ALBUM_ITEM, album.id, album.name) - .joinToString("|"), - null, - R.string.search_albums - ) - } - - searchSongsCache = searchResult.songs - searchResult.songs.map { song -> - mediaItems.add( - MediaBrowserCompat.MediaItem( - Util.getMediaDescriptionForEntry( - song, - listOf(MEDIA_SEARCH_SONG_ITEM, song.id).joinToString("|"), - R.string.search_songs - ), - MediaBrowserCompat.MediaItem.FLAG_PLAYABLE - ) - ) - } - } - result.sendResult(mediaItems) - } - } - private fun playSearch(id: String) { serviceScope.launch { // If there is no cache, we can't play the selected song. @@ -320,112 +367,108 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() { } } - private fun getRootItems(result: Result>) { - val mediaItems: MutableList = ArrayList() + private fun getRootItems(): ListenableFuture>> { + val mediaItems: MutableList = ArrayList() if (!isOffline) mediaItems.add( R.string.music_library_label, MEDIA_LIBRARY_ID, - R.drawable.ic_library, null ) mediaItems.add( R.string.main_artists_title, MEDIA_ARTIST_ID, - R.drawable.ic_artist, - null + null, + folderType = FOLDER_TYPE_ARTISTS ) if (!isOffline) mediaItems.add( R.string.main_albums_title, MEDIA_ALBUM_ID, - R.drawable.ic_menu_browse_dark, - null + null, + folderType = FOLDER_TYPE_ALBUMS ) mediaItems.add( R.string.playlist_label, MEDIA_PLAYLIST_ID, - R.drawable.ic_menu_playlists_dark, - null + null, + folderType = FOLDER_TYPE_PLAYLISTS ) - result.sendResult(mediaItems) + return Futures.immediateFuture(LibraryResult.ofItemList(mediaItems, null)) } - private fun getLibrary(result: Result>) { - val mediaItems: MutableList = ArrayList() + private fun getLibrary(): ListenableFuture>> { + val mediaItems: MutableList = ArrayList() // Songs mediaItems.add( R.string.main_songs_random, MEDIA_SONG_RANDOM_ID, - null, - R.string.main_songs_title + R.string.main_songs_title, + folderType = FOLDER_TYPE_TITLES ) mediaItems.add( R.string.main_songs_starred, MEDIA_SONG_STARRED_ID, - null, - R.string.main_songs_title + R.string.main_songs_title, + folderType = FOLDER_TYPE_TITLES ) // Albums mediaItems.add( R.string.main_albums_newest, MEDIA_ALBUM_NEWEST_ID, - null, R.string.main_albums_title ) mediaItems.add( R.string.main_albums_recent, MEDIA_ALBUM_RECENT_ID, - null, - R.string.main_albums_title + R.string.main_albums_title, + folderType = FOLDER_TYPE_ALBUMS ) mediaItems.add( R.string.main_albums_frequent, MEDIA_ALBUM_FREQUENT_ID, - null, - R.string.main_albums_title + R.string.main_albums_title, + folderType = FOLDER_TYPE_ALBUMS ) mediaItems.add( R.string.main_albums_random, MEDIA_ALBUM_RANDOM_ID, - null, - R.string.main_albums_title + R.string.main_albums_title, + folderType = FOLDER_TYPE_ALBUMS ) mediaItems.add( R.string.main_albums_starred, MEDIA_ALBUM_STARRED_ID, - null, - R.string.main_albums_title + R.string.main_albums_title, + folderType = FOLDER_TYPE_ALBUMS ) // Other - mediaItems.add(R.string.button_bar_shares, MEDIA_SHARE_ID, null, null) - mediaItems.add(R.string.button_bar_bookmarks, MEDIA_BOOKMARK_ID, null, null) - mediaItems.add(R.string.button_bar_podcasts, MEDIA_PODCAST_ID, null, null) + mediaItems.add(R.string.button_bar_shares, MEDIA_SHARE_ID, null) + mediaItems.add(R.string.button_bar_bookmarks, MEDIA_BOOKMARK_ID, null) + mediaItems.add(R.string.button_bar_podcasts, MEDIA_PODCAST_ID, null) - result.sendResult(mediaItems) + return Futures.immediateFuture(LibraryResult.ofItemList(mediaItems, null)) } private fun getArtists( - result: Result>, section: String? = null - ) { - val mediaItems: MutableList = ArrayList() - result.detach() + ): ListenableFuture>> { + val mediaItems: MutableList = ArrayList() - serviceScope.launch { + return serviceScope.future { val childMediaId: String var artists = if (!isOffline && useId3Tags) { childMediaId = MEDIA_ARTIST_ITEM @@ -456,7 +499,7 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() { mediaItems.add( currentSection, listOf(MEDIA_ARTIST_SECTION, currentSection).joinToString("|"), - null + FOLDER_TYPE_ARTISTS ) } } @@ -465,23 +508,22 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() { mediaItems.add( artist.name ?: "", listOf(childMediaId, artist.id, artist.name).joinToString("|"), - null + FOLDER_TYPE_ARTISTS ) } } - result.sendResult(mediaItems) } + return@future LibraryResult.ofItemList(mediaItems, null) } } private fun getAlbumsForArtist( - result: Result>, id: String, name: String - ) { - val mediaItems: MutableList = ArrayList() - result.detach() - serviceScope.launch { + ): ListenableFuture>> { + val mediaItems: MutableList = ArrayList() + + return serviceScope.future { val albums = if (!isOffline && useId3Tags) { callWithErrorHandling { musicService.getArtist(id, name, false) } } else { @@ -495,22 +537,20 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() { album.title ?: "", listOf(MEDIA_ALBUM_ITEM, album.id, album.name) .joinToString("|"), - null + FOLDER_TYPE_ALBUMS ) } - result.sendResult(mediaItems) + return@future LibraryResult.ofItemList(mediaItems, null) } } private fun getSongsForAlbum( - result: Result>, id: String, name: String - ) { - val mediaItems: MutableList = ArrayList() - result.detach() + ): ListenableFuture>> { + val mediaItems: MutableList = ArrayList() - serviceScope.launch { + return serviceScope.future { val songs = listSongsInMusicService(id, name) if (songs != null) { @@ -524,43 +564,36 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() { items.map { item -> if (item.isDirectory) mediaItems.add( - MediaBrowserCompat.MediaItem( - Util.getMediaDescriptionForEntry( - item, - listOf(MEDIA_ALBUM_ITEM, item.id, item.name).joinToString("|") - ), - MediaBrowserCompat.MediaItem.FLAG_BROWSABLE - ) + item.title ?: "", + listOf(MEDIA_ALBUM_ITEM, item.id, item.name).joinToString("|"), + FOLDER_TYPE_TITLES ) else mediaItems.add( - MediaBrowserCompat.MediaItem( - Util.getMediaDescriptionForEntry( - item, - listOf( - MEDIA_ALBUM_SONG_ITEM, - id, - name, - item.id - ).joinToString("|") - ), - MediaBrowserCompat.MediaItem.FLAG_PLAYABLE + buildMediaItemFromTrack( + item, + listOf( + MEDIA_ALBUM_SONG_ITEM, + id, + name, + item.id + ).joinToString("|"), + isPlayable = true ) ) } } - result.sendResult(mediaItems) + return@future LibraryResult.ofItemList(mediaItems, null) } } private fun getAlbums( - result: Result>, type: AlbumListType, page: Int? = null - ) { - val mediaItems: MutableList = ArrayList() - result.detach() - serviceScope.launch { + ): ListenableFuture>> { + val mediaItems: MutableList = ArrayList() + + return serviceScope.future { val offset = (page ?: 0) * DISPLAY_LIMIT val albums = if (useId3Tags) { callWithErrorHandling { @@ -581,7 +614,7 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() { album.title ?: "", listOf(MEDIA_ALBUM_ITEM, album.id, album.name) .joinToString("|"), - null + FOLDER_TYPE_ALBUMS ) } @@ -589,41 +622,37 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() { mediaItems.add( R.string.search_more, listOf(MEDIA_ALBUM_PAGE_ID, type.typeName, (page ?: 0) + 1).joinToString("|"), - R.drawable.ic_menu_forward_dark, null ) - result.sendResult(mediaItems) + return@future LibraryResult.ofItemList(mediaItems, null) } } - private fun getPlaylists(result: Result>) { - val mediaItems: MutableList = ArrayList() - result.detach() + private fun getPlaylists(): ListenableFuture>> { + val mediaItems: MutableList = ArrayList() - serviceScope.launch { + return serviceScope.future { val playlists = callWithErrorHandling { musicService.getPlaylists(true) } playlists?.map { playlist -> mediaItems.add( playlist.name, listOf(MEDIA_PLAYLIST_ITEM, playlist.id, playlist.name) .joinToString("|"), - null + FOLDER_TYPE_PLAYLISTS ) } - result.sendResult(mediaItems) + return@future LibraryResult.ofItemList(mediaItems, null) } } private fun getPlaylist( id: String, name: String, - result: Result> - ) { - val mediaItems: MutableList = ArrayList() - result.detach() + ): ListenableFuture>> { + val mediaItems: MutableList = ArrayList() - serviceScope.launch { + return serviceScope.future { val content = callWithErrorHandling { musicService.getPlaylist(id, name) } if (content != null) { @@ -636,22 +665,20 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() { playlistCache = content.getTracks() playlistCache!!.take(DISPLAY_LIMIT).map { item -> mediaItems.add( - MediaBrowserCompat.MediaItem( - Util.getMediaDescriptionForEntry( - item, - listOf( - MEDIA_PLAYLIST_SONG_ITEM, - id, - name, - item.id - ).joinToString("|") - ), - MediaBrowserCompat.MediaItem.FLAG_PLAYABLE + buildMediaItemFromTrack( + item, + listOf( + MEDIA_PLAYLIST_SONG_ITEM, + id, + name, + item.id + ).joinToString("|"), + isPlayable = true ) ) } - result.sendResult(mediaItems) } + return@future LibraryResult.ofItemList(mediaItems, null) } } @@ -662,7 +689,7 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() { val content = callWithErrorHandling { musicService.getPlaylist(id, name) } playlistCache = content?.getTracks() } - if (playlistCache != null) playSongs(playlistCache) + if (playlistCache != null) playSongs(playlistCache!!) } } @@ -693,30 +720,28 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() { } } - private fun getPodcasts(result: Result>) { - val mediaItems: MutableList = ArrayList() - result.detach() - serviceScope.launch { + private fun getPodcasts(): ListenableFuture>> { + val mediaItems: MutableList = ArrayList() + + return serviceScope.future { val podcasts = callWithErrorHandling { musicService.getPodcastsChannels(false) } podcasts?.map { podcast -> mediaItems.add( podcast.title ?: "", listOf(MEDIA_PODCAST_ITEM, podcast.id).joinToString("|"), - null + FOLDER_TYPE_MIXED ) } - result.sendResult(mediaItems) + return@future LibraryResult.ofItemList(mediaItems, null) } } private fun getPodcastEpisodes( - result: Result>, id: String - ) { - val mediaItems: MutableList = ArrayList() - result.detach() - serviceScope.launch { + ): ListenableFuture>> { + val mediaItems: MutableList = ArrayList() + return serviceScope.future { val episodes = callWithErrorHandling { musicService.getPodcastEpisodes(id) } if (episodes != null) { @@ -725,18 +750,16 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() { episodes.getTracks().map { episode -> mediaItems.add( - MediaBrowserCompat.MediaItem( - Util.getMediaDescriptionForEntry( - episode, - listOf(MEDIA_PODCAST_EPISODE_ITEM, id, episode.id) - .joinToString("|") - ), - MediaBrowserCompat.MediaItem.FLAG_PLAYABLE + buildMediaItemFromTrack( + episode, + listOf(MEDIA_PODCAST_EPISODE_ITEM, id, episode.id) + .joinToString("|"), + isPlayable = true ) ) } - result.sendResult(mediaItems) } + return@future LibraryResult.ofItemList(mediaItems, null) } } @@ -761,27 +784,24 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() { } } - private fun getBookmarks(result: Result>) { - val mediaItems: MutableList = ArrayList() - result.detach() - serviceScope.launch { + private fun getBookmarks(): ListenableFuture>> { + val mediaItems: MutableList = ArrayList() + return serviceScope.future { val bookmarks = callWithErrorHandling { musicService.getBookmarks() } if (bookmarks != null) { val songs = Util.getSongsFromBookmarks(bookmarks) songs.getTracks().map { song -> mediaItems.add( - MediaBrowserCompat.MediaItem( - Util.getMediaDescriptionForEntry( - song, - listOf(MEDIA_BOOKMARK_ITEM, song.id).joinToString("|") - ), - MediaBrowserCompat.MediaItem.FLAG_PLAYABLE + buildMediaItemFromTrack( + song, + listOf(MEDIA_BOOKMARK_ITEM, song.id).joinToString("|"), + isPlayable = true ) ) } - result.sendResult(mediaItems) } + return@future LibraryResult.ofItemList(mediaItems, null) } } @@ -796,11 +816,10 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() { } } - private fun getShares(result: Result>) { - val mediaItems: MutableList = ArrayList() - result.detach() + private fun getShares(): ListenableFuture>> { + val mediaItems: MutableList = ArrayList() - serviceScope.launch { + return serviceScope.future { val shares = callWithErrorHandling { musicService.getShares(false) } shares?.map { share -> @@ -808,21 +827,19 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() { share.name ?: "", listOf(MEDIA_SHARE_ITEM, share.id) .joinToString("|"), - null + FOLDER_TYPE_MIXED ) } - result.sendResult(mediaItems) + return@future LibraryResult.ofItemList(mediaItems, null) } } private fun getSongsForShare( - result: Result>, id: String - ) { - val mediaItems: MutableList = ArrayList() - result.detach() + ): ListenableFuture>> { + val mediaItems: MutableList = ArrayList() - serviceScope.launch { + return serviceScope.future { val shares = callWithErrorHandling { musicService.getShares(false) } val selectedShare = shares?.firstOrNull { share -> share.id == id } @@ -833,17 +850,15 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() { selectedShare.getEntries().map { song -> mediaItems.add( - MediaBrowserCompat.MediaItem( - Util.getMediaDescriptionForEntry( - song, - listOf(MEDIA_SHARE_SONG_ITEM, id, song.id).joinToString("|") - ), - MediaBrowserCompat.MediaItem.FLAG_PLAYABLE + buildMediaItemFromTrack( + song, + listOf(MEDIA_SHARE_SONG_ITEM, id, song.id).joinToString("|"), + isPlayable = true ) ) } } - result.sendResult(mediaItems) + return@future LibraryResult.ofItemList(mediaItems, null) } } @@ -868,11 +883,10 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() { } } - private fun getStarredSongs(result: Result>) { - val mediaItems: MutableList = ArrayList() - result.detach() + private fun getStarredSongs(): ListenableFuture>> { + val mediaItems: MutableList = ArrayList() - serviceScope.launch { + return serviceScope.future { val songs = listStarredSongsInMusicService() if (songs != null) { @@ -884,17 +898,15 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() { starredSongsCache = items items.map { song -> mediaItems.add( - MediaBrowserCompat.MediaItem( - Util.getMediaDescriptionForEntry( - song, - listOf(MEDIA_SONG_STARRED_ITEM, song.id).joinToString("|") - ), - MediaBrowserCompat.MediaItem.FLAG_PLAYABLE + buildMediaItemFromTrack( + song, + listOf(MEDIA_SONG_STARRED_ITEM, song.id).joinToString("|"), + isPlayable = true ) ) } } - result.sendResult(mediaItems) + return@future LibraryResult.ofItemList(mediaItems, null) } } @@ -905,7 +917,7 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() { val content = listStarredSongsInMusicService() starredSongsCache = content?.songs } - if (starredSongsCache != null) playSongs(starredSongsCache) + if (starredSongsCache != null) playSongs(starredSongsCache!!) } } @@ -921,11 +933,10 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() { } } - private fun getRandomSongs(result: Result>) { - val mediaItems: MutableList = ArrayList() - result.detach() + private fun getRandomSongs(): ListenableFuture>> { + val mediaItems: MutableList = ArrayList() - serviceScope.launch { + return serviceScope.future { val songs = callWithErrorHandling { musicService.getRandomSongs(DISPLAY_LIMIT) } if (songs != null) { @@ -937,17 +948,15 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() { randomSongsCache = items items.map { song -> mediaItems.add( - MediaBrowserCompat.MediaItem( - Util.getMediaDescriptionForEntry( - song, - listOf(MEDIA_SONG_RANDOM_ITEM, song.id).joinToString("|") - ), - MediaBrowserCompat.MediaItem.FLAG_PLAYABLE + buildMediaItemFromTrack( + song, + listOf(MEDIA_SONG_RANDOM_ITEM, song.id).joinToString("|"), + isPlayable = true ) ) } } - result.sendResult(mediaItems) + return@future LibraryResult.ofItemList(mediaItems, null) } } @@ -959,7 +968,7 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() { val content = callWithErrorHandling { musicService.getRandomSongs(DISPLAY_LIMIT) } randomSongsCache = content?.getTracks() } - if (randomSongsCache != null) playSongs(randomSongsCache) + if (randomSongsCache != null) playSongs(randomSongsCache!!) } } @@ -989,77 +998,47 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() { } } - private fun MutableList.add( + private fun MutableList.add( title: String, mediaId: String, - icon: Int?, - groupNameId: Int? = null + folderType: Int ) { - val builder = MediaDescriptionCompat.Builder() - builder.setTitle(title) - builder.setMediaId(mediaId) - if (icon != null) - builder.setIconUri(Util.getUriToDrawable(applicationContext, icon)) - - if (groupNameId != null) - builder.setExtras( - Bundle().apply { - putString( - MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, - getString(groupNameId) - ) - } - ) - - val mediaItem = MediaBrowserCompat.MediaItem( - builder.build(), - MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + val mediaItem = buildMediaItem( + title, + mediaId, + isPlayable = false, + folderType = folderType ) this.add(mediaItem) } - private fun MutableList.add( + private fun MutableList.add( resId: Int, mediaId: String, - icon: Int?, groupNameId: Int?, - browsable: Boolean = true + browsable: Boolean = true, + folderType: Int = FOLDER_TYPE_MIXED ) { - val builder = MediaDescriptionCompat.Builder() - builder.setTitle(getString(resId)) - builder.setMediaId(mediaId) + val applicationContext = UApp.applicationContext() - if (icon != null) - builder.setIconUri(Util.getUriToDrawable(applicationContext, icon)) - - if (groupNameId != null) - builder.setExtras( - Bundle().apply { - putString( - MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, - getString(groupNameId) - ) - } - ) - - val mediaItem = MediaBrowserCompat.MediaItem( - builder.build(), - if (browsable) MediaBrowserCompat.MediaItem.FLAG_BROWSABLE - else MediaBrowserCompat.MediaItem.FLAG_PLAYABLE + val mediaItem = buildMediaItem( + applicationContext.getString(resId), + mediaId, + isPlayable = false, + folderType = folderType ) this.add(mediaItem) } - private fun MutableList.addPlayAllItem( + private fun MutableList.addPlayAllItem( mediaId: String, ) { this.add( R.string.select_album_play_all, mediaId, - R.drawable.ic_stat_play_dark, null, false ) @@ -1071,27 +1050,25 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() { return section.toString() } - private fun playSongs(songs: List?) { + private fun playSongs(songs: List) { mediaPlayerController.addToPlaylist( songs, - save = false, + cachePermanently = false, autoPlay = true, - playNext = false, shuffle = false, - newPlaylist = true + insertionMode = MediaPlayerController.InsertionMode.CLEAR ) } private fun playSong(song: Track) { mediaPlayerController.addToPlaylist( listOf(song), - save = false, + cachePermanently = false, autoPlay = false, - playNext = true, shuffle = false, - newPlaylist = false + insertionMode = MediaPlayerController.InsertionMode.AFTER_CURRENT ) - if (mediaPlayerController.playlistSize > 1) mediaPlayerController.next() + if (mediaPlayerController.mediaItemCount > 1) mediaPlayerController.next() else mediaPlayerController.play() } @@ -1104,4 +1081,50 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() { null } } + + private fun buildMediaItemFromTrack( + track: Track, + mediaId: String, + isPlayable: Boolean + ): MediaItem { + + return buildMediaItem( + title = track.title ?: "", + mediaId = mediaId, + isPlayable = isPlayable, + folderType = FOLDER_TYPE_NONE, + album = track.album, + artist = track.artist, + genre = track.genre, + ) + } + + @Suppress("LongParameterList") + private fun buildMediaItem( + title: String, + mediaId: String, + isPlayable: Boolean, + @MediaMetadata.FolderType folderType: Int, + album: String? = null, + artist: String? = null, + genre: String? = null, + sourceUri: Uri? = null, + imageUri: Uri? = null, + ): MediaItem { + val metadata = + MediaMetadata.Builder() + .setAlbumTitle(album) + .setTitle(title) + .setArtist(artist) + .setGenre(genre) + .setFolderType(folderType) + .setIsPlayable(isPlayable) + .setArtworkUri(imageUri) + .build() + return MediaItem.Builder() + .setMediaId(mediaId) + .setMediaMetadata(metadata) + .setUri(sourceUri) + .build() + } } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/CachedDataSource.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/CachedDataSource.kt new file mode 100644 index 00000000..79bae338 --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/CachedDataSource.kt @@ -0,0 +1,220 @@ +/* + * CachedDataSource.kt + * Copyright (C) 2009-2022 Ultrasonic developers + * + * Distributed under terms of the GNU GPLv3 license. + */ + +package org.moire.ultrasonic.playback + +import android.net.Uri +import androidx.core.net.toUri +import androidx.media3.common.C +import androidx.media3.common.PlaybackException +import androidx.media3.common.util.Util +import androidx.media3.datasource.BaseDataSource +import androidx.media3.datasource.DataSource +import androidx.media3.datasource.DataSpec +import androidx.media3.datasource.HttpDataSource.HttpDataSourceException +import java.io.IOException +import java.io.InputStream +import java.io.InterruptedIOException +import org.moire.ultrasonic.util.AbstractFile +import org.moire.ultrasonic.util.FileUtil +import org.moire.ultrasonic.util.Storage +import timber.log.Timber + +@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) +class CachedDataSource( + private var upstreamDataSource: DataSource +) : BaseDataSource(true) { + + class Factory( + private var upstreamDataSourceFactory: DataSource.Factory + ) : DataSource.Factory { + + override fun createDataSource(): CachedDataSource { + return createDataSourceInternal( + upstreamDataSourceFactory.createDataSource() + ) + } + + private fun createDataSourceInternal( + upstreamDataSource: DataSource + ): CachedDataSource { + return CachedDataSource( + upstreamDataSource + ) + } + } + + private var bytesToRead: Long = 0 + private var bytesRead: Long = 0 + private var dataSpec: DataSpec? = null + private var responseByteStream: InputStream? = null + private var openedFile = false + private var cachePath: String? = null + private var cacheFile: AbstractFile? = null + + override fun open(dataSpec: DataSpec): Long { + Timber.i( + "CachedDatasource: Open: %s", + dataSpec.toString() + ) + + this.dataSpec = dataSpec + bytesRead = 0 + bytesToRead = 0 + + val components = dataSpec.uri.toString().split('|') + val path = components[2] + val cacheLength = checkCache(path) + + // We have found an item in the cache, return early + if (cacheLength > 0) { + transferInitializing(dataSpec) + bytesToRead = cacheLength + transferStarted(dataSpec) + skipFully(dataSpec.position, dataSpec) + return bytesToRead + } + + // else forward the call to upstream + return upstreamDataSource.open(dataSpec) + } + + @Suppress("MagicNumber") + override fun read(buffer: ByteArray, offset: Int, length: Int): Int { + // if (offset > 0 || length > 4) + // Timber.d("CachedDatasource: Read: %s %s", offset, length) + return if (cachePath != null) { + try { + readInternal(buffer, offset, length) + } catch (e: IOException) { + throw HttpDataSourceException.createForIOException( + e, Util.castNonNull(dataSpec), HttpDataSourceException.TYPE_READ + ) + } + } else { + upstreamDataSource.read(buffer, offset, length) + } + } + + private fun readInternal(buffer: ByteArray, offset: Int, readLength: Int): Int { + var readLength = readLength + if (readLength == 0) { + return 0 + } + if (bytesToRead != C.LENGTH_UNSET.toLong()) { + val bytesRemaining = bytesToRead - bytesRead + if (bytesRemaining == 0L) { + return C.RESULT_END_OF_INPUT + } + readLength = readLength.toLong().coerceAtMost(bytesRemaining).toInt() + } + val read = Util.castNonNull(responseByteStream).read(buffer, offset, readLength) + if (read == -1) { + Timber.i("CachedDatasource: EndOfInput") + return C.RESULT_END_OF_INPUT + } + bytesRead += read.toLong() + bytesTransferred(read) + return read + } + + /** + * Attempts to skip the specified number of bytes in full. + * + * @param bytesToSkip The number of bytes to skip. + * @param dataSpec The [DataSpec]. + * @throws HttpDataSourceException If the thread is interrupted during the operation, or an error + * occurs while reading from the source, or if the data ended before skipping the specified + * number of bytes. + */ + @Suppress("ThrowsCount") + @Throws(HttpDataSourceException::class) + private fun skipFully(bytesToSkip: Long, dataSpec: DataSpec) { + var bytesToSkip = bytesToSkip + if (bytesToSkip == 0L) { + return + } + val skipBuffer = ByteArray(4096) + try { + while (bytesToSkip > 0) { + val readLength = + bytesToSkip.coerceAtMost(skipBuffer.size.toLong()).toInt() + val read = Util.castNonNull(responseByteStream).read(skipBuffer, 0, readLength) + if (Thread.currentThread().isInterrupted) { + throw InterruptedIOException() + } + if (read == -1) { + throw HttpDataSourceException( + dataSpec, + PlaybackException.ERROR_CODE_IO_READ_POSITION_OUT_OF_RANGE, + HttpDataSourceException.TYPE_OPEN + ) + } + bytesToSkip -= read.toLong() + bytesTransferred(read) + } + return + } catch (e: IOException) { + if (e is HttpDataSourceException) { + throw e + } else { + throw HttpDataSourceException( + dataSpec, + PlaybackException.ERROR_CODE_IO_UNSPECIFIED, + HttpDataSourceException.TYPE_OPEN + ) + } + } + } + + /* + * This method is called by StatsDataSource to verify that the loading succeeded, + * so its important that we return the correct value here.. + */ + override fun getUri(): Uri? { + return cachePath?.toUri() ?: upstreamDataSource.uri + } + + override fun close() { + Timber.i("CachedDatasource: close %s", openedFile) + if (openedFile) { + openedFile = false + transferEnded() + responseByteStream?.close() + responseByteStream = null + } else { + upstreamDataSource.close() + } + } + + /** + * Checks our cache for a matching media file + */ + private fun checkCache(path: String): Long { + var filePath: String = path + var found = Storage.isPathExists(path) + + if (!found) { + filePath = FileUtil.getCompleteFile(path) + found = Storage.isPathExists(filePath) + } + + if (!found) return -1 + + cachePath = filePath + openedFile = true + + cacheFile = Storage.getFromPath(filePath)!! + responseByteStream = cacheFile!!.getFileInputStream() + + val descriptor = cacheFile!!.getDocumentFileDescriptor("r") + val length = descriptor!!.length + descriptor.close() + + return length + } +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/LegacyPlaylistManager.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/LegacyPlaylistManager.kt new file mode 100644 index 00000000..88d5dc13 --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/LegacyPlaylistManager.kt @@ -0,0 +1,110 @@ +/* + * LegacyPlaylist.kt + * Copyright (C) 2009-2022 Ultrasonic developers + * + * Distributed under terms of the GNU GPLv3 license. + */ + +package org.moire.ultrasonic.playback + +import androidx.media3.common.MediaItem +import androidx.media3.session.MediaController +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import org.moire.ultrasonic.domain.Track +import org.moire.ultrasonic.service.DownloadFile +import org.moire.ultrasonic.service.Downloader +import org.moire.ultrasonic.service.JukeboxMediaPlayer +import org.moire.ultrasonic.service.RxBus +import org.moire.ultrasonic.util.LRUCache +import timber.log.Timber + +/** + * This class keeps a legacy playlist maintained which + * reflects the internal timeline of the Media3.Player + */ +class LegacyPlaylistManager : KoinComponent { + + private val _playlist = mutableListOf() + + @JvmField + var currentPlaying: DownloadFile? = null + + // TODO This limits the maximum size of the playlist. + // This will be fixed when this class is refactored and removed + private val mediaItemCache = LRUCache(2000) + + val jukeboxMediaPlayer: JukeboxMediaPlayer by inject() + val downloader: Downloader by inject() + + private var playlistUpdateRevision: Long = 0 + private set(value) { + field = value + RxBus.playlistPublisher.onNext(_playlist) + } + + fun rebuildPlaylist(controller: MediaController) { + _playlist.clear() + + val n = controller.mediaItemCount + + for (i in 0 until n) { + val item = controller.getMediaItemAt(i) + val file = mediaItemCache[item.mediaMetadata.mediaUri.toString()] + if (file != null) + _playlist.add(file) + } + + playlistUpdateRevision++ + } + + fun addToCache(item: MediaItem, file: DownloadFile) { + mediaItemCache.put(item.mediaMetadata.mediaUri.toString(), file) + } + + fun updateCurrentPlaying(item: MediaItem?) { + currentPlaying = mediaItemCache[item?.mediaMetadata?.mediaUri.toString()] + } + + @Synchronized + fun clearPlaylist() { + _playlist.clear() + playlistUpdateRevision++ + } + + fun onDestroy() { + clearPlaylist() + Timber.i("PlaylistManager destroyed") + } + + // Public facing playlist (immutable) + val playlist: List + get() = _playlist + + @get:Synchronized + val playlistDuration: Long + get() { + var totalDuration: Long = 0 + for (downloadFile in _playlist) { + val song = downloadFile.track + if (!song.isDirectory) { + if (song.artist != null) { + if (song.duration != null) { + totalDuration += song.duration!!.toLong() + } + } + } + } + return totalDuration + } + + /** + * Extension function + * Gathers the download file for a given song, and modifies shouldSave if provided. + */ + fun Track.getDownloadFile(save: Boolean? = null): DownloadFile { + return downloader.getDownloadFileForSong(this).apply { + if (save != null) this.shouldSave = save + } + } +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/MediaNotificationProvider.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/MediaNotificationProvider.kt new file mode 100644 index 00000000..5ecb4fe4 --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/MediaNotificationProvider.kt @@ -0,0 +1,150 @@ +/* + * MediaNotificationProvider.kt + * Copyright (C) 2009-2022 Ultrasonic developers + * + * Distributed under terms of the GNU GPLv3 license. + */ + +package org.moire.ultrasonic.playback + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import android.graphics.BitmapFactory +import android.os.Build +import android.os.Bundle +import androidx.core.app.NotificationCompat +import androidx.core.graphics.drawable.IconCompat +import androidx.media3.common.Player +import androidx.media3.common.util.Assertions +import androidx.media3.common.util.UnstableApi +import androidx.media3.common.util.Util +import androidx.media3.session.MediaController +import androidx.media3.session.MediaNotification +import androidx.media3.session.MediaNotification.ActionFactory +import org.moire.ultrasonic.R + +/* +* This is a copy of DefaultMediaNotificationProvider.java with some small changes +* I have opened a bug https://github.com/androidx/media/issues/65 to make it easier to customize +* the icons and actions without creating our own copy of this class.. + */ +@UnstableApi +/* package */ +internal class MediaNotificationProvider(context: Context) : + MediaNotification.Provider { + private val context: Context = context.applicationContext + private val notificationManager: NotificationManager = Assertions.checkStateNotNull( + context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + ) + + @Suppress("LongMethod") + override fun createNotification( + mediaController: MediaController, + actionFactory: ActionFactory, + onNotificationChangedCallback: MediaNotification.Provider.Callback + ): MediaNotification { + ensureNotificationChannel() + val builder: NotificationCompat.Builder = NotificationCompat.Builder( + context, + NOTIFICATION_CHANNEL_ID + ) + // Skip to previous action. + builder.addAction( + actionFactory.createMediaAction( + IconCompat.createWithResource( + context, + R.drawable.media3_notification_seek_to_previous + ), + context.getString(R.string.media3_controls_seek_to_previous_description), + ActionFactory.COMMAND_SKIP_TO_PREVIOUS + ) + ) + if (mediaController.playbackState == Player.STATE_ENDED || + !mediaController.playWhenReady + ) { + // Play action. + builder.addAction( + actionFactory.createMediaAction( + IconCompat.createWithResource(context, R.drawable.media3_notification_play), + context.getString(R.string.media3_controls_play_description), + ActionFactory.COMMAND_PLAY + ) + ) + } else { + // Pause action. + builder.addAction( + actionFactory.createMediaAction( + IconCompat.createWithResource(context, R.drawable.media3_notification_pause), + context.getString(R.string.media3_controls_pause_description), + ActionFactory.COMMAND_PAUSE + ) + ) + } + // Skip to next action. + builder.addAction( + actionFactory.createMediaAction( + IconCompat.createWithResource(context, R.drawable.media3_notification_seek_to_next), + context.getString(R.string.media3_controls_seek_to_next_description), + ActionFactory.COMMAND_SKIP_TO_NEXT + ) + ) + + // Set metadata info in the notification. + val metadata = mediaController.mediaMetadata + builder.setContentTitle(metadata.title).setContentText(metadata.artist) + if (metadata.artworkData != null) { + val artworkBitmap = + BitmapFactory.decodeByteArray(metadata.artworkData, 0, metadata.artworkData!!.size) + builder.setLargeIcon(artworkBitmap) + } + val mediaStyle = androidx.media.app.NotificationCompat.MediaStyle() + .setShowActionsInCompactView(0, 1, 2) + val notification: Notification = builder + .setContentIntent(mediaController.sessionActivity) + .setOnlyAlertOnce(true) + .setSmallIcon(getSmallIconResId()) + .setStyle(mediaStyle) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .setOngoing(false) + .build() + return MediaNotification( + NOTIFICATION_ID, + notification + ) + } + + override fun handleCustomAction( + mediaController: MediaController, + action: String, + extras: Bundle + ) { + // We don't handle custom commands. + } + + private fun ensureNotificationChannel() { + if (Util.SDK_INT < Build.VERSION_CODES.O || + notificationManager.getNotificationChannel(NOTIFICATION_CHANNEL_ID) != null + ) { + return + } + val channel = NotificationChannel( + NOTIFICATION_CHANNEL_ID, + NOTIFICATION_CHANNEL_NAME, + NotificationManager.IMPORTANCE_LOW + ) + channel.setShowBadge(false) + + notificationManager.createNotificationChannel(channel) + } + + companion object { + private const val NOTIFICATION_CHANNEL_ID = "org.moire.ultrasonic" + private const val NOTIFICATION_CHANNEL_NAME = "Ultrasonic background service" + private const val NOTIFICATION_ID = 3032 + private fun getSmallIconResId(): Int { + return R.drawable.ic_stat_ultrasonic + } + } +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/Plan.md b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/Plan.md new file mode 100644 index 00000000..0019ee0b --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/Plan.md @@ -0,0 +1,17 @@ + + + +UI: +[x] Display tracks +[x] On selection: Translate Tracks to MediaItems +[x] Move playlist val to Controller: Keep it around for easier migration!! +[x] Also make a LRU Cache to help with translation between MediaItem and DownloadFile +[x] Hand MediaItems to Service +[] If wanted also hand them to Downloader.kt +[x] Service plays MediaItem through OkHttp +[x] UI needs to receive info from service +[x] Create a Cache Layer +[] Translate AutoMediaBrowserService +[] Add new shuffle icon.... + +convertToPlaybackStateCompatState() diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/PlaybackService.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/PlaybackService.kt new file mode 100644 index 00000000..8404d0f9 --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/PlaybackService.kt @@ -0,0 +1,177 @@ +/* + * PlaybackService.kt + * Copyright (C) 2009-2022 Ultrasonic developers + * + * Distributed under terms of the GNU GPLv3 license. + */ +package org.moire.ultrasonic.playback + +import android.app.PendingIntent +import android.content.Intent +import android.os.Build +import androidx.media3.common.AudioAttributes +import androidx.media3.common.C +import androidx.media3.common.C.CONTENT_TYPE_MUSIC +import androidx.media3.common.C.USAGE_MEDIA +import androidx.media3.common.MediaItem +import androidx.media3.datasource.DataSource +import androidx.media3.exoplayer.DefaultRenderersFactory +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.source.DefaultMediaSourceFactory +import androidx.media3.session.MediaLibraryService +import androidx.media3.session.MediaSession +import io.reactivex.rxjava3.disposables.CompositeDisposable +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import org.moire.ultrasonic.activity.NavigationActivity +import org.moire.ultrasonic.api.subsonic.SubsonicAPIClient +import org.moire.ultrasonic.app.UApp +import org.moire.ultrasonic.data.ActiveServerProvider +import org.moire.ultrasonic.service.RxBus +import org.moire.ultrasonic.service.plusAssign +import org.moire.ultrasonic.util.Constants +import org.moire.ultrasonic.util.Settings +import timber.log.Timber + +class PlaybackService : MediaLibraryService(), KoinComponent { + private lateinit var player: ExoPlayer + private lateinit var mediaLibrarySession: MediaLibrarySession + private lateinit var apiDataSource: APIDataSource.Factory + + private lateinit var librarySessionCallback: MediaLibrarySession.MediaLibrarySessionCallback + + private var rxBusSubscription = CompositeDisposable() + + private var isStarted = false + + /* + * For some reason the LocalConfiguration of MediaItem are stripped somewhere in ExoPlayer, + * and thereby customarily it is required to rebuild it.. + */ + private class CustomMediaItemFiller : MediaSession.MediaItemFiller { + override fun fillInLocalConfiguration( + session: MediaSession, + controller: MediaSession.ControllerInfo, + mediaItem: MediaItem + ): MediaItem { + // Again, set the Uri, so that it will get a LocalConfiguration + return mediaItem.buildUpon() + .setUri(mediaItem.mediaMetadata.mediaUri) + .build() + } + } + + override fun onCreate() { + Timber.i("onCreate called") + super.onCreate() + initializeSessionAndPlayer() + } + + private fun getWakeModeFlag(): Int { + return if (ActiveServerProvider.isOffline()) C.WAKE_MODE_LOCAL else C.WAKE_MODE_NETWORK + } + + override fun onDestroy() { + Timber.i("onDestroy called") + releasePlayerAndSession() + super.onDestroy() + } + + override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaLibrarySession { + return mediaLibrarySession + } + + override fun onTaskRemoved(rootIntent: Intent?) { + Timber.i("Stopping the playback because we were swiped away") + releasePlayerAndSession() + } + + private fun releasePlayerAndSession() { + // Broadcast that the service is being shutdown + RxBus.stopCommandPublisher.onNext(Unit) + + player.release() + mediaLibrarySession.release() + rxBusSubscription.dispose() + isStarted = false + stopForeground(true) + stopSelf() + } + + @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) + private fun initializeSessionAndPlayer() { + if (isStarted) return + + setMediaNotificationProvider(MediaNotificationProvider(UApp.applicationContext())) + + val subsonicAPIClient: SubsonicAPIClient by inject() + + // Create a MediaSource which passes calls through our OkHttp Stack + apiDataSource = APIDataSource.Factory(subsonicAPIClient) + val cacheDataSourceFactory: DataSource.Factory = CachedDataSource.Factory(apiDataSource) + + // Create a renderer with HW rendering support + val renderer = DefaultRenderersFactory(this) + + if (Settings.useHwOffload) + renderer.setEnableAudioOffload(true) + + // Create the player + player = ExoPlayer.Builder(this) + .setAudioAttributes(getAudioAttributes(), true) + .setWakeMode(getWakeModeFlag()) + .setHandleAudioBecomingNoisy(true) + .setMediaSourceFactory(DefaultMediaSourceFactory(cacheDataSourceFactory)) + .setRenderersFactory(renderer) + .build() + + // Enable audio offload + if (Settings.useHwOffload) + player.experimentalSetOffloadSchedulingEnabled(true) + + // Create browser interface + librarySessionCallback = AutoMediaBrowserCallback(player) + + // This will need to use the AutoCalls + mediaLibrarySession = MediaLibrarySession.Builder(this, player, librarySessionCallback) + .setMediaItemFiller(CustomMediaItemFiller()) + .setSessionActivity(getPendingIntentForContent()) + .build() + + // Set a listener to update the API client when the active server has changed + rxBusSubscription += RxBus.activeServerChangeObservable.subscribe { + val newClient: SubsonicAPIClient by inject() + apiDataSource.setAPIClient(newClient) + + // Set the player wake mode + player.setWakeMode(getWakeModeFlag()) + } + + // Listen to the shutdown command + rxBusSubscription += RxBus.shutdownCommandObservable.subscribe { + Timber.i("Received destroy command via Rx") + onDestroy() + } + + isStarted = true + } + + private fun getPendingIntentForContent(): PendingIntent { + val intent = Intent(this, NavigationActivity::class.java) + .addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) + var flags = PendingIntent.FLAG_UPDATE_CURRENT + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + // needed starting Android 12 (S = 31) + flags = flags or PendingIntent.FLAG_IMMUTABLE + } + intent.putExtra(Constants.INTENT_SHOW_PLAYER, true) + return PendingIntent.getActivity(this, 0, intent, flags) + } + + private fun getAudioAttributes(): AudioAttributes { + return AudioAttributes.Builder() + .setUsage(USAGE_MEDIA) + .setContentType(CONTENT_TYPE_MUSIC) + .build() + } +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/provider/UltrasonicAppWidgetProvider.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/provider/UltrasonicAppWidgetProvider.kt new file mode 100644 index 00000000..e74fbbae --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/provider/UltrasonicAppWidgetProvider.kt @@ -0,0 +1,218 @@ +/* + * UltrasonicAppWidgetProvider.kt + * Copyright (C) 2009-2022 Ultrasonic developers + * + * Distributed under terms of the GNU GPLv3 license. + */ + +package org.moire.ultrasonic.provider + +import android.app.PendingIntent +import android.appwidget.AppWidgetManager +import android.appwidget.AppWidgetProvider +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.Environment +import android.view.KeyEvent +import android.widget.RemoteViews +import java.lang.Exception +import org.moire.ultrasonic.R +import org.moire.ultrasonic.activity.NavigationActivity +import org.moire.ultrasonic.domain.Track +import org.moire.ultrasonic.imageloader.BitmapUtils +import org.moire.ultrasonic.receiver.MediaButtonIntentReceiver +import org.moire.ultrasonic.util.Constants +import timber.log.Timber + +/** + * Widget Provider for the Ultrasonic Widgets + */ +@Suppress("MagicNumber") +open class UltrasonicAppWidgetProvider : AppWidgetProvider() { + @JvmField + protected var layoutId = 0 + override fun onUpdate( + context: Context, + appWidgetManager: AppWidgetManager, + appWidgetIds: IntArray + ) { + defaultAppWidget(context, appWidgetIds) + } + + /** + * Initialize given widgets to default state, where we launch Ultrasonic on default click + * and hide actions if service not running. + */ + private fun defaultAppWidget(context: Context, appWidgetIds: IntArray) { + val res = context.resources + val views = RemoteViews(context.packageName, layoutId) + views.setTextViewText(R.id.title, null) + views.setTextViewText(R.id.album, null) + views.setTextViewText(R.id.artist, res.getText(R.string.widget_initial_text)) + linkButtons(context, views, false) + pushUpdate(context, appWidgetIds, views) + } + + private fun pushUpdate(context: Context, appWidgetIds: IntArray?, views: RemoteViews) { + // Update specific list of appWidgetIds if given, otherwise default to all + val manager = AppWidgetManager.getInstance(context) + if (manager != null) { + if (appWidgetIds != null) { + manager.updateAppWidget(appWidgetIds, views) + } else { + manager.updateAppWidget(ComponentName(context, this.javaClass), views) + } + } + } + + /** + * Handle a change notification coming over from [MediaPlayerController] + */ + fun notifyChange(context: Context, currentSong: Track?, playing: Boolean, setAlbum: Boolean) { + if (hasInstances(context)) { + performUpdate(context, currentSong, playing, setAlbum) + } + } + + /** + * Check against [AppWidgetManager] if there are any instances of this widget. + */ + private fun hasInstances(context: Context): Boolean { + val manager = AppWidgetManager.getInstance(context) + if (manager != null) { + val appWidgetIds = manager.getAppWidgetIds(ComponentName(context, javaClass)) + return appWidgetIds.isNotEmpty() + } + return false + } + + /** + * Update all active widget instances by pushing changes + */ + private fun performUpdate( + context: Context, + currentSong: Track?, + playing: Boolean, + setAlbum: Boolean + ) { + Timber.d("Updating Widget") + val res = context.resources + val views = RemoteViews(context.packageName, layoutId) + val title = currentSong?.title + val artist = currentSong?.artist + val album = currentSong?.album + var errorState: CharSequence? = null + + // Show error message? + val status = Environment.getExternalStorageState() + if (status == Environment.MEDIA_SHARED || status == Environment.MEDIA_UNMOUNTED) { + errorState = res.getText(R.string.widget_sdcard_busy) + } else if (status == Environment.MEDIA_REMOVED) { + errorState = res.getText(R.string.widget_sdcard_missing) + } else if (currentSong == null) { + errorState = res.getText(R.string.widget_initial_text) + } + if (errorState != null) { + // Show error state to user + views.setTextViewText(R.id.title, null) + views.setTextViewText(R.id.artist, errorState) + if (setAlbum) { + views.setTextViewText(R.id.album, null) + } + views.setImageViewResource(R.id.appwidget_coverart, R.drawable.unknown_album) + } else { + // No error, so show normal titles + views.setTextViewText(R.id.title, title) + views.setTextViewText(R.id.artist, artist) + if (setAlbum) { + views.setTextViewText(R.id.album, album) + } + } + + // Set correct drawable for pause state + if (playing) { + views.setImageViewResource(R.id.control_play, R.drawable.media_pause_normal_dark) + } else { + views.setImageViewResource(R.id.control_play, R.drawable.media_start_normal_dark) + } + + // Set the cover art + try { + val bitmap = + if (currentSong == null) null else BitmapUtils.getAlbumArtBitmapFromDisk( + currentSong, + 240 + ) + if (bitmap == null) { + // Set default cover art + views.setImageViewResource(R.id.appwidget_coverart, R.drawable.unknown_album) + } else { + views.setImageViewBitmap(R.id.appwidget_coverart, bitmap) + } + } catch (all: Exception) { + Timber.e(all, "Failed to load cover art") + views.setImageViewResource(R.id.appwidget_coverart, R.drawable.unknown_album) + } + + // Link actions buttons to intents + linkButtons(context, views, currentSong != null) + pushUpdate(context, null, views) + } + + companion object { + /** + * Link up various button actions using [PendingIntent]. + */ + private fun linkButtons(context: Context, views: RemoteViews, playerActive: Boolean) { + var intent = Intent( + context, + NavigationActivity::class.java + ).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) + if (playerActive) intent.putExtra(Constants.INTENT_SHOW_PLAYER, true) + intent.action = "android.intent.action.MAIN" + intent.addCategory("android.intent.category.LAUNCHER") + var flags = PendingIntent.FLAG_UPDATE_CURRENT + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + // needed starting Android 12 (S = 31) + flags = flags or PendingIntent.FLAG_IMMUTABLE + } + var pendingIntent = + PendingIntent.getActivity(context, 10, intent, flags) + views.setOnClickPendingIntent(R.id.appwidget_coverart, pendingIntent) + views.setOnClickPendingIntent(R.id.appwidget_top, pendingIntent) + + // Emulate media button clicks. + intent = Intent(Constants.CMD_PROCESS_KEYCODE) + intent.component = ComponentName(context, MediaButtonIntentReceiver::class.java) + intent.putExtra( + Intent.EXTRA_KEY_EVENT, + KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE) + ) + flags = 0 + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + // needed starting Android 12 (S = 31) + flags = flags or PendingIntent.FLAG_IMMUTABLE + } + pendingIntent = PendingIntent.getBroadcast(context, 11, intent, flags) + views.setOnClickPendingIntent(R.id.control_play, pendingIntent) + intent = Intent(Constants.CMD_PROCESS_KEYCODE) + intent.component = ComponentName(context, MediaButtonIntentReceiver::class.java) + intent.putExtra( + Intent.EXTRA_KEY_EVENT, + KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_NEXT) + ) + pendingIntent = PendingIntent.getBroadcast(context, 12, intent, flags) + views.setOnClickPendingIntent(R.id.control_next, pendingIntent) + intent = Intent(Constants.CMD_PROCESS_KEYCODE) + intent.component = ComponentName(context, MediaButtonIntentReceiver::class.java) + intent.putExtra( + Intent.EXTRA_KEY_EVENT, + KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_PREVIOUS) + ) + pendingIntent = PendingIntent.getBroadcast(context, 13, intent, flags) + views.setOnClickPendingIntent(R.id.control_previous, pendingIntent) + } + } +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/provider/UltrasonicAppWidgetProvider4X1.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/provider/UltrasonicAppWidgetProvider4X1.kt new file mode 100644 index 00000000..5efd3ca8 --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/provider/UltrasonicAppWidgetProvider4X1.kt @@ -0,0 +1,28 @@ +/* + * UltrasonicAppWidgetProvider4X1.kt + * Copyright (C) 2009-2022 Ultrasonic developers + * + * Distributed under terms of the GNU GPLv3 license. + */ + +package org.moire.ultrasonic.provider + +import org.moire.ultrasonic.R + +class UltrasonicAppWidgetProvider4X1 : UltrasonicAppWidgetProvider() { + companion object { + @get:Synchronized + var instance: UltrasonicAppWidgetProvider4X1? = null + get() { + if (field == null) { + field = UltrasonicAppWidgetProvider4X1() + } + return field + } + private set + } + + init { + layoutId = R.layout.appwidget4x1 + } +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/provider/UltrasonicAppWidgetProvider4X2.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/provider/UltrasonicAppWidgetProvider4X2.kt new file mode 100644 index 00000000..7235a998 --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/provider/UltrasonicAppWidgetProvider4X2.kt @@ -0,0 +1,28 @@ +/* + * UltrasonicAppWidgetProvider4X2.kt + * Copyright (C) 2009-2022 Ultrasonic developers + * + * Distributed under terms of the GNU GPLv3 license. + */ + +package org.moire.ultrasonic.provider + +import org.moire.ultrasonic.R + +class UltrasonicAppWidgetProvider4X2 : UltrasonicAppWidgetProvider() { + companion object { + @get:Synchronized + var instance: UltrasonicAppWidgetProvider4X2? = null + get() { + if (field == null) { + field = UltrasonicAppWidgetProvider4X2() + } + return field + } + private set + } + + init { + layoutId = R.layout.appwidget4x2 + } +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/provider/UltrasonicAppWidgetProvider4X3.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/provider/UltrasonicAppWidgetProvider4X3.kt new file mode 100644 index 00000000..7b9187cf --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/provider/UltrasonicAppWidgetProvider4X3.kt @@ -0,0 +1,28 @@ +/* + * UltrasonicAppWidgetProvider4X3.kt + * Copyright (C) 2009-2022 Ultrasonic developers + * + * Distributed under terms of the GNU GPLv3 license. + */ + +package org.moire.ultrasonic.provider + +import org.moire.ultrasonic.R + +class UltrasonicAppWidgetProvider4X3 : UltrasonicAppWidgetProvider() { + companion object { + @get:Synchronized + var instance: UltrasonicAppWidgetProvider4X3? = null + get() { + if (field == null) { + field = UltrasonicAppWidgetProvider4X3() + } + return field + } + private set + } + + init { + layoutId = R.layout.appwidget4x3 + } +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/provider/UltrasonicAppWidgetProvider4X4.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/provider/UltrasonicAppWidgetProvider4X4.kt new file mode 100644 index 00000000..d641ff4a --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/provider/UltrasonicAppWidgetProvider4X4.kt @@ -0,0 +1,28 @@ +/* + * UltrasonicAppWidgetProvider4X4.kt + * Copyright (C) 2009-2022 Ultrasonic developers + * + * Distributed under terms of the GNU GPLv3 license. + */ + +package org.moire.ultrasonic.provider + +import org.moire.ultrasonic.R + +class UltrasonicAppWidgetProvider4X4 : UltrasonicAppWidgetProvider() { + companion object { + @get:Synchronized + var instance: UltrasonicAppWidgetProvider4X4? = null + get() { + if (field == null) { + field = UltrasonicAppWidgetProvider4X4() + } + return field + } + private set + } + + init { + layoutId = R.layout.appwidget4x4 + } +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/receiver/MediaButtonIntentReceiver.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/receiver/MediaButtonIntentReceiver.kt new file mode 100644 index 00000000..ec2dd114 --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/receiver/MediaButtonIntentReceiver.kt @@ -0,0 +1,52 @@ +/* + * MediaButtonIntentReceiver.kt + * Copyright (C) 2009-2022 Ultrasonic developers + * + * Distributed under terms of the GNU GPLv3 license. + */ + +package org.moire.ultrasonic.receiver + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.os.Parcelable +import java.lang.Exception +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import org.moire.ultrasonic.service.MediaPlayerLifecycleSupport +import org.moire.ultrasonic.util.Constants +import org.moire.ultrasonic.util.Settings +import timber.log.Timber + +/** + * This class is used to receive commands from the widget + */ +class MediaButtonIntentReceiver : BroadcastReceiver(), KoinComponent { + private val lifecycleSupport: MediaPlayerLifecycleSupport by inject() + + override fun onReceive(context: Context, intent: Intent) { + val intentAction = intent.action + + // If media button are turned off and we received a media button, exit + if (!Settings.mediaButtonsEnabled && Intent.ACTION_MEDIA_BUTTON == intentAction) return + + // Only process media buttons and CMD_PROCESS_KEYCODE, which is received from the widgets + if (Intent.ACTION_MEDIA_BUTTON != intentAction && + Constants.CMD_PROCESS_KEYCODE != intentAction + ) return + val extras = intent.extras ?: return + val event = extras[Intent.EXTRA_KEY_EVENT] as Parcelable? + Timber.i("Got MEDIA_BUTTON key event: %s", event) + try { + val serviceIntent = Intent(Constants.CMD_PROCESS_KEYCODE) + serviceIntent.putExtra(Intent.EXTRA_KEY_EVENT, event) + lifecycleSupport.receiveIntent(serviceIntent) + if (isOrderedBroadcast) { + abortBroadcast() + } + } catch (ignored: Exception) { + // Ignored. + } + } +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/AudioFocusHandler.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/AudioFocusHandler.kt deleted file mode 100644 index 34ad87cc..00000000 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/AudioFocusHandler.kt +++ /dev/null @@ -1,118 +0,0 @@ -package org.moire.ultrasonic.service - -import android.content.Context -import android.media.AudioAttributes -import android.media.AudioManager -import android.media.AudioManager.OnAudioFocusChangeListener -import androidx.media.AudioAttributesCompat -import androidx.media.AudioFocusRequestCompat -import androidx.media.AudioManagerCompat -import org.koin.java.KoinJavaComponent.inject -import org.moire.ultrasonic.domain.PlayerState -import org.moire.ultrasonic.util.Settings -import timber.log.Timber - -class AudioFocusHandler(private val context: Context) { - // TODO: This is a circular reference, try to remove it - // This should be doable by using the native MediaController framework - private val mediaPlayerControllerLazy = - inject(MediaPlayerController::class.java) - - private val audioManager by lazy { - context.getSystemService(Context.AUDIO_SERVICE) as AudioManager - } - - private val lossPref: Int - get() = Settings.tempLoss - - private val audioAttributesCompat by lazy { - AudioAttributesCompat.Builder() - .setUsage(AudioAttributesCompat.USAGE_MEDIA) - .setContentType(AudioAttributesCompat.CONTENT_TYPE_MUSIC) - .setLegacyStreamType(AudioManager.STREAM_MUSIC) - .build() - } - - fun requestAudioFocus() { - if (!hasFocus) { - hasFocus = true - AudioManagerCompat.requestAudioFocus(audioManager, focusRequest) - } - } - - private val listener = OnAudioFocusChangeListener { focusChange -> - - val mediaPlayerController = mediaPlayerControllerLazy.value - - when (focusChange) { - AudioManager.AUDIOFOCUS_GAIN -> { - Timber.v("Regained Audio Focus") - if (pauseFocus) { - pauseFocus = false - mediaPlayerController.start() - } else if (lowerFocus) { - lowerFocus = false - mediaPlayerController.setVolume(1.0f) - } - } - AudioManager.AUDIOFOCUS_LOSS -> { - if (!mediaPlayerController.isJukeboxEnabled) { - hasFocus = false - mediaPlayerController.pause() - AudioManagerCompat.abandonAudioFocusRequest(audioManager, focusRequest) - Timber.v("Abandoned Audio Focus") - } - } - AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> { - if (!mediaPlayerController.isJukeboxEnabled) { - Timber.v("Lost Audio Focus") - - if (mediaPlayerController.playerState === PlayerState.STARTED) { - if (lossPref == 0 || lossPref == 1) { - pauseFocus = true - mediaPlayerController.pause() - } - } - } - } - AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> { - if (!mediaPlayerController.isJukeboxEnabled) { - Timber.v("Lost Audio Focus") - - if (mediaPlayerController.playerState === PlayerState.STARTED) { - if (lossPref == 2 || lossPref == 1) { - lowerFocus = true - mediaPlayerController.setVolume(0.1f) - } else if (lossPref == 0 || lossPref == 1) { - pauseFocus = true - mediaPlayerController.pause() - } - } - } - } - } - } - - private val focusRequest: AudioFocusRequestCompat by lazy { - AudioFocusRequestCompat.Builder(AudioManagerCompat.AUDIOFOCUS_GAIN) - .setAudioAttributes(audioAttributesCompat) - .setWillPauseWhenDucked(true) - .setOnAudioFocusChangeListener(listener) - .build() - } - - companion object { - private var hasFocus = false - private var pauseFocus = false - private var lowerFocus = false - - // TODO: This can be removed if we switch to androidx.media2.player - fun getAudioAttributes(): AudioAttributes { - return AudioAttributes.Builder() - .setUsage(AudioAttributes.USAGE_MEDIA) - .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) - .setLegacyStreamType(AudioManager.STREAM_MUSIC) - .build() - } - } -} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadFile.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadFile.kt index 9b99eb9b..e52d6a90 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadFile.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadFile.kt @@ -7,27 +7,18 @@ package org.moire.ultrasonic.service -import android.text.TextUtils import androidx.lifecycle.MutableLiveData +import androidx.media3.common.MediaItem import java.io.IOException -import java.io.InputStream -import java.io.OutputStream import java.util.Locale import org.koin.core.component.KoinComponent -import org.koin.core.component.inject -import org.moire.ultrasonic.data.ActiveServerProvider -import org.moire.ultrasonic.domain.Artist import org.moire.ultrasonic.domain.Identifiable import org.moire.ultrasonic.domain.Track -import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService -import org.moire.ultrasonic.subsonic.ImageLoaderProvider -import org.moire.ultrasonic.util.CacheCleaner import org.moire.ultrasonic.util.CancellableTask import org.moire.ultrasonic.util.FileUtil import org.moire.ultrasonic.util.Settings import org.moire.ultrasonic.util.Storage import org.moire.ultrasonic.util.Util -import org.moire.ultrasonic.util.Util.safeClose import timber.log.Timber /** @@ -44,29 +35,22 @@ class DownloadFile( ) : KoinComponent, Identifiable { val partialFile: String lateinit var completeFile: String - val saveFile: String = FileUtil.getSongFile(track) + val pinnedFile: String = FileUtil.getSongFile(track) var shouldSave = save - private var downloadTask: CancellableTask? = null + internal var downloadTask: CancellableTask? = null var isFailed = false - private var retryCount = MAX_RETRIES + internal var retryCount = MAX_RETRIES - private val desiredBitRate: Int = Settings.maxBitRate + val desiredBitRate: Int = Settings.maxBitRate var priority = 100 var downloadPrepared = false @Volatile - private var isPlaying = false + internal var saveWhenDone = false @Volatile - private var saveWhenDone = false - - @Volatile - private var completeWhenDone = false - - private val downloader: Downloader by inject() - private val imageLoaderProvider: ImageLoaderProvider by inject() - private val activeServerProvider: ActiveServerProvider by inject() + var completeWhenDone = false val progress: MutableLiveData = MutableLiveData(0) @@ -78,7 +62,7 @@ class DownloadFile( private val lazyInitialStatus: Lazy = lazy { when { - Storage.isPathExists(saveFile) -> { + Storage.isPathExists(pinnedFile) -> { DownloadStatus.PINNED } Storage.isPathExists(completeFile) -> { @@ -95,10 +79,10 @@ class DownloadFile( } init { - partialFile = FileUtil.getParentPath(saveFile) + "/" + - FileUtil.getPartialFile(FileUtil.getNameFromPath(saveFile)) - completeFile = FileUtil.getParentPath(saveFile) + "/" + - FileUtil.getCompleteFile(FileUtil.getNameFromPath(saveFile)) + partialFile = FileUtil.getParentPath(pinnedFile) + "/" + + FileUtil.getPartialFile(FileUtil.getNameFromPath(pinnedFile)) + completeFile = FileUtil.getParentPath(pinnedFile) + "/" + + FileUtil.getCompleteFile(FileUtil.getNameFromPath(pinnedFile)) } /** @@ -115,44 +99,29 @@ class DownloadFile( downloadPrepared = true } - @Synchronized - fun download() { - FileUtil.createDirectoryForParent(saveFile) - isFailed = false - downloadTask = DownloadTask() - downloadTask!!.start() - } - @Synchronized fun cancelDownload() { downloadTask?.cancel() } val completeOrSaveFile: String - get() = if (Storage.isPathExists(saveFile)) { - saveFile + get() = if (Storage.isPathExists(pinnedFile)) { + pinnedFile } else { completeFile } - val completeOrPartialFile: String - get() = if (isCompleteFileAvailable) { - completeOrSaveFile - } else { - partialFile - } - val isSaved: Boolean - get() = Storage.isPathExists(saveFile) + get() = Storage.isPathExists(pinnedFile) @get:Synchronized val isCompleteFileAvailable: Boolean - get() = Storage.isPathExists(completeFile) || Storage.isPathExists(saveFile) + get() = Storage.isPathExists(completeFile) || Storage.isPathExists(pinnedFile) @get:Synchronized val isWorkDone: Boolean get() = Storage.isPathExists(completeFile) && !shouldSave || - Storage.isPathExists(saveFile) || saveWhenDone || completeWhenDone + Storage.isPathExists(pinnedFile) || saveWhenDone || completeWhenDone @get:Synchronized val isDownloading: Boolean @@ -170,54 +139,66 @@ class DownloadFile( cancelDownload() Storage.delete(partialFile) Storage.delete(completeFile) - Storage.delete(saveFile) + Storage.delete(pinnedFile) status.postValue(DownloadStatus.IDLE) - Util.scanMedia(saveFile) + Util.scanMedia(pinnedFile) } fun unpin() { - val file = Storage.getFromPath(saveFile) ?: return + Timber.e("CLEANING") + val file = Storage.getFromPath(pinnedFile) ?: return Storage.rename(file, completeFile) status.postValue(DownloadStatus.DONE) } fun cleanup(): Boolean { + Timber.e("CLEANING") var ok = true - if (Storage.isPathExists(completeFile) || Storage.isPathExists(saveFile)) { + if (Storage.isPathExists(completeFile) || Storage.isPathExists(pinnedFile)) { ok = Storage.delete(partialFile) } - if (Storage.isPathExists(saveFile)) { + if (Storage.isPathExists(pinnedFile)) { ok = ok and Storage.delete(completeFile) } return ok } - fun setPlaying(isPlaying: Boolean) { - if (!isPlaying) doPendingRename() - this.isPlaying = isPlaying + /** + * Create a MediaItem instance representing the data inside this DownloadFile + */ + val mediaItem: MediaItem by lazy { + track.toMediaItem() } + var isPlaying: Boolean = false + get() = field + set(isPlaying) { + if (!isPlaying) doPendingRename() + field = isPlaying + } + // Do a pending rename after the song has stopped playing private fun doPendingRename() { try { + Timber.e("CLEANING") if (saveWhenDone) { - Storage.rename(completeFile, saveFile) + Storage.rename(completeFile, pinnedFile) saveWhenDone = false } else if (completeWhenDone) { if (shouldSave) { - Storage.rename(partialFile, saveFile) - Util.scanMedia(saveFile) + Storage.rename(partialFile, pinnedFile) + Util.scanMedia(pinnedFile) } else { Storage.rename(partialFile, completeFile) } completeWhenDone = false } } catch (e: IOException) { - Timber.w(e, "Failed to rename file %s to %s", completeFile, saveFile) + Timber.w(e, "Failed to rename file %s to %s", completeFile, pinnedFile) } } @@ -225,176 +206,7 @@ class DownloadFile( return String.format(Locale.ROOT, "DownloadFile (%s)", track) } - private inner class DownloadTask : CancellableTask() { - val musicService = getMusicService() - - override fun execute() { - - downloadPrepared = false - var inputStream: InputStream? = null - var outputStream: OutputStream? = null - try { - if (Storage.isPathExists(saveFile)) { - Timber.i("%s already exists. Skipping.", saveFile) - status.postValue(DownloadStatus.PINNED) - return - } - - if (Storage.isPathExists(completeFile)) { - var newStatus: DownloadStatus = DownloadStatus.DONE - if (shouldSave) { - if (isPlaying) { - saveWhenDone = true - } else { - Storage.rename(completeFile, saveFile) - newStatus = DownloadStatus.PINNED - } - } else { - Timber.i("%s already exists. Skipping.", completeFile) - } - status.postValue(newStatus) - return - } - - status.postValue(DownloadStatus.DOWNLOADING) - - // Some devices seem to throw error on partial file which doesn't exist - val needsDownloading: Boolean - val duration = track.duration - val fileLength = Storage.getFromPath(partialFile)?.length ?: 0 - - needsDownloading = ( - desiredBitRate == 0 || duration == null || duration == 0 || fileLength == 0L - ) - - if (needsDownloading) { - // Attempt partial HTTP GET, appending to the file if it exists. - val (inStream, isPartial) = musicService.getDownloadInputStream( - track, fileLength, desiredBitRate, shouldSave - ) - - inputStream = inStream - - if (isPartial) { - Timber.i("Executed partial HTTP GET, skipping %d bytes", fileLength) - } - - outputStream = Storage.getOrCreateFileFromPath(partialFile) - .getFileOutputStream(isPartial) - - val len = inputStream.copyTo(outputStream) { totalBytesCopied -> - setProgress(totalBytesCopied) - } - - Timber.i("Downloaded %d bytes to %s", len, partialFile) - - inputStream.close() - outputStream.flush() - outputStream.close() - - if (isCancelled) { - status.postValue(DownloadStatus.CANCELLED) - throw RuntimeException( - String.format(Locale.ROOT, "Download of '%s' was cancelled", track) - ) - } - - if (track.artistId != null) { - cacheMetadata(track.artistId!!) - } - - downloadAndSaveCoverArt() - } - - if (isPlaying) { - completeWhenDone = true - } else { - if (shouldSave) { - Storage.rename(partialFile, saveFile) - status.postValue(DownloadStatus.PINNED) - Util.scanMedia(saveFile) - } else { - Storage.rename(partialFile, completeFile) - status.postValue(DownloadStatus.DONE) - } - } - } catch (all: Exception) { - outputStream.safeClose() - Storage.delete(completeFile) - Storage.delete(saveFile) - if (!isCancelled) { - isFailed = true - if (retryCount > 1) { - status.postValue(DownloadStatus.RETRYING) - --retryCount - } else if (retryCount == 1) { - status.postValue(DownloadStatus.FAILED) - --retryCount - } - Timber.w(all, "Failed to download '%s'.", track) - } - } finally { - inputStream.safeClose() - outputStream.safeClose() - CacheCleaner().cleanSpace() - downloader.checkDownloads() - } - } - - override fun toString(): String { - return String.format(Locale.ROOT, "DownloadTask (%s)", track) - } - - private fun cacheMetadata(artistId: String) { - // TODO: Right now it's caching the track artist. - // Once the albums are cached in db, we should retrieve the album, - // and then cache the album artist. - if (artistId.isEmpty()) return - var artist: Artist? = - activeServerProvider.getActiveMetaDatabase().artistsDao().get(artistId) - - // If we are downloading a new album, and the user has not visited the Artists list - // recently, then the artist won't be in the database. - if (artist == null) { - val artists: List = musicService.getArtists(true) - artist = artists.find { - it.id == artistId - } - } - - // If we have found an artist, catch it. - if (artist != null) { - activeServerProvider.offlineMetaDatabase.artistsDao().insert(artist) - } - } - - private fun downloadAndSaveCoverArt() { - try { - if (!TextUtils.isEmpty(track.coverArt)) { - // Download the largest size that we can display in the UI - imageLoaderProvider.getImageLoader().cacheCoverArt(track) - } - } catch (all: Exception) { - Timber.e(all, "Failed to get cover art.") - } - } - - @Throws(IOException::class) - fun InputStream.copyTo(out: OutputStream, onCopy: (totalBytesCopied: Long) -> Any): Long { - var bytesCopied: Long = 0 - val buffer = ByteArray(DEFAULT_BUFFER_SIZE) - var bytes = read(buffer) - while (!isCancelled && bytes >= 0) { - out.write(buffer, 0, bytes) - bytesCopied += bytes - onCopy(bytesCopied) - bytes = read(buffer) - } - return bytesCopied - } - } - - private fun setProgress(totalBytesCopied: Long) { + internal fun setProgress(totalBytesCopied: Long) { if (track.size != null) { progress.postValue((totalBytesCopied * 100 / track.size!!).toInt()) } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadService.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadService.kt new file mode 100644 index 00000000..a111d29b --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadService.kt @@ -0,0 +1,231 @@ +/* + * MediaPlayerService.kt + * Copyright (C) 2009-2021 Ultrasonic developers + * + * Distributed under terms of the GNU GPLv3 license. + */ + +package org.moire.ultrasonic.service + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Intent +import android.os.Build +import android.os.IBinder +import android.support.v4.media.session.MediaSessionCompat +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import java.util.concurrent.Semaphore +import java.util.concurrent.TimeUnit +import org.koin.android.ext.android.inject +import org.moire.ultrasonic.R +import org.moire.ultrasonic.activity.NavigationActivity +import org.moire.ultrasonic.app.UApp +import org.moire.ultrasonic.util.Constants +import org.moire.ultrasonic.util.SimpleServiceBinder +import timber.log.Timber + +/** + * Android Foreground service which is used to download tracks even when the app is not visible + * + * "A foreground service is a service that the user is + * actively aware of and isn’t a candidate for the system to kill when low on memory." + * + * TODO: Migrate this to use the Media3 DownloadHelper + */ +class DownloadService : Service() { + private val binder: IBinder = SimpleServiceBinder(this) + + private val downloader by inject() + + private var mediaSession: MediaSessionCompat? = null + + private var isInForeground = false + + override fun onBind(intent: Intent): IBinder { + return binder + } + + override fun onCreate() { + super.onCreate() + + // Create Notification Channel + createNotificationChannel() + updateNotification() + + instance = this + startedSemaphore.release() + Timber.i("DownloadService initiated") + } + + override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int { + super.onStartCommand(intent, flags, startId) + return START_NOT_STICKY + } + + override fun onDestroy() { + super.onDestroy() + instance = null + try { + downloader.stop() + + mediaSession?.release() + mediaSession = null + } catch (ignored: Throwable) { + } + Timber.i("DownloadService destroyed") + } + + fun notifyDownloaderStopped() { + Timber.i("DownloadService stopped") + isInForeground = false + stopForeground(true) + stopSelf() + } + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + + // The suggested importance of a startForeground service notification is IMPORTANCE_LOW + val channel = NotificationChannel( + NOTIFICATION_CHANNEL_ID, + NOTIFICATION_CHANNEL_NAME, + NotificationManager.IMPORTANCE_LOW + ) + + channel.lightColor = android.R.color.holo_blue_dark + channel.lockscreenVisibility = Notification.VISIBILITY_PUBLIC + channel.setShowBadge(false) + + val manager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager + manager.createNotificationChannel(channel) + } + } + + // We should use a single notification builder, otherwise the notification may not be updated + // Set some values that never change + private val notificationBuilder: NotificationCompat.Builder by lazy { + NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID) + .setSmallIcon(R.drawable.ic_stat_ultrasonic) + .setAutoCancel(false) + .setOngoing(true) + .setOnlyAlertOnce(true) + .setWhen(System.currentTimeMillis()) + .setShowWhen(false) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .setContentIntent(getPendingIntentForContent()) + .setPriority(NotificationCompat.PRIORITY_LOW) + } + + private fun updateNotification() { + + val notification = buildForegroundNotification() + + if (isInForeground) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val manager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager + manager.notify(NOTIFICATION_ID, notification) + } else { + val manager = NotificationManagerCompat.from(this) + manager.notify(NOTIFICATION_ID, notification) + } + Timber.v("Updated notification") + } else { + startForeground(NOTIFICATION_ID, notification) + isInForeground = true + Timber.v("Created Foreground notification") + } + } + + /** + * This method builds a notification, reusing the Notification Builder if possible + */ + @Suppress("SpreadOperator") + private fun buildForegroundNotification(): Notification { + + if (downloader.started) { + // No song is playing, but Ultrasonic is downloading files + notificationBuilder.setContentTitle( + getString(R.string.notification_downloading_title) + ) + } + + return notificationBuilder.build() + } + + private fun getPendingIntentForContent(): PendingIntent { + val intent = Intent(this, NavigationActivity::class.java) + .addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) + var flags = PendingIntent.FLAG_UPDATE_CURRENT + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + // needed starting Android 12 (S = 31) + flags = flags or PendingIntent.FLAG_IMMUTABLE + } + intent.putExtra(Constants.INTENT_SHOW_PLAYER, true) + return PendingIntent.getActivity(this, 0, intent, flags) + } + + @Suppress("MagicNumber") + companion object { + + private const val NOTIFICATION_CHANNEL_ID = "org.moire.ultrasonic" + private const val NOTIFICATION_CHANNEL_NAME = "Ultrasonic background service" + private const val NOTIFICATION_ID = 3033 + + @Volatile + private var instance: DownloadService? = null + private val instanceLock = Any() + private val startedSemaphore: Semaphore = Semaphore(0) + + @JvmStatic + fun getInstance(): DownloadService? { + val context = UApp.applicationContext() + if (instance != null) return instance + synchronized(instanceLock) { + if (instance != null) return instance + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService( + Intent(context, DownloadService::class.java) + ) + } else { + context.startService(Intent(context, DownloadService::class.java)) + } + Timber.i("DownloadService starting...") + if (startedSemaphore.tryAcquire(10, TimeUnit.SECONDS)) { + Timber.i("DownloadService started") + return instance + } + Timber.w("DownloadService failed to start!") + return null + } + } + + @JvmStatic + val runningInstance: DownloadService? + get() { + synchronized(instanceLock) { return instance } + } + + @JvmStatic + fun executeOnStartedDownloadService( + taskToExecute: (DownloadService) -> Unit + ) { + + val t: Thread = object : Thread() { + override fun run() { + val instance = getInstance() + if (instance == null) { + Timber.e("ExecuteOnStarted.. failed to get a DownloadService instance!") + return + } else { + taskToExecute(instance) + } + } + } + t.start() + } + } +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/Downloader.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/Downloader.kt index a0a33c50..100a8fdd 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/Downloader.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/Downloader.kt @@ -1,89 +1,115 @@ package org.moire.ultrasonic.service import android.net.wifi.WifiManager +import android.os.Handler +import android.os.Looper +import android.text.TextUtils import androidx.lifecycle.MutableLiveData -import java.util.ArrayList +import io.reactivex.rxjava3.disposables.CompositeDisposable +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream +import java.util.Locale import java.util.PriorityQueue -import java.util.concurrent.Executors -import java.util.concurrent.RejectedExecutionException -import java.util.concurrent.ScheduledExecutorService -import java.util.concurrent.TimeUnit import org.koin.core.component.KoinComponent import org.koin.core.component.inject -import org.moire.ultrasonic.domain.PlayerState +import org.moire.ultrasonic.data.ActiveServerProvider +import org.moire.ultrasonic.domain.Artist import org.moire.ultrasonic.domain.Track +import org.moire.ultrasonic.playback.LegacyPlaylistManager +import org.moire.ultrasonic.subsonic.ImageLoaderProvider +import org.moire.ultrasonic.util.CacheCleaner +import org.moire.ultrasonic.util.CancellableTask +import org.moire.ultrasonic.util.FileUtil import org.moire.ultrasonic.util.LRUCache import org.moire.ultrasonic.util.Settings -import org.moire.ultrasonic.util.ShufflePlayBuffer +import org.moire.ultrasonic.util.Storage import org.moire.ultrasonic.util.Util +import org.moire.ultrasonic.util.Util.safeClose import timber.log.Timber /** * This class is responsible for maintaining the playlist and downloading * its items from the network to the filesystem. * - * TODO: Move away from managing the queue with scheduled checks, instead use callbacks when - * Downloads are finished + * TODO: Move entirely to subclass the Media3.DownloadService */ class Downloader( - private val shufflePlayBuffer: ShufflePlayBuffer, - private val externalStorageMonitor: ExternalStorageMonitor, - private val localMediaPlayer: LocalMediaPlayer + private val storageMonitor: ExternalStorageMonitor, + private val legacyPlaylistManager: LegacyPlaylistManager, ) : KoinComponent { - private val playlist = mutableListOf() + // Dependencies + private val imageLoaderProvider: ImageLoaderProvider by inject() + private val activeServerProvider: ActiveServerProvider by inject() + private val mediaController: MediaPlayerController by inject() var started: Boolean = false + var shouldStop: Boolean = false + var isPolling: Boolean = false private val downloadQueue = PriorityQueue() private val activelyDownloading = mutableListOf() - // TODO: The playlist is now published with RX, while the observableDownloads is using LiveData. - // Use the same for both + // The generic list models expect a LiveData, so even though we are using Rx for many events + // surrounding playback the list of Downloads is published as LiveData. val observableDownloads = MutableLiveData>() - private val jukeboxMediaPlayer: JukeboxMediaPlayer by inject() - // This cache helps us to avoid creating duplicate DownloadFile instances when showing Entries - private val downloadFileCache = LRUCache(100) + private val downloadFileCache = LRUCache(500) - private var executorService: ScheduledExecutorService? = null + private var handler: Handler = Handler(Looper.getMainLooper()) private var wifiLock: WifiManager.WifiLock? = null - private var playlistUpdateRevision: Long = 0 - private set(value) { - field = value - RxBus.playlistPublisher.onNext(playlist) + private var backgroundPriorityCounter = 100 + + private val rxBusSubscription: CompositeDisposable = CompositeDisposable() + + init { + Timber.i("Init called") + // Check downloads if the playlist changed + rxBusSubscription += RxBus.playlistObservable.subscribe { + Timber.v("Playlist has changed, checking Downloads...") + checkDownloads() } + } - var backgroundPriorityCounter = 100 - - val downloadChecker = Runnable { - try { - Timber.w("Checking Downloads") - checkDownloadsInternal() - } catch (all: Exception) { - Timber.e(all, "checkDownloads() failed.") + private var downloadChecker = object : Runnable { + override fun run() { + try { + Timber.w("Checking Downloads") + checkDownloadsInternal() + } catch (all: Exception) { + Timber.e(all, "checkDownloads() failed.") + } finally { + if (!isPolling) { + isPolling = true + if (!shouldStop) { + Handler(Looper.getMainLooper()).postDelayed(this, CHECK_INTERVAL) + } else { + shouldStop = false + isPolling = false + } + } + } } } fun onDestroy() { stop() - clearPlaylist() + rxBusSubscription.dispose() clearBackground() observableDownloads.value = listOf() Timber.i("Downloader destroyed") } + @Synchronized fun start() { + if (started) return started = true - if (executorService == null) { - executorService = Executors.newSingleThreadScheduledExecutor() - executorService!!.scheduleWithFixedDelay( - downloadChecker, 0L, CHECK_INTERVAL, TimeUnit.SECONDS - ) - Timber.i("Downloader started") - } + + // Start our loop + handler.postDelayed(downloadChecker, 100) if (wifiLock == null) { wifiLock = Util.createWifiLock(toString()) @@ -92,61 +118,56 @@ class Downloader( } fun stop() { + if (!started) return started = false - executorService?.shutdown() - executorService = null + shouldStop = true wifiLock?.release() wifiLock = null - MediaPlayerService.runningInstance?.notifyDownloaderStopped() + DownloadService.runningInstance?.notifyDownloaderStopped() Timber.i("Downloader stopped") } fun checkDownloads() { - if ( - executorService == null || - executorService!!.isTerminated || - executorService!!.isShutdown - ) { + if (!started) { start() } else { try { - executorService?.execute(downloadChecker) - } catch (exception: RejectedExecutionException) { + handler.postDelayed(downloadChecker, 100) + } catch (all: Exception) { Timber.w( - exception, + all, "checkDownloads() can't run, maybe the Downloader is shutting down..." ) } } } - @Synchronized @Suppress("ComplexMethod", "ComplexCondition") - fun checkDownloadsInternal() { - if ( - !Util.isExternalStoragePresent() || - !externalStorageMonitor.isExternalStorageAvailable - ) { + @Synchronized + private fun checkDownloadsInternal() { + if (!Util.isExternalStoragePresent() || !storageMonitor.isExternalStorageAvailable) { return } - if (shufflePlayBuffer.isEnabled) { - checkShufflePlay() - } - if (jukeboxMediaPlayer.isEnabled || !Util.isNetworkConnected()) { + + if (legacyPlaylistManager.jukeboxMediaPlayer.isEnabled || !Util.isNetworkConnected()) { return } Timber.v("Downloader checkDownloadsInternal checking downloads") + // Check the active downloads for failures or completions and remove them // Store the result in a flag to know if changes have occurred var listChanged = cleanupActiveDownloads() + val playlist = legacyPlaylistManager.playlist + // Check if need to preload more from playlist val preloadCount = Settings.preloadCount // Start preloading at the current playing song - var start = currentPlayingIndex - if (start == -1) start = 0 + var start = mediaController.currentMediaItemIndex + + if (start == -1 || start > playlist.size) start = 0 val end = (start + preloadCount).coerceAtMost(playlist.size) @@ -173,10 +194,6 @@ class Downloader( activelyDownloading.add(task) startDownloadOnService(task) - // The next file on the playlist is currently downloading - if (playlist.indexOf(task) == 1) { - localMediaPlayer.setNextPlayerState(PlayerState.DOWNLOADING) - } listChanged = true } @@ -194,10 +211,15 @@ class Downloader( observableDownloads.postValue(downloads) } - private fun startDownloadOnService(task: DownloadFile) { - task.prepare() - MediaPlayerService.executeOnStartedMediaPlayerService { - task.download() + private fun startDownloadOnService(file: DownloadFile) { + if (file.isDownloading) return + file.prepare() + DownloadService.executeOnStartedDownloadService { + FileUtil.createDirectoryForParent(file.pinnedFile) + file.isFailed = false + file.downloadTask = DownloadTask(file) + file.downloadTask!!.start() + Timber.v("startDownloadOnService started downloading file ${file.completeFile}") } } @@ -225,34 +247,13 @@ class Downloader( return (oldSize != activelyDownloading.size) } - @get:Synchronized - val currentPlayingIndex: Int - get() = playlist.indexOf(localMediaPlayer.currentPlaying) - - @get:Synchronized - val downloadListDuration: Long - get() { - var totalDuration: Long = 0 - for (downloadFile in playlist) { - val song = downloadFile.track - if (!song.isDirectory) { - if (song.artist != null) { - if (song.duration != null) { - totalDuration += song.duration!!.toLong() - } - } - } - } - return totalDuration - } - @get:Synchronized val all: List get() { val temp: MutableList = ArrayList() temp.addAll(activelyDownloading) temp.addAll(downloadQueue) - temp.addAll(playlist) + temp.addAll(legacyPlaylistManager.playlist) return temp.distinct().sorted() } @@ -267,7 +268,7 @@ class Downloader( temp.addAll(activelyDownloading) temp.addAll(downloadQueue) temp.addAll( - playlist.filter { + legacyPlaylistManager.playlist.filter { if (!it.isStatusInitialized) false else when (it.status.value) { DownloadStatus.DOWNLOADING -> true @@ -278,37 +279,13 @@ class Downloader( return temp.distinct().sorted() } - // Public facing playlist (immutable) - @Synchronized - fun getPlaylist(): List = playlist - @Synchronized fun clearDownloadFileCache() { downloadFileCache.clear() } @Synchronized - fun clearPlaylist() { - playlist.clear() - - val toRemove = mutableListOf() - - // Cancel all active downloads with a high priority - for (download in activelyDownloading) { - if (download.priority < 100) { - download.cancelDownload() - toRemove.add(download) - } - } - - activelyDownloading.removeAll(toRemove) - - playlistUpdateRevision++ - updateLiveData() - } - - @Synchronized - private fun clearBackground() { + fun clearBackground() { // Clear the pending queue downloadQueue.clear() @@ -333,79 +310,6 @@ class Downloader( updateLiveData() } - @Synchronized - fun removeFromPlaylist(downloadFile: DownloadFile) { - if (activelyDownloading.contains(downloadFile)) { - downloadFile.cancelDownload() - } - playlist.remove(downloadFile) - playlistUpdateRevision++ - checkDownloads() - } - - @Synchronized - fun addToPlaylist( - songs: List, - save: Boolean, - autoPlay: Boolean, - playNext: Boolean, - newPlaylist: Boolean - ) { - shufflePlayBuffer.isEnabled = false - var offset = 1 - if (songs.isEmpty()) { - return - } - if (newPlaylist) { - playlist.clear() - } - if (playNext) { - if (autoPlay && currentPlayingIndex >= 0) { - offset = 0 - } - for (song in songs) { - val downloadFile = song.getDownloadFile(save) - playlist.add(currentPlayingIndex + offset, downloadFile) - offset++ - } - } else { - for (song in songs) { - val downloadFile = song.getDownloadFile(save) - playlist.add(downloadFile) - } - } - playlistUpdateRevision++ - checkDownloads() - } - - fun moveItemInPlaylist(oldPos: Int, newPos: Int) { - val item = playlist[oldPos] - playlist.remove(item) - - if (newPos < oldPos) { - playlist.add(newPos + 1, item) - } else { - playlist.add(newPos - 1, item) - } - - playlistUpdateRevision++ - checkDownloads() - } - - @Synchronized - fun clearIncomplete() { - val iterator = playlist.iterator() - var changedPlaylist = false - while (iterator.hasNext()) { - val downloadFile = iterator.next() - if (!downloadFile.isCompleteFileAvailable) { - iterator.remove() - changedPlaylist = true - } - } - if (changedPlaylist) playlistUpdateRevision++ - } - @Synchronized fun downloadBackground(songs: List, save: Boolean) { @@ -413,30 +317,20 @@ class Downloader( for (song in songs) { val file = song.getDownloadFile() file.shouldSave = save - file.priority = backgroundPriorityCounter++ - downloadQueue.add(file) + if (!file.isDownloading) { + file.priority = backgroundPriorityCounter++ + downloadQueue.add(file) + } } + Timber.v("downloadBackground Checking Downloads") checkDownloads() } - @Synchronized - fun shuffle() { - playlist.shuffle() - - // Move the current song to the top.. - if (localMediaPlayer.currentPlaying != null) { - playlist.remove(localMediaPlayer.currentPlaying) - playlist.add(0, localMediaPlayer.currentPlaying!!) - } - - playlistUpdateRevision++ - } - @Synchronized @Suppress("ReturnCount") fun getDownloadFileForSong(song: Track): DownloadFile { - for (downloadFile in playlist) { + for (downloadFile in legacyPlaylistManager.playlist) { if (downloadFile.track == song) { return downloadFile } @@ -459,63 +353,209 @@ class Downloader( return downloadFile } - @Synchronized - private fun checkShufflePlay() { - // Get users desired random playlist size - val listSize = Settings.maxSongs - val wasEmpty = playlist.isEmpty() - val revisionBefore = playlistUpdateRevision - - // First, ensure that list is at least 20 songs long. - val size = playlist.size - if (size < listSize) { - for (song in shufflePlayBuffer[listSize - size]) { - val downloadFile = song.getDownloadFile(false) - playlist.add(downloadFile) - playlistUpdateRevision++ - } - } - - val currIndex = if (localMediaPlayer.currentPlaying == null) 0 else currentPlayingIndex - - // Only shift playlist if playing song #5 or later. - if (currIndex > SHUFFLE_BUFFER_LIMIT) { - val songsToShift = currIndex - 2 - for (song in shufflePlayBuffer[songsToShift]) { - playlist.add(song.getDownloadFile(false)) - playlist[0].cancelDownload() - playlist.removeAt(0) - playlistUpdateRevision++ - } - } - - if (revisionBefore != playlistUpdateRevision) { - jukeboxMediaPlayer.updatePlaylist() - } - - if (wasEmpty && playlist.isNotEmpty()) { - if (jukeboxMediaPlayer.isEnabled) { - jukeboxMediaPlayer.skip(0, 0) - localMediaPlayer.setPlayerState(PlayerState.STARTED, playlist[0]) - } else { - localMediaPlayer.play(playlist[0]) - } - } - } - companion object { - const val PARALLEL_DOWNLOADS = 3 - const val CHECK_INTERVAL = 5L - const val SHUFFLE_BUFFER_LIMIT = 4 + const val PARALLEL_DOWNLOADS = 2 + const val CHECK_INTERVAL = 5000L } /** * Extension function * Gathers the download file for a given song, and modifies shouldSave if provided. */ - fun Track.getDownloadFile(save: Boolean? = null): DownloadFile { + private fun Track.getDownloadFile(save: Boolean? = null): DownloadFile { return getDownloadFileForSong(this).apply { if (save != null) this.shouldSave = save } } + + private inner class DownloadTask(private val downloadFile: DownloadFile) : CancellableTask() { + val musicService = MusicServiceFactory.getMusicService() + + @Suppress("LongMethod", "ComplexMethod", "NestedBlockDepth") + override fun execute() { + + downloadFile.downloadPrepared = false + var inputStream: InputStream? = null + var outputStream: OutputStream? = null + try { + if (Storage.isPathExists(downloadFile.pinnedFile)) { + Timber.i("%s already exists. Skipping.", downloadFile.pinnedFile) + downloadFile.status.postValue(DownloadStatus.PINNED) + return + } + + if (Storage.isPathExists(downloadFile.completeFile)) { + var newStatus: DownloadStatus = DownloadStatus.DONE + if (downloadFile.shouldSave) { + if (downloadFile.isPlaying) { + downloadFile.saveWhenDone = true + } else { + Storage.rename( + downloadFile.completeFile, + downloadFile.pinnedFile + ) + newStatus = DownloadStatus.PINNED + } + } else { + Timber.i( + "%s already exists. Skipping.", + downloadFile.completeFile + ) + } + downloadFile.status.postValue(newStatus) + return + } + + downloadFile.status.postValue(DownloadStatus.DOWNLOADING) + + // Some devices seem to throw error on partial file which doesn't exist + val needsDownloading: Boolean + val duration = downloadFile.track.duration + val fileLength = Storage.getFromPath(downloadFile.partialFile)?.length ?: 0 + + needsDownloading = ( + downloadFile.desiredBitRate == 0 || + duration == null || + duration == 0 || + fileLength == 0L + ) + + if (needsDownloading) { + // Attempt partial HTTP GET, appending to the file if it exists. + val (inStream, isPartial) = musicService.getDownloadInputStream( + downloadFile.track, fileLength, + downloadFile.desiredBitRate, + downloadFile.shouldSave + ) + + inputStream = inStream + + if (isPartial) { + Timber.i("Executed partial HTTP GET, skipping %d bytes", fileLength) + } + + outputStream = Storage.getOrCreateFileFromPath(downloadFile.partialFile) + .getFileOutputStream(isPartial) + + val len = inputStream.copyTo(outputStream) { totalBytesCopied -> + downloadFile.setProgress(totalBytesCopied) + } + + Timber.i("Downloaded %d bytes to %s", len, downloadFile.partialFile) + + inputStream.close() + outputStream.flush() + outputStream.close() + + if (isCancelled) { + downloadFile.status.postValue(DownloadStatus.CANCELLED) + throw RuntimeException( + String.format( + Locale.ROOT, "Download of '%s' was cancelled", + downloadFile.track + ) + ) + } + + if (downloadFile.track.artistId != null) { + cacheMetadata(downloadFile.track.artistId!!) + } + + downloadAndSaveCoverArt() + } + + if (downloadFile.isPlaying) { + downloadFile.completeWhenDone = true + } else { + if (downloadFile.shouldSave) { + Storage.rename( + downloadFile.partialFile, + downloadFile.pinnedFile + ) + downloadFile.status.postValue(DownloadStatus.PINNED) + Util.scanMedia(downloadFile.pinnedFile) + } else { + Storage.rename( + downloadFile.partialFile, + downloadFile.completeFile + ) + downloadFile.status.postValue(DownloadStatus.DONE) + } + } + } catch (all: Exception) { + outputStream.safeClose() + Storage.delete(downloadFile.completeFile) + Storage.delete(downloadFile.pinnedFile) + if (!isCancelled) { + downloadFile.isFailed = true + if (downloadFile.retryCount > 1) { + downloadFile.status.postValue(DownloadStatus.RETRYING) + --downloadFile.retryCount + } else if (downloadFile.retryCount == 1) { + downloadFile.status.postValue(DownloadStatus.FAILED) + --downloadFile.retryCount + } + Timber.w(all, "Failed to download '%s'.", downloadFile.track) + } + } finally { + inputStream.safeClose() + outputStream.safeClose() + CacheCleaner().cleanSpace() + Timber.v("DownloadTask checking downloads") + checkDownloads() + } + } + + override fun toString(): String { + return String.format(Locale.ROOT, "DownloadTask (%s)", downloadFile.track) + } + + private fun cacheMetadata(artistId: String) { + // TODO: Right now it's caching the track artist. + // Once the albums are cached in db, we should retrieve the album, + // and then cache the album artist. + if (artistId.isEmpty()) return + var artist: Artist? = + activeServerProvider.getActiveMetaDatabase().artistsDao().get(artistId) + + // If we are downloading a new album, and the user has not visited the Artists list + // recently, then the artist won't be in the database. + if (artist == null) { + val artists: List = musicService.getArtists(true) + artist = artists.find { + it.id == artistId + } + } + + // If we have found an artist, catch it. + if (artist != null) { + activeServerProvider.offlineMetaDatabase.artistsDao().insert(artist) + } + } + + private fun downloadAndSaveCoverArt() { + try { + if (!TextUtils.isEmpty(downloadFile.track.coverArt)) { + // Download the largest size that we can display in the UI + imageLoaderProvider.getImageLoader().cacheCoverArt(downloadFile.track) + } + } catch (all: Exception) { + Timber.e(all, "Failed to get cover art.") + } + } + + @Throws(IOException::class) + fun InputStream.copyTo(out: OutputStream, onCopy: (totalBytesCopied: Long) -> Any): Long { + var bytesCopied: Long = 0 + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + var bytes = read(buffer) + while (!isCancelled && bytes >= 0) { + out.write(buffer, 0, bytes) + bytesCopied += bytes + onCopy(bytesCopied) + bytes = read(buffer) + } + return bytesCopied + } + } } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/JukeboxMediaPlayer.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/JukeboxMediaPlayer.kt new file mode 100644 index 00000000..75c926a1 --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/JukeboxMediaPlayer.kt @@ -0,0 +1,341 @@ +/* + * JukeboxMediaPlayer.kt + * Copyright (C) 2009-2022 Ultrasonic developers + * + * Distributed under terms of the GNU GPLv3 license. + */ +package org.moire.ultrasonic.service + +import android.content.Context +import android.os.Handler +import android.os.Looper +import android.view.Gravity +import android.view.LayoutInflater +import android.view.View +import android.widget.ProgressBar +import android.widget.Toast +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.koin.java.KoinJavaComponent.inject +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.service.MusicServiceFactory.getMusicService +import org.moire.ultrasonic.util.Util.sleepQuietly +import org.moire.ultrasonic.util.Util.toast +import timber.log.Timber + +/** + * Provides an asynchronous interface to the remote jukebox on the Subsonic server. + * + * TODO: Report warning if queue fills up. + * TODO: Create shutdown method? + * TODO: Disable repeat. + * TODO: Persist RC state? + * TODO: Minimize status updates. + */ +class JukeboxMediaPlayer(private val downloader: Downloader) { + private val tasks = TaskQueue() + private val executorService = Executors.newSingleThreadScheduledExecutor() + private var statusUpdateFuture: ScheduledFuture<*>? = null + private val timeOfLastUpdate = AtomicLong() + private var jukeboxStatus: JukeboxStatus? = null + private var gain = 0.5f + private var volumeToast: VolumeToast? = null + private val running = AtomicBoolean() + private var serviceThread: Thread? = null + private var enabled = false + + // TODO: These create circular references, try to refactor + private val mediaPlayerControllerLazy = inject( + MediaPlayerController::class.java + ) + + fun startJukeboxService() { + if (running.get()) { + return + } + running.set(true) + startProcessTasks() + Timber.d("Started Jukebox Service") + } + + fun stopJukeboxService() { + running.set(false) + sleepQuietly(1000) + if (serviceThread != null) { + serviceThread!!.interrupt() + } + Timber.d("Stopped Jukebox Service") + } + + private fun startProcessTasks() { + serviceThread = object : Thread() { + override fun run() { + processTasks() + } + } + (serviceThread as Thread).start() + } + + @Synchronized + private fun startStatusUpdate() { + stopStatusUpdate() + val updateTask = Runnable { + tasks.remove(GetStatus::class.java) + tasks.add(GetStatus()) + } + statusUpdateFuture = executorService.scheduleWithFixedDelay( + updateTask, + STATUS_UPDATE_INTERVAL_SECONDS, + STATUS_UPDATE_INTERVAL_SECONDS, + TimeUnit.SECONDS + ) + } + + @Synchronized + private fun stopStatusUpdate() { + if (statusUpdateFuture != null) { + statusUpdateFuture!!.cancel(false) + statusUpdateFuture = null + } + } + + private fun processTasks() { + while (running.get()) { + var task: JukeboxTask? = null + try { + if (!isOffline()) { + task = tasks.take() + val status = task.execute() + onStatusUpdate(status) + } + } catch (ignored: InterruptedException) { + } catch (x: Throwable) { + onError(task, x) + } + sleepQuietly(1) + } + } + + private fun onStatusUpdate(jukeboxStatus: JukeboxStatus) { + timeOfLastUpdate.set(System.currentTimeMillis()) + this.jukeboxStatus = jukeboxStatus + } + + private fun onError(task: JukeboxTask?, x: Throwable) { + if (x is ApiNotSupportedException && task !is Stop) { + disableJukeboxOnError(x, R.string.download_jukebox_server_too_old) + } else if (x is OfflineException && task !is Stop) { + disableJukeboxOnError(x, R.string.download_jukebox_offline) + } else if (x is SubsonicRESTException && x.code == 50 && task !is Stop) { + disableJukeboxOnError(x, R.string.download_jukebox_not_authorized) + } else { + Timber.e(x, "Failed to process jukebox task") + } + } + + private fun disableJukeboxOnError(x: Throwable, resourceId: Int) { + Timber.w(x.toString()) + val context = applicationContext() + Handler(Looper.getMainLooper()).post { toast(context, resourceId, false) } + mediaPlayerControllerLazy.value.isJukeboxEnabled = false + } + + fun updatePlaylist() { + if (!enabled) return + tasks.remove(Skip::class.java) + tasks.remove(Stop::class.java) + tasks.remove(Start::class.java) + val ids: MutableList = ArrayList() + for (file in downloader.all) { + ids.add(file.track.id) + } + tasks.add(SetPlaylist(ids)) + } + + fun skip(index: Int, offsetSeconds: Int) { + tasks.remove(Skip::class.java) + tasks.remove(Stop::class.java) + tasks.remove(Start::class.java) + startStatusUpdate() + if (jukeboxStatus != null) { + jukeboxStatus!!.positionSeconds = offsetSeconds + } + tasks.add(Skip(index, offsetSeconds)) + } + + fun stop() { + tasks.remove(Stop::class.java) + tasks.remove(Start::class.java) + stopStatusUpdate() + tasks.add(Stop()) + } + + fun start() { + tasks.remove(Stop::class.java) + tasks.remove(Start::class.java) + startStatusUpdate() + tasks.add(Start()) + } + + @Synchronized + fun adjustVolume(up: Boolean) { + val delta = if (up) 0.05f else -0.05f + gain += delta + gain = gain.coerceAtLeast(0.0f) + gain = gain.coerceAtMost(1.0f) + tasks.remove(SetGain::class.java) + tasks.add(SetGain(gain)) + val context = applicationContext() + if (volumeToast == null) volumeToast = VolumeToast(context) + volumeToast!!.setVolume(gain) + } + + private val musicService: MusicService + get() = getMusicService() + + val positionSeconds: Int + get() { + if (jukeboxStatus == null || + jukeboxStatus!!.positionSeconds == null || + timeOfLastUpdate.get() == 0L + ) { + return 0 + } + if (jukeboxStatus!!.isPlaying) { + val secondsSinceLastUpdate = + ((System.currentTimeMillis() - timeOfLastUpdate.get()) / 1000L).toInt() + return jukeboxStatus!!.positionSeconds!! + secondsSinceLastUpdate + } + return jukeboxStatus!!.positionSeconds!! + } + + var isEnabled: Boolean + set(enabled) { + Timber.d("Jukebox Service setting enabled to %b", enabled) + this.enabled = enabled + tasks.clear() + if (enabled) { + updatePlaylist() + } + stop() + } + get() { + return enabled + } + + private class TaskQueue { + private val queue = LinkedBlockingQueue() + fun add(jukeboxTask: JukeboxTask) { + queue.add(jukeboxTask) + } + + @Throws(InterruptedException::class) + fun take(): JukeboxTask { + return queue.take() + } + + fun remove(taskClass: Class) { + try { + val iterator = queue.iterator() + while (iterator.hasNext()) { + val task = iterator.next() + if (taskClass == task.javaClass) { + iterator.remove() + } + } + } catch (x: Throwable) { + Timber.w(x, "Failed to clean-up task queue.") + } + } + + fun clear() { + queue.clear() + } + } + + private abstract class JukeboxTask { + @Throws(Exception::class) + abstract fun execute(): JukeboxStatus + override fun toString(): String { + return javaClass.simpleName + } + } + + private inner class GetStatus : JukeboxTask() { + @Throws(Exception::class) + override fun execute(): JukeboxStatus { + return musicService.getJukeboxStatus() + } + } + + private inner class SetPlaylist(private val ids: List) : + JukeboxTask() { + @Throws(Exception::class) + override fun execute(): JukeboxStatus { + return musicService.updateJukeboxPlaylist(ids) + } + } + + private inner class Skip( + private val index: Int, + private val offsetSeconds: Int + ) : JukeboxTask() { + @Throws(Exception::class) + override fun execute(): JukeboxStatus { + return musicService.skipJukebox(index, offsetSeconds) + } + } + + private inner class Stop : JukeboxTask() { + @Throws(Exception::class) + override fun execute(): JukeboxStatus { + return musicService.stopJukebox() + } + } + + private inner class Start : JukeboxTask() { + @Throws(Exception::class) + override fun execute(): JukeboxStatus { + return musicService.startJukebox() + } + } + + private inner class SetGain(private val gain: Float) : JukeboxTask() { + @Throws(Exception::class) + override fun execute(): JukeboxStatus { + return musicService.setJukeboxGain(gain) + } + } + + private class VolumeToast(context: Context) : Toast(context) { + private val progressBar: ProgressBar + fun setVolume(volume: Float) { + progressBar.progress = (100 * volume).roundToInt() + show() + } + + init { + duration = LENGTH_SHORT + val inflater = + context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater + val view = inflater.inflate(R.layout.jukebox_volume, null) + progressBar = view.findViewById(R.id.jukebox_volume_progress_bar) as ProgressBar + setView(view) + setGravity(Gravity.TOP, 0, 0) + } + } + + companion object { + private const val STATUS_UPDATE_INTERVAL_SECONDS = 5L + } +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/LocalMediaPlayer.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/LocalMediaPlayer.kt deleted file mode 100644 index 0f69e247..00000000 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/LocalMediaPlayer.kt +++ /dev/null @@ -1,745 +0,0 @@ -/* - * LocalMediaPlayer.kt - * Copyright (C) 2009-2021 Ultrasonic developers - * - * Distributed under terms of the GNU GPLv3 license. - */ - -package org.moire.ultrasonic.service - -import android.content.Context -import android.content.Context.POWER_SERVICE -import android.content.Intent -import android.media.MediaPlayer -import android.media.MediaPlayer.OnCompletionListener -import android.media.audiofx.AudioEffect -import android.os.Handler -import android.os.Looper -import android.os.PowerManager -import android.os.PowerManager.PARTIAL_WAKE_LOCK -import android.os.PowerManager.WakeLock -import androidx.lifecycle.MutableLiveData -import java.net.URLEncoder -import java.util.Locale -import kotlin.math.abs -import kotlin.math.max -import org.koin.core.component.KoinComponent -import org.koin.core.component.inject -import org.moire.ultrasonic.audiofx.EqualizerController -import org.moire.ultrasonic.audiofx.VisualizerController -import org.moire.ultrasonic.data.ActiveServerProvider.Companion.isOffline -import org.moire.ultrasonic.domain.PlayerState -import org.moire.ultrasonic.util.CancellableTask -import org.moire.ultrasonic.util.Constants -import org.moire.ultrasonic.util.Settings -import org.moire.ultrasonic.util.Storage -import org.moire.ultrasonic.util.StreamProxy -import org.moire.ultrasonic.util.Util -import timber.log.Timber - -/** - * Represents a Media Player which uses the mobile's resources for playback - */ -@Suppress("TooManyFunctions") -class LocalMediaPlayer : KoinComponent { - - private val audioFocusHandler by inject() - private val context by inject() - - @JvmField - var onSongCompleted: ((DownloadFile?) -> Unit?)? = null - - @JvmField - var onPrepared: (() -> Any?)? = null - - @JvmField - var onNextSongRequested: Runnable? = null - - @JvmField - @Volatile - var playerState = PlayerState.IDLE - - @JvmField - var currentPlaying: DownloadFile? = null - - @JvmField - var nextPlaying: DownloadFile? = null - - private var nextPlayerState = PlayerState.IDLE - private var nextSetup = false - private var nextPlayingTask: CancellableTask? = null - private var mediaPlayer: MediaPlayer = MediaPlayer() - private var nextMediaPlayer: MediaPlayer? = null - private var mediaPlayerLooper: Looper? = null - private var mediaPlayerHandler: Handler? = null - private var cachedPosition = 0 - private var proxy: StreamProxy? = null - private var bufferTask: CancellableTask? = null - private var positionCache: PositionCache? = null - - private val pm = context.getSystemService(POWER_SERVICE) as PowerManager - private val wakeLock: WakeLock = pm.newWakeLock(PARTIAL_WAKE_LOCK, this.javaClass.name) - - val secondaryProgress: MutableLiveData = MutableLiveData(0) - - fun init() { - Thread { - Thread.currentThread().name = "MediaPlayerThread" - Looper.prepare() - mediaPlayer.setWakeMode(context, PARTIAL_WAKE_LOCK) - mediaPlayer.setOnErrorListener { _, what, more -> - handleError( - Exception( - String.format( - Locale.getDefault(), - "MediaPlayer error: %d (%d)", what, more - ) - ) - ) - false - } - try { - val i = Intent(AudioEffect.ACTION_OPEN_AUDIO_EFFECT_CONTROL_SESSION) - i.putExtra(AudioEffect.EXTRA_AUDIO_SESSION, mediaPlayer.audioSessionId) - i.putExtra(AudioEffect.EXTRA_PACKAGE_NAME, context.packageName) - context.sendBroadcast(i) - } catch (ignored: Throwable) { - // Froyo or lower - } - mediaPlayerLooper = Looper.myLooper() - mediaPlayerHandler = Handler(mediaPlayerLooper!!) - Looper.loop() - }.start() - - // Create Equalizer and Visualizer on a new thread as this can potentially take some time - Thread { - EqualizerController.create(context, mediaPlayer) - VisualizerController.create(mediaPlayer) - }.start() - - wakeLock.setReferenceCounted(false) - Timber.i("LocalMediaPlayer created") - } - - fun release() { - // Calling reset() will result in changing this player's state. If we allow - // the onPlayerStateChanged callback, then the state change will cause this - // to resurrect the media session which has just been destroyed. - reset() - try { - val i = Intent(AudioEffect.ACTION_CLOSE_AUDIO_EFFECT_CONTROL_SESSION) - i.putExtra(AudioEffect.EXTRA_AUDIO_SESSION, mediaPlayer.audioSessionId) - i.putExtra(AudioEffect.EXTRA_PACKAGE_NAME, context.packageName) - context.sendBroadcast(i) - EqualizerController.release() - VisualizerController.release() - mediaPlayer.release() - - mediaPlayer = MediaPlayer() - - if (nextMediaPlayer != null) { - nextMediaPlayer!!.release() - } - mediaPlayerLooper!!.quit() - if (bufferTask != null) { - bufferTask!!.cancel() - } - if (nextPlayingTask != null) { - nextPlayingTask!!.cancel() - } - - wakeLock.release() - } catch (exception: Throwable) { - Timber.w(exception, "LocalMediaPlayer onDestroy exception: ") - } - Timber.i("LocalMediaPlayer destroyed") - } - - @Synchronized - fun setPlayerState(playerState: PlayerState, track: DownloadFile?) { - Timber.i("%s -> %s (%s)", this.playerState.name, playerState.name, track) - synchronized(playerState) { - this.playerState = playerState - } - if (playerState === PlayerState.STARTED) { - audioFocusHandler.requestAudioFocus() - } - - RxBus.playerStatePublisher.onNext(RxBus.StateWithTrack(playerState, track)) - - if (playerState === PlayerState.STARTED && positionCache == null) { - positionCache = PositionCache() - val thread = Thread(positionCache) - thread.start() - } else if (playerState !== PlayerState.STARTED && positionCache != null) { - positionCache!!.stop() - positionCache = null - } - } - - /* - * Set the current playing file. It's called with null to reset the player. - */ - @Synchronized - fun setCurrentPlaying(currentPlaying: DownloadFile?) { - // In some cases this function is called twice - if (this.currentPlaying == currentPlaying) return - this.currentPlaying = currentPlaying - RxBus.playerStatePublisher.onNext(RxBus.StateWithTrack(playerState, currentPlaying)) - } - - /* - * Set the next playing file. nextToPlay cannot be null - */ - @Synchronized - fun setNextPlaying(nextToPlay: DownloadFile) { - nextPlaying = nextToPlay - nextPlayingTask = CheckCompletionTask(nextPlaying) - nextPlayingTask?.start() - } - - /* - * Clear the next playing file. setIdle controls whether the playerState is affected as well - */ - @Synchronized - fun clearNextPlaying(setIdle: Boolean) { - nextSetup = false - nextPlaying = null - if (nextPlayingTask != null) { - nextPlayingTask!!.cancel() - nextPlayingTask = null - } - - if (setIdle) { - setNextPlayerState(PlayerState.IDLE) - } - } - - @Synchronized - fun setNextPlayerState(playerState: PlayerState) { - Timber.i("Next: %s -> %s (%s)", nextPlayerState.name, playerState.name, nextPlaying) - nextPlayerState = playerState - } - - /* - * Public method to play a given file. - * Optionally specify a position to start at. - */ - @Synchronized - @JvmOverloads - fun play(fileToPlay: DownloadFile?, position: Int = 0, autoStart: Boolean = true) { - if (nextPlayingTask != null) { - nextPlayingTask!!.cancel() - nextPlayingTask = null - } - setCurrentPlaying(fileToPlay) - - if (fileToPlay != null) { - bufferAndPlay(fileToPlay, position, autoStart) - } - } - - @Synchronized - fun playNext() { - if (nextMediaPlayer == null || nextPlaying == null) return - - mediaPlayer = nextMediaPlayer!! - - setCurrentPlaying(nextPlaying) - setPlayerState(PlayerState.STARTED, currentPlaying) - - attachHandlersToPlayer(mediaPlayer, nextPlaying!!, false) - - postRunnable(onNextSongRequested) - - // Proxy should not be being used here since the next player was already setup to play - proxy?.stop() - proxy = null - } - - @Synchronized - fun pause() { - try { - mediaPlayer.pause() - } catch (x: Exception) { - handleError(x) - } - } - - @Synchronized - fun start() { - try { - mediaPlayer.start() - } catch (x: Exception) { - handleError(x) - } - } - - @Synchronized - fun seekTo(position: Int) { - try { - mediaPlayer.seekTo(position) - cachedPosition = position - } catch (x: Exception) { - handleError(x) - } - } - - @get:Synchronized - val playerPosition: Int - get() = try { - when (playerState) { - PlayerState.IDLE -> 0 - PlayerState.DOWNLOADING -> 0 - PlayerState.PREPARING -> 0 - else -> cachedPosition - } - } catch (x: Exception) { - handleError(x) - 0 - } - - @get:Synchronized - val playerDuration: Int - get() { - if (currentPlaying != null) { - val duration = currentPlaying!!.track.duration - if (duration != null) { - return duration * 1000 - } - } - if (playerState !== PlayerState.IDLE && - playerState !== PlayerState.DOWNLOADING && - playerState !== PlayerState.PREPARING - ) { - try { - return mediaPlayer.duration - } catch (x: Exception) { - handleError(x) - } - } - return 0 - } - - fun setVolume(volume: Float) { - mediaPlayer.setVolume(volume, volume) - } - - @Synchronized - private fun bufferAndPlay(fileToPlay: DownloadFile, position: Int, autoStart: Boolean) { - if (playerState !== PlayerState.PREPARED && !fileToPlay.isWorkDone) { - reset() - bufferTask = BufferTask(fileToPlay, position, autoStart) - bufferTask!!.start() - } else { - doPlay(fileToPlay, position, autoStart) - } - } - - @Synchronized - private fun doPlay(downloadFile: DownloadFile, position: Int, start: Boolean) { - setPlayerState(PlayerState.IDLE, downloadFile) - - // In many cases we will be resetting the mediaPlayer a second time here. - // figure out if we can remove this call... - resetMediaPlayer() - - try { - downloadFile.setPlaying(false) - - val file = Storage.getFromPath(downloadFile.completeOrPartialFile) - val partial = !downloadFile.isCompleteFileAvailable - - // TODO this won't work with SAF, we should use something else, e.g. a recent list - // downloadFile.updateModificationDate() - mediaPlayer.setOnCompletionListener(null) - - setAudioAttributes(mediaPlayer) - - var streamUrl: String? = null - if (partial) { - if (proxy == null) { - proxy = StreamProxy(object : Supplier() { - override fun get(): DownloadFile { - return currentPlaying!! - } - }) - proxy!!.start() - } - streamUrl = String.format( - Locale.getDefault(), "http://127.0.0.1:%d/%s", - proxy!!.port, URLEncoder.encode(file!!.path, Constants.UTF_8) - ) - Timber.i("Data Source: %s", streamUrl) - } else if (proxy != null) { - proxy?.stop() - proxy = null - } - - Timber.i("Preparing media player") - - if (streamUrl != null) { - Timber.v("LocalMediaPlayer doPlay dataSource: %s", streamUrl) - mediaPlayer.setDataSource(streamUrl) - } else { - Timber.v("LocalMediaPlayer doPlay Path: %s", file!!.path) - val descriptor = file.getDocumentFileDescriptor("r")!! - mediaPlayer.setDataSource(descriptor.fileDescriptor) - descriptor.close() - } - - setPlayerState(PlayerState.PREPARING, downloadFile) - - mediaPlayer.setOnBufferingUpdateListener { mp, percent -> - val song = downloadFile.track - - if (percent == 100) { - mp.setOnBufferingUpdateListener(null) - } - - // The secondary progress is an indicator of how far the song is cached. - if (song.transcodedContentType == null && Settings.maxBitRate == 0) { - val progress = (percent.toDouble() / 100.toDouble() * playerDuration).toInt() - secondaryProgress.postValue(progress) - } - } - - mediaPlayer.setOnPreparedListener { - Timber.i("Media player prepared") - setPlayerState(PlayerState.PREPARED, downloadFile) - - // Populate seek bar secondary progress if we have a complete file for consistency - if (downloadFile.isWorkDone) { - secondaryProgress.postValue(playerDuration) - } - - synchronized(this@LocalMediaPlayer) { - if (position != 0) { - Timber.i("Restarting player from position %d", position) - seekTo(position) - } - cachedPosition = position - if (start) { - mediaPlayer.start() - setPlayerState(PlayerState.STARTED, downloadFile) - } else { - setPlayerState(PlayerState.PAUSED, downloadFile) - } - } - - postRunnable { - onPrepared - } - } - - attachHandlersToPlayer(mediaPlayer, downloadFile, partial) - mediaPlayer.prepareAsync() - } catch (x: Exception) { - handleError(x) - } - } - - private fun setAudioAttributes(player: MediaPlayer) { - player.setAudioAttributes(AudioFocusHandler.getAudioAttributes()) - } - - @Suppress("ComplexCondition") - @Synchronized - private fun setupNext(downloadFile: DownloadFile) { - try { - val file = Storage.getFromPath(downloadFile.completeOrPartialFile) - - // Release the media player if it is not our active player - if (nextMediaPlayer != null && nextMediaPlayer != mediaPlayer) { - nextMediaPlayer!!.setOnCompletionListener(null) - nextMediaPlayer!!.release() - nextMediaPlayer = null - } - nextMediaPlayer = MediaPlayer() - nextMediaPlayer!!.setWakeMode(context, PARTIAL_WAKE_LOCK) - - setAudioAttributes(nextMediaPlayer!!) - - // This has nothing to do with the MediaSession, it is used to associate - // the equalizer or visualizer with the player - try { - nextMediaPlayer!!.audioSessionId = mediaPlayer.audioSessionId - } catch (ignored: Throwable) { - } - - Timber.v("LocalMediaPlayer setupNext Path: %s", file!!.path) - val descriptor = file.getDocumentFileDescriptor("r")!! - nextMediaPlayer!!.setDataSource(descriptor.fileDescriptor) - descriptor.close() - - setNextPlayerState(PlayerState.PREPARING) - nextMediaPlayer!!.setOnPreparedListener { - try { - setNextPlayerState(PlayerState.PREPARED) - if (Settings.gaplessPlayback && - (playerState === PlayerState.STARTED || playerState === PlayerState.PAUSED) - ) { - mediaPlayer.setNextMediaPlayer(nextMediaPlayer) - nextSetup = true - } - } catch (x: Exception) { - handleErrorNext(x) - } - } - nextMediaPlayer!!.setOnErrorListener { _, what, extra -> - Timber.w("Error on playing next (%d, %d): %s", what, extra, downloadFile) - true - } - nextMediaPlayer!!.prepareAsync() - } catch (x: Exception) { - handleErrorNext(x) - } - } - - private fun attachHandlersToPlayer( - mediaPlayer: MediaPlayer, - downloadFile: DownloadFile, - isPartial: Boolean - ) { - mediaPlayer.setOnErrorListener { _, what, extra -> - Timber.w("Error on playing file (%d, %d): %s", what, extra, downloadFile) - val pos = cachedPosition - reset() - downloadFile.setPlaying(false) - doPlay(downloadFile, pos, true) - downloadFile.setPlaying(true) - true - } - - var duration = 0 - if (downloadFile.track.duration != null) { - duration = downloadFile.track.duration!! * 1000 - } - - mediaPlayer.setOnCompletionListener(object : OnCompletionListener { - override fun onCompletion(mediaPlayer: MediaPlayer) { - // Acquire a temporary wakelock, since when we return from - // this callback the MediaPlayer will release its wakelock - // and allow the device to go to sleep. - wakeLock.acquire(60000) - val pos = cachedPosition - Timber.i("Ending position %d of %d", pos, duration) - - if (!isPartial || downloadFile.isWorkDone && abs(duration - pos) < 1000) { - setPlayerState(PlayerState.COMPLETED, downloadFile) - if (Settings.gaplessPlayback && - nextPlaying != null && - nextPlayerState === PlayerState.PREPARED - ) { - if (nextSetup) { - nextSetup = false - } - playNext() - } else { - if (onSongCompleted != null) { - val mainHandler = Handler(context.mainLooper) - val myRunnable = Runnable { onSongCompleted!!(currentPlaying) } - mainHandler.post(myRunnable) - } - } - return - } - - synchronized(this) { - if (downloadFile.isWorkDone) { - // Complete was called early even though file is fully buffered - Timber.i("Requesting restart from %d of %d", pos, duration) - reset() - downloadFile.setPlaying(false) - doPlay(downloadFile, pos, true) - downloadFile.setPlaying(true) - } else { - Timber.i("Requesting restart from %d of %d", pos, duration) - reset() - bufferTask = BufferTask(downloadFile, pos) - bufferTask!!.start() - } - } - } - }) - } - - @Synchronized - fun reset() { - if (bufferTask != null) { - bufferTask!!.cancel() - } - - resetMediaPlayer() - - try { - setPlayerState(PlayerState.IDLE, currentPlaying) - mediaPlayer.setOnErrorListener(null) - mediaPlayer.setOnCompletionListener(null) - } catch (x: Exception) { - handleError(x) - } - } - - @Synchronized - fun resetMediaPlayer() { - try { - mediaPlayer.reset() - } catch (x: Exception) { - Timber.w(x, "MediaPlayer was released but LocalMediaPlayer was not destroyed") - - // Recreate MediaPlayer - mediaPlayer = MediaPlayer() - } - } - - private inner class BufferTask( - private val downloadFile: DownloadFile, - private val position: Int, - private val autoStart: Boolean = true - ) : CancellableTask() { - private val expectedFileSize: Long - private val partialFile: String = downloadFile.partialFile - - override fun execute() { - setPlayerState(PlayerState.DOWNLOADING, downloadFile) - while (!bufferComplete() && !isOffline()) { - Util.sleepQuietly(1000L) - if (isCancelled) { - return - } - } - - doPlay(downloadFile, position, autoStart) - } - - private fun bufferComplete(): Boolean { - val completeFileAvailable = downloadFile.isWorkDone - val size = Storage.getFromPath(partialFile)?.length ?: 0 - - Timber.i( - "Buffering %s (%d/%d, %s)", - partialFile, size, expectedFileSize, completeFileAvailable - ) - - return completeFileAvailable || size >= expectedFileSize - } - - override fun toString(): String { - return String.format("BufferTask (%s)", downloadFile) - } - - init { - var bufferLength = Settings.bufferLength.toLong() - if (bufferLength == 0L) { - // Set to seconds in a day, basically infinity - bufferLength = 86400L - } - - // Calculate roughly how many bytes BUFFER_LENGTH_SECONDS corresponds to. - val bitRate = downloadFile.getBitRate() - val byteCount = max(100000, bitRate * 1024L / 8L * bufferLength) - - // Find out how large the file should grow before resuming playback. - Timber.i("Buffering from position %d and bitrate %d", position, bitRate) - expectedFileSize = position * bitRate / 8 + byteCount - } - } - - private inner class CheckCompletionTask(downloadFile: DownloadFile?) : CancellableTask() { - private val downloadFile: DownloadFile? - private val partialFile: String? - override fun execute() { - Thread.currentThread().name = "CheckCompletionTask" - if (downloadFile == null) { - return - } - - // Do an initial sleep so this prepare can't compete with main prepare - Util.sleepQuietly(5000L) - while (!bufferComplete()) { - Util.sleepQuietly(5000L) - if (isCancelled) { - return - } - } - - // Start the setup of the next media player - mediaPlayerHandler!!.post { setupNext(downloadFile) } - } - - private fun bufferComplete(): Boolean { - val completeFileAvailable = downloadFile!!.isWorkDone - val state = (playerState === PlayerState.STARTED || playerState === PlayerState.PAUSED) - - val length = if (partialFile == null) 0 - else Storage.getFromPath(partialFile)?.length ?: 0 - - Timber.i("Buffering next %s (%d)", partialFile, length) - - return completeFileAvailable && state - } - - override fun toString(): String { - return String.format("CheckCompletionTask (%s)", downloadFile) - } - - init { - setNextPlayerState(PlayerState.IDLE) - this.downloadFile = downloadFile - partialFile = downloadFile?.partialFile - } - } - - private inner class PositionCache : Runnable { - var isRunning = true - fun stop() { - isRunning = false - } - - override fun run() { - Thread.currentThread().name = "PositionCache" - - // Stop checking position before the song reaches completion - while (isRunning) { - try { - if (playerState === PlayerState.STARTED) { - synchronized(playerState) { - if (playerState === PlayerState.STARTED) { - cachedPosition = mediaPlayer.currentPosition - } - } - RxBus.playbackPositionPublisher.onNext(cachedPosition) - } - Util.sleepQuietly(100L) - } catch (e: Exception) { - Timber.w(e, "Crashed getting current position") - isRunning = false - positionCache = null - } - } - } - } - - private fun handleError(x: Exception) { - Timber.w(x, "Media player error") - try { - mediaPlayer.reset() - } catch (ex: Exception) { - Timber.w(ex, "Exception encountered when resetting media player") - } - } - - private fun handleErrorNext(x: Exception) { - Timber.w(x, "Next Media player error") - nextMediaPlayer!!.reset() - } - - private fun postRunnable(runnable: Runnable?) { - if (runnable != null) { - val mainHandler = Handler(context.mainLooper) - val myRunnable = Runnable { runnable.run() } - mainHandler.post(myRunnable) - } - } -} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerController.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerController.kt index 9e17fde5..749e30e0 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerController.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerController.kt @@ -6,20 +6,35 @@ */ package org.moire.ultrasonic.service +import android.content.ComponentName +import android.content.Context import android.content.Intent +import androidx.core.net.toUri +import androidx.media3.common.MediaItem +import androidx.media3.common.MediaMetadata +import androidx.media3.common.Player +import androidx.media3.common.Timeline +import androidx.media3.session.MediaController +import androidx.media3.session.SessionToken +import com.google.common.util.concurrent.MoreExecutors +import io.reactivex.rxjava3.disposables.CompositeDisposable +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent import org.koin.core.component.inject import org.moire.ultrasonic.app.UApp import org.moire.ultrasonic.data.ActiveServerProvider -import org.moire.ultrasonic.domain.PlayerState -import org.moire.ultrasonic.domain.RepeatMode import org.moire.ultrasonic.domain.Track -import org.moire.ultrasonic.service.MediaPlayerService.Companion.executeOnStartedMediaPlayerService -import org.moire.ultrasonic.service.MediaPlayerService.Companion.getInstance -import org.moire.ultrasonic.service.MediaPlayerService.Companion.runningInstance +import org.moire.ultrasonic.playback.LegacyPlaylistManager +import org.moire.ultrasonic.playback.PlaybackService +import org.moire.ultrasonic.provider.UltrasonicAppWidgetProvider4X1 +import org.moire.ultrasonic.provider.UltrasonicAppWidgetProvider4X2 +import org.moire.ultrasonic.provider.UltrasonicAppWidgetProvider4X3 +import org.moire.ultrasonic.provider.UltrasonicAppWidgetProvider4X4 import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService +import org.moire.ultrasonic.util.FileUtil import org.moire.ultrasonic.util.Settings -import org.moire.ultrasonic.util.ShufflePlayBuffer import timber.log.Timber /** @@ -32,8 +47,8 @@ class MediaPlayerController( private val playbackStateSerializer: PlaybackStateSerializer, private val externalStorageMonitor: ExternalStorageMonitor, private val downloader: Downloader, - private val shufflePlayBuffer: ShufflePlayBuffer, - private val localMediaPlayer: LocalMediaPlayer + private val legacyPlaylistManager: LegacyPlaylistManager, + val context: Context ) : KoinComponent { private var created = false @@ -42,22 +57,192 @@ class MediaPlayerController( var showVisualization = false private var autoPlayStart = false + private val scrobbler = Scrobbler() + private val jukeboxMediaPlayer: JukeboxMediaPlayer by inject() private val activeServerProvider: ActiveServerProvider by inject() - fun onCreate() { + private val rxBusSubscription: CompositeDisposable = CompositeDisposable() + + private var mainScope = CoroutineScope(Dispatchers.Main) + + private var sessionToken = + SessionToken(context, ComponentName(context, PlaybackService::class.java)) + + private var mediaControllerFuture = MediaController.Builder( + context, + sessionToken + ).buildAsync() + + var controller: MediaController? = null + + private lateinit var listeners: Player.Listener + + fun onCreate(onCreated: () -> Unit) { if (created) return externalStorageMonitor.onCreate { reset() } isJukeboxEnabled = activeServerProvider.getActiveServer().jukeboxByDefault + + mediaControllerFuture.addListener({ + controller = mediaControllerFuture.get() + + Timber.i("MediaController Instance received") + + listeners = object : Player.Listener { + + /* + * Log all events + */ + override fun onEvents(player: Player, events: Player.Events) { + for (i in 0 until events.size()) { + Timber.i("Media3 Event, event type: %s", events[i]) + } + } + + /* + * This will be called everytime the playlist has changed. + * We run the event through RxBus in order to throttle them + */ + override fun onTimelineChanged(timeline: Timeline, reason: Int) { + legacyPlaylistManager.rebuildPlaylist(controller!!) + } + + override fun onPlaybackStateChanged(playbackState: Int) { + playerStateChangedHandler() + publishPlaybackState() + } + + override fun onIsPlayingChanged(isPlaying: Boolean) { + playerStateChangedHandler() + publishPlaybackState() + } + + override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { + onTrackCompleted() + legacyPlaylistManager.updateCurrentPlaying(mediaItem) + publishPlaybackState() + } + + /* + * If the same item is contained in a playlist multiple times directly after each + * other, Media3 on emits a PositionDiscontinuity event. + * Can be removed if https://github.com/androidx/media/issues/68 is fixed. + */ + override fun onPositionDiscontinuity( + oldPosition: Player.PositionInfo, + newPosition: Player.PositionInfo, + reason: Int + ) { + playerStateChangedHandler() + publishPlaybackState() + } + } + + controller?.addListener(listeners) + + onCreated() + + Timber.i("MediaPlayerController creation complete") + + // controller?.play() + }, MoreExecutors.directExecutor()) + + rxBusSubscription += RxBus.activeServerChangeObservable.subscribe { + // Update the Jukebox state when the active server has changed + isJukeboxEnabled = activeServerProvider.getActiveServer().jukeboxByDefault + } + + rxBusSubscription += RxBus.throttledPlaylistObservable.subscribe { + // Even though Rx should launch on the main thread it doesn't always :( + mainScope.launch { + serializeCurrentSession() + } + } + + rxBusSubscription += RxBus.throttledPlayerStateObservable.subscribe { + // Even though Rx should launch on the main thread it doesn't always :( + mainScope.launch { + serializeCurrentSession() + } + } + + rxBusSubscription += RxBus.stopCommandObservable.subscribe { + // Clear the widget when we stop the service + updateWidget(null) + } + created = true - Timber.i("MediaPlayerController created") + Timber.i("MediaPlayerController started") + } + + private fun playerStateChangedHandler() { + + val currentPlaying = legacyPlaylistManager.currentPlaying + + when (playbackState) { + Player.STATE_READY -> { + if (isPlaying) { + scrobbler.scrobble(currentPlaying, false) + } + } + Player.STATE_ENDED -> { + scrobbler.scrobble(currentPlaying, true) + } + } + + // Update widget + if (currentPlaying != null) { + updateWidget(currentPlaying.track) + } + } + + private fun onTrackCompleted() { + // This method is called before we update the currentPlaying, + // so in fact currentPlaying will refer to the track that has just finished. + if (legacyPlaylistManager.currentPlaying != null) { + val song = legacyPlaylistManager.currentPlaying!!.track + if (song.bookmarkPosition > 0 && Settings.shouldClearBookmark) { + val musicService = getMusicService() + try { + musicService.deleteBookmark(song.id) + } catch (ignored: Exception) { + } + } + } + } + + private fun publishPlaybackState() { + val newState = RxBus.StateWithTrack( + track = legacyPlaylistManager.currentPlaying, + index = currentMediaItemIndex, + isPlaying = isPlaying, + state = playbackState + ) + RxBus.playerStatePublisher.onNext(newState) + Timber.i("New PlaybackState: %s", newState) + } + + private fun updateWidget(song: Track?) { + val context = UApp.applicationContext() + + UltrasonicAppWidgetProvider4X1.instance?.notifyChange(context, song, isPlaying, false) + UltrasonicAppWidgetProvider4X2.instance?.notifyChange(context, song, isPlaying, true) + UltrasonicAppWidgetProvider4X3.instance?.notifyChange(context, song, isPlaying, false) + UltrasonicAppWidgetProvider4X4.instance?.notifyChange(context, song, isPlaying, false) } fun onDestroy() { if (!created) return + + // First stop listening to events + rxBusSubscription.dispose() + controller?.removeListener(listeners) + + // Shutdown the rest val context = UApp.applicationContext() externalStorageMonitor.onDestroy() - context.stopService(Intent(context, MediaPlayerService::class.java)) + context.stopService(Intent(context, DownloadService::class.java)) + legacyPlaylistManager.onDestroy() downloader.onDestroy() created = false Timber.i("MediaPlayerController destroyed") @@ -65,140 +250,144 @@ class MediaPlayerController( @Synchronized fun restore( - songs: List?, + songs: List, currentPlayingIndex: Int, currentPlayingPosition: Int, autoPlay: Boolean, newPlaylist: Boolean ) { + val insertionMode = if (newPlaylist) InsertionMode.CLEAR + else InsertionMode.APPEND + addToPlaylist( songs, - save = false, + cachePermanently = false, autoPlay = false, - playNext = false, shuffle = false, - newPlaylist = newPlaylist + insertionMode = insertionMode ) - if (currentPlayingIndex != -1) { - executeOnStartedMediaPlayerService { mediaPlayerService: MediaPlayerService -> - mediaPlayerService.play(currentPlayingIndex, autoPlayStart) - if (localMediaPlayer.currentPlaying != null) { - if (autoPlay && jukeboxMediaPlayer.isEnabled) { - jukeboxMediaPlayer.skip( - downloader.currentPlayingIndex, - currentPlayingPosition / 1000 - ) - } else { - if (localMediaPlayer.currentPlaying!!.isCompleteFileAvailable) { - localMediaPlayer.play( - localMediaPlayer.currentPlaying, - currentPlayingPosition, - autoPlay - ) - } - } - } - autoPlayStart = false - } - } - } - @Synchronized - fun preload() { - getInstance() + if (currentPlayingIndex != -1) { + if (jukeboxMediaPlayer.isEnabled) { + jukeboxMediaPlayer.skip( + currentPlayingIndex, + currentPlayingPosition / 1000 + ) + } else { + seekTo(currentPlayingIndex, currentPlayingPosition) + } + + prepare() + + if (autoPlay) { + play() + } + + autoPlayStart = false + } } @Synchronized fun play(index: Int) { - executeOnStartedMediaPlayerService { service: MediaPlayerService -> - service.play(index, true) - } + controller?.seekTo(index, 0L) + controller?.play() } @Synchronized fun play() { - executeOnStartedMediaPlayerService { service: MediaPlayerService -> - service.play() + if (jukeboxMediaPlayer.isEnabled) { + jukeboxMediaPlayer.start() + } else { + controller?.prepare() + controller?.play() } } + @Synchronized + fun prepare() { + controller?.prepare() + } + @Synchronized fun resumeOrPlay() { - executeOnStartedMediaPlayerService { service: MediaPlayerService -> - service.resumeOrPlay() - } + controller?.play() } @Synchronized fun togglePlayPause() { - if (localMediaPlayer.playerState === PlayerState.IDLE) autoPlayStart = true - executeOnStartedMediaPlayerService { service: MediaPlayerService -> - service.togglePlayPause() - } - } - - @Synchronized - fun start() { - executeOnStartedMediaPlayerService { service: MediaPlayerService -> - service.start() + if (playbackState == Player.STATE_IDLE) autoPlayStart = true + if (controller?.isPlaying == true) { + controller?.pause() + } else { + controller?.play() } } @Synchronized fun seekTo(position: Int) { - val mediaPlayerService = runningInstance - mediaPlayerService?.seekTo(position) + Timber.i("SeekTo: %s", position) + controller?.seekTo(position.toLong()) + } + + @Synchronized + fun seekTo(index: Int, position: Int) { + Timber.i("SeekTo: %s %s", index, position) + controller?.seekTo(index, position.toLong()) } @Synchronized fun pause() { - val mediaPlayerService = runningInstance - mediaPlayerService?.pause() + if (jukeboxMediaPlayer.isEnabled) { + jukeboxMediaPlayer.stop() + } else { + controller?.pause() + } } @Synchronized fun stop() { - val mediaPlayerService = runningInstance - mediaPlayerService?.stop() + if (jukeboxMediaPlayer.isEnabled) { + jukeboxMediaPlayer.stop() + } else { + controller?.stop() + } } @Synchronized - @Suppress("LongParameterList") fun addToPlaylist( - songs: List?, - save: Boolean, + songs: List, + cachePermanently: Boolean, autoPlay: Boolean, - playNext: Boolean, shuffle: Boolean, - newPlaylist: Boolean + insertionMode: InsertionMode ) { - if (songs == null) return - val filteredSongs = songs.filterNotNull() - downloader.addToPlaylist(filteredSongs, save, autoPlay, playNext, newPlaylist) - jukeboxMediaPlayer.updatePlaylist() - if (shuffle) shuffle() - val isLastTrack = (downloader.getPlaylist().size - 1 == downloader.currentPlayingIndex) + var insertAt = 0 - if (!playNext && !autoPlay && isLastTrack) { - val mediaPlayerService = runningInstance - mediaPlayerService?.setNextPlaying() + when (insertionMode) { + InsertionMode.CLEAR -> clear() + InsertionMode.APPEND -> insertAt = mediaItemCount + InsertionMode.AFTER_CURRENT -> insertAt = currentMediaItemIndex + 1 } + val mediaItems: List = songs.map { + val downloadFile = downloader.getDownloadFileForSong(it) + if (cachePermanently) downloadFile.shouldSave = true + val result = it.toMediaItem() + legacyPlaylistManager.addToCache(result, downloader.getDownloadFileForSong(it)) + result + } + + controller?.addMediaItems(insertAt, mediaItems) + + jukeboxMediaPlayer.updatePlaylist() + + if (shuffle) isShufflePlayEnabled = true + + prepare() + if (autoPlay) { play(0) - } else { - if (localMediaPlayer.currentPlaying == null && downloader.getPlaylist().isNotEmpty()) { - localMediaPlayer.currentPlaying = downloader.getPlaylist()[0] - downloader.getPlaylist()[0].setPlaying(true) - } - downloader.checkDownloads() } - - playbackStateSerializer.serialize( - downloader.getPlaylist(), - downloader.currentPlayingIndex, - playerPosition - ) } @Synchronized @@ -206,17 +395,6 @@ class MediaPlayerController( if (songs == null) return val filteredSongs = songs.filterNotNull() downloader.downloadBackground(filteredSongs, save) - playbackStateSerializer.serialize( - downloader.getPlaylist(), - downloader.currentPlayingIndex, - playerPosition - ) - } - - @Synchronized - fun setCurrentPlaying(index: Int) { - val mediaPlayerService = runningInstance - mediaPlayerService?.setCurrentPlaying(index) } fun stopJukeboxService() { @@ -225,58 +403,47 @@ class MediaPlayerController( @set:Synchronized var isShufflePlayEnabled: Boolean - get() = shufflePlayBuffer.isEnabled + get() = controller?.shuffleModeEnabled == true set(enabled) { - shufflePlayBuffer.isEnabled = enabled + controller?.shuffleModeEnabled = enabled if (enabled) { - clear() downloader.checkDownloads() } } @Synchronized - fun shuffle() { - downloader.shuffle() - playbackStateSerializer.serialize( - downloader.getPlaylist(), - downloader.currentPlayingIndex, - playerPosition - ) - jukeboxMediaPlayer.updatePlaylist() - val mediaPlayerService = runningInstance - mediaPlayerService?.setNextPlaying() + fun toggleShuffle(): Boolean { + isShufflePlayEnabled = !isShufflePlayEnabled + return isShufflePlayEnabled } + val bufferedPercentage: Int + get() = controller?.bufferedPercentage ?: 0 + @Synchronized fun moveItemInPlaylist(oldPos: Int, newPos: Int) { - downloader.moveItemInPlaylist(oldPos, newPos) + controller?.moveMediaItem(oldPos, newPos) } @set:Synchronized - var repeatMode: RepeatMode - get() = Settings.repeatMode - set(repeatMode) { - Settings.repeatMode = repeatMode - val mediaPlayerService = runningInstance - mediaPlayerService?.setNextPlaying() + var repeatMode: Int + get() = controller?.repeatMode ?: 0 + set(newMode) { + controller?.repeatMode = newMode } @Synchronized @JvmOverloads fun clear(serialize: Boolean = true) { - val mediaPlayerService = runningInstance - if (mediaPlayerService != null) { - mediaPlayerService.clear(serialize) - } else { - // If no MediaPlayerService is available, just empty the playlist - downloader.clearPlaylist() - if (serialize) { - playbackStateSerializer.serialize( - downloader.getPlaylist(), - downloader.currentPlayingIndex, playerPosition - ) - } + + controller?.clearMediaItems() + + if (controller != null && serialize) { + playbackStateSerializer.serialize( + listOf(), -1, 0 + ) } + jukeboxMediaPlayer.updatePlaylist() } @@ -289,38 +456,30 @@ class MediaPlayerController( fun clearIncomplete() { reset() - downloader.clearIncomplete() - - playbackStateSerializer.serialize( - downloader.getPlaylist(), - downloader.currentPlayingIndex, - playerPosition - ) + downloader.clearActiveDownloads() + downloader.clearBackground() jukeboxMediaPlayer.updatePlaylist() } @Synchronized - // TODO: If a playlist contains an item twice, this call will wrongly remove all - fun removeFromPlaylist(downloadFile: DownloadFile) { - if (downloadFile == localMediaPlayer.currentPlaying) { - reset() - currentPlaying = null - } - downloader.removeFromPlaylist(downloadFile) + fun removeFromPlaylist(position: Int) { - playbackStateSerializer.serialize( - downloader.getPlaylist(), - downloader.currentPlayingIndex, - playerPosition - ) + controller?.removeMediaItem(position) jukeboxMediaPlayer.updatePlaylist() + } - if (downloadFile == localMediaPlayer.nextPlaying) { - val mediaPlayerService = runningInstance - mediaPlayerService?.setNextPlaying() - } + @Synchronized + private fun serializeCurrentSession() { + // Don't serialize invalid sessions + if (currentMediaItemIndex == -1) return + + playbackStateSerializer.serialize( + legacyPlaylistManager.playlist, + currentMediaItemIndex, + playerPosition + ) } @Synchronized @@ -341,80 +500,52 @@ class MediaPlayerController( @Synchronized fun previous() { - val index = downloader.currentPlayingIndex - if (index == -1) { - return - } - - // Restart song if played more than five seconds. - @Suppress("MagicNumber") - if (playerPosition > 5000 || index == 0) { - play(index) - } else { - play(index - 1) - } + controller?.seekToPrevious() } @Synchronized operator fun next() { - val index = downloader.currentPlayingIndex - if (index != -1) { - when (repeatMode) { - RepeatMode.SINGLE, RepeatMode.OFF -> { - // Play next if exists - if (index + 1 >= 0 && index + 1 < downloader.getPlaylist().size) { - play(index + 1) - } - } - RepeatMode.ALL -> { - play((index + 1) % downloader.getPlaylist().size) - } - else -> { - } - } - } + controller?.seekToNext() } @Synchronized fun reset() { - val mediaPlayerService = runningInstance - if (mediaPlayerService != null) localMediaPlayer.reset() + controller?.clearMediaItems() } @get:Synchronized val playerPosition: Int get() { - val mediaPlayerService = runningInstance ?: return 0 - return mediaPlayerService.playerPosition + return if (jukeboxMediaPlayer.isEnabled) { + jukeboxMediaPlayer.positionSeconds * 1000 + } else { + controller?.currentPosition?.toInt() ?: 0 + } } @get:Synchronized val playerDuration: Int get() { - val mediaPlayerService = runningInstance ?: return 0 - return mediaPlayerService.playerDuration + return controller?.duration?.toInt() ?: return 0 } - @set:Synchronized - var playerState: PlayerState - get() = localMediaPlayer.playerState - set(state) { - val mediaPlayerService = runningInstance - if (mediaPlayerService != null) - localMediaPlayer.setPlayerState(state, localMediaPlayer.currentPlaying) - } + val playbackState: Int + get() = controller?.playbackState ?: 0 + + val isPlaying: Boolean + get() = controller?.isPlaying ?: false @set:Synchronized var isJukeboxEnabled: Boolean get() = jukeboxMediaPlayer.isEnabled set(jukeboxEnabled) { jukeboxMediaPlayer.isEnabled = jukeboxEnabled - playerState = PlayerState.IDLE + if (jukeboxEnabled) { jukeboxMediaPlayer.startJukeboxService() reset() - // Cancel current download, if necessary. + // Cancel current downloads downloader.clearActiveDownloads() } else { jukeboxMediaPlayer.stopJukeboxService() @@ -441,19 +572,12 @@ class MediaPlayerController( } fun setVolume(volume: Float) { - if (runningInstance != null) localMediaPlayer.setVolume(volume) - } - - private fun updateNotification() { - runningInstance?.updateNotification( - localMediaPlayer.playerState, - localMediaPlayer.currentPlaying - ) + controller?.volume = volume } fun toggleSongStarred() { - if (localMediaPlayer.currentPlaying == null) return - val song = localMediaPlayer.currentPlaying!!.track + if (legacyPlaylistManager.currentPlaying == null) return + val song = legacyPlaylistManager.currentPlaying!!.track Thread { val musicService = getMusicService() @@ -469,15 +593,16 @@ class MediaPlayerController( }.start() // Trigger an update - localMediaPlayer.setCurrentPlaying(localMediaPlayer.currentPlaying) + // TODO Update Metadata of MediaItem... + // localMediaPlayer.setCurrentPlaying(localMediaPlayer.currentPlaying) song.starred = !song.starred } @Suppress("TooGenericExceptionCaught") // The interface throws only generic exceptions fun setSongRating(rating: Int) { if (!Settings.useFiveStarRating) return - if (localMediaPlayer.currentPlaying == null) return - val song = localMediaPlayer.currentPlaying!!.track + if (legacyPlaylistManager.currentPlaying == null) return + val song = legacyPlaylistManager.currentPlaying!!.track song.userRating = rating Thread { try { @@ -487,33 +612,64 @@ class MediaPlayerController( } }.start() // TODO this would be better handled with a Rx command - updateNotification() + // updateNotification() } - @set:Synchronized - var currentPlaying: DownloadFile? - get() = localMediaPlayer.currentPlaying - set(currentPlaying) { - if (runningInstance != null) localMediaPlayer.setCurrentPlaying(currentPlaying) - } + val currentMediaItem: MediaItem? + get() = controller?.currentMediaItem + val currentMediaItemIndex: Int + get() = controller?.currentMediaItemIndex ?: -1 + + @Deprecated("Use currentMediaItem") + val currentPlayingLegacy: DownloadFile? + get() = legacyPlaylistManager.currentPlaying + + val mediaItemCount: Int + get() = controller?.mediaItemCount ?: 0 + + @Deprecated("Use mediaItemCount") val playlistSize: Int - get() = downloader.getPlaylist().size - - val currentPlayingNumberOnPlaylist: Int - get() = downloader.currentPlayingIndex + get() = legacyPlaylistManager.playlist.size + @Deprecated("Use native APIs") val playList: List - get() = downloader.getPlaylist() + get() = legacyPlaylistManager.playlist + @Deprecated("Use timeline") val playListDuration: Long - get() = downloader.downloadListDuration + get() = legacyPlaylistManager.playlistDuration fun getDownloadFileForSong(song: Track): DownloadFile { return downloader.getDownloadFileForSong(song) } init { - Timber.i("MediaPlayerController constructed") + Timber.i("MediaPlayerController instance initiated") + } + + enum class InsertionMode { + CLEAR, APPEND, AFTER_CURRENT } } + +fun Track.toMediaItem(): MediaItem { + + val filePath = FileUtil.getSongFile(this) + val bitrate = Settings.maxBitRate + val uri = "$id|$bitrate|$filePath" + + val metadata = MediaMetadata.Builder() + metadata.setTitle(title) + .setArtist(artist) + .setAlbumTitle(album) + .setMediaUri(uri.toUri()) + .setAlbumArtist(artist) + + val mediaItem = MediaItem.Builder() + .setUri(uri) + .setMediaId(id) + .setMediaMetadata(metadata.build()) + + return mediaItem.build() +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerLifecycleSupport.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerLifecycleSupport.kt index 87288df8..2700747b 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerLifecycleSupport.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerLifecycleSupport.kt @@ -13,12 +13,9 @@ import android.content.Intent import android.content.IntentFilter import android.media.AudioManager import android.view.KeyEvent -import io.reactivex.rxjava3.disposables.Disposable import org.koin.core.component.KoinComponent import org.koin.core.component.inject -import org.moire.ultrasonic.R import org.moire.ultrasonic.app.UApp.Companion.applicationContext -import org.moire.ultrasonic.domain.PlayerState import org.moire.ultrasonic.util.CacheCleaner import org.moire.ultrasonic.util.Constants import org.moire.ultrasonic.util.Settings @@ -27,60 +24,51 @@ import timber.log.Timber /** * This class is responsible for handling received events for the Media Player implementation - * - * @author Sindre Mehus */ class MediaPlayerLifecycleSupport : KoinComponent { private val playbackStateSerializer by inject() private val mediaPlayerController by inject() - private val downloader by inject() private var created = false private var headsetEventReceiver: BroadcastReceiver? = null - private var mediaButtonEventSubscription: Disposable? = null fun onCreate() { onCreate(false, null) } - private fun onCreate(autoPlay: Boolean, afterCreated: Runnable?) { + private fun onCreate(autoPlay: Boolean, afterRestore: Runnable?) { if (created) { - afterCreated?.run() + afterRestore?.run() return } - mediaButtonEventSubscription = RxBus.mediaButtonEventObservable.subscribe { - handleKeyEvent(it) + mediaPlayerController.onCreate { + restoreLastSession(autoPlay, afterRestore) } registerHeadsetReceiver() - mediaPlayerController.onCreate() - if (autoPlay) mediaPlayerController.preload() + CacheCleaner().clean() + created = true + Timber.i("LifecycleSupport created") + } + + private fun restoreLastSession(autoPlay: Boolean, afterRestore: Runnable?) { playbackStateSerializer.deserialize { + Timber.i("Restoring %s songs", it!!.songs.size) + mediaPlayerController.restore( - it!!.songs, + it.songs, it.currentPlayingIndex, it.currentPlayingPosition, autoPlay, false ) - // Work-around: Serialize again, as the restore() method creates a - // serialization without current playing info. - playbackStateSerializer.serialize( - downloader.getPlaylist(), - downloader.currentPlayingIndex, - mediaPlayerController.playerPosition - ) - afterCreated?.run() + afterRestore?.run() } - - CacheCleaner().clean() - created = true - Timber.i("LifecycleSupport created") } fun onDestroy() { @@ -88,13 +76,14 @@ class MediaPlayerLifecycleSupport : KoinComponent { if (!created) return playbackStateSerializer.serializeNow( - downloader.getPlaylist(), - downloader.currentPlayingIndex, + mediaPlayerController.playList, + mediaPlayerController.currentMediaItemIndex, mediaPlayerController.playerPosition ) mediaPlayerController.clear(false) - mediaButtonEventSubscription?.dispose() + RxBus.shutdownCommandPublisher.onNext(Unit) + applicationContext().unregisterReceiver(headsetEventReceiver) mediaPlayerController.onDestroy() @@ -129,11 +118,6 @@ class MediaPlayerLifecycleSupport : KoinComponent { */ private fun registerHeadsetReceiver() { - val sp = Settings.preferences - val context = applicationContext() - val spKey = context - .getString(R.string.settings_playback_resume_play_on_headphones_plug) - headsetEventReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { val extras = intent.extras ?: return @@ -148,12 +132,10 @@ class MediaPlayerLifecycleSupport : KoinComponent { } } else if (state == 1) { if (!mediaPlayerController.isJukeboxEnabled && - sp.getBoolean( - spKey, - false - ) && mediaPlayerController.playerState === PlayerState.PAUSED + Settings.resumePlayOnHeadphonePlug && !mediaPlayerController.isPlaying ) { - mediaPlayerController.start() + mediaPlayerController.prepare() + mediaPlayerController.play() } } } @@ -169,18 +151,7 @@ class MediaPlayerLifecycleSupport : KoinComponent { if (event.action != KeyEvent.ACTION_DOWN || event.repeatCount > 0) return - val keyCode: Int - val receivedKeyCode = event.keyCode - - // Translate PLAY and PAUSE codes to PLAY_PAUSE to improve compatibility with old Bluetooth devices - keyCode = if (Settings.singleButtonPlayPause && ( - receivedKeyCode == KeyEvent.KEYCODE_MEDIA_PLAY || - receivedKeyCode == KeyEvent.KEYCODE_MEDIA_PAUSE - ) - ) { - Timber.i("Single button Play/Pause is set, rewriting keyCode to PLAY_PAUSE") - KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE - } else receivedKeyCode + val keyCode: Int = event.keyCode val autoStart = keyCode == KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE || @@ -197,14 +168,7 @@ class MediaPlayerLifecycleSupport : KoinComponent { KeyEvent.KEYCODE_MEDIA_PREVIOUS -> mediaPlayerController.previous() KeyEvent.KEYCODE_MEDIA_NEXT -> mediaPlayerController.next() KeyEvent.KEYCODE_MEDIA_STOP -> mediaPlayerController.stop() - - KeyEvent.KEYCODE_MEDIA_PLAY -> - if (mediaPlayerController.playerState === PlayerState.IDLE) { - mediaPlayerController.play() - } else if (mediaPlayerController.playerState !== PlayerState.STARTED) { - mediaPlayerController.start() - } - + KeyEvent.KEYCODE_MEDIA_PLAY -> mediaPlayerController.play() KeyEvent.KEYCODE_MEDIA_PAUSE -> mediaPlayerController.pause() KeyEvent.KEYCODE_1 -> mediaPlayerController.setSongRating(1) KeyEvent.KEYCODE_2 -> mediaPlayerController.setSongRating(2) @@ -222,28 +186,23 @@ class MediaPlayerLifecycleSupport : KoinComponent { * This function processes the intent that could come from other applications. */ @Suppress("ComplexMethod") - private fun handleUltrasonicIntent(intentAction: String) { + private fun handleUltrasonicIntent(action: String) { val isRunning = created // If Ultrasonic is not running, do nothing to stop or pause - if ( - !isRunning && ( - intentAction == Constants.CMD_PAUSE || - intentAction == Constants.CMD_STOP - ) - ) return + if (!isRunning && (action == Constants.CMD_PAUSE || action == Constants.CMD_STOP)) + return - val autoStart = - intentAction == Constants.CMD_PLAY || - intentAction == Constants.CMD_RESUME_OR_PLAY || - intentAction == Constants.CMD_TOGGLEPAUSE || - intentAction == Constants.CMD_PREVIOUS || - intentAction == Constants.CMD_NEXT + val autoStart = action == Constants.CMD_PLAY || + action == Constants.CMD_RESUME_OR_PLAY || + action == Constants.CMD_TOGGLEPAUSE || + action == Constants.CMD_PREVIOUS || + action == Constants.CMD_NEXT // We can receive intents when everything is stopped, so we need to start onCreate(autoStart) { - when (intentAction) { + when (action) { Constants.CMD_PLAY -> mediaPlayerController.play() Constants.CMD_RESUME_OR_PLAY -> // If Ultrasonic wasn't running, the autoStart is enough to resume, @@ -253,12 +212,7 @@ class MediaPlayerLifecycleSupport : KoinComponent { Constants.CMD_NEXT -> mediaPlayerController.next() Constants.CMD_PREVIOUS -> mediaPlayerController.previous() Constants.CMD_TOGGLEPAUSE -> mediaPlayerController.togglePlayPause() - - Constants.CMD_STOP -> { - // TODO: There is a stop() function, shouldn't we use that? - mediaPlayerController.pause() - mediaPlayerController.seekTo(0) - } + Constants.CMD_STOP -> mediaPlayerController.stop() Constants.CMD_PAUSE -> mediaPlayerController.pause() } } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerService.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerService.kt deleted file mode 100644 index 55951d8e..00000000 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerService.kt +++ /dev/null @@ -1,769 +0,0 @@ -/* - * MediaPlayerService.kt - * Copyright (C) 2009-2021 Ultrasonic developers - * - * Distributed under terms of the GNU GPLv3 license. - */ - -package org.moire.ultrasonic.service - -import android.app.Notification -import android.app.NotificationChannel -import android.app.NotificationManager -import android.app.PendingIntent -import android.app.Service -import android.content.Context -import android.content.Intent -import android.os.Build -import android.os.Handler -import android.os.IBinder -import android.os.Looper -import android.support.v4.media.session.MediaSessionCompat -import android.view.KeyEvent -import androidx.core.app.NotificationCompat -import androidx.core.app.NotificationManagerCompat -import io.reactivex.rxjava3.disposables.CompositeDisposable -import kotlin.collections.ArrayList -import org.koin.android.ext.android.inject -import org.moire.ultrasonic.R -import org.moire.ultrasonic.activity.NavigationActivity -import org.moire.ultrasonic.app.UApp -import org.moire.ultrasonic.domain.PlayerState -import org.moire.ultrasonic.domain.RepeatMode -import org.moire.ultrasonic.domain.Track -import org.moire.ultrasonic.imageloader.BitmapUtils -import org.moire.ultrasonic.provider.UltrasonicAppWidgetProvider4X1 -import org.moire.ultrasonic.provider.UltrasonicAppWidgetProvider4X2 -import org.moire.ultrasonic.provider.UltrasonicAppWidgetProvider4X3 -import org.moire.ultrasonic.provider.UltrasonicAppWidgetProvider4X4 -import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService -import org.moire.ultrasonic.util.Constants -import org.moire.ultrasonic.util.MediaSessionHandler -import org.moire.ultrasonic.util.Settings -import org.moire.ultrasonic.util.ShufflePlayBuffer -import org.moire.ultrasonic.util.SimpleServiceBinder -import org.moire.ultrasonic.util.Util -import timber.log.Timber - -/** - * Android Foreground Service for playing music - * while the rest of the Ultrasonic App is in the background. - * - * "A foreground service is a service that the user is - * actively aware of and isn’t a candidate for the system to kill when low on memory." - */ -@Suppress("LargeClass") -class MediaPlayerService : Service() { - private val binder: IBinder = SimpleServiceBinder(this) - private val scrobbler = Scrobbler() - - private val jukeboxMediaPlayer by inject() - private val playbackStateSerializer by inject() - private val shufflePlayBuffer by inject() - private val downloader by inject() - private val localMediaPlayer by inject() - private val mediaSessionHandler by inject() - - private var mediaSession: MediaSessionCompat? = null - private var mediaSessionToken: MediaSessionCompat.Token? = null - private var isInForeground = false - private var notificationBuilder: NotificationCompat.Builder? = null - private var rxBusSubscription: CompositeDisposable = CompositeDisposable() - - private var currentPlayerState: PlayerState? = null - private var currentTrack: DownloadFile? = null - - override fun onBind(intent: Intent): IBinder { - return binder - } - - override fun onCreate() { - super.onCreate() - - shufflePlayBuffer.onCreate() - localMediaPlayer.init() - - setupOnSongCompletedHandler() - - localMediaPlayer.onPrepared = { - playbackStateSerializer.serialize( - downloader.getPlaylist(), - downloader.currentPlayingIndex, - playerPosition - ) - null - } - - localMediaPlayer.onNextSongRequested = Runnable { setNextPlaying() } - - // Create Notification Channel - createNotificationChannel() - - // Update notification early. It is better to show an empty one temporarily - // than waiting too long and letting Android kill the app - updateNotification(PlayerState.IDLE, null) - - // Subscribing should be after updateNotification to avoid concurrency - rxBusSubscription += RxBus.playerStateObservable.subscribe { - playerStateChangedHandler(it.state, it.track) - } - - rxBusSubscription += RxBus.mediaSessionTokenObservable.subscribe { - mediaSessionToken = it - } - - rxBusSubscription += RxBus.skipToQueueItemCommandObservable.subscribe { - play(it.toInt()) - } - - mediaSessionHandler.initialize() - - instance = this - Timber.i("MediaPlayerService created") - } - - override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int { - super.onStartCommand(intent, flags, startId) - return START_NOT_STICKY - } - - override fun onDestroy() { - super.onDestroy() - instance = null - try { - mediaSessionHandler.release() - rxBusSubscription.dispose() - - localMediaPlayer.release() - downloader.stop() - shufflePlayBuffer.onDestroy() - - mediaSession?.release() - mediaSession = null - } catch (ignored: Throwable) { - } - Timber.i("MediaPlayerService stopped") - } - - private fun stopIfIdle() { - synchronized(instanceLock) { - // currentPlaying could be changed from another thread in the meantime, - // so check again before stopping for good - if (localMediaPlayer.currentPlaying == null || - localMediaPlayer.playerState === PlayerState.STOPPED - ) { - stopSelf() - } - } - } - - fun notifyDownloaderStopped() { - // TODO It would be nice to know if the service really can be stopped instead of just - // checking if it is idle once... - val handler = Handler(Looper.getMainLooper()) - handler.postDelayed({ stopIfIdle() }, 1000) - } - - @Synchronized - fun seekTo(position: Int) { - if (jukeboxMediaPlayer.isEnabled) { - // TODO These APIs should be more aligned - val seconds = position / 1000 - jukeboxMediaPlayer.skip(downloader.currentPlayingIndex, seconds) - } else { - localMediaPlayer.seekTo(position) - } - } - - @get:Synchronized - val playerPosition: Int - get() { - if (localMediaPlayer.playerState === PlayerState.IDLE || - localMediaPlayer.playerState === PlayerState.DOWNLOADING || - localMediaPlayer.playerState === PlayerState.PREPARING - ) { - return 0 - } - return if (jukeboxMediaPlayer.isEnabled) { - jukeboxMediaPlayer.positionSeconds * 1000 - } else { - localMediaPlayer.playerPosition - } - } - - @get:Synchronized - val playerDuration: Int - get() = localMediaPlayer.playerDuration - - @Synchronized - fun setCurrentPlaying(currentPlayingIndex: Int) { - try { - localMediaPlayer.setCurrentPlaying(downloader.getPlaylist()[currentPlayingIndex]) - } catch (ignored: IndexOutOfBoundsException) { - } - } - - @Synchronized - fun setNextPlaying() { - // Download the next few songs if necessary - downloader.checkDownloads() - - if (!Settings.gaplessPlayback) { - localMediaPlayer.clearNextPlaying(true) - return - } - - var index = downloader.currentPlayingIndex - - if (index != -1) { - when (Settings.repeatMode) { - RepeatMode.OFF -> index += 1 - RepeatMode.ALL -> index = (index + 1) % downloader.getPlaylist().size - RepeatMode.SINGLE -> { - } - else -> { - } - } - } - - localMediaPlayer.clearNextPlaying(false) - if (index < downloader.getPlaylist().size && index != -1) { - localMediaPlayer.setNextPlaying(downloader.getPlaylist()[index]) - } else { - localMediaPlayer.clearNextPlaying(true) - } - } - - @Synchronized - fun togglePlayPause() { - if (localMediaPlayer.playerState === PlayerState.PAUSED || - localMediaPlayer.playerState === PlayerState.COMPLETED || - localMediaPlayer.playerState === PlayerState.STOPPED - ) { - start() - } else if (localMediaPlayer.playerState === PlayerState.IDLE) { - play() - } else if (localMediaPlayer.playerState === PlayerState.STARTED) { - pause() - } - } - - @Synchronized - fun resumeOrPlay() { - if (localMediaPlayer.playerState === PlayerState.PAUSED || - localMediaPlayer.playerState === PlayerState.COMPLETED || - localMediaPlayer.playerState === PlayerState.STOPPED - ) { - start() - } else if (localMediaPlayer.playerState === PlayerState.IDLE) { - play() - } - } - - /** - * Plays either the current song (resume) or the first/next one in queue. - */ - @Synchronized - fun play() { - val current = downloader.currentPlayingIndex - if (current == -1) { - play(0) - } else { - play(current) - } - } - - @Synchronized - fun play(index: Int) { - play(index, true) - } - - @Synchronized - fun play(index: Int, start: Boolean) { - Timber.v("play requested for %d", index) - if (index < 0 || index >= downloader.getPlaylist().size) { - resetPlayback() - } else { - setCurrentPlaying(index) - if (start) { - if (jukeboxMediaPlayer.isEnabled) { - jukeboxMediaPlayer.skip(index, 0) - } else { - localMediaPlayer.play(downloader.getPlaylist()[index]) - } - } - setNextPlaying() - } - } - - @Synchronized - private fun resetPlayback() { - localMediaPlayer.reset() - localMediaPlayer.setCurrentPlaying(null) - playbackStateSerializer.serialize( - downloader.getPlaylist(), - downloader.currentPlayingIndex, playerPosition - ) - } - - @Synchronized - fun pause() { - if (localMediaPlayer.playerState === PlayerState.STARTED) { - if (jukeboxMediaPlayer.isEnabled) { - jukeboxMediaPlayer.stop() - } else { - localMediaPlayer.pause() - } - localMediaPlayer.setPlayerState(PlayerState.PAUSED, localMediaPlayer.currentPlaying) - } - } - - @Synchronized - fun stop() { - if (localMediaPlayer.playerState === PlayerState.STARTED) { - if (jukeboxMediaPlayer.isEnabled) { - jukeboxMediaPlayer.stop() - } else { - localMediaPlayer.pause() - } - } - localMediaPlayer.setPlayerState(PlayerState.STOPPED, null) - } - - @Synchronized - fun start() { - if (jukeboxMediaPlayer.isEnabled) { - jukeboxMediaPlayer.start() - } else { - localMediaPlayer.start() - } - localMediaPlayer.setPlayerState(PlayerState.STARTED, localMediaPlayer.currentPlaying) - } - - private fun updateWidget(playerState: PlayerState, song: Track?) { - val started = playerState === PlayerState.STARTED - val context = this@MediaPlayerService - - UltrasonicAppWidgetProvider4X1.getInstance().notifyChange(context, song, started, false) - UltrasonicAppWidgetProvider4X2.getInstance().notifyChange(context, song, started, true) - UltrasonicAppWidgetProvider4X3.getInstance().notifyChange(context, song, started, false) - UltrasonicAppWidgetProvider4X4.getInstance().notifyChange(context, song, started, false) - } - - private fun playerStateChangedHandler( - playerState: PlayerState, - currentPlaying: DownloadFile? - ) { - val context = this@MediaPlayerService - // AVRCP handles these separately so we must differentiate between the cases - val isStateChanged = playerState != currentPlayerState - val isTrackChanged = currentPlaying != currentTrack - if (!isStateChanged && !isTrackChanged) return - - val showWhenPaused = playerState !== PlayerState.STOPPED && - Settings.isNotificationAlwaysEnabled - - val show = playerState === PlayerState.STARTED || showWhenPaused - val song = currentPlaying?.track - - if (isStateChanged) { - when { - playerState === PlayerState.PAUSED -> { - playbackStateSerializer.serialize( - downloader.getPlaylist(), downloader.currentPlayingIndex, playerPosition - ) - } - playerState === PlayerState.STARTED -> { - scrobbler.scrobble(currentPlaying, false) - } - playerState === PlayerState.COMPLETED -> { - scrobbler.scrobble(currentPlaying, true) - } - } - - Util.broadcastPlaybackStatusChange(context, playerState) - Util.broadcastA2dpPlayStatusChange( - context, playerState, song, - downloader.getPlaylist().size, - downloader.getPlaylist().indexOf(currentPlaying) + 1, playerPosition - ) - } else { - // State didn't change, only the track - Util.broadcastA2dpMetaDataChange( - this@MediaPlayerService, playerPosition, currentPlaying, - downloader.all.size, downloader.currentPlayingIndex + 1 - ) - } - - if (isTrackChanged) { - Util.broadcastNewTrackInfo(this@MediaPlayerService, currentPlaying?.track) - } - - // Update widget - updateWidget(playerState, song) - - if (show) { - // Only update notification if player state is one that will change the icon - if (playerState === PlayerState.STARTED || playerState === PlayerState.PAUSED) { - updateNotification(playerState, currentPlaying) - } - } else { - stopForeground(true) - isInForeground = false - stopIfIdle() - } - - currentPlayerState = playerState - currentTrack = currentPlaying - - Timber.d("Processed player state change") - } - - private fun setupOnSongCompletedHandler() { - localMediaPlayer.onSongCompleted = { currentPlaying: DownloadFile? -> - val index = downloader.currentPlayingIndex - - if (currentPlaying != null) { - val song = currentPlaying.track - if (song.bookmarkPosition > 0 && Settings.shouldClearBookmark) { - val musicService = getMusicService() - try { - musicService.deleteBookmark(song.id) - } catch (ignored: Exception) { - } - } - } - if (index != -1) { - when (Settings.repeatMode) { - RepeatMode.OFF -> { - if (index + 1 < 0 || index + 1 >= downloader.getPlaylist().size) { - if (Settings.shouldClearPlaylist) { - clear(true) - jukeboxMediaPlayer.updatePlaylist() - } - resetPlayback() - } else { - play(index + 1) - } - } - RepeatMode.ALL -> { - play((index + 1) % downloader.getPlaylist().size) - } - RepeatMode.SINGLE -> play(index) - else -> { - } - } - } - null - } - } - - @Synchronized - fun clear(serialize: Boolean) { - localMediaPlayer.reset() - downloader.clearPlaylist() - localMediaPlayer.setCurrentPlaying(null) - setNextPlaying() - if (serialize) { - playbackStateSerializer.serialize( - downloader.getPlaylist(), - downloader.currentPlayingIndex, playerPosition - ) - } - } - - private fun createNotificationChannel() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - - // The suggested importance of a startForeground service notification is IMPORTANCE_LOW - val channel = NotificationChannel( - NOTIFICATION_CHANNEL_ID, - NOTIFICATION_CHANNEL_NAME, - NotificationManager.IMPORTANCE_LOW - ) - - channel.lightColor = android.R.color.holo_blue_dark - channel.lockscreenVisibility = Notification.VISIBILITY_PUBLIC - channel.setShowBadge(false) - - val manager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager - manager.createNotificationChannel(channel) - } - } - - fun updateNotification(playerState: PlayerState, currentPlaying: DownloadFile?) { - val notification = buildForegroundNotification(playerState, currentPlaying) - - if (Settings.isNotificationEnabled) { - if (isInForeground) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val manager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager - manager.notify(NOTIFICATION_ID, notification) - } else { - val manager = NotificationManagerCompat.from(this) - manager.notify(NOTIFICATION_ID, notification) - } - Timber.v("Updated notification") - } else { - startForeground(NOTIFICATION_ID, notification) - isInForeground = true - Timber.v("Created Foreground notification") - } - } - } - - /** - * This method builds a notification, reusing the Notification Builder if possible - */ - @Suppress("SpreadOperator") - private fun buildForegroundNotification( - playerState: PlayerState, - currentPlaying: DownloadFile? - ): Notification { - - // Init - val context = applicationContext - val song = currentPlaying?.track - val stopIntent = Util.getPendingIntentForMediaAction( - context, - KeyEvent.KEYCODE_MEDIA_STOP, - 100 - ) - - // We should use a single notification builder, otherwise the notification may not be updated - if (notificationBuilder == null) { - notificationBuilder = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID) - - // Set some values that never change - notificationBuilder!!.setSmallIcon(R.drawable.ic_stat_ultrasonic) - notificationBuilder!!.setAutoCancel(false) - notificationBuilder!!.setOngoing(true) - notificationBuilder!!.setOnlyAlertOnce(true) - notificationBuilder!!.setWhen(System.currentTimeMillis()) - notificationBuilder!!.setShowWhen(false) - notificationBuilder!!.setVisibility(NotificationCompat.VISIBILITY_PUBLIC) - notificationBuilder!!.priority = NotificationCompat.PRIORITY_LOW - - // Add content intent (when user taps on notification) - notificationBuilder!!.setContentIntent(getPendingIntentForContent()) - - // This intent is executed when the user closes the notification - notificationBuilder!!.setDeleteIntent(stopIntent) - } - - // Use the Media Style, to enable native Android support for playback notification - val style = androidx.media.app.NotificationCompat.MediaStyle() - - if (mediaSessionToken != null) { - style.setMediaSession(mediaSessionToken) - } - - // Clear old actions - notificationBuilder!!.clearActions() - - if (song != null) { - // Add actions - val compactActions = addActions(context, notificationBuilder!!, playerState, song) - // Configure shortcut actions - style.setShowActionsInCompactView(*compactActions) - notificationBuilder!!.setStyle(style) - - // Set song title, artist and cover - val iconSize = (256 * context.resources.displayMetrics.density).toInt() - val bitmap = BitmapUtils.getAlbumArtBitmapFromDisk(song, iconSize) - notificationBuilder!!.setContentTitle(song.title) - notificationBuilder!!.setContentText(song.artist) - notificationBuilder!!.setLargeIcon(bitmap) - notificationBuilder!!.setSubText(song.album) - } else if (downloader.started) { - // No song is playing, but Ultrasonic is downloading files - notificationBuilder!!.setContentTitle( - getString(R.string.notification_downloading_title) - ) - } - - return notificationBuilder!!.build() - } - - private fun addActions( - context: Context, - notificationBuilder: NotificationCompat.Builder, - playerState: PlayerState, - song: Track? - ): IntArray { - // Init - val compactActionList = ArrayList() - var numActions = 0 // we start and 0 and then increment by 1 for each call to generateAction - - // Star - if (song != null) { - notificationBuilder.addAction(generateStarAction(context, numActions, song.starred)) - } - numActions++ - - // Next - notificationBuilder.addAction(generateAction(context, numActions)) - compactActionList.add(numActions) - numActions++ - - // Play/Pause button - notificationBuilder.addAction(generatePlayPauseAction(context, numActions, playerState)) - compactActionList.add(numActions) - numActions++ - - // Previous - notificationBuilder.addAction(generateAction(context, numActions)) - compactActionList.add(numActions) - numActions++ - - // Close - notificationBuilder.addAction(generateAction(context, numActions)) - val actionArray = IntArray(compactActionList.size) - for (i in actionArray.indices) { - actionArray[i] = compactActionList[i] - } - return actionArray - // notificationBuilder.setShowActionsInCompactView()) - } - - private fun generateAction(context: Context, requestCode: Int): NotificationCompat.Action? { - val keycode: Int - val icon: Int - val label: String - - when (requestCode) { - 1 -> { - keycode = KeyEvent.KEYCODE_MEDIA_PREVIOUS - label = getString(R.string.common_play_previous) - icon = R.drawable.media_backward_medium_dark - } - 2 -> // Is handled in generatePlayPauseAction() - return null - 3 -> { - keycode = KeyEvent.KEYCODE_MEDIA_NEXT - label = getString(R.string.common_play_next) - icon = R.drawable.media_forward_medium_dark - } - 4 -> { - keycode = KeyEvent.KEYCODE_MEDIA_STOP - label = getString(R.string.buttons_stop) - icon = R.drawable.ic_baseline_close - } - else -> return null - } - - val pendingIntent = Util.getPendingIntentForMediaAction(context, keycode, requestCode) - return NotificationCompat.Action.Builder(icon, label, pendingIntent).build() - } - - private fun generatePlayPauseAction( - context: Context, - requestCode: Int, - playerState: PlayerState - ): NotificationCompat.Action { - val isPlaying = playerState === PlayerState.STARTED - val keycode = KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE - val pendingIntent = Util.getPendingIntentForMediaAction(context, keycode, requestCode) - val label: String - val icon: Int - - if (isPlaying) { - label = getString(R.string.common_pause) - icon = R.drawable.media_pause_large_dark - } else { - label = getString(R.string.common_play) - icon = R.drawable.media_start_large_dark - } - - return NotificationCompat.Action.Builder(icon, label, pendingIntent).build() - } - - private fun generateStarAction( - context: Context, - requestCode: Int, - isStarred: Boolean - ): NotificationCompat.Action { - - val label: String - val icon: Int - val keyCode: Int = KeyEvent.KEYCODE_STAR - - if (isStarred) { - label = getString(R.string.download_menu_star) - icon = R.drawable.ic_star_full_dark - } else { - label = getString(R.string.download_menu_star) - icon = R.drawable.ic_star_hollow_dark - } - - val pendingIntent = Util.getPendingIntentForMediaAction(context, keyCode, requestCode) - return NotificationCompat.Action.Builder(icon, label, pendingIntent).build() - } - - private fun getPendingIntentForContent(): PendingIntent { - val intent = Intent(this, NavigationActivity::class.java) - .addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) - val flags = PendingIntent.FLAG_UPDATE_CURRENT - intent.putExtra(Constants.INTENT_SHOW_PLAYER, true) - return PendingIntent.getActivity(this, 0, intent, flags) - } - - @Suppress("MagicNumber") - companion object { - private const val NOTIFICATION_CHANNEL_ID = "org.moire.ultrasonic" - private const val NOTIFICATION_CHANNEL_NAME = "Ultrasonic background service" - private const val NOTIFICATION_ID = 3033 - - @Volatile - private var instance: MediaPlayerService? = null - private val instanceLock = Any() - - @JvmStatic - fun getInstance(): MediaPlayerService? { - val context = UApp.applicationContext() - // Try for twenty times to retrieve a running service, - // sleep 100 millis between each try, - // and run the block that creates a service only synchronized. - for (i in 0..19) { - if (instance != null) return instance - synchronized(instanceLock) { - if (instance != null) return instance - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - context.startForegroundService( - Intent(context, MediaPlayerService::class.java) - ) - } else { - context.startService(Intent(context, MediaPlayerService::class.java)) - } - } - Util.sleepQuietly(100L) - } - return instance - } - - @JvmStatic - val runningInstance: MediaPlayerService? - get() { - synchronized(instanceLock) { return instance } - } - - @JvmStatic - fun executeOnStartedMediaPlayerService( - taskToExecute: (MediaPlayerService) -> Unit - ) { - - val t: Thread = object : Thread() { - override fun run() { - val instance = getInstance() - if (instance == null) { - Timber.e("ExecuteOnStarted.. failed to get a MediaPlayerService instance!") - return - } else { - taskToExecute(instance) - } - } - } - t.start() - } - } -} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MusicServiceFactory.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MusicServiceFactory.kt index 02ddb4a5..865dc29d 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MusicServiceFactory.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MusicServiceFactory.kt @@ -27,8 +27,14 @@ import org.moire.ultrasonic.data.ActiveServerProvider import org.moire.ultrasonic.di.OFFLINE_MUSIC_SERVICE import org.moire.ultrasonic.di.ONLINE_MUSIC_SERVICE import org.moire.ultrasonic.di.musicServiceModule +import timber.log.Timber -// TODO Refactor everywhere to use DI way to get MusicService, and then remove this class +/* + * TODO: When resetMusicService is called, a large number of classes are completely newly instantiated, + * which take quite a bit of time. + * + * Instead it would probably be faster to listen to Rx + */ object MusicServiceFactory : KoinComponent { @JvmStatic fun getMusicService(): MusicService { @@ -45,6 +51,7 @@ object MusicServiceFactory : KoinComponent { */ @JvmStatic fun resetMusicService() { + Timber.i("Regenerating Koin Music Service Module") unloadKoinModules(musicServiceModule) loadKoinModules(musicServiceModule) } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/OfflineMusicService.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/OfflineMusicService.kt index 0034db0b..27d84aa9 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/OfflineMusicService.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/OfflineMusicService.kt @@ -563,7 +563,7 @@ class OfflineMusicService : MusicService, KoinComponent { } catch (ignored: Exception) { } - artist = meta.artist ?: file.parent!!.parent!!.name + artist = meta.artist ?: file.parent!!.parent?.name ?: "" album = meta.album ?: file.parent!!.name title = meta.title ?: title isVideo = meta.hasVideo != null diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/PlaybackStateSerializer.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/PlaybackStateSerializer.kt index 7115140a..552c399c 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/PlaybackStateSerializer.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/PlaybackStateSerializer.kt @@ -9,8 +9,6 @@ package org.moire.ultrasonic.service import android.content.Context import java.util.concurrent.atomic.AtomicBoolean -import java.util.concurrent.locks.Lock -import java.util.concurrent.locks.ReentrantLock import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -30,35 +28,32 @@ class PlaybackStateSerializer : KoinComponent { private val context by inject() - private val lock: Lock = ReentrantLock() - private val setup = AtomicBoolean(false) - - private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val mainScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) fun serialize( songs: Iterable, currentPlayingIndex: Int, currentPlayingPosition: Int ) { - if (!setup.get()) return + if (isSerializing.get() || !isSetup.get()) return - appScope.launch { - if (lock.tryLock()) { - try { - serializeNow(songs, currentPlayingIndex, currentPlayingPosition) - } finally { - lock.unlock() - } - } + isSerializing.set(true) + + ioScope.launch { + serializeNow(songs, currentPlayingIndex, currentPlayingPosition) + }.invokeOnCompletion { + isSerializing.set(false) } } fun serializeNow( - songs: Iterable, + referencedList: Iterable, currentPlayingIndex: Int, currentPlayingPosition: Int ) { val state = State() + val songs = referencedList.toList() for (downloadFile in songs) { state.songs.add(downloadFile.track) @@ -77,16 +72,15 @@ class PlaybackStateSerializer : KoinComponent { } fun deserialize(afterDeserialized: (State?) -> Unit?) { - - appScope.launch { + if (isDeserializing.get()) return + ioScope.launch { try { - lock.lock() deserializeNow(afterDeserialized) - setup.set(true) + isSetup.set(true) } catch (all: Exception) { Timber.e(all, "Had a problem deserializing:") } finally { - lock.unlock() + isDeserializing.set(false) } } } @@ -103,6 +97,14 @@ class PlaybackStateSerializer : KoinComponent { state.currentPlayingPosition ) - afterDeserialized(state) + mainScope.launch { + afterDeserialized(state) + } + } + + companion object { + private val isSetup = AtomicBoolean(false) + private val isSerializing = AtomicBoolean(false) + private val isDeserializing = AtomicBoolean(false) } } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/RxBus.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/RxBus.kt index f5686372..8d996f8c 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/RxBus.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/RxBus.kt @@ -1,89 +1,81 @@ package org.moire.ultrasonic.service -import android.os.Bundle -import android.support.v4.media.session.MediaSessionCompat -import android.view.KeyEvent +import android.os.Looper import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.core.Observable import io.reactivex.rxjava3.disposables.CompositeDisposable import io.reactivex.rxjava3.disposables.Disposable import io.reactivex.rxjava3.subjects.PublishSubject import java.util.concurrent.TimeUnit -import org.moire.ultrasonic.domain.PlayerState class RxBus { - companion object { - var mediaSessionTokenPublisher: PublishSubject = - PublishSubject.create() - val mediaSessionTokenObservable: Observable = - mediaSessionTokenPublisher.observeOn(AndroidSchedulers.mainThread()) - .replay(1) - .autoConnect(0) - val mediaButtonEventPublisher: PublishSubject = + companion object { + + private fun mainThread() = AndroidSchedulers.from(Looper.getMainLooper()) + + var activeServerChangePublisher: PublishSubject = PublishSubject.create() - val mediaButtonEventObservable: Observable = - mediaButtonEventPublisher.observeOn(AndroidSchedulers.mainThread()) + var activeServerChangeObservable: Observable = + activeServerChangePublisher.observeOn(mainThread()) val themeChangedEventPublisher: PublishSubject = PublishSubject.create() val themeChangedEventObservable: Observable = - themeChangedEventPublisher.observeOn(AndroidSchedulers.mainThread()) + themeChangedEventPublisher.observeOn(mainThread()) val musicFolderChangedEventPublisher: PublishSubject = PublishSubject.create() val musicFolderChangedEventObservable: Observable = - musicFolderChangedEventPublisher.observeOn(AndroidSchedulers.mainThread()) + musicFolderChangedEventPublisher.observeOn(mainThread()) val playerStatePublisher: PublishSubject = PublishSubject.create() val playerStateObservable: Observable = - playerStatePublisher.observeOn(AndroidSchedulers.mainThread()) + playerStatePublisher.observeOn(mainThread()) .replay(1) .autoConnect(0) + val throttledPlayerStateObservable: Observable = + playerStatePublisher.observeOn(mainThread()) + .replay(1) + .autoConnect(0) + .throttleLatest(300, TimeUnit.MILLISECONDS) val playlistPublisher: PublishSubject> = PublishSubject.create() val playlistObservable: Observable> = - playlistPublisher.observeOn(AndroidSchedulers.mainThread()) + playlistPublisher.observeOn(mainThread()) .replay(1) .autoConnect(0) - - val playbackPositionPublisher: PublishSubject = - PublishSubject.create() - val playbackPositionObservable: Observable = - playbackPositionPublisher.observeOn(AndroidSchedulers.mainThread()) - .throttleFirst(1, TimeUnit.SECONDS) + val throttledPlaylistObservable: Observable> = + playlistPublisher.observeOn(mainThread()) .replay(1) .autoConnect(0) + .throttleLatest(300, TimeUnit.MILLISECONDS) // Commands val dismissNowPlayingCommandPublisher: PublishSubject = PublishSubject.create() val dismissNowPlayingCommandObservable: Observable = - dismissNowPlayingCommandPublisher.observeOn(AndroidSchedulers.mainThread()) + dismissNowPlayingCommandPublisher.observeOn(mainThread()) - val playFromMediaIdCommandPublisher: PublishSubject> = + val shutdownCommandPublisher: PublishSubject = PublishSubject.create() - val playFromMediaIdCommandObservable: Observable> = - playFromMediaIdCommandPublisher.observeOn(AndroidSchedulers.mainThread()) + val shutdownCommandObservable: Observable = + shutdownCommandPublisher.observeOn(mainThread()) - val playFromSearchCommandPublisher: PublishSubject> = + val stopCommandPublisher: PublishSubject = PublishSubject.create() - val playFromSearchCommandObservable: Observable> = - playFromSearchCommandPublisher.observeOn(AndroidSchedulers.mainThread()) - - val skipToQueueItemCommandPublisher: PublishSubject = - PublishSubject.create() - val skipToQueueItemCommandObservable: Observable = - skipToQueueItemCommandPublisher.observeOn(AndroidSchedulers.mainThread()) - - fun releaseMediaSessionToken() { - mediaSessionTokenPublisher = PublishSubject.create() - } + val stopCommandObservable: Observable = + stopCommandPublisher.observeOn(mainThread()) } - data class StateWithTrack(val state: PlayerState, val track: DownloadFile?) + data class StateWithTrack( + val track: DownloadFile?, + val index: Int = -1, + val isPlaying: Boolean = false, + val state: Int + ) } operator fun CompositeDisposable.plusAssign(disposable: Disposable) { diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/subsonic/DownloadHandler.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/subsonic/DownloadHandler.kt index 3dca314c..3165b58b 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/subsonic/DownloadHandler.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/subsonic/DownloadHandler.kt @@ -34,20 +34,23 @@ class DownloadHandler( autoPlay: Boolean, playNext: Boolean, shuffle: Boolean, - songs: List + songs: List, ) { val onValid = Runnable { - if (!append && !playNext) { - mediaPlayerController.clear() + // TODO: The logic here is different than in the controller... + val insertionMode = when { + playNext -> MediaPlayerController.InsertionMode.AFTER_CURRENT + append -> MediaPlayerController.InsertionMode.APPEND + else -> MediaPlayerController.InsertionMode.CLEAR } + networkAndStorageChecker.warnIfNetworkOrStorageUnavailable() mediaPlayerController.addToPlaylist( songs, save, autoPlay, - playNext, shuffle, - false + insertionMode ) val playlistName: String? = fragment.arguments?.getString( Constants.INTENT_PLAYLIST_NAME @@ -281,26 +284,28 @@ class DownloadHandler( } } + // Called when we have collected the tracks override fun done(songs: List) { if (Settings.shouldSortByDisc) { Collections.sort(songs, EntryByDiscAndTrackComparator()) } if (songs.isNotEmpty()) { - if (!append && !playNext && !unpin && !background) { - mediaPlayerController.clear() - } networkAndStorageChecker.warnIfNetworkOrStorageUnavailable() if (!background) { if (unpin) { mediaPlayerController.unpin(songs) } else { + val insertionMode = when { + append -> MediaPlayerController.InsertionMode.APPEND + playNext -> MediaPlayerController.InsertionMode.AFTER_CURRENT + else -> MediaPlayerController.InsertionMode.CLEAR + } mediaPlayerController.addToPlaylist( songs, save, autoPlay, - playNext, shuffle, - false + insertionMode ) if ( !append && diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/CacheCleaner.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/CacheCleaner.kt index 9990ca7c..e585ea66 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/CacheCleaner.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/CacheCleaner.kt @@ -233,7 +233,7 @@ class CacheCleaner : CoroutineScope by CoroutineScope(Dispatchers.IO) { ) { if (file.isFile && (isPartial(file) || isComplete(file))) { files.add(file) - } else { + } else if (file.isDirectory) { // Depth-first for (child in listFiles(file)) { findCandidatesForDeletion(child, files, dirs) @@ -257,7 +257,7 @@ class CacheCleaner : CoroutineScope by CoroutineScope(Dispatchers.IO) { for (downloadFile in downloader.value.all) { filesToNotDelete.add(downloadFile.partialFile) filesToNotDelete.add(downloadFile.completeFile) - filesToNotDelete.add(downloadFile.saveFile) + filesToNotDelete.add(downloadFile.pinnedFile) } filesToNotDelete.add(musicDirectory.path) diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Constants.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Constants.kt index 0c9538f5..75b349b4 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Constants.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Constants.kt @@ -71,13 +71,9 @@ object Constants { const val PREFERENCES_KEY_MEDIA_BUTTONS = "mediaButtons" const val PREFERENCES_KEY_SCROBBLE = "scrobble" const val PREFERENCES_KEY_SERVER_SCALING = "serverScaling" - const val PREFERENCES_KEY_REPEAT_MODE = "repeatMode" const val PREFERENCES_KEY_WIFI_REQUIRED_FOR_DOWNLOAD = "wifiRequiredForDownload" const val PREFERENCES_KEY_BUFFER_LENGTH = "bufferLength" const val PREFERENCES_KEY_NETWORK_TIMEOUT = "networkTimeout" - const val PREFERENCES_KEY_SHOW_NOTIFICATION = "showNotification" - const val PREFERENCES_KEY_ALWAYS_SHOW_NOTIFICATION = "alwaysShowNotification" - const val PREFERENCES_KEY_SHOW_LOCK_SCREEN_CONTROLS = "showLockScreen" const val PREFERENCES_KEY_MAX_ALBUMS = "maxAlbums" const val PREFERENCES_KEY_MAX_SONGS = "maxSongs" const val PREFERENCES_KEY_MAX_ARTISTS = "maxArtists" @@ -85,35 +81,27 @@ object Constants { const val PREFERENCES_KEY_DEFAULT_SONGS = "defaultSongs" const val PREFERENCES_KEY_DEFAULT_ARTISTS = "defaultArtists" const val PREFERENCES_KEY_SHOW_NOW_PLAYING = "showNowPlaying" - const val PREFERENCES_KEY_GAPLESS_PLAYBACK = "gaplessPlayback" - const val PREFERENCES_KEY_PLAYBACK_CONTROL_SETTINGS = "playbackControlSettings" const val PREFERENCES_KEY_CLEAR_SEARCH_HISTORY = "clearSearchHistory" const val PREFERENCES_KEY_DOWNLOAD_TRANSITION = "transitionToDownloadOnPlay" const val PREFERENCES_KEY_INCREMENT_TIME = "incrementTime" const val PREFERENCES_KEY_SHOW_NOW_PLAYING_DETAILS = "showNowPlayingDetails" const val PREFERENCES_KEY_ID3_TAGS = "useId3Tags" const val PREFERENCES_KEY_SHOW_ARTIST_PICTURE = "showArtistPicture" - const val PREFERENCES_KEY_TEMP_LOSS = "tempLoss" const val PREFERENCES_KEY_CHAT_REFRESH_INTERVAL = "chatRefreshInterval" const val PREFERENCES_KEY_DIRECTORY_CACHE_TIME = "directoryCacheTime" - const val PREFERENCES_KEY_CLEAR_PLAYLIST = "clearPlaylist" const val PREFERENCES_KEY_CLEAR_BOOKMARK = "clearBookmark" const val PREFERENCES_KEY_DISC_SORT = "discAndTrackSort" - const val PREFERENCES_KEY_SEND_BLUETOOTH_NOTIFICATIONS = "sendBluetoothNotifications" - const val PREFERENCES_KEY_SEND_BLUETOOTH_ALBUM_ART = "sendBluetoothAlbumArt" - const val PREFERENCES_KEY_DISABLE_SEND_NOW_PLAYING_LIST = "disableNowPlayingListSending" - const val PREFERENCES_KEY_VIEW_REFRESH = "viewRefresh" const val PREFERENCES_KEY_ASK_FOR_SHARE_DETAILS = "sharingAlwaysAskForDetails" const val PREFERENCES_KEY_DEFAULT_SHARE_DESCRIPTION = "sharingDefaultDescription" const val PREFERENCES_KEY_DEFAULT_SHARE_GREETING = "sharingDefaultGreeting" const val PREFERENCES_KEY_SHARE_ON_SERVER = "sharingCreateOnServer" const val PREFERENCES_KEY_DEFAULT_SHARE_EXPIRATION = "sharingDefaultExpiration" const val PREFERENCES_KEY_USE_FIVE_STAR_RATING = "use_five_star_rating" + const val PREFERENCES_KEY_HARDWARE_OFFLOAD = "use_hw_offload" const val PREFERENCES_KEY_CATEGORY_NOTIFICATIONS = "notificationsCategory" const val PREFERENCES_KEY_FIRST_RUN_EXECUTED = "firstRunExecuted" const val PREFERENCES_KEY_RESUME_ON_BLUETOOTH_DEVICE = "resumeOnBluetoothDevice" const val PREFERENCES_KEY_PAUSE_ON_BLUETOOTH_DEVICE = "pauseOnBluetoothDevice" - const val PREFERENCES_KEY_SINGLE_BUTTON_PLAY_PAUSE = "singleButtonPlayPause" const val PREFERENCES_KEY_DEBUG_LOG_TO_FILE = "debugLogToFile" const val PREFERENCES_KEY_OVERRIDE_LANGUAGE = "overrideLanguage" const val PREFERENCE_VALUE_ALL = 0 diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/FileUtil.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/FileUtil.kt index 8511d9c1..8e7fe064 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/FileUtil.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/FileUtil.kt @@ -406,7 +406,7 @@ object FileUtil { return path.substringBeforeLast('/') } - fun getSaveFile(name: String): String { + fun getPinnedFile(name: String): String { val baseName = getBaseName(name) if (baseName.endsWith(".partial") || baseName.endsWith(".complete")) { return "${getBaseName(baseName)}.${getExtension(name)}" diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MediaSessionHandler.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MediaSessionHandler.kt deleted file mode 100644 index ae3def28..00000000 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MediaSessionHandler.kt +++ /dev/null @@ -1,332 +0,0 @@ -/* - * MediaSessionHandler.kt - * Copyright (C) 2009-2021 Ultrasonic developers - * - * Distributed under terms of the GNU GPLv3 license. - */ - -package org.moire.ultrasonic.util - -import android.app.PendingIntent -import android.content.ComponentName -import android.content.Context -import android.content.Intent -import android.os.Bundle -import android.support.v4.media.MediaMetadataCompat -import android.support.v4.media.session.MediaSessionCompat -import android.support.v4.media.session.PlaybackStateCompat -import android.support.v4.media.session.PlaybackStateCompat.PLAYBACK_POSITION_UNKNOWN -import android.view.KeyEvent -import io.reactivex.rxjava3.disposables.CompositeDisposable -import kotlin.Pair -import org.koin.core.component.KoinComponent -import org.koin.core.component.inject -import org.moire.ultrasonic.R -import org.moire.ultrasonic.domain.PlayerState -import org.moire.ultrasonic.imageloader.BitmapUtils -import org.moire.ultrasonic.receiver.MediaButtonIntentReceiver -import org.moire.ultrasonic.service.DownloadFile -import org.moire.ultrasonic.service.RxBus -import org.moire.ultrasonic.service.plusAssign -import org.moire.ultrasonic.util.Util.ifNotNull -import timber.log.Timber - -private const val INTENT_CODE_MEDIA_BUTTON = 161 -/** - * Central place to handle the state of the MediaSession - */ -class MediaSessionHandler : KoinComponent { - - private var mediaSession: MediaSessionCompat? = null - private var playbackState: Int? = null - private var playbackActions: Long? = null - private var cachedPlayingIndex: Long? = null - - private val applicationContext by inject() - - private var referenceCount: Int = 0 - private var cachedPlaylist: List? = null - private var cachedPosition: Long = 0 - - private val rxBusSubscription: CompositeDisposable = CompositeDisposable() - - fun release() { - - if (referenceCount > 0) referenceCount-- - if (referenceCount > 0) return - - mediaSession?.isActive = false - RxBus.releaseMediaSessionToken() - rxBusSubscription.dispose() - mediaSession?.release() - mediaSession = null - - Timber.i("MediaSessionHandler.release Media Session released") - } - - fun initialize() { - - referenceCount++ - if (referenceCount > 1) return - - @Suppress("MagicNumber") - val keycode = 110 - - Timber.d("MediaSessionHandler.initialize Creating Media Session") - - mediaSession = MediaSessionCompat(applicationContext, "UltrasonicService") - val mediaSessionToken = mediaSession?.sessionToken ?: return - RxBus.mediaSessionTokenPublisher.onNext(mediaSessionToken) - - updateMediaButtonReceiver() - - mediaSession?.setCallback(object : MediaSessionCompat.Callback() { - override fun onPlay() { - super.onPlay() - - Util.getPendingIntentForMediaAction( - applicationContext, - KeyEvent.KEYCODE_MEDIA_PLAY, - keycode - ).send() - - Timber.v("Media Session Callback: onPlay") - } - - override fun onPlayFromMediaId(mediaId: String?, extras: Bundle?) { - super.onPlayFromMediaId(mediaId, extras) - - Timber.d("Media Session Callback: onPlayFromMediaId %s", mediaId) - RxBus.playFromMediaIdCommandPublisher.onNext(Pair(mediaId, extras)) - } - - override fun onPlayFromSearch(query: String?, extras: Bundle?) { - super.onPlayFromSearch(query, extras) - - Timber.d("Media Session Callback: onPlayFromSearch %s", query) - RxBus.playFromSearchCommandPublisher.onNext(Pair(query, extras)) - } - - override fun onPause() { - super.onPause() - Util.getPendingIntentForMediaAction( - applicationContext, - KeyEvent.KEYCODE_MEDIA_PAUSE, - keycode - ).send() - Timber.v("Media Session Callback: onPause") - } - - override fun onStop() { - super.onStop() - Util.getPendingIntentForMediaAction( - applicationContext, - KeyEvent.KEYCODE_MEDIA_STOP, - keycode - ).send() - Timber.v("Media Session Callback: onStop") - } - - override fun onSkipToNext() { - super.onSkipToNext() - Util.getPendingIntentForMediaAction( - applicationContext, - KeyEvent.KEYCODE_MEDIA_NEXT, - keycode - ).send() - Timber.v("Media Session Callback: onSkipToNext") - } - - override fun onSkipToPrevious() { - super.onSkipToPrevious() - Util.getPendingIntentForMediaAction( - applicationContext, - KeyEvent.KEYCODE_MEDIA_PREVIOUS, - keycode - ).send() - Timber.v("Media Session Callback: onSkipToPrevious") - } - - override fun onMediaButtonEvent(mediaButtonEvent: Intent): Boolean { - // This probably won't be necessary once we implement more - // of the modern media APIs, like the MediaController etc. - val event = mediaButtonEvent.extras!!["android.intent.extra.KEY_EVENT"] as KeyEvent? - event.ifNotNull { RxBus.mediaButtonEventPublisher.onNext(it) } - return true - } - - override fun onSkipToQueueItem(id: Long) { - super.onSkipToQueueItem(id) - RxBus.skipToQueueItemCommandPublisher.onNext(id) - } - } - ) - - // It seems to be the best practice to set this to true for the lifetime of the session - mediaSession?.isActive = true - rxBusSubscription += RxBus.playbackPositionObservable.subscribe { - updateMediaSessionPlaybackPosition(it) - } - rxBusSubscription += RxBus.playlistObservable.subscribe { - updateMediaSessionQueue(it) - } - rxBusSubscription += RxBus.playerStateObservable.subscribe { - updateMediaSession(it.state, it.track) - } - - Timber.i("MediaSessionHandler.initialize Media Session created") - } - - @Suppress("LongMethod", "ComplexMethod") - private fun updateMediaSession( - playerState: PlayerState, - currentPlaying: DownloadFile? - ) { - Timber.d("Updating the MediaSession") - - // Set Metadata - val metadata = MediaMetadataCompat.Builder() - if (currentPlaying != null) { - try { - val song = currentPlaying.track - val cover = BitmapUtils.getAlbumArtBitmapFromDisk( - song, Util.getMinDisplayMetric() - ) - val duration = song.duration?.times(1000) ?: -1 - metadata.putLong(MediaMetadataCompat.METADATA_KEY_DURATION, duration.toLong()) - metadata.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, song.artist) - metadata.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ARTIST, song.artist) - metadata.putString(MediaMetadataCompat.METADATA_KEY_ALBUM, song.album) - metadata.putString(MediaMetadataCompat.METADATA_KEY_TITLE, song.title) - metadata.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, cover) - } catch (all: Exception) { - Timber.e(all, "Error setting the metadata") - } - } - - // Save the metadata - mediaSession?.setMetadata(metadata.build()) - - playbackActions = PlaybackStateCompat.ACTION_PLAY_PAUSE or - PlaybackStateCompat.ACTION_SKIP_TO_NEXT or - PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS or - PlaybackStateCompat.ACTION_PLAY_FROM_MEDIA_ID or - PlaybackStateCompat.ACTION_PLAY_FROM_SEARCH or - PlaybackStateCompat.ACTION_SKIP_TO_QUEUE_ITEM - - // Map our playerState to native PlaybackState - // TODO: Synchronize these APIs - when (playerState) { - PlayerState.STARTED -> { - playbackState = PlaybackStateCompat.STATE_PLAYING - playbackActions = playbackActions!! or - PlaybackStateCompat.ACTION_PAUSE or - PlaybackStateCompat.ACTION_STOP - } - PlayerState.COMPLETED, - PlayerState.STOPPED -> { - playbackState = PlaybackStateCompat.STATE_STOPPED - cachedPosition = PLAYBACK_POSITION_UNKNOWN - } - PlayerState.IDLE -> { - // IDLE state usually just means the playback is stopped - // STATE_NONE means that there is no track to play (playlist is empty) - playbackState = if (currentPlaying == null) - PlaybackStateCompat.STATE_NONE - else - PlaybackStateCompat.STATE_STOPPED - playbackActions = 0L - cachedPosition = PLAYBACK_POSITION_UNKNOWN - } - PlayerState.PAUSED -> { - playbackState = PlaybackStateCompat.STATE_PAUSED - playbackActions = playbackActions!! or - PlaybackStateCompat.ACTION_PLAY or - PlaybackStateCompat.ACTION_STOP - } - else -> { - // These are the states PREPARING, PREPARED & DOWNLOADING - playbackState = PlaybackStateCompat.STATE_PAUSED - } - } - - val playbackStateBuilder = PlaybackStateCompat.Builder() - playbackStateBuilder.setState(playbackState!!, cachedPosition, 1.0f) - - // Set actions - playbackStateBuilder.setActions(playbackActions!!) - - val index = cachedPlaylist?.indexOf(currentPlaying) - cachedPlayingIndex = if (index == null || index < 0) null else index.toLong() - cachedPlaylist.ifNotNull { setMediaSessionQueue(it) } - - if (cachedPlaylist != null && !Settings.shouldDisableNowPlayingListSending) - cachedPlayingIndex.ifNotNull { playbackStateBuilder.setActiveQueueItemId(it) } - - // Save the playback state - mediaSession?.setPlaybackState(playbackStateBuilder.build()) - } - - private fun updateMediaSessionQueue(playlist: List) { - cachedPlaylist = playlist - setMediaSessionQueue(playlist) - } - - private fun setMediaSessionQueue(playlist: List) { - if (mediaSession == null) return - if (Settings.shouldDisableNowPlayingListSending) return - - val queue = playlist.mapIndexed { id, file -> - MediaSessionCompat.QueueItem( - Util.getMediaDescriptionForEntry(file.track), - id.toLong() - ) - } - mediaSession?.setQueueTitle(applicationContext.getString(R.string.button_bar_now_playing)) - mediaSession?.setQueue(queue) - } - - private fun updateMediaSessionPlaybackPosition(playbackPosition: Int) { - cachedPosition = playbackPosition.toLong() - if (playbackState == null || playbackActions == null) return - - val playbackStateBuilder = PlaybackStateCompat.Builder() - playbackStateBuilder.setState(playbackState!!, cachedPosition, 1.0f) - playbackStateBuilder.setActions(playbackActions!!) - - if (cachedPlaylist != null && !Settings.shouldDisableNowPlayingListSending) - cachedPlayingIndex.ifNotNull { playbackStateBuilder.setActiveQueueItemId(it) } - - mediaSession?.setPlaybackState(playbackStateBuilder.build()) - } - - fun updateMediaButtonReceiver() { - if (Settings.mediaButtonsEnabled) { - registerMediaButtonEventReceiver() - } else { - unregisterMediaButtonEventReceiver() - } - } - - private fun registerMediaButtonEventReceiver() { - val component = ComponentName( - applicationContext.packageName, - MediaButtonIntentReceiver::class.java.name - ) - val mediaButtonIntent = Intent(Intent.ACTION_MEDIA_BUTTON) - mediaButtonIntent.component = component - - val pendingIntent = PendingIntent.getBroadcast( - applicationContext, - INTENT_CODE_MEDIA_BUTTON, - mediaButtonIntent, - PendingIntent.FLAG_CANCEL_CURRENT - ) - - mediaSession?.setMediaButtonReceiver(pendingIntent) - } - - private fun unregisterMediaButtonEventReceiver() { - mediaSession?.setMediaButtonReceiver(null) - } -} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Settings.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Settings.kt index c286bd69..356fca5c 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Settings.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Settings.kt @@ -9,13 +9,11 @@ package org.moire.ultrasonic.util import android.content.Context import android.content.SharedPreferences -import android.os.Build import androidx.preference.PreferenceManager import java.util.regex.Pattern import org.moire.ultrasonic.R import org.moire.ultrasonic.app.UApp import org.moire.ultrasonic.data.ActiveServerProvider -import org.moire.ultrasonic.domain.RepeatMode /** * Contains convenience functions for reading and writing preferences @@ -23,43 +21,6 @@ import org.moire.ultrasonic.domain.RepeatMode object Settings { private val PATTERN = Pattern.compile(":") - var repeatMode: RepeatMode - get() { - val preferences = preferences - return RepeatMode.valueOf( - preferences.getString( - Constants.PREFERENCES_KEY_REPEAT_MODE, - RepeatMode.OFF.name - )!! - ) - } - set(repeatMode) { - val preferences = preferences - val editor = preferences.edit() - editor.putString(Constants.PREFERENCES_KEY_REPEAT_MODE, repeatMode.name) - editor.apply() - } - - // After API26 foreground services must be used for music playback, - // and they must have a notification - val isNotificationEnabled: Boolean - get() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) return true - val preferences = preferences - return preferences.getBoolean(Constants.PREFERENCES_KEY_SHOW_NOTIFICATION, false) - } - - // After API26 foreground services must be used for music playback, - // and they must have a notification - val isNotificationAlwaysEnabled: Boolean - get() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) return true - val preferences = preferences - return preferences.getBoolean(Constants.PREFERENCES_KEY_ALWAYS_SHOW_NOTIFICATION, false) - } - - var isLockScreenEnabled by BooleanSetting(Constants.PREFERENCES_KEY_SHOW_LOCK_SCREEN_CONTROLS) - @JvmStatic var theme by StringSetting( Constants.PREFERENCES_KEY_THEME, @@ -163,10 +124,6 @@ object Settings { var defaultArtists by StringIntSetting(Constants.PREFERENCES_KEY_DEFAULT_ARTISTS, "3") - @JvmStatic - var bufferLength - by StringIntSetting(Constants.PREFERENCES_KEY_BUFFER_LENGTH, "5") - @JvmStatic var incrementTime by StringIntSetting(Constants.PREFERENCES_KEY_INCREMENT_TIME, "5") @@ -174,15 +131,25 @@ object Settings { @JvmStatic var mediaButtonsEnabled by BooleanSetting(Constants.PREFERENCES_KEY_MEDIA_BUTTONS, true) + var resumePlayOnHeadphonePlug + by BooleanSetting(R.string.setting_keys_resume_play_on_headphones_plug, true) + + @JvmStatic + var resumeOnBluetoothDevice by IntSetting( + Constants.PREFERENCES_KEY_RESUME_ON_BLUETOOTH_DEVICE, + Constants.PREFERENCE_VALUE_DISABLED + ) + + @JvmStatic + var pauseOnBluetoothDevice by IntSetting( + Constants.PREFERENCES_KEY_PAUSE_ON_BLUETOOTH_DEVICE, + Constants.PREFERENCE_VALUE_A2DP + ) @JvmStatic var showNowPlaying by BooleanSetting(Constants.PREFERENCES_KEY_SHOW_NOW_PLAYING, true) - @JvmStatic - var gaplessPlayback - by BooleanSetting(Constants.PREFERENCES_KEY_GAPLESS_PLAYBACK, false) - @JvmStatic var shouldTransitionOnPlayback by BooleanSetting( Constants.PREFERENCES_KEY_DOWNLOAD_TRANSITION, @@ -197,9 +164,6 @@ object Settings { var shouldUseId3Tags by BooleanSetting(Constants.PREFERENCES_KEY_ID3_TAGS, false) - @JvmStatic - var tempLoss by StringIntSetting(Constants.PREFERENCES_KEY_TEMP_LOSS, "1") - var activeServer by IntSetting(Constants.PREFERENCES_KEY_SERVER_INSTANCE, -1) var serverScaling by BooleanSetting(Constants.PREFERENCES_KEY_SERVER_SCALING, false) @@ -227,37 +191,12 @@ object Settings { "300" ) - var shouldClearPlaylist - by BooleanSetting(Constants.PREFERENCES_KEY_CLEAR_PLAYLIST, false) - var shouldSortByDisc by BooleanSetting(Constants.PREFERENCES_KEY_DISC_SORT, false) var shouldClearBookmark by BooleanSetting(Constants.PREFERENCES_KEY_CLEAR_BOOKMARK, false) - var singleButtonPlayPause - by BooleanSetting( - Constants.PREFERENCES_KEY_SINGLE_BUTTON_PLAY_PAUSE, - false - ) - - // Inverted for readability - var shouldSendBluetoothNotifications by BooleanSetting( - Constants.PREFERENCES_KEY_SEND_BLUETOOTH_NOTIFICATIONS, - true - ) - - var shouldSendBluetoothAlbumArt - by BooleanSetting(Constants.PREFERENCES_KEY_SEND_BLUETOOTH_ALBUM_ART, true) - - var shouldDisableNowPlayingListSending - by BooleanSetting(Constants.PREFERENCES_KEY_DISABLE_SEND_NOW_PLAYING_LIST, false) - - @JvmStatic - var viewRefreshInterval - by StringIntSetting(Constants.PREFERENCES_KEY_VIEW_REFRESH, "1000") - var shouldAskForShareDetails by BooleanSetting(Constants.PREFERENCES_KEY_ASK_FOR_SHARE_DETAILS, true) @@ -300,18 +239,6 @@ object Settings { return 0 } - @JvmStatic - var resumeOnBluetoothDevice by IntSetting( - Constants.PREFERENCES_KEY_RESUME_ON_BLUETOOTH_DEVICE, - Constants.PREFERENCE_VALUE_DISABLED - ) - - @JvmStatic - var pauseOnBluetoothDevice by IntSetting( - Constants.PREFERENCES_KEY_PAUSE_ON_BLUETOOTH_DEVICE, - Constants.PREFERENCE_VALUE_A2DP - ) - @JvmStatic var debugLogToFile by BooleanSetting(Constants.PREFERENCES_KEY_DEBUG_LOG_TO_FILE, false) @@ -324,6 +251,8 @@ object Settings { var useFiveStarRating by BooleanSetting(Constants.PREFERENCES_KEY_USE_FIVE_STAR_RATING, false) + var useHwOffload by BooleanSetting(Constants.PREFERENCES_KEY_HARDWARE_OFFLOAD, false) + // TODO: Remove in December 2022 fun migrateFeatureStorage() { val sp = appContext.getSharedPreferences("feature_flags", Context.MODE_PRIVATE) diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/SettingsDelegate.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/SettingsDelegate.kt index 3e24864e..03cfbd31 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/SettingsDelegate.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/SettingsDelegate.kt @@ -76,4 +76,8 @@ class BooleanSetting(private val key: String, private val defaultValue: Boolean override fun setValue(thisRef: Any, property: KProperty<*>, value: Boolean) = sharedPreferences.edit { putBoolean(key, value) } + + constructor(stringId: Int, defaultValue: Boolean = false) : this( + Util.appContext().getString(stringId), defaultValue + ) } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Util.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Util.kt index febda154..f3e16d44 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Util.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Util.kt @@ -9,10 +9,8 @@ package org.moire.ultrasonic.util import android.annotation.SuppressLint import android.app.Activity -import android.app.PendingIntent import android.content.ContentResolver import android.content.Context -import android.content.Intent import android.content.pm.PackageManager import android.graphics.Bitmap import android.graphics.BitmapFactory @@ -28,18 +26,13 @@ import android.net.Uri import android.net.wifi.WifiManager import android.net.wifi.WifiManager.WifiLock import android.os.Build -import android.os.Bundle import android.os.Environment -import android.os.Parcelable -import android.support.v4.media.MediaDescriptionCompat import android.text.TextUtils import android.util.TypedValue import android.view.Gravity -import android.view.KeyEvent import android.view.inputmethod.InputMethodManager import android.widget.Toast import androidx.annotation.AnyRes -import androidx.media.utils.MediaConstants import java.io.Closeable import java.io.UnsupportedEncodingException import java.security.MessageDigest @@ -53,10 +46,8 @@ import org.moire.ultrasonic.R import org.moire.ultrasonic.app.UApp.Companion.applicationContext import org.moire.ultrasonic.domain.Bookmark import org.moire.ultrasonic.domain.MusicDirectory -import org.moire.ultrasonic.domain.PlayerState import org.moire.ultrasonic.domain.SearchResult import org.moire.ultrasonic.domain.Track -import org.moire.ultrasonic.service.DownloadFile import timber.log.Timber private const val LINE_LENGTH = 60 @@ -77,11 +68,6 @@ object Util { private var MEGA_BYTE_LOCALIZED_FORMAT: DecimalFormat? = null private var KILO_BYTE_LOCALIZED_FORMAT: DecimalFormat? = null private var BYTE_LOCALIZED_FORMAT: DecimalFormat? = null - private const val EVENT_META_CHANGED = "org.moire.ultrasonic.EVENT_META_CHANGED" - private const val EVENT_PLAYSTATE_CHANGED = "org.moire.ultrasonic.EVENT_PLAYSTATE_CHANGED" - private const val CM_AVRCP_PLAYSTATE_CHANGED = "com.android.music.playstatechanged" - private const val CM_AVRCP_PLAYBACK_COMPLETE = "com.android.music.playbackcomplete" - private const val CM_AVRCP_METADATA_CHANGED = "com.android.music.metachanged" // Used by hexEncode() private val HEX_DIGITS = @@ -448,150 +434,6 @@ object Util { return musicDirectory } - /** - * Broadcasts the given song info as the new song being played. - */ - fun broadcastNewTrackInfo(context: Context, song: Track?) { - val intent = Intent(EVENT_META_CHANGED) - if (song != null) { - intent.putExtra("title", song.title) - intent.putExtra("artist", song.artist) - intent.putExtra("album", song.album) - val albumArtFile = FileUtil.getAlbumArtFile(song) - intent.putExtra("coverart", albumArtFile) - } else { - intent.putExtra("title", "") - intent.putExtra("artist", "") - intent.putExtra("album", "") - intent.putExtra("coverart", "") - } - context.sendBroadcast(intent) - } - - fun broadcastA2dpMetaDataChange( - context: Context, - playerPosition: Int, - currentPlaying: DownloadFile?, - listSize: Int, - id: Int - ) { - if (!Settings.shouldSendBluetoothNotifications) return - - var song: Track? = null - val avrcpIntent = Intent(CM_AVRCP_METADATA_CHANGED) - if (currentPlaying != null) song = currentPlaying.track - - fillIntent(avrcpIntent, song, playerPosition, id, listSize) - - context.sendBroadcast(avrcpIntent) - } - - @Suppress("LongParameterList") - fun broadcastA2dpPlayStatusChange( - context: Context, - state: PlayerState?, - newSong: Track?, - listSize: Int, - id: Int, - playerPosition: Int - ) { - if (!Settings.shouldSendBluetoothNotifications) return - - if (newSong != null) { - - val avrcpIntent = Intent( - if (state == PlayerState.COMPLETED) CM_AVRCP_PLAYBACK_COMPLETE - else CM_AVRCP_PLAYSTATE_CHANGED - ) - - fillIntent(avrcpIntent, newSong, playerPosition, id, listSize) - - if (state != PlayerState.COMPLETED) { - when (state) { - PlayerState.STARTED -> avrcpIntent.putExtra("playing", true) - PlayerState.STOPPED, - PlayerState.PAUSED -> avrcpIntent.putExtra("playing", false) - else -> return // No need to broadcast. - } - } - - context.sendBroadcast(avrcpIntent) - } - } - - private fun fillIntent( - intent: Intent, - song: Track?, - playerPosition: Int, - id: Int, - listSize: Int - ) { - if (song == null) { - intent.putExtra("track", "") - intent.putExtra("track_name", "") - intent.putExtra("artist", "") - intent.putExtra("artist_name", "") - intent.putExtra("album", "") - intent.putExtra("album_name", "") - intent.putExtra("album_artist", "") - intent.putExtra("album_artist_name", "") - - if (Settings.shouldSendBluetoothAlbumArt) { - intent.putExtra("coverart", null as Parcelable?) - intent.putExtra("cover", null as Parcelable?) - } - - intent.putExtra("ListSize", 0.toLong()) - intent.putExtra("id", 0.toLong()) - intent.putExtra("duration", 0.toLong()) - intent.putExtra("position", 0.toLong()) - } else { - val title = song.title - val artist = song.artist - val album = song.album - val duration = song.duration - - intent.putExtra("track", title) - intent.putExtra("track_name", title) - intent.putExtra("artist", artist) - intent.putExtra("artist_name", artist) - intent.putExtra("album", album) - intent.putExtra("album_name", album) - intent.putExtra("album_artist", artist) - intent.putExtra("album_artist_name", artist) - - if (Settings.shouldSendBluetoothAlbumArt) { - val albumArtFile = FileUtil.getAlbumArtFile(song) - intent.putExtra("coverart", albumArtFile) - intent.putExtra("cover", albumArtFile) - } - - intent.putExtra("position", playerPosition.toLong()) - intent.putExtra("id", id.toLong()) - intent.putExtra("ListSize", listSize.toLong()) - - if (duration != null) { - intent.putExtra("duration", duration.toLong()) - } - } - } - - /** - * - * Broadcasts the given player state as the one being set. - */ - fun broadcastPlaybackStatusChange(context: Context, state: PlayerState?) { - val intent = Intent(EVENT_PLAYSTATE_CHANGED) - when (state) { - PlayerState.STARTED -> intent.putExtra("state", "play") - PlayerState.STOPPED -> intent.putExtra("state", "stop") - PlayerState.PAUSED -> intent.putExtra("state", "pause") - PlayerState.COMPLETED -> intent.putExtra("state", "complete") - else -> return // No need to broadcast. - } - context.sendBroadcast(intent) - } - @JvmStatic @Suppress("MagicNumber") fun getNotificationImageSize(context: Context): Int { @@ -776,39 +618,6 @@ object Util { var fileFormat: String?, ) - fun getMediaDescriptionForEntry( - song: Track, - mediaId: String? = null, - groupNameId: Int? = null - ): MediaDescriptionCompat { - - val descriptionBuilder = MediaDescriptionCompat.Builder() - val desc = readableEntryDescription(song) - val title: String - - if (groupNameId != null) - descriptionBuilder.setExtras( - Bundle().apply { - putString( - MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, - appContext().getString(groupNameId) - ) - } - ) - - if (desc.trackNumber.isNotEmpty()) { - title = "${desc.trackNumber} - ${desc.title}" - } else { - title = desc.title - } - - descriptionBuilder.setTitle(title) - descriptionBuilder.setSubtitle(desc.artist) - descriptionBuilder.setMediaId(mediaId) - - return descriptionBuilder.build() - } - @Suppress("ComplexMethod", "LongMethod") fun readableEntryDescription(song: Track): ReadableEntryDescription { val artist = StringBuilder(LINE_LENGTH) @@ -880,18 +689,6 @@ object Util { ) } - fun getPendingIntentForMediaAction( - context: Context, - keycode: Int, - requestCode: Int - ): PendingIntent { - val intent = Intent(Constants.CMD_PROCESS_KEYCODE) - val flags = PendingIntent.FLAG_UPDATE_CURRENT - intent.setPackage(context.packageName) - intent.putExtra(Intent.EXTRA_KEY_EVENT, KeyEvent(KeyEvent.ACTION_DOWN, keycode)) - return PendingIntent.getBroadcast(context, requestCode, intent, flags) - } - fun getConnectivityManager(): ConnectivityManager { val context = appContext() return context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager diff --git a/ultrasonic/src/main/res/drawable/ic_artist.xml b/ultrasonic/src/main/res/drawable/ic_artist.xml deleted file mode 100644 index c3daf609..00000000 --- a/ultrasonic/src/main/res/drawable/ic_artist.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/ultrasonic/src/main/res/drawable/ic_library.xml b/ultrasonic/src/main/res/drawable/ic_library.xml deleted file mode 100644 index 6981f924..00000000 --- a/ultrasonic/src/main/res/drawable/ic_library.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/ultrasonic/src/main/res/drawable/media_pause_large_dark.xml b/ultrasonic/src/main/res/drawable/media3_notification_pause.xml similarity index 100% rename from ultrasonic/src/main/res/drawable/media_pause_large_dark.xml rename to ultrasonic/src/main/res/drawable/media3_notification_pause.xml diff --git a/ultrasonic/src/main/res/drawable/media_start_large_dark.xml b/ultrasonic/src/main/res/drawable/media3_notification_play.xml similarity index 100% rename from ultrasonic/src/main/res/drawable/media_start_large_dark.xml rename to ultrasonic/src/main/res/drawable/media3_notification_play.xml diff --git a/ultrasonic/src/main/res/drawable/media_forward_medium_dark.xml b/ultrasonic/src/main/res/drawable/media3_notification_seek_to_next.xml similarity index 100% rename from ultrasonic/src/main/res/drawable/media_forward_medium_dark.xml rename to ultrasonic/src/main/res/drawable/media3_notification_seek_to_next.xml diff --git a/ultrasonic/src/main/res/drawable/media_backward_medium_dark.xml b/ultrasonic/src/main/res/drawable/media3_notification_seek_to_previous.xml similarity index 100% rename from ultrasonic/src/main/res/drawable/media_backward_medium_dark.xml rename to ultrasonic/src/main/res/drawable/media3_notification_seek_to_previous.xml diff --git a/ultrasonic/src/main/res/drawable/menu_arrow.xml b/ultrasonic/src/main/res/drawable/menu_arrow.xml deleted file mode 100644 index f6db0abe..00000000 --- a/ultrasonic/src/main/res/drawable/menu_arrow.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/ultrasonic/src/main/res/values-cs/strings.xml b/ultrasonic/src/main/res/values-cs/strings.xml index 1512de8e..70334b12 100644 --- a/ultrasonic/src/main/res/values-cs/strings.xml +++ b/ultrasonic/src/main/res/values-cs/strings.xml @@ -61,10 +61,7 @@ Obrazovka zapnuta Zobrazit album Náhodně - Playlist byl náhodně zamíchán Vizualizér - Načítám - Stahuji - %s Přehrávám mix Playlist úspěšně uložen. Chyba ukládání playlistu, zkuste později. @@ -95,7 +92,6 @@ Žánry Hudba Bez připojení - Náhodné přehrávání Náhodné Označené hvězdičkou Skladby @@ -106,26 +102,19 @@ Smazaný playlist %s Chyba smazání playlistu %s Ukončit - Navigace Nastavení Obnovit Knihovna médií Offline média - Došlo k chybě sítě. Pokus %1$d z %2$d. - Dostupných %d umělců. - Načítání dat serveru. - Načítání ze serveru. Hotovo! Playlisty Aktualizovat informace Aktualizované informace playlistu pro %s Chyba aktualizace informací playlistu %s - Chvilku strpení… Alba Umělci Vyhledávání Zobrazit více Nenalezeno, zkuste znovu - Kliknout pro vyhledání Skladby Hledat Média nenalezena @@ -135,7 +124,6 @@ Vybrat adresář Žánry nenalezeny Žádné uložené playlisty na serveru - Kontaktuji server, chvilku strpení. Vzhled Délka bufferu Vypnuto @@ -175,8 +163,6 @@ Interval obnovení chatu Zahodit záložku Zahodit záložku po dokončení přehrávání skladby - Zahození playlistu - Zahodit playlist po dokončení přehrávání všech skladeb Vyčistit historii vyhledávání Chyba připojení. Výchozí alba @@ -193,14 +179,11 @@ Řadit skladby podle čísla CD Řadit seznam skladeb dle čísla CD a čísla skladby Připojovat jméno umělce, bitrate a příponu souboru - Přehrávání bez pauz - Zapnout přehrávání bez pauz Skrýt hudební soubory před ostatními aplikacemi. Skrýt před ostatními Nabyde účinnosti při příštím skenování hudby systému Android. Interval přeskočení Zadejte funkční adresu URL. - Zadejte správné uživatelské jméno (bez mezer za jménem). Maximum alb Maximum umělců 112 Kbps @@ -257,25 +240,13 @@ 75 Historie hledání vyčištěna Nastavení vyhledávání - Odesílat upozornění přehrávání přes bluetooth - Odesílat bluetooth upozornění - Odesílat obrázky alb přes bluetooth (může způsobit selhávání bluetooth upozornění) - Obrázky alb přes bluetooth Spravovat servery Adresa serveru Název Heslo - Vzdálený server Stahovat škálované obrázky ze serveru místo plné velikosti (šetří přenos dat) Škálování obrázků alb na serveru - Nepoužitý Uživatelské jméno - Zobrazit ovládání na zamknuté obrazovce - Zobrazí ovládání přehrávače na zamknuté obrazovce - Zobrazení upozornění - Vždy zobrazovat upozornění - Vždy zobrazovat upozornění přehrávané skladby při vytvoření playlistu - Zobrazovat přehrávanou skladbu ve stavovém panelu Zobrazovat přehrávanou skladbu Zobrazovat přehrávanou skladbu v aktivitách Zobrazovat číslo skladby @@ -346,7 +317,6 @@ Všechny bluetooth přístroje Pouze audio (A2DP) přístroje Vypnuto - Povolení tohoto nastavení může pomoci zlepšit funkci spuštění/pozastavení přehrávání na starších bluetooth přístrojích Možnosti ladění aplikace Zapisovat logy ladění do souboru Soubory logů jsou dostupné v %1$s/%2$s @@ -372,12 +342,6 @@ %d skladeb %d skladeb - - Zbývá %d den zkušební doby - Zbývají %d dny zkušební doby - Zbývá %d dní zkušební doby - Zbývá %d dní zkušební doby - Obecná api chyba: %1$s diff --git a/ultrasonic/src/main/res/values-de/strings.xml b/ultrasonic/src/main/res/values-de/strings.xml index bc7bfed3..a859a80a 100644 --- a/ultrasonic/src/main/res/values-de/strings.xml +++ b/ultrasonic/src/main/res/values-de/strings.xml @@ -41,11 +41,8 @@ Name OK Anheften - Pause - Abspielen Zuletzt spielen Als nächstes spielen - Vorheriges abspielen Jetzt spielen Zufällig spielen Öffentlich @@ -75,10 +72,7 @@ Bildschirm an Album anzeigen Mischen - Die Wiedergabeliste wurde gemischt Grafik - Zwischenspeichern - Herunterladen - %s Wiedergabeliste mischen Die Wiedergabeliste wurde gespeichert Konnte die Wiedergabeliste nicht speichern, bitte später erneut versuchen. @@ -125,7 +119,6 @@ Musik Offline %s - Server einrichten - Gemischte Wiedergabe Zufällig Mit Stern Titel @@ -139,26 +132,19 @@ Löschen der Wiedergabeliste %s ist fehlgeschlagen Downloads Beenden - Navigation Einstellungen Aktualisierung Medienbibliothek Offline Medien - Netzwerkfehler. Neuer Versuch %1$d von %2$d. - %d Künstler*innen gefunden - Lese vom Server. - Lese vom Server. Fertig! Wiedergabelisten Aktualisierungs-Informationen Wiedergabeliste für %s aktualisiert Aktualisierung der Wiedergabeliste %s ist fehlgeschlagen - Bitte warten… Alben Künstler*innen Suche Zeige mehr Keine Treffer, bitte erneut versuchen - Neue Suche Titel Suche Keine Medien gefunden @@ -170,7 +156,6 @@ Ordner wählen Keine Genres gefunden Keine Wiedergabelisten auf dem Server - Kontaktiere Server, bitte warten. Aussehen Puffer-Länge Deaktiviert @@ -211,8 +196,6 @@ Chat Aktualisierungsintervall Lesezeichen löschen Lesezeichen nach Wiedergabe löschen - Wiedergabeliste löschen - Wiedergableliste nach Wiedergabe aller Titel löschen Suchverlauf löschen Verbindungsfehler Anzahl der Alben @@ -232,14 +215,11 @@ Bitrate und Dateityp hinter der Künstler*in anzeigen Zeige Aktuelle Wiedergabe bei Play Zeige Aktuelle Wiedergabe nach dem Start der Wiedergabe in der Medienansicht - Lückenlose Wiedergabe - Lückenlose Wiedergabe aktivieren Musikdateien vor anderen Apps verbergen Vor anderen verbergen Wird beim nächsten Durchsuchen nach Musik durch Android wirksam. Sprunglänge Bitte eine gültige URL angeben. - Bitte einen gültigen Benutzernamen eingeben (ohne führende Leerzeichen). Max. Anzahl der Alben Max. Anzahl der Künstler*innen 112 Kbps @@ -300,28 +280,14 @@ 75 Suchhistorie gelöscht Sucheinstellungen - Wiedergabe-Benachrichtigungen über Bluetooth senden - Bluetooth-Benachrichtigung - Albumcover über Bluetooth versenden (kann dazu führen, dass Bluetooth-Benachrichtigungen fehlschlagen) - Album Cover über Bluetooth - Die aktuellen Wiedergabeliste wird nicht an verbundene Geräte gesendet. Das kann die Kompatibilität mit AVRCP 1.3 Geräten herstellen, wenn die aktuelle Titelanzeige nicht dargestellt wird - Deaktiviere senden der aktuellen Wiedergabeliste Server verwalten Server Adresse Name Kennwort - Server entfernen Skalierte Cover vom Server laden (spart Bandbreite) Serverseitige Skalierung der Cover - Unbenutzt Benutzername Server Farbe - Steuerelemente auf Sperrbildschirm - Wiedergabeelemente auf dem Sperrbildschirm anzeigen - Benachrichtigungen anzeigen - Immer Benachrichtigungen zeigen - Benachrichtigung beim Abspielen immer anzeigen, wenn Einträge in der Wiedergabeliste sind - Abspielbenachrichtigung in der Statusleiste anzeigen Aktuellen Titel anzeigen Aktuellen Titel in allen Aktivitäten anzeigen Titelnummer anzeigen @@ -401,8 +367,6 @@ Alle Bluetooth Geräte Nur Audio (A2DP) Geräte Deaktiviert - Bluetooth Gerät mit einer Play/Pause Taste - Dies kann bei älteren Bluetooth Geräten helfen, wenn Play/Pause nicht richtig funktioniert Debug Optionen Schreibe Debug Log in Datei Die Log Dateien sind unter %1$s/%2$s verfügbar @@ -455,10 +419,6 @@ %d Titel nach aktuellen Titel hinzugefügt %d Titel nach aktuellen Titel hinzugefügt - - %d Tag Testphase übrig - %d Tage Testphase übrig - Allgemeiner API Fehler: %1$s diff --git a/ultrasonic/src/main/res/values-es/strings.xml b/ultrasonic/src/main/res/values-es/strings.xml index d4c91a1c..d01cbb81 100644 --- a/ultrasonic/src/main/res/values-es/strings.xml +++ b/ultrasonic/src/main/res/values-es/strings.xml @@ -41,11 +41,8 @@ Nombre OK Anclar - Pausar - Reproducir Reproducir última Reproducir a continuación - Reproducir anterior Reproducir ahora Reproducción aleatoria Public @@ -75,10 +72,7 @@ Pantalla encendida Mostrar Álbum Aleatorio - Lista de reproducción en modo aleatorio Visualizador - Almacenando en el buffer - Descargando - %s Reproduciendo en modo aleatorio Lista de reproducción guardada con éxito. Fallo al guardar la lista de reproducción, por favor reinténtalo mas tarde. @@ -125,7 +119,6 @@ Música Sin conexión %s - Configurar servidor - Reproducción aleatoria Aleatorio Me gusta Canciones @@ -139,26 +132,19 @@ Fallo al eliminar la lista de reproducción %s Descargas Salir - Navegación Configuración Actualizar Biblioteca de medios Medios sin conexión - Se ha producido un error de red. Reintento %1$d de %2$d. - Obtenido(s) %d artista(s). - Leyendo del servidor. - Leyendo del servidor. ¡Hecho! Listas de reproducción Actualizar Información Actualizada la información de la lista de reproducción para %s Fallo al actualizar la información de la lista de reproducción para %s - Por favor espere… Álbumes Artistas Buscar Mostrar mas Sin resultados, por favor inténtalo de nuevo - Haz click para buscar Canciones Buscar No se han encontrado medios @@ -170,7 +156,6 @@ Seleccionar la carpeta No se han encontrado géneros No hay listas de reproducción almacenadas en el servidor - Contactando con el servidor, por favor espera. Apariencia Duración del Buffer Deshabilitado @@ -211,8 +196,6 @@ Intervalo de refresco del Chat Limpiar marcador Limpiar marcador tras la finalización de la reproducción de una canción - Limpiar lista de reproducción - Limpiar la lista de reproducción tras la finalización de la reproducción de todas las canciones Limpiar el historial de búsqueda Fallo de conexión. Álbumes predeterminados @@ -232,14 +215,11 @@ Añadir el nombre del artista con la tasa de bits y la extensión del archivo Mostrar reproduciendo ahora al reproducir Cambiar a reproduciendo ahora después de iniciar la reproducción en la vista multimedia - Reproducción sin pausas - Activa la reproducción sin pausas Oculta los archivos de música desde otras aplicaciones. Ocultar desde otras Tiene efecto la próxima vez que Android escanee la música de tu dispositivo. Intervalo de salto Por favor especifica una URL válida. - Por favor especifica un nombre de usuario válido (sin espacios al final). Máximo de Álbumes Máximo de Artistas 112 Kbps @@ -300,28 +280,14 @@ 75 Se ha limpiado el historial de búsqueda Configuración de la búsqueda - Enviar notificaciones de reproducción vía Bluetooth - Enviar notificaciones Bluetooth - Enviar la carátula del álbum vía Bluetooth (Puede causar que las notificaciones Bluetooth fallen) - Carátula del Álbum vía Bluetooth - La lista de reproducción actual no se enviará a los dispositivos conectados. Esto puede restaurar la compatibilidad con dispositivos AVRCP 1.3, cuando la visualización de la pista actual no se actualiza - Desactivar el envío de la lista de reproducción actual Administrar servidores Dirección del servidor Nombre Contraseña - Quitar servidor Descarga imágenes escaladas del servidor en lugar del tamaño completo (salva ancho de banda) Escalado de caratulas en el servidor - Sin usar Nombre de usuario Color del servidor - Mostrar controles en la pantalla de bloqueo - Mostrar controles de reproducción en la pantalla de bloqueo - Mostrar notificación - Mostrar siempre la notificación - Mostrar siempre la notificación de reproduciendo ahora cuando la lista de reproducción contiene datos - Mostrar la notificación de reproduciendo ahora en la barra de estado Mostrar reproduciendo ahora Mostrar la pista que se esta reproduciendo en todas las actividades Mostrar número de pista @@ -401,8 +367,6 @@ Todos los dispositivos Bluetooth Solo dispositivos de audio (A2DP) Deshabilitado - Dispositivo Bluetooth con solo un único botón Reproducir / Pausa - Habilitar esto puede ayudar con los dispositivos Bluetooth más antiguos cuando la reproducción / pausa no funciona correctamente Opciones de depuración Escribir registro de depuración en un archivo Los archivos de registro están disponibles en %1$s/%2$s @@ -458,10 +422,6 @@ %d canción insertada después de la canción actual %d canciones insertadas después de la canción actual. - - Queda %d día de periodo de prueba - Quedan %d días de periodo de prueba - Error genérico de api: %1$s diff --git a/ultrasonic/src/main/res/values-fr/strings.xml b/ultrasonic/src/main/res/values-fr/strings.xml index 0b78fce7..309c136d 100644 --- a/ultrasonic/src/main/res/values-fr/strings.xml +++ b/ultrasonic/src/main/res/values-fr/strings.xml @@ -41,11 +41,8 @@ Nom OK Épingler - Pause - Lecture Jouer en dernier Jouer à la suite - Lire le précédent Jouer maintenant Jouer aléatoirement Public @@ -75,10 +72,7 @@ Sur l\'écran Afficher l\'album Aléatoire - Playlist aléatoire Visualiseur - Mise en mémoire - Téléchargement - %s En lecture aléatoire Playlist enregistrée avec succès ! Échec de l\'enregistrement de la playlist, veuillez réessayer plus tard. @@ -110,7 +104,6 @@ Musique Hors-ligne %s - Configurer le serveur - Lecture aléatoire Aléatoire Favoris Titres @@ -124,26 +117,19 @@ Échec de suppression de la playlist %s Téléchargements Quitter - Navigation Paramètres Rafraichir Bibliothèque musicale Musique hors-ligne - Une erreur de réseau s\'est produite. Tentative %1$d sur %2$d. - %d artistes récupérés. - Lecture du serveur. - Lecture du serveur. Terminé ! Playlists Mise à jour des informations Informations de la playlist %s mises à jour Échec de mise à jour des informations de la playlist %s - Veuillez patienter… Albums Artistes Recherche Afficher plus Aucun résultat, veuillez essayer à nouveau - Cliquer pour rechercher Titres Recherche Aucun titre trouvé @@ -154,7 +140,6 @@ Sélectionner le dossier Aucun genre trouvé Aucune playlist sur le serveur - Contact du serveur, veuillez patienter. Apparence Taille de la mémoire tampon Désactivé @@ -195,8 +180,6 @@ Délai de rafraichissement du salon de discussion Effacer le signet Effacer le signet à la fin de la lecture d\'un titre - Effacer la playlist - Effacer la playlist à la fin de la lecture de tous les titres Effacer l\'historique des recherches Échec de la connexion Albums par défaut @@ -214,14 +197,11 @@ Trier la liste des titres par numéro de disques/pistes Afficher le débit et l’extension de fichier Ajouter le nom d\'artiste, débit et suffixe du fichier - Lecture sans interruption - Activer la lecture sans interruption Masquer les fichiers musicaux et les couvertures d\'album aux autres applis (Galerie, Musique, etc.) Masquer aux autres Prendra effet la prochaine fois qu\'Android recensera les médias disponibles sur l\'appareil. Intervalle de saut Veuillez spécifier une URL valide. - Veuillez spécifier un nom d\'utilisateur valide (sans espace à la fin). Albums maximum Artistes maximum 112 kbit/s @@ -280,28 +260,14 @@ 75 Historique des recherches effacé Paramètres de recherche - Envoyer des notifications de lecture via Bluetooth - Envoyer une notification Bluetooth - Envoyer la pochette de l\'album via Bluetooth (peut causer l\'échec des notifications Bluetooth) - Pochette de l\'album via Bluetooth - La liste de lecture ne sera pas envoyée aux appareils connectés. Cela peut restaurer la compatibilité avec les appareils AVRCP 1.3 lorsque l’affichage de la piste actuelle n’est pas mise à jour. - Désactiver l’envoi de la liste de lecture Gérer les serveurs Adresse du serveur Nom Mot de passe - Supprimer le serveur Télécharger sur le serveur des images réduites au lieu des images grand format (bande passante réduite) Mise à l\'échelle des pochettes d\'album sur le serveur - Inutilisé Nom d\'utilisateur Couleur du serveur - Boutons de contrôle sur l\'écran de verrouillage - Afficher les contrôles de lecture sur l\'écran de verrouillage - Notifications - Toujours afficher les notifications - Toujours afficher la notification de lecture en cours lorsque la liste de lecture est remplie - Afficher la notification d\'un nouveau titre en lecture dans la barre d\'état Montrer la lecture en cours Afficher les pistes en cours de lecture dans les autres activités d\'Ultrasonic Afficher le numéro du titre @@ -378,7 +344,6 @@ Tous les appareils Bluetooth Seulement les appareils audio (A2DP) Désactivé - Activer cela peut aider sur les anciens appareils Bluetooth lorsque Lecture/Pause ne fonctionne pas correctement Paramètres de debug Enregistrer les logs de debug dans des fichiers Les fichiers de log sont disponibles dans %1$s/%2$s @@ -410,10 +375,6 @@ %d titre %d titres - - %d jour restant à la période d\'essai - %d jours restant à la période d\'essai - Erreur de l\'API générique : %1$s diff --git a/ultrasonic/src/main/res/values-hu/strings.xml b/ultrasonic/src/main/res/values-hu/strings.xml index 5e0c9e00..117ec7bd 100644 --- a/ultrasonic/src/main/res/values-hu/strings.xml +++ b/ultrasonic/src/main/res/values-hu/strings.xml @@ -39,11 +39,8 @@ Név OK Tárolás (Megőrzés az eszközön) - Szünet - Lejátszás Lejátszás (Utolsóként) Lejátszás (Következőként) - Előző lejátszása Lejátszás Véletlen sorrendű lejátszás Nyilvános @@ -70,10 +67,7 @@ Kijelző be Ugrás az albumhoz Véletlen sorrendű - Véletlen sorrendű lejátszás Visualizer - Pufferelés - Letöltés - %s Véletlen sorrendű Lejátszási lista mentése sikeres. Lejátszási lista mentése sikertelen, próbálja később! @@ -104,7 +98,6 @@ Műfajok Zenék Kapcsolat nélküli - Véletlen sorrendű Véletlenszerű Csillaggal megjelölt Dalok @@ -115,26 +108,19 @@ Törölt lejátszási lista %s Lejátszási lista törlése sikertelen %s Kilépés - Navigáció Beállítások Frissítés Mediakönyvtár Kapcsolat nélküli médiák - Hálózati hiba történt! Újrapróbálkozás %1$d - %2$d. - %d előadó található a médiakönyvtárban. - Olvasás a kiszolgálóról… - Olvasás a kiszolgálóról… Kész! Lejátszási listák Módosítás Módosított lejátszási lista %s Lejátszási lista módosítása sikertelen %s - Kérem várjon!… Albumok Előadók Keresés Továbbiak Nincs találat, próbálja újra! - Érintse meg a kereséshez Dalok Keresés Nem található média! @@ -144,7 +130,6 @@ Mappa kiválasztása Műfajok nem találhatók! Nincs mentett lejátszási lista a kiszolgálón. - Csatlakozás a kiszolgálóhoz, kérem várjon! Megjelenés Pufferméret Letiltva @@ -184,8 +169,6 @@ Csevegés frissítési gyakorisága Könyvjelző törlése Könyvjelző törlése a dal lejátszása után. - Várólista törlése - Várólista törlése az összes dal lejátszása után. Keresési előzmények törlése Csatlakozási hiba! Albumok találati száma @@ -202,14 +185,11 @@ Dalok rendezése albumok szerint Dalok rendezése albumsorszám és dalsorszám szerint. Bitráta és fájlkiterjesztés megjelenítése az előadónév mellett. - Egybefüggő lejátszás - Kihagyás (dalszünet) nélküli egybefüggő lejátszás (Gapless). Zenefájlok elrejtése egyéb alkalmazások elől. Elrejtés A következő alkalomtól lép életbe, amikor az Android zenefájlokat keres a telefonon. Ugrás időintervalluma Adjon meg egy érvényes URL-t! - Adjon meg egy érvényes felhasználónevet (szóközt nem tartalmazhat)! Albumok max. találati száma Előadók max. találati száma 112 Kbps @@ -268,25 +248,13 @@ 75 Keresési előzmények törölve. Keresés beállításai - Lejátszási értesítések küldése Bluetooth-on. - Bluetooth értesítések küldése - Albumborító küldése Bluetooth-on (Problémát okozhat a Bluetooth értesítéseknél) - Albumborító Bluetooth-on Kiszolgálók kezelése Kiszolgáló címe Név Jelszó - Kiszolgáló eltávolítása Teljes méretű helyett átméretezett képek letöltése a kiszolgálóról (sávszélesség-takarékos). Albumborító átméretezés (Kiszolgáló-oldali) - Kiszolgáló Felhasználónév - Képernyőzár kezelése - Lejátszó-kezelőpanel megjelenítése a képernyőzáron. - Értesítések megjelenítése - Állandó kijelzés - Lejátszás jelzése az értesítési sávon, míg a várólista aktív. - Lejátszás jelzése az értesítési sávon. Lejátszó-kezelőpanel Lejátszó-kezelőpanel megjelenítése minden oldalon. Sorszám megjelenítése @@ -357,7 +325,6 @@ Minden Bluetooth eszköz Csak audio (A2DP) eszközök Kikapcsolva - Régebbi Bluetooth eszközök esetén segíthet, ha a Lejátszás/Szünet nem működik megfelelően Hibakeresési lehetőségek Hibakeresési napló írása fájlba A naplófájlok elérhetőek a következő helyen: %1$s/%2$s @@ -381,10 +348,6 @@ %d dal %d dal - - %d nap van hátra a próba időszakból. - %d nap van hátra a próba időszakból. - Általános api hiba: %1$s diff --git a/ultrasonic/src/main/res/values-it/strings.xml b/ultrasonic/src/main/res/values-it/strings.xml index 319c1b2d..b7fe167f 100644 --- a/ultrasonic/src/main/res/values-it/strings.xml +++ b/ultrasonic/src/main/res/values-it/strings.xml @@ -58,10 +58,7 @@ Schermo acceso Visualizza Album Casuale - Playlist casuale Visualizzatore - Buffering - In scaricamento - %s Riproduzione casuale Playlist salvata con successo Impossibile salvare la playlist, riprovare più tardi. @@ -92,7 +89,6 @@ Generi Musica Disconnesso - Riproduzione casuale Casuale Preferiti Canzoni @@ -103,25 +99,18 @@ Playlist %s eliminata Impossibile eliminare la playlist %s Esci - Navigazione Impostazioni Libreria Media Offline - Problema di rete. Tentativo %1$d di %2$d. - Ottenuti%d Artisti. - Lettura dal server. - Lettura dal server. Completato! Playlist Aggiorna Informazioni Aggiorna informazioni playlist per %s Impossibile aggiornare informazioni playlist per %s - Attendere, per favore… Album Artisti Cerca Mostra di più Nessun risultato, riprova per favore - Selezione per cercare Canzoni Cerca Nessun media trovato @@ -131,7 +120,6 @@ Seleziona cartella Nessun genere trovato Nessuna playlist salvata sul server - Server contattato, attendere. Aspetto Lunghezza buffer Disabilitato @@ -171,8 +159,6 @@ Intervallo Aggiornamento Chat Pulisci Segnalibro Pulisci segnalibro al completamento della riproduzione di una canzone - Pulisci Playlist - Pulisci playlist al completamento della riproduzione di tutte le canzoni Pulisci Storico Ricerca Errore connessione. Album predefiniti @@ -189,13 +175,10 @@ Ordina Canzoni secondo Disco Ordina lista canzoni secondo il numero disco e traccia Aggiungi nome artista con bitrare ed estensione file - Riproduzione Ininterrotta - Abilita riproduzione ininterrotta Nascondi file musicali di altre app Nascondi Da Altro Effettivo alla prossima scansione Android per file musicali sul telefono. Specifica un URL valido per favore. - Per favore specifica un nome utente valido (senza spazi) N° Max Album N° Max Artisti 112 Kbps @@ -251,24 +234,12 @@ 75 Storico ricerche eliminato Impostazioni ricerca - Invia notifiche di riproduzione via Bluetooth - Invia notifica Bluetooth - Invia copertine degli album tramite Bluetooth (potrebbe causare errori nelle notifiche) - Copertine Album tramite Bluetooth Indirizzo Server Nome Password - Elimina Server Scarica dal server le immagini ridimensionate (risparmia larghezza di banda) Ridimensionamento copertine Album lato server - Inutilizzato Username - Mostra i controlli del blocco schermo - Mostra i controlli di riproduzione sulla schermata di blocco - Mostra notifica - Mostra sempre notifica - Mostra sempre la notifica In Riproduzione quando viene popolata la playlist - Mostra la notifica In Riproduzione nella barra di stato Mostra In Riproduzione Mostra la traccia attualmente in riproduzione in tutte le attività Visualizza numero traccia diff --git a/ultrasonic/src/main/res/values-nl/strings.xml b/ultrasonic/src/main/res/values-nl/strings.xml index 93a1df76..850d0a53 100644 --- a/ultrasonic/src/main/res/values-nl/strings.xml +++ b/ultrasonic/src/main/res/values-nl/strings.xml @@ -41,11 +41,8 @@ Naam Oké Vastmaken - Pauzeren - Afspelen Laatste afspelen Volgende afspelen - Vorige afspelen Nu afspelen Willekeurig afspelen Openbaar @@ -75,10 +72,7 @@ Scherm aan Album tonen Willekeurig - Afspeellijst wordt willekeurig afgespeeld Visualisatie - Bezig met bufferen - Bezig met downloaden - %s Bezig met willekeurig afspelen Afspeellijst is opgeslagen. Afspeellijst kan niet worden opgeslagen. Probeer het later opnieuw. @@ -125,7 +119,6 @@ Muziek Offline %s - Server instellen - Willekeurig afspelen Willekeurig Favorieten Nummers @@ -139,26 +132,19 @@ Afspeellijst %s kan niet worden verwijderd Downloads Afsluiten - Navigatie Instellingen Verversen Mediabibliotheek Offline media - Er is een netwerkfout opgetreden. Bezig met opnieuw proberen; poging %1$d van %2$d. - %d artiesten opgehaald. - Bezig met uitlezen van server… - Klaar! Afspeellijsten Informatie bijwerken Afspeellijstinformatie bijgewerkt voor %s Kan afspeellijstinformatie voor %s niet bijwerken - Even geduld… Albums Artiesten Zoeken Meer tonen Geen overeenkomsten; probeer het opnieuw - Druk om te zoeken Nummers Zoeken Geen media gevonden @@ -170,7 +156,6 @@ Map kiezen Geen genres gevonden Geen opgeslagen afspeellijsten op server - Bezig met verbinden met server; even geduld… Uiterlijk Bufferduur Uitgeschakeld @@ -211,8 +196,6 @@ Chat-ververstussenpoos Bladwijzer verwijderen Bladwijzer verwijderen nadat nummer is afgespeeld - Afspeellijst wissen - Afspeellijst wissen nadat alle nummers zijn afgespeeld Zoekgeschiedenis wissen Verbindingsfout. Standaardalbums @@ -232,14 +215,11 @@ Bitsnelheid en bestandsextensie toevoegen aan artiestennaam Nu aan het afspelen tonen op afspeelscherm Toon ‘Nu aan het afspelen’ in de mediaweergave - Naadloze overgang - Naadloze overgang tussen nummers inschakelen Muziekbestanden verbergen voor andere apps. Verbergen voor andere apps Dit wordt toegepast bij de volgende keer dat Android je muziek doorzoekt. Overslaantussenpoos Geef een geldige URL op. - Geef een geldige gebruikersnaam op (geen spaties erachter). Max. aantal albums Max. aantal artiesten 112 Kbps @@ -300,28 +280,14 @@ 75 Zoekgeschiedenis gewist Zoekinstellingen - Afspeelmeldingen sturen via bluetooth - Bluetoothmelding sturen - Albumhoezen versturen via bluetooth (dit kan leiden tot mislukte bluetoothmeldingen) - Albumhoezen versturen via bluetooth - De lijst ‘Nu aan het afspelen’ wordt niet gedeeld met verbonden apparaten. Hierdoor wordt de comptabiliteit met AVCRP 1.3-apparaten hersteld als het huidige nummer niet wordt bijgewerkt. - ‘Nu aan het afspelen’-lijst niet delen Manage Servers Serveradres Naam Wachtwoord - Server verwijderen Verkleinde afbeeldingen ophalen van server in plaats van volledige (bespaart bandbreedte) Verkleinde afbeeldingen ophalen van server - Ongebruikt Gebruikersnaam Serverkleur - Vergrendelschermbediening tonen - Toont afspeelbediening op het vergrendelscherm - Melding tonen - Altijd melding tonen - Altijd een \"nu aan het afspelen\"-melding tonen tijdens het samenstellen van een afspeellijst - \"Nu aan het afspelen\"-melding tonen op de statusbalk \"Nu aan het afspelen\"-melding tonen Toont het momenteel afspelende nummer in alle activiteiten Itemnummer tonen @@ -401,8 +367,6 @@ Alle bluetoothapparaten Alleen audio-apparaten (AD2P) Uitgeschakeld - Bluetoothapparaat met één afspeel- en pauzeknop - Schakel dit in bij gebruik van oudere bluetoothapparaten om de afspeel- en pauzeerknop goed te laten werken Foutopsporingsopties Foutopsporingslogboek bijhouden De logboeken worden opgeslagen in %1$s/%2$s @@ -458,10 +422,6 @@ %d nummer ingevoegd na het huidige nummer %d nummers ingevoegd na het huidige nummer - - Nog %d dag over van de proefperiode - Nog %d dagen over van de proefperiode - Algemene API-fout: %1$s diff --git a/ultrasonic/src/main/res/values-pl/strings.xml b/ultrasonic/src/main/res/values-pl/strings.xml index 83d6c65b..c6031dd2 100644 --- a/ultrasonic/src/main/res/values-pl/strings.xml +++ b/ultrasonic/src/main/res/values-pl/strings.xml @@ -61,10 +61,7 @@ Ekran włączony Wyświetl album Wymieszaj - Playlista została wymieszana Efekt wizualny - Buforowanie - Pobieranie - %s Odtwarzanie losowe Playlista została zapisana. Błąd zapisu playlisty. Proszę spróbować później. @@ -95,7 +92,6 @@ Gatunki Muzyka Offline - Losowo Losowe Ulubione Utwory @@ -106,26 +102,19 @@ Usunięto playlistę %s Usunięcie playlisty %s nie powiodło się Zakończ - Nawigacja Ustawienia Refresh Biblioteka mediów Media offline - Wystąpił błąd sieci. Ponawiam %1$d z %2$d. - Znaleziono %d artystów. - Trwa odczyt z serwera. - Odczyt z serwera zakończony! Playlisty Aktualizacja informacji Zaktualizowano informacje dla playlisty %s Błąc podczas aktualizacji playlisty %s - Proszę czekać… Albumy Artyści Wyszukaj Wyświetl więcej Brak wyników, proszę spróbować ponownie - Kliknij, aby wyszukać Utwory Wyszukiwanie Brak mediów @@ -135,7 +124,6 @@ Wybierz folder Brak gatunków Brak zapisanych playlist na serwerze - Trwa łączenie z serwerem, proszę czekać. Wygląd Wielkość bufora Wyłączone @@ -175,8 +163,6 @@ Okres odświeżania czatu Czyszczenie zakładek Czyść zakładkę po zakończeniu odtwarzania utworu - Czyszczenie playlist - Czyść playlistę po zakończeniu odtwarzania wszystkich utworów Wyczyść historię wyszukiwania Błąd połączenia. Domyślna ilość wyników - albumy @@ -193,14 +179,11 @@ Sortuj utwory wg dysku Sortuje listę utworów wg numeru dysku i numeru utworu Dołącza bitrate i typ pliku do nazwy artysty - Odtwarzanie bez przerw - Włącz odtwarzanie bez przerw między utworami Ukrywa pliki muzyczne przed innymi aplikacjami. Ukryj pliki Efekt widoczny będzie po następnym skanowaniu muzyki przez system Android Skok przewijania Proszę wprowadzić prawidłowy URL - Proszę wprowadzić prawidłową nazwę użytkownika (bez spacji na końcu) Maksymalna ilość wyników - albumy Maksymalna ilość wyników - artyści 112 Kbps @@ -257,25 +240,13 @@ 75 Wyczyść historię wyszukiwania Ustawienia wyszukiwania - Wysyła powiadomienia o odtwarzaniu przez Bluetooth - Wysyłaj powiadomienia Bluetooth - Wysyła okładki przez Bluetooth (może powodować problemy z powiadomieniami) - Okładki przez Bluetooth Manage Servers Adres serwera Nazwa Hasło - Usuń serwer Pobiera przeskalowane obrazy z serwera zamiast pełnego rozmiaru (oszczędza ilość przesyłanych danych) Skalowanie okładek po stronie serwera - Bez nazwy Nazwa użytkownika - Wyświetlaj na ekranie blokady - Wyświetla widżet odtwarzacza na ekranie blokady - Wyświetlaj powiadomienia - Zawsze wyświetlaj powiadomienia - Zawsze wyświetla powiadomienia o odtwarzanych utworach, gdy playlista jest wypełniona - Wyświetla powiadomienia o odtwarzanym utworze na pasku statusu Wyświetlaj powiadomienia o utworach Wyświetla bieżący utwór we wszystkich aktywnościach Wyświetlaj numer utworu @@ -361,12 +332,6 @@ %d utworów %d utworów - - %d dzień pozostał do zakończenia okresu próbnego - %d dni pozostały do zakończenia okresu próbnego - %d dni pozostało do zakończenia okresu próbnego - %d dni pozostało do zakończenia okresu próbnego - Ogólny błąd interfejsu API: %1$s diff --git a/ultrasonic/src/main/res/values-pt-rBR/strings.xml b/ultrasonic/src/main/res/values-pt-rBR/strings.xml index cb8808e8..4fafe9e8 100644 --- a/ultrasonic/src/main/res/values-pt-rBR/strings.xml +++ b/ultrasonic/src/main/res/values-pt-rBR/strings.xml @@ -41,11 +41,8 @@ Nome OK Fixar - Pausar - Tocar Tocar por Último Tocar na Próxima - Tocar a Anterior Tocar Agora Tocar Aleatoriamente Público @@ -73,10 +70,7 @@ Tela Ligada Mostrar Álbum Misturar - Playlist foi misturada Visualizador - Armazenando - Baixando - %s Tocando misturado Playlist salva com sucesso. Falha ao salvar a playlist, Tente mais tarde. @@ -107,7 +101,6 @@ Gêneros Música Offline - Misturar Músicas Aleatórias Favoritas Músicas @@ -121,26 +114,19 @@ Falha ao excluir a playlist %s Downloads Sair - Navegação Configurações Atualizar Biblioteca de Mídia Mídia Offline - Ocorreu um erro de rede. Tentativa %1$d de %2$d. - Obtive %d Artistas. - Lendo do servidor. - Lendo do servidor. Pronto! Playlists Atualizar Informação Informação da playlist atualizada para %s Falha ao atualizar a informação da playlist para %s - Por favor aguarde… Álbuns Artistas Pesquisar Mostrar Mais Nada coincide, tente novamente - Clique para pesquisar Músicas Pesquisar Nenhuma mídia encontrada @@ -150,7 +136,6 @@ Selecionar Pasta Nenhum gênero encontrado Não existe nenhuma playlist no servidor - Contactando o servidor, por favor aguarde. Aparência Tamanho do Buffer Desativado @@ -190,8 +175,6 @@ Intervalo de Atualização do Chat Limpar Favoritos Limpar favoritos após terminar de tocar a música - Limpar Playlist - Limpar a playlist após terminar de tocar todas as músicas Limpar Histórico de Pesquisas Falha na conexão. Álbuns Padrões @@ -209,14 +192,11 @@ Classificar músicas pelo número do álbum e faixas Mostrar Bitrate se Sufixo do Arquivo Adicionar o nome do artista com a taxa de bits e sufixo do arquivo - Reprodução sem Interrupção - Ativar reprodução sem interrupção Esconder arquivos de músicas de outros aplicativos Esconder de Outros Será efetivado na próxima vez que o Android procurar por músicas em seu celular. Intervalo de Salto Especifique uma URL válida. - Especifique um nome de usuário válido (sem espaços). Máximo de Álbuns Máximo de Artistas 112 Kbps @@ -275,27 +255,13 @@ 75 Histórico de pesquisas apagado Configurações de Pesquisa - Enviar notificações de reprodução via Bluetooth - Notificações via Bluetooth - Enviar a arte do álbum via Bluetooth (Pode causar falhas nas notificações do Bluetooth) - Arte do Álbum via Bluetooth - A Lista Tocando Agora não será enviada aos dispositivos conectados. Isso pode restaurar a compatibilidade com dispositivos AVRCP 1.3 quando a exibição da trilha atual não é atualizada - Desativar Envio da Lista Tocando Agora Gerenciar Servidores Endereço do Servidor Nome Senha - Excluir Servidor Baixar imagens reduzidas do servidor ao invés do tamanho completo (economiza banda) Reduzir Arte dos Álbuns - Não usado Login - Controles na Tela de Bloqueio - Mostrar controles de reprodução na tela de bloqueio - Mostrar Notificações - Sempre Mostrar Notificações - Sempre mostrar a reprodução atual quando uma playlist é preenchida - Mostrar a notificação de reprodução atual na barra de status Mostrar Reprodução Atual Mostrar a faixa tocada atualmente em todas as atividades Mostrar o Número da Faixa @@ -370,7 +336,6 @@ Todos os dispositivos Bluetooth Somente dispositivos de áudio (A2DP) Desativado - Ativar isso pode ajudar com dispositivos Bluetooth mais antigos quando Reproduzir/Pausar não funciona corretamente Opções de Depuração Log de Depuração em Arquivo Os arquivos com log estão disponíveis em %1$s/%2$s @@ -399,10 +364,6 @@ %d música %d músicas - - %d dia restante do período de teste - %d dias restantes do período de teste - Erro de api genérico: %1$s diff --git a/ultrasonic/src/main/res/values-pt/strings.xml b/ultrasonic/src/main/res/values-pt/strings.xml index 834cd696..33821f9b 100644 --- a/ultrasonic/src/main/res/values-pt/strings.xml +++ b/ultrasonic/src/main/res/values-pt/strings.xml @@ -61,10 +61,7 @@ Ecrã Ligado Mostrar Álbum Misturar - Playlist foi misturada Visualizador - Armazenando - Descarregando - %s Tocando misturado Playlist salva com sucesso. Falha ao salvar a playlist, Tente mais tarde. @@ -95,7 +92,6 @@ Gêneros Música Offline - Misturar Músicas Aleatórias Favoritas Músicas @@ -106,26 +102,19 @@ Playlist apagada %s Falha ao apagar a playlist %s Sair - Navegação Configurações Refresh Biblioteca de Mídia Mídia Offline - Ocorreu um erro de rede. Tentativa %1$d de %2$d. - Obtive %d Artistas. - Lendo do servidor. - Lendo do servidor. Pronto! Playlists Atualizar Informação Informação da playlist atualizada para %s Falha ao atualizar a informação da playlist para %s - Por favor aguarde… Álbuns Artistas Pesquisar Mostrar Mais Nada coincide, tente novamente - Clique para pesquisar Músicas Pesquisar Nenhuma mídia encontrada @@ -135,7 +124,6 @@ Selecionar Pasta Nenhum gênero encontrado Não existe nenhuma playlist no servidor - Contactando o servidor, por favor aguarde. Aparência Tamanho do Buffer Disabilitando @@ -175,8 +163,6 @@ Intervalo de Atualização do Chat Limpar Favoritos Limpar favoritos após terminar de tocar a música - Limpar Playlist - Limpar a playlist após terminar de tocar todas as músicas Limpar Histórico de Pesquisas Falha na conexão. Álbuns Padrões @@ -193,14 +179,11 @@ Classificar Músicas por Álbum Classificar músicas pelo número do álbum e faixas. Adiciona o nome do artista com a taxa de bits e sufixo do ficheiro - Reprodução sem Interrupção - Habilita reprodução sem interrupção Esconder músicas de outros aplicativos. Esconder de Outros Será realizado na próxima vez que o Android procurar por músicas em seu telemóvel. Intervalo de Salto Especifique uma URL válida. - Especifique um nome de usuário válido (sem espaços). Máximo de Álbuns Máximo de Artistas 112 Kbps @@ -257,25 +240,13 @@ 75 Histórico de pesquisas apagado Configurações de Pesquisa - Envia notificações de reprodução via Bluetooth - Notificações via Bluetooth - Envia a arte do álbum via Bluetooth (Pode causar falhas nas notificações do Bluetooth) - Arte do Álbum via Bluetooth Manage Servers Endereço do Servidor Nome Senha - Apagar Servidor Descarrega imagens reduzidas do servidor ao invés do tamanho completo (economiza banda) Reduzir Arte dos Álbuns - Não usado Login - Controles no Ecrã de Bloqueio - Mostra controles de reprodução no ecrã de bloqueio - Mostrar Notificações - Sempre Mostrar Notificações - Sempre mostrar a reprodução atual quando uma playlist é preenchida - Mostra a notificação de reprodução atual na barra de estado Mostrar Reprodução Atual Mostrar a faixa tocada atualmente em todas as atividades Mostrar o Número da Faixa @@ -359,10 +330,6 @@ %d música %d músicas - - %d dia restante do período de teste - %d dias restantes do período de teste - Erro de api genérico: %1$s diff --git a/ultrasonic/src/main/res/values-ru/strings.xml b/ultrasonic/src/main/res/values-ru/strings.xml index 2e5b1935..27ae0982 100644 --- a/ultrasonic/src/main/res/values-ru/strings.xml +++ b/ultrasonic/src/main/res/values-ru/strings.xml @@ -41,11 +41,8 @@ Имя Ок Пин - Пауза - Воспроизведение Воспроизвести последний Воспроизвести следующий - Воспроизвести предыдущий Воспроизвести сейчас Играть в случайном порядке Публичный @@ -75,10 +72,7 @@ Включение дисплея Показать альбом Случайное воспроизведение - Плейлист в случайном порядке Визуализатор - Буферизация - Загрузка - %s Игра в случайном порядке Плейлист был успешно сохранен. Не удалось сохранить плейлист, попробуйте позже. @@ -119,7 +113,6 @@ Жанры Музыка Не в сети - Играть в случайном порядке Случайный Отмеченные Песни @@ -133,26 +126,19 @@ Не удалось удалить плейлист %s Загрузки Выход - Навигация Настройки Обновить Медиа библиотека Медиа Оффлайн - Произошла ошибка сети. Повторная %1$d из %2$d. - Получил %d исполнители. - Чтение с сервера - Чтение с сервера. Готово! Плейлисты Обновление информации Обновлена информация о плейлисте для %s Не удалось обновить информацию о плейлисте для %s - Пожалуйста, подождите#8230; Альбомы Исполнители Поиск Показать еще Нет совпадений, пожалуйста попробуйте еще раз - Нажми для поиска Песни Поиск Медиа не найдена @@ -162,7 +148,6 @@ Выбрать папку Жанры не найдены Нет сохраненных плейлистов на сервере - Свяжитесь с сервером, пожалуйста, подождите. Появление Размер буфера Отключить @@ -202,8 +187,6 @@ Интервал обновления чата Очистить закладку Очистить закладку после завершения воспроизведения песни - Очистить плейлист - Очистите плейлист после завершения воспроизведения всех песен Очистить историю поиска Ошибка подключения. Альбомы по умолчанию @@ -220,14 +203,11 @@ Время кэша каталогов Сортировать список песен по номеру диска и треку Добавить имя исполнителя с битрейтом и суффиксом файла - Воспроизведение без промежутка - Включить воспроизведение без паузы Включить воспроизведение без паузы Скрыть от других Вступает в силу в следующий раз, Android сканирует ваш телефон на предмет музыки Пропустить интервал Пожалуйста, укажите действительный URL. - Пожалуйста, укажите правильное имя пользователя (без пробелов). Максимум альбомов Максимум исполнителей 112 Kbps @@ -286,25 +266,13 @@ 75 История поиска очищена Настройки поиска - Отправлять уведомления о воспроизведении через Bluetooth - Отправить уведомление Bluetooth - Отправить обложку альбома через Bluetooth (может привести к сбою уведомлений Bluetooth) - Обложка альбома через Bluetooth Управление серверами Адрес сервера Имя Пароль - Удалить сервер Загрузка масштабированных изображений с сервера вместо полноразмерного (экономит трафик) Серверное масштабирование обложек альбомов - Неиспользуемый Имя пользователя - Показать блокировку экрана - Показать элементы управления воспроизведением на экране блокировки - Показывать уведомления - Всегда показывать уведомления - Всегда показывать воспроизведение, когда плейлист заполнен - Показать уведомление о воспроизведении в строке состояния Показать что сейчас играет Показать текущий воспроизводимый трек во всех активностях Показать номер трека @@ -375,7 +343,6 @@ Все устройства Bluetooth Только аудио (A2DP) устройства Отключено - Включение этого может помочь со старыми устройствами Bluetooth, когда Воспроизведение/Пауза работает некорректно. Настройки отладки Записать журнал отладки в файл Файлы журнала доступны по адресу %1$s/%2$s @@ -404,12 +371,6 @@ %d песен %d песен - - Остался %d день пробного периода - Осталось %d дня пробного периода - Осталось %d дней пробного периода - Осталось %d дней пробного периода - Общая ошибка API: %1$s diff --git a/ultrasonic/src/main/res/values-zh-rCN/strings.xml b/ultrasonic/src/main/res/values-zh-rCN/strings.xml index 28afd246..bcb04683 100644 --- a/ultrasonic/src/main/res/values-zh-rCN/strings.xml +++ b/ultrasonic/src/main/res/values-zh-rCN/strings.xml @@ -41,11 +41,8 @@ 名称 确定 固定 - 暂停 - 播放 最后一首 下一首 - 上一首 现在播放 随机播放 公开 @@ -75,10 +72,7 @@ 开启屏幕常亮 显示专辑 随机 - 已随机排列播放列表 可视化 - 缓冲中 - 下载中 - %s 随机播放 已成功保存播放列表。 保存播放列表失败,请重试。 @@ -110,7 +104,6 @@ 音乐 离线 %s - 已设置服务器 - 随机播放 随机 收藏夹 歌曲 @@ -124,26 +117,19 @@ 播放列表删除失败%s 下载 退出 - 导航 设置 刷新 媒体库 离线媒体 - 发生网络错误,正在重试 %1$d of %2$d. - 有 %d 位艺术家。 - 正在加载服务器。 - 正在加载服务器。完成! 播放列表 更新信息 已更新此播放列表信息 - %s 更新播放列表信息失败 - %s - 请稍等… 专辑 艺人 搜索 显示更多 没有匹配的结果,请重试 - 点击搜索 歌曲 搜索 找不到歌曲 @@ -154,7 +140,6 @@ 选择文件夹 找不到流派 服务器上没有保存的播放列表 - 服务器连接中,请稍等。 外观 缓冲长度 已禁用 @@ -195,8 +180,6 @@ 聊天消息刷新时间间隔 清空书签 歌曲播放完毕后清除书签 - 清空播放列表 - 所有歌曲播放完毕后清空播放列表 清空搜索历史 连接失败 默认专辑 @@ -214,14 +197,11 @@ 按光盘编号和曲目编号对歌曲列表进行排序 展示比特率和文件后缀 在艺术家姓名后追加比特率和文件后缀 - 无缝播放 - 启用无缝播放 隐藏来自其他应用的音乐 隐藏其他来源 在安卓系统下次扫描音乐时生效。 快进间隔 请填写有效的URL。 - 请填写有效用户名 (请去除尾部空格)。 最大专辑 最大艺术家 112 Kbps @@ -280,28 +260,14 @@ 75 搜索记录已清除 搜索设置 - 通过蓝牙发送播放通知 - 发送蓝牙通知 - 通过蓝牙发送专辑封面(可能导致蓝牙通知失败) - 通过蓝牙发送专辑封面 - 现在播放列表不会发送到已连接的设备。 当前曲目显示未更新时,这可能会恢复AVRCP 1.3的设备的兼容性。 - 禁用发送正在播放列表 管理服务器 服务器地址 名称 密码 - 删除服务器 从服务器下载缩放图像而不是全尺寸(节省数据流量) 服务器端专辑图片缩放 - 未启用 用户名 服务器颜色 - 锁屏显示控制器 - 在锁定屏幕上显示播放控件 - 显示通知 - 总是显示通知 - 当播放列表有音乐时,总是在通知栏显示播放信息 - 在状态栏中显示正在播放通知 显示正在播放 在所有活动页面显示正在播放信息 显示曲目编号 @@ -378,7 +344,6 @@ 所有蓝牙设备 仅音频 (A2DP) 设备 已禁用 - 当播放/暂停无法正常工作时,启用此功能可能对较旧的蓝牙设备有所帮助 调试选项 将调试日志写入文件 日志文件可在 %1$s/%2$s 获取 @@ -427,9 +392,6 @@ 在当前歌曲之后插入了 %d 首歌曲。 - - 试用期还剩 %d 天 - 一般api错误: %1$s diff --git a/ultrasonic/src/main/res/values/playback_preferences_keys.xml b/ultrasonic/src/main/res/values/playback_preferences_keys.xml deleted file mode 100644 index 99c2cfa9..00000000 --- a/ultrasonic/src/main/res/values/playback_preferences_keys.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - playback.resume_play_on_headphones_plug - \ No newline at end of file diff --git a/ultrasonic/src/main/res/values/setting_keys.xml b/ultrasonic/src/main/res/values/setting_keys.xml new file mode 100644 index 00000000..3378560d --- /dev/null +++ b/ultrasonic/src/main/res/values/setting_keys.xml @@ -0,0 +1,4 @@ + + + playback.resume_play_on_headphones_plug + \ No newline at end of file diff --git a/ultrasonic/src/main/res/values/strings.xml b/ultrasonic/src/main/res/values/strings.xml index 3228ef45..4ace333f 100644 --- a/ultrasonic/src/main/res/values/strings.xml +++ b/ultrasonic/src/main/res/values/strings.xml @@ -41,11 +41,8 @@ Name OK Pin - Pause - Play Play Last Play Next - Play Previous Play Now Play Shuffled Public @@ -75,10 +72,10 @@ Screen On Show Album Shuffle - Playlist was shuffled + Shuffle mode enabled + Shuffle mode disabled Visualizer - Buffering - Downloading - %s + Buffering … Playing shuffle Playlist was successfully saved. Failed to save playlist, please try later. @@ -125,7 +122,6 @@ Music Offline %s - Set up Server - Shuffle Play Random Starred Songs @@ -139,26 +135,19 @@ Failed to delete playlist %s Downloads Exit - Navigation Settings Refresh Media Library Offline Media - A network error occurred. Retrying %1$d of %2$d. - Got %d Artists. - Reading from server. - Reading from server. Done! Playlists Update Information Updated playlist information for %s Failed to update playlist information for %s - Please wait… Albums Artists Search Show More No matches, please try again - Click to search Songs Search No media found @@ -170,9 +159,6 @@ Select Folder No genres found No saved playlists on server - Contacting server, please wait. - allowSelfSignedCertificate - enableLdapUserSupport Appearance Buffer Length Disabled @@ -213,8 +199,6 @@ Chat Refresh Interval Clear Bookmark Clear bookmark upon completion of playback of a song - Clear Playlist - Clear the playlist upon completion of playback of all songs Clear Search History Connection failure. Default Albums @@ -234,14 +218,11 @@ Append artist name with bitrate and file suffix Show Now Playing on Play Switch to Now Playing after starting playback in media view - Gapless Playback - Enable gapless playback Hide music files from other apps. Hide From Other Takes effect next time Android scans your phone for music. Skip Interval Please specify a valid URL. - Please specify a valid username (no trailing spaces). Max Albums Max Artists 112 Kbps @@ -275,6 +256,13 @@ Override the language You need to restart the app after changing the language Playback Control Settings + Resume when a Bluetooth device is connected + Pause when a Bluetooth device is disconnected + All Bluetooth devices + Only audio (A2DP) devices + Disabled + Resume on headphones insertion + App will resume paused playback on wired headphones insertion into device. Songs To Preload 1 song 10 songs @@ -282,8 +270,6 @@ 3 songs 5 songs Unlimited - Resume on headphones insertion - App will resume paused playback on wired headphones insertion into device. Remember to set up your user and password in the Scrobble service(s) on the server Scrobble my plays 1 @@ -302,28 +288,14 @@ 75 Search history cleared Search Settings - Send playback notifications via Bluetooth - Send Bluetooth Notification - Send album art over Bluetooth (May cause Bluetooth notifications to fail) - Album Art Over Bluetooth - Now Playing List won\'t be sent to connected devices. This may restore compatibility with AVRCP 1.3 devices, when current track display is not updated - Disable sending of Now Playing List Manage Servers Server Address Name Password - Remove Server Download scaled images from the server instead of full size (saves bandwidth) Server-Side Album Art Scaling - Unused Username Server color - Show Lock Screen Controls - Show playback controls on the lock screen - Show Notification - Always Show Notification - Always show now playing notification when playlist is populated - Show now playing notification in the status bar Show Now Playing Show currently playing track in all activities Show Track Number @@ -399,14 +371,6 @@ Show Artist albumArt Multiple Years - http://example.com - Resume when a Bluetooth device is connected - Pause when a Bluetooth device is disconnected - All Bluetooth devices - Only audio (A2DP) devices - Disabled - Bluetooth device with a single Play/Pause button - Enabling this may help with older Bluetooth devices when Play/Pause doesn\'t work correctly Debug options Write debug log to file The log files are available at %1$s/%2$s @@ -465,10 +429,6 @@ %d song inserted after current song %d songs inserted after current song - - %d day left of trial period - %d days left of trial period - Generic api error: %1$s @@ -486,4 +446,8 @@ Use five star rating for songs Use five star rating system for songs instead of simply starring/unstarring items. + Use hardware playback (experimental) + Try to play the media using the media decoder chip on your phone. This can improve battery usage. + + diff --git a/ultrasonic/src/main/res/xml/settings.xml b/ultrasonic/src/main/res/xml/settings.xml index 99c9fc0a..04f81008 100644 --- a/ultrasonic/src/main/res/xml/settings.xml +++ b/ultrasonic/src/main/res/xml/settings.xml @@ -77,18 +77,6 @@ a:summary="@string/settings.download_transition_summary" a:title="@string/settings.download_transition" app:iconSpaceReserved="false"/> - - @@ -116,18 +104,18 @@ a:key="pauseOnBluetoothDevice" a:title="@string/settings.playback.pause_on_bluetooth_device" app:iconSpaceReserved="false"/> - + - - - - - -