commit
d8b5b774ee
|
@ -1,12 +0,0 @@
|
|||
package org.moire.ultrasonic.domain
|
||||
|
||||
enum class PlayerState {
|
||||
IDLE,
|
||||
DOWNLOADING,
|
||||
PREPARING,
|
||||
PREPARED,
|
||||
STARTED,
|
||||
STOPPED,
|
||||
PAUSED,
|
||||
COMPLETED
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
@ -2,41 +2,24 @@
|
|||
<SmellBaseline>
|
||||
<ManuallySuppressedIssues/>
|
||||
<CurrentIssues>
|
||||
<ID>ComplexCondition:DownloadHandler.kt$DownloadHandler.<no name provided>$!append && !playNext && !unpin && !background</ID>
|
||||
<ID>ComplexMethod:DownloadFile.kt$DownloadFile.DownloadTask$override fun execute()</ID>
|
||||
<ID>ImplicitDefaultLocale:EditServerFragment.kt$EditServerFragment.<no name provided>$String.format( "%s %s", resources.getString(R.string.settings_connection_failure), getErrorMessage(error) )</ID>
|
||||
<ID>ImplicitDefaultLocale:FileLoggerTree.kt$FileLoggerTree$String.format("Failed to write log to %s", file)</ID>
|
||||
<ID>ImplicitDefaultLocale:FileLoggerTree.kt$FileLoggerTree$String.format("Log file rotated, logging into file %s", file?.name)</ID>
|
||||
<ID>ImplicitDefaultLocale:FileLoggerTree.kt$FileLoggerTree$String.format("Logging into file %s", file?.name)</ID>
|
||||
<ID>ImplicitDefaultLocale:LocalMediaPlayer.kt$LocalMediaPlayer.BufferTask$String.format("BufferTask (%s)", downloadFile)</ID>
|
||||
<ID>ImplicitDefaultLocale:LocalMediaPlayer.kt$LocalMediaPlayer.CheckCompletionTask$String.format("CheckCompletionTask (%s)", downloadFile)</ID>
|
||||
<ID>ImplicitDefaultLocale:ShareHandler.kt$ShareHandler$String.format("%d:%s", timeSpanAmount, timeSpanType)</ID>
|
||||
<ID>LongMethod:DownloadFile.kt$DownloadFile.DownloadTask$override fun execute()</ID>
|
||||
<ID>LongMethod:EditServerFragment.kt$EditServerFragment$override fun onViewCreated(view: View, savedInstanceState: Bundle?)</ID>
|
||||
<ID>LongMethod:LocalMediaPlayer.kt$LocalMediaPlayer$@Synchronized private fun doPlay(downloadFile: DownloadFile, position: Int, start: Boolean)</ID>
|
||||
<ID>LongMethod:NavigationActivity.kt$NavigationActivity$override fun onCreate(savedInstanceState: Bundle?)</ID>
|
||||
<ID>LongMethod:ShareHandler.kt$ShareHandler$private fun showDialog( fragment: Fragment, shareDetails: ShareDetails, swipe: SwipeRefreshLayout?, cancellationToken: CancellationToken )</ID>
|
||||
<ID>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) )</ID>
|
||||
<ID>LongParameterList:ServerRowAdapter.kt$ServerRowAdapter$( private var context: Context, passedData: Array<ServerSetting>, private val model: ServerSettingsModel, private val activeServerProvider: ActiveServerProvider, private val manageMode: Boolean, private val serverDeletedCallback: ((Int) -> Unit), private val serverEditRequestedCallback: ((Int) -> Unit) )</ID>
|
||||
<ID>MagicNumber:ActiveServerProvider.kt$ActiveServerProvider$8192</ID>
|
||||
<ID>MagicNumber:LocalMediaPlayer.kt$LocalMediaPlayer.<no name provided>$60000</ID>
|
||||
<ID>MagicNumber:LocalMediaPlayer.kt$LocalMediaPlayer.BufferTask$100000</ID>
|
||||
<ID>MagicNumber:LocalMediaPlayer.kt$LocalMediaPlayer.BufferTask$8</ID>
|
||||
<ID>MagicNumber:LocalMediaPlayer.kt$LocalMediaPlayer.BufferTask$86400L</ID>
|
||||
<ID>MagicNumber:LocalMediaPlayer.kt$LocalMediaPlayer.BufferTask$8L</ID>
|
||||
<ID>MagicNumber:LocalMediaPlayer.kt$LocalMediaPlayer.CheckCompletionTask$5000L</ID>
|
||||
<ID>MagicNumber:MediaPlayerService.kt$MediaPlayerService$3</ID>
|
||||
<ID>MagicNumber:MediaPlayerService.kt$MediaPlayerService$4</ID>
|
||||
<ID>MagicNumber:JukeboxMediaPlayer.kt$JukeboxMediaPlayer$0.05f</ID>
|
||||
<ID>MagicNumber:JukeboxMediaPlayer.kt$JukeboxMediaPlayer$50</ID>
|
||||
<ID>MagicNumber:RESTMusicService.kt$RESTMusicService$206</ID>
|
||||
<ID>NestedBlockDepth:DownloadFile.kt$DownloadFile.DownloadTask$override fun execute()</ID>
|
||||
<ID>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 )</ID>
|
||||
<ID>NestedBlockDepth:MediaPlayerService.kt$MediaPlayerService$private fun setupOnSongCompletedHandler()</ID>
|
||||
<ID>TooGenericExceptionCaught:FileLoggerTree.kt$FileLoggerTree$x: Throwable</ID>
|
||||
<ID>TooGenericExceptionCaught:LocalMediaPlayer.kt$LocalMediaPlayer$ex: Exception</ID>
|
||||
<ID>TooGenericExceptionCaught:LocalMediaPlayer.kt$LocalMediaPlayer$exception: Throwable</ID>
|
||||
<ID>TooGenericExceptionCaught:LocalMediaPlayer.kt$LocalMediaPlayer$x: Exception</ID>
|
||||
<ID>TooGenericExceptionCaught:LocalMediaPlayer.kt$LocalMediaPlayer.PositionCache$e: Exception</ID>
|
||||
<ID>TooGenericExceptionThrown:DownloadFile.kt$DownloadFile.DownloadTask$throw RuntimeException( String.format(Locale.ROOT, "Download of '%s' was cancelled", track) )</ID>
|
||||
<ID>TooManyFunctions:MediaPlayerService.kt$MediaPlayerService : Service</ID>
|
||||
<ID>TooGenericExceptionCaught:JukeboxMediaPlayer.kt$JukeboxMediaPlayer$x: Throwable</ID>
|
||||
<ID>TooGenericExceptionCaught:JukeboxMediaPlayer.kt$JukeboxMediaPlayer.TaskQueue$x: Throwable</ID>
|
||||
<ID>TooGenericExceptionThrown:Downloader.kt$Downloader.DownloadTask$throw RuntimeException( String.format( Locale.ROOT, "Download of '%s' was cancelled", downloadFile.track ) )</ID>
|
||||
<ID>TooManyFunctions:RESTMusicService.kt$RESTMusicService : MusicService</ID>
|
||||
<ID>UtilityClassWithPublicConstructor:FragmentTitle.kt$FragmentTitle</ID>
|
||||
</CurrentIssues>
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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" }
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -1,37 +1,15 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<issues format="6" by="lint 7.0.4" type="baseline" client="gradle" name="AGP (7.0.4)" variant="all" version="7.0.4">
|
||||
<issues format="6" by="lint 7.1.1" type="baseline" client="gradle" dependencies="false" name="AGP (7.1.1)" variant="all" version="7.1.1">
|
||||
|
||||
<issue
|
||||
id="InflateParams"
|
||||
message="Avoid passing `null` as the view root (needed to resolve layout parameters on the inflated layout's root element)"
|
||||
errorLine1=" View view = inflater.inflate(R.layout.jukebox_volume, null);"
|
||||
errorLine2=" ~~~~">
|
||||
errorLine1=" val view = inflater.inflate(R.layout.jukebox_volume, null)"
|
||||
errorLine2=" ~~~~">
|
||||
<location
|
||||
file="src/main/java/org/moire/ultrasonic/service/JukeboxMediaPlayer.java"
|
||||
line="477"
|
||||
column="58"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="Typos"
|
||||
message=""lizensiert" is a common misspelling; did you mean "lizenziert" ?"
|
||||
errorLine1=" <string name="settings.testing_unlicensed">Verbindung OK, Server nicht lizensiert.</string>"
|
||||
errorLine2=" ^">
|
||||
<location
|
||||
file="src/main/res/values-de/strings.xml"
|
||||
line="289"
|
||||
column="76"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="PluralsCandidate"
|
||||
message="Formatting %d followed by words ("Artists"): This should probably be a plural rather than a string"
|
||||
errorLine1=" <string name="parser.artist_count">Got %d Artists.</string>"
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/values/strings.xml"
|
||||
line="134"
|
||||
column="5"/>
|
||||
file="src/main/kotlin/org/moire/ultrasonic/service/JukeboxMediaPlayer.kt"
|
||||
line="331"
|
||||
column="66"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
|
@ -41,7 +19,7 @@
|
|||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/values/strings.xml"
|
||||
line="151"
|
||||
line="154"
|
||||
column="5"/>
|
||||
</issue>
|
||||
|
||||
|
@ -56,17 +34,6 @@
|
|||
column="73"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="SetJavaScriptEnabled"
|
||||
message="Using `setJavaScriptEnabled` can introduce XSS vulnerabilities into your application, review carefully"
|
||||
errorLine1=" webView.getSettings().setJavaScriptEnabled(true);"
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/java/org/moire/ultrasonic/fragment/AboutFragment.java"
|
||||
line="51"
|
||||
column="9"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="TrustAllX509TrustManager"
|
||||
message="`checkClientTrusted` is empty, which could cause insecure network traffic due to trusting arbitrary TLS/SSL certificates presented by peers">
|
||||
|
@ -88,18 +55,29 @@
|
|||
errorLine2=" ~~~~~~~~">
|
||||
<location
|
||||
file="src/main/AndroidManifest.xml"
|
||||
line="146"
|
||||
line="151"
|
||||
column="10"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="ExportedReceiver"
|
||||
message="Exported receiver does not require permission"
|
||||
errorLine1=" <receiver android:name=".receiver.UltrasonicIntentReceiver">"
|
||||
errorLine1=" <receiver android:name=".receiver.UltrasonicIntentReceiver""
|
||||
errorLine2=" ~~~~~~~~">
|
||||
<location
|
||||
file="src/main/AndroidManifest.xml"
|
||||
line="81"
|
||||
line="75"
|
||||
column="10"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="ExportedService"
|
||||
message="Exported service does not require permission"
|
||||
errorLine1=" <service android:name=".playback.PlaybackService""
|
||||
errorLine2=" ~~~~~~~">
|
||||
<location
|
||||
file="src/main/AndroidManifest.xml"
|
||||
line="65"
|
||||
column="10"/>
|
||||
</issue>
|
||||
|
||||
|
@ -114,171 +92,6 @@
|
|||
column="10"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="IntentFilterExportedReceiver"
|
||||
message="As of Android 12, `android:exported` must be set; use `true` to make the activity \
available to other apps, and `false` otherwise."
|
||||
errorLine1=" <receiver android:name=".receiver.MediaButtonIntentReceiver">"
|
||||
errorLine2=" ~~~~~~~~">
|
||||
<location
|
||||
file="src/main/AndroidManifest.xml"
|
||||
line="76"
|
||||
column="10"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="IntentFilterExportedReceiver"
|
||||
message="As of Android 12, `android:exported` must be set; use `true` to make the activity \
available to other apps, and `false` otherwise."
|
||||
errorLine1=" <receiver android:name=".receiver.UltrasonicIntentReceiver">"
|
||||
errorLine2=" ~~~~~~~~">
|
||||
<location
|
||||
file="src/main/AndroidManifest.xml"
|
||||
line="81"
|
||||
column="10"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="IntentFilterExportedReceiver"
|
||||
message="As of Android 12, `android:exported` must be set; use `true` to make the activity \
available to other apps, and `false` otherwise."
|
||||
errorLine1=" <receiver android:name=".receiver.BluetoothIntentReceiver">"
|
||||
errorLine2=" ~~~~~~~~">
|
||||
<location
|
||||
file="src/main/AndroidManifest.xml"
|
||||
line="93"
|
||||
column="10"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="IntentFilterExportedReceiver"
|
||||
message="As of Android 12, `android:exported` must be set; use `true` to make the activity \
available to other apps, and `false` otherwise."
|
||||
errorLine1=" <receiver"
|
||||
errorLine2=" ~~~~~~~~">
|
||||
<location
|
||||
file="src/main/AndroidManifest.xml"
|
||||
line="101"
|
||||
column="10"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="IntentFilterExportedReceiver"
|
||||
message="As of Android 12, `android:exported` must be set; use `true` to make the activity \
available to other apps, and `false` otherwise."
|
||||
errorLine1=" <receiver"
|
||||
errorLine2=" ~~~~~~~~">
|
||||
<location
|
||||
file="src/main/AndroidManifest.xml"
|
||||
line="112"
|
||||
column="10"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="IntentFilterExportedReceiver"
|
||||
message="As of Android 12, `android:exported` must be set; use `true` to make the activity \
available to other apps, and `false` otherwise."
|
||||
errorLine1=" <receiver"
|
||||
errorLine2=" ~~~~~~~~">
|
||||
<location
|
||||
file="src/main/AndroidManifest.xml"
|
||||
line="123"
|
||||
column="10"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="IntentFilterExportedReceiver"
|
||||
message="As of Android 12, `android:exported` must be set; use `true` to make the activity \
available to other apps, and `false` otherwise."
|
||||
errorLine1=" <receiver"
|
||||
errorLine2=" ~~~~~~~~">
|
||||
<location
|
||||
file="src/main/AndroidManifest.xml"
|
||||
line="134"
|
||||
column="10"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="UnspecifiedImmutableFlag"
|
||||
message="Missing `PendingIntent` mutability flag"
|
||||
errorLine1=" return PendingIntent.getActivity(this, 0, intent, flags)"
|
||||
errorLine2=" ~~~~~">
|
||||
<location
|
||||
file="src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerService.kt"
|
||||
line="708"
|
||||
column="59"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="UnspecifiedImmutableFlag"
|
||||
message="Missing `PendingIntent` mutability flag"
|
||||
errorLine1=" PendingIntent.FLAG_CANCEL_CURRENT"
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/kotlin/org/moire/ultrasonic/util/MediaSessionHandler.kt"
|
||||
line="323"
|
||||
column="13"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="UnspecifiedImmutableFlag"
|
||||
message="Missing `PendingIntent` mutability flag"
|
||||
errorLine1=" PendingIntent pendingIntent = PendingIntent.getActivity(context, 10, intent, PendingIntent.FLAG_UPDATE_CURRENT);"
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/java/org/moire/ultrasonic/provider/UltrasonicAppWidgetProvider.java"
|
||||
line="198"
|
||||
column="80"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="UnspecifiedImmutableFlag"
|
||||
message="Missing `PendingIntent` mutability flag"
|
||||
errorLine1=" pendingIntent = PendingIntent.getBroadcast(context, 11, intent, 0);"
|
||||
errorLine2=" ~">
|
||||
<location
|
||||
file="src/main/java/org/moire/ultrasonic/provider/UltrasonicAppWidgetProvider.java"
|
||||
line="206"
|
||||
column="67"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="UnspecifiedImmutableFlag"
|
||||
message="Missing `PendingIntent` mutability flag"
|
||||
errorLine1=" pendingIntent = PendingIntent.getBroadcast(context, 12, intent, 0);"
|
||||
errorLine2=" ~">
|
||||
<location
|
||||
file="src/main/java/org/moire/ultrasonic/provider/UltrasonicAppWidgetProvider.java"
|
||||
line="212"
|
||||
column="67"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="UnspecifiedImmutableFlag"
|
||||
message="Missing `PendingIntent` mutability flag"
|
||||
errorLine1=" pendingIntent = PendingIntent.getBroadcast(context, 13, intent, 0);"
|
||||
errorLine2=" ~">
|
||||
<location
|
||||
file="src/main/java/org/moire/ultrasonic/provider/UltrasonicAppWidgetProvider.java"
|
||||
line="218"
|
||||
column="67"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="UnspecifiedImmutableFlag"
|
||||
message="Missing `PendingIntent` mutability flag"
|
||||
errorLine1=" return PendingIntent.getBroadcast(context, requestCode, intent, flags)"
|
||||
errorLine2=" ~~~~~">
|
||||
<location
|
||||
file="src/main/kotlin/org/moire/ultrasonic/util/Util.kt"
|
||||
line="891"
|
||||
column="73"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="NotifyDataSetChanged"
|
||||
message="It will always be more efficient to use more specific change events if you can. Rely on `notifyDataSetChanged` as a last resort."
|
||||
errorLine1=" viewAdapter.notifyDataSetChanged()"
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/kotlin/org/moire/ultrasonic/fragment/PlayerFragment.kt"
|
||||
line="908"
|
||||
column="21"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="ObsoleteLayoutParam"
|
||||
message="Invalid layout param in a `LinearLayout`: `layout_above`"
|
||||
|
@ -345,6 +158,17 @@
|
|||
column="5"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="UnusedResources"
|
||||
message="The resource `R.drawable.ic_baseline_close` appears to be unused"
|
||||
errorLine1="<vector xmlns:android="http://schemas.android.com/apk/res/android""
|
||||
errorLine2="^">
|
||||
<location
|
||||
file="src/main/res/drawable/ic_baseline_close.xml"
|
||||
line="1"
|
||||
column="1"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="UnusedResources"
|
||||
message="The resource `R.drawable.ic_menu_arrow` appears to be unused"
|
||||
|
@ -356,193 +180,6 @@
|
|||
column="1"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="UnusedResources"
|
||||
message="The resource `R.drawable.menu_arrow` appears to be unused"
|
||||
errorLine1="<vector xmlns:android="http://schemas.android.com/apk/res/android""
|
||||
errorLine2="^">
|
||||
<location
|
||||
file="src/main/res/drawable/menu_arrow.xml"
|
||||
line="1"
|
||||
column="1"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="UnusedResources"
|
||||
message="The resource `R.string.main_shuffle` appears to be unused"
|
||||
errorLine1=" <string name="main.shuffle">Shuffle Play</string>"
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/values/strings.xml"
|
||||
line="114"
|
||||
column="13"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="UnusedResources"
|
||||
message="The resource `R.string.menu_navigation` appears to be unused"
|
||||
errorLine1=" <string name="menu.navigation">Navigation</string>"
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/values/strings.xml"
|
||||
line="128"
|
||||
column="13"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="UnusedResources"
|
||||
message="The resource `R.string.music_service_retry` appears to be unused"
|
||||
errorLine1=" <string name="music_service.retry">A network error occurred. Retrying %1$d of %2$d.</string>"
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/values/strings.xml"
|
||||
line="133"
|
||||
column="13"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="UnusedResources"
|
||||
message="The resource `R.string.parser_artist_count` appears to be unused"
|
||||
errorLine1=" <string name="parser.artist_count">Got %d Artists.</string>"
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/values/strings.xml"
|
||||
line="134"
|
||||
column="13"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="UnusedResources"
|
||||
message="The resource `R.string.parser_reading` appears to be unused"
|
||||
errorLine1=" <string name="parser.reading">Reading from server.</string>"
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/values/strings.xml"
|
||||
line="135"
|
||||
column="13"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="UnusedResources"
|
||||
message="The resource `R.string.parser_reading_done` appears to be unused"
|
||||
errorLine1=" <string name="parser.reading_done">Reading from server. Done!</string>"
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/values/strings.xml"
|
||||
line="136"
|
||||
column="13"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="UnusedResources"
|
||||
message="The resource `R.string.progress_wait` appears to be unused"
|
||||
errorLine1=" <string name="progress.wait">Please wait&#8230;</string>"
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/values/strings.xml"
|
||||
line="141"
|
||||
column="13"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="UnusedResources"
|
||||
message="The resource `R.string.search_search` appears to be unused"
|
||||
errorLine1=" <string name="search.search">Click to search</string>"
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/values/strings.xml"
|
||||
line="147"
|
||||
column="13"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="UnusedResources"
|
||||
message="The resource `R.string.service_connecting` appears to be unused"
|
||||
errorLine1=" <string name="service.connecting">Contacting server, please wait.</string>"
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/values/strings.xml"
|
||||
line="159"
|
||||
column="13"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="UnusedResources"
|
||||
message="The resource `R.string.settings_allow_self_signed_certificate` appears to be unused"
|
||||
errorLine1=" <string name="settings.allow_self_signed_certificate" translatable="false">allowSelfSignedCertificate</string>"
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/values/strings.xml"
|
||||
line="160"
|
||||
column="13"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="UnusedResources"
|
||||
message="The resource `R.string.settings_enable_ldap_user_support` appears to be unused"
|
||||
errorLine1=" <string name="settings.enable_ldap_user_support" translatable="false">enableLdapUserSupport</string>"
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/values/strings.xml"
|
||||
line="161"
|
||||
column="13"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="UnusedResources"
|
||||
message="The resource `R.string.settings_invalid_username` appears to be unused"
|
||||
errorLine1=" <string name="settings.invalid_username">Please specify a valid username (no trailing spaces).</string>"
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/values/strings.xml"
|
||||
line="230"
|
||||
column="13"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="UnusedResources"
|
||||
message="The resource `R.string.settings_server_remove_server` appears to be unused"
|
||||
errorLine1=" <string name="settings.server_remove_server">Remove Server</string>"
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/values/strings.xml"
|
||||
line="299"
|
||||
column="13"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="UnusedResources"
|
||||
message="The resource `R.string.settings_server_unused` appears to be unused"
|
||||
errorLine1=" <string name="settings.server_unused">Unused</string>"
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/values/strings.xml"
|
||||
line="302"
|
||||
column="13"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="UnusedResources"
|
||||
message="The resource `R.string.settings_server_address_unset` appears to be unused"
|
||||
errorLine1=" <string name="settings.server_address_unset" translatable="false">http://example.com</string>"
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/values/strings.xml"
|
||||
line="387"
|
||||
column="13"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="UnusedResources"
|
||||
message="The resource `R.plurals.select_album_donate_dialog_n_trial_days_left` appears to be unused"
|
||||
errorLine1=" <plurals name="select_album_donate_dialog_n_trial_days_left">"
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/values/strings.xml"
|
||||
line="447"
|
||||
column="14"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="IconDuplicates"
|
||||
message="The following unrelated icon files have identical contents: list_pressed_holo_dark.9.png, list_pressed_holo_light.9.png">
|
||||
|
@ -838,39 +475,6 @@
|
|||
column="6"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="ContentDescription"
|
||||
message="Missing `contentDescription` attribute on image"
|
||||
errorLine1=" <ImageView a:id="@+id/help_back""
|
||||
errorLine2=" ~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/layout/help.xml"
|
||||
line="12"
|
||||
column="10"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="ContentDescription"
|
||||
message="Missing `contentDescription` attribute on image"
|
||||
errorLine1=" <ImageView a:id="@+id/help_stop""
|
||||
errorLine2=" ~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/layout/help.xml"
|
||||
line="18"
|
||||
column="10"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="ContentDescription"
|
||||
message="Missing `contentDescription` attribute on image"
|
||||
errorLine1=" <ImageView a:id="@+id/help_forward""
|
||||
errorLine2=" ~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/layout/help.xml"
|
||||
line="24"
|
||||
column="10"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="LabelFor"
|
||||
message="Missing accessibility label: provide either a view with an `android:labelFor` that references this view or provide an `android:hint`"
|
||||
|
@ -1025,17 +629,6 @@
|
|||
column="13"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="RelativeOverlap"
|
||||
message="`LinearLayout-3` can overlap `LinearLayout-1` if LinearLayout-1, LinearLayout-3 grow due to localized text expansion"
|
||||
errorLine1=" <LinearLayout"
|
||||
errorLine2=" ~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/layout/player_media_info.xml"
|
||||
line="52"
|
||||
column="6"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="RelativeOverlap"
|
||||
message="`@id/current_playing_duration` can overlap `@id/current_playing_position` if @string/util.no_time, @string/util.no_time grow due to localized text expansion"
|
||||
|
|
|
@ -56,28 +56,24 @@
|
|||
</activity>
|
||||
|
||||
<service
|
||||
android:name=".service.MediaPlayerService"
|
||||
android:name=".service.DownloadService"
|
||||
android:label="Ultrasonic Media Player Service"
|
||||
android:exported="false">
|
||||
</service>
|
||||
|
||||
<service
|
||||
tools:ignore="ExportedService"
|
||||
android:name=".service.AutoMediaBrowserService"
|
||||
<!-- TODO: Check if it works with exported=false as well -->
|
||||
<service android:name=".playback.PlaybackService"
|
||||
android:label="@string/common.appname"
|
||||
android:exported="true">
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="androidx.media3.session.MediaLibraryService" />
|
||||
<action android:name="android.media.browse.MediaBrowserService" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<receiver android:name=".receiver.MediaButtonIntentReceiver">
|
||||
<intent-filter android:priority="2147483647">
|
||||
<action android:name="android.intent.action.MEDIA_BUTTON"/>
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
<receiver android:name=".receiver.UltrasonicIntentReceiver">
|
||||
<receiver android:name=".receiver.UltrasonicIntentReceiver"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="org.moire.ultrasonic.CMD_TOGGLEPAUSE"/>
|
||||
<action android:name="org.moire.ultrasonic.CMD_PLAY"/>
|
||||
|
@ -89,7 +85,8 @@
|
|||
<action android:name="org.moire.ultrasonic.CMD_PROCESS_KEYCODE"/>
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
<receiver android:name=".receiver.BluetoothIntentReceiver">
|
||||
<receiver android:name=".receiver.BluetoothIntentReceiver"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.bluetooth.device.action.ACL_CONNECTED"/>
|
||||
<action android:name="android.bluetooth.device.action.ACL_DISCONNECTED"/>
|
||||
|
@ -99,7 +96,8 @@
|
|||
</receiver>
|
||||
<receiver
|
||||
android:name=".provider.UltrasonicAppWidgetProvider4X1"
|
||||
android:label="Ultrasonic (4x1)">
|
||||
android:label="Ultrasonic (4x1)"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
</intent-filter>
|
||||
|
@ -110,7 +108,8 @@
|
|||
</receiver>
|
||||
<receiver
|
||||
android:name=".provider.UltrasonicAppWidgetProvider4X2"
|
||||
android:label="Ultrasonic (4x2)">
|
||||
android:label="Ultrasonic (4x2)"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
</intent-filter>
|
||||
|
@ -121,7 +120,8 @@
|
|||
</receiver>
|
||||
<receiver
|
||||
android:name=".provider.UltrasonicAppWidgetProvider4X3"
|
||||
android:label="Ultrasonic (4x3)">
|
||||
android:label="Ultrasonic (4x3)"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
</intent-filter>
|
||||
|
@ -132,7 +132,8 @@
|
|||
</receiver>
|
||||
<receiver
|
||||
android:name=".provider.UltrasonicAppWidgetProvider4X4"
|
||||
android:label="Ultrasonic (4x4)">
|
||||
android:label="Ultrasonic (4x4)"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
</intent-filter>
|
||||
|
@ -141,18 +142,16 @@
|
|||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/appwidget_info_4x4"/>
|
||||
</receiver>
|
||||
|
||||
<receiver android:name=".receiver.MediaButtonIntentReceiver"
|
||||
android:exported="true">
|
||||
<intent-filter android:priority="2147483647">
|
||||
<action android:name="android.intent.action.MEDIA_BUTTON"/>
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
<provider
|
||||
android:name=".provider.SearchSuggestionProvider"
|
||||
android:authorities="org.moire.ultrasonic.provider.SearchSuggestionProvider"/>
|
||||
|
||||
<receiver
|
||||
android:name=".receiver.A2dpIntentReceiver"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="com.android.music.playstatusrequest"/>
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
|
@ -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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
|
@ -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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
|
@ -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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
|
@ -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<MediaPlayerController> 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);
|
||||
}
|
||||
}
|
|
@ -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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
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<MediaPlayerLifecycleSupport> 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.
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
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<MediaPlayerController> 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<String> 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<JukeboxTask> queue = new LinkedBlockingQueue<>();
|
||||
|
||||
void add(JukeboxTask jukeboxTask)
|
||||
{
|
||||
queue.add(jukeboxTask);
|
||||
}
|
||||
|
||||
JukeboxTask take() throws InterruptedException
|
||||
{
|
||||
return queue.take();
|
||||
}
|
||||
|
||||
void remove(Class<? extends JukeboxTask> taskClass)
|
||||
{
|
||||
try
|
||||
{
|
||||
Iterator<JukeboxTask> 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<String> ids;
|
||||
|
||||
SetPlaylist(List<String> 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();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
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<Track> 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<Track> get(int size)
|
||||
{
|
||||
clearBufferIfNecessary();
|
||||
|
||||
List<Track> 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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<DownloadFile> currentPlaying;
|
||||
|
||||
public StreamProxy(Supplier<DownloadFile> 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -30,7 +30,7 @@ import org.moire.ultrasonic.util.Util
|
|||
*/
|
||||
internal class ServerRowAdapter(
|
||||
private var context: Context,
|
||||
private var data: Array<ServerSetting>,
|
||||
passedData: Array<ServerSetting>,
|
||||
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<ServerSetting> = 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<ServerSetting>) {
|
||||
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<ConstraintLayout>(R.id.server_layout)
|
||||
val image = vi?.findViewById<ImageView>(R.id.server_image)
|
||||
val serverMenu = vi?.findViewById<ImageButton>(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 -> {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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<Int> = 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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -11,7 +11,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase
|
|||
*/
|
||||
@Database(
|
||||
entities = [ServerSetting::class],
|
||||
version = 4,
|
||||
version = 5,
|
||||
exportSchema = true
|
||||
)
|
||||
abstract class AppDatabase : RoomDatabase() {
|
||||
|
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
|
@ -69,12 +69,6 @@ interface ServerSettingDao {
|
|||
@Query("SELECT COUNT(*) FROM serverSetting")
|
||||
fun liveServerCount(): LiveData<Int?>
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
|
|
|
@ -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() }
|
||||
}
|
||||
|
|
|
@ -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()) }
|
||||
|
|
|
@ -54,7 +54,7 @@ class DownloadsFragment : MultiListFragment<DownloadFile>() {
|
|||
|
||||
viewAdapter.register(
|
||||
TrackViewBinder(
|
||||
{ },
|
||||
{ _, _ -> },
|
||||
{ _, _ -> true },
|
||||
checkable = false,
|
||||
draggable = false,
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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<View>(R.id.button_shuffle)
|
||||
shuffleButton = view.findViewById(R.id.button_shuffle)
|
||||
updateShuffleButtonState(mediaPlayerController.isShufflePlayEnabled)
|
||||
updateRepeatButtonState(mediaPlayerController.repeatMode)
|
||||
|
||||
val ratingLinearLayout = view.findViewById<LinearLayout>(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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -109,7 +109,7 @@ class SearchFragment : MultiListFragment<Identifiable>(), KoinComponent {
|
|||
|
||||
viewAdapter.register(
|
||||
TrackViewBinder(
|
||||
onItemClick = ::onItemClick,
|
||||
onItemClick = { file, _ -> onItemClick(file) },
|
||||
onContextMenuClick = ::onContextMenuItemSelected,
|
||||
checkable = false,
|
||||
draggable = false,
|
||||
|
@ -303,13 +303,12 @@ class SearchFragment : MultiListFragment<Identifiable>(), 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))
|
||||
}
|
||||
|
||||
|
|
|
@ -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<FloatingActionButton>(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)
|
||||
|
|
|
@ -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>(
|
||||
MediaPlayerController::class.java
|
||||
)
|
||||
private val mediaSessionHandler = inject<MediaSessionHandler>(
|
||||
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<PreferenceCategory>(Constants.PREFERENCES_KEY_CATEGORY_NOTIFICATIONS)
|
||||
var preferenceToRemove =
|
||||
findPreference<Preference>(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()
|
||||
}
|
||||
|
||||
|
|
|
@ -122,7 +122,7 @@ open class TrackCollectionFragment : MultiListFragment<MusicDirectory.Child>() {
|
|||
|
||||
viewAdapter.register(
|
||||
TrackViewBinder(
|
||||
onItemClick = { onItemClick(it.track) },
|
||||
onItemClick = { file, _ -> onItemClick(file.track) },
|
||||
onContextMenuClick = { menu, id -> onContextMenuItemSelected(menu, id.track) },
|
||||
checkable = true,
|
||||
draggable = false,
|
||||
|
|
|
@ -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<List<ServerSetting>> {
|
||||
// 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
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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<String, String>
|
||||
): 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<ResponseBody>? = 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<String, List<String>> {
|
||||
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<ResponseBody>?
|
||||
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
|
||||
}
|
||||
}
|
File diff suppressed because it is too large
Load Diff
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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<DownloadFile>()
|
||||
|
||||
@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<String, DownloadFile>(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<DownloadFile>
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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()
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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.
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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>(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()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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<Int> = MutableLiveData(0)
|
||||
|
||||
|
@ -78,7 +62,7 @@ class DownloadFile(
|
|||
|
||||
private val lazyInitialStatus: Lazy<DownloadStatus> = 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<Artist> = 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())
|
||||
}
|
||||
|
|
|
@ -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<Downloader>()
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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<DownloadFile>()
|
||||
// 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<DownloadFile>()
|
||||
private val activelyDownloading = mutableListOf<DownloadFile>()
|
||||
|
||||
// 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<List<DownloadFile>>()
|
||||
|
||||
private val jukeboxMediaPlayer: JukeboxMediaPlayer by inject()
|
||||
|
||||
// This cache helps us to avoid creating duplicate DownloadFile instances when showing Entries
|
||||
private val downloadFileCache = LRUCache<Track, DownloadFile>(100)
|
||||
private val downloadFileCache = LRUCache<Track, DownloadFile>(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<DownloadFile>
|
||||
get() {
|
||||
val temp: MutableList<DownloadFile> = 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<DownloadFile> = playlist
|
||||
|
||||
@Synchronized
|
||||
fun clearDownloadFileCache() {
|
||||
downloadFileCache.clear()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun clearPlaylist() {
|
||||
playlist.clear()
|
||||
|
||||
val toRemove = mutableListOf<DownloadFile>()
|
||||
|
||||
// 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<Track>,
|
||||
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<Track>, 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<Artist> = 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>(
|
||||
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<String> = 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<JukeboxTask>()
|
||||
fun add(jukeboxTask: JukeboxTask) {
|
||||
queue.add(jukeboxTask)
|
||||
}
|
||||
|
||||
@Throws(InterruptedException::class)
|
||||
fun take(): JukeboxTask {
|
||||
return queue.take()
|
||||
}
|
||||
|
||||
fun remove(taskClass: Class<out JukeboxTask?>) {
|
||||
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<String>) :
|
||||
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<View>(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
|
||||
}
|
||||
}
|
|
@ -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<AudioFocusHandler>()
|
||||
private val context by inject<Context>()
|
||||
|
||||
@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<Int> = 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<DownloadFile?>() {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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<Track?>?,
|
||||
songs: List<Track>,
|
||||
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<Track?>?,
|
||||
save: Boolean,
|
||||
songs: List<Track>,
|
||||
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<MediaItem> = 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<DownloadFile>
|
||||
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()
|
||||
}
|
||||
|
|
|
@ -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<PlaybackStateSerializer>()
|
||||
private val mediaPlayerController by inject<MediaPlayerController>()
|
||||
private val downloader by inject<Downloader>()
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<JukeboxMediaPlayer>()
|
||||
private val playbackStateSerializer by inject<PlaybackStateSerializer>()
|
||||
private val shufflePlayBuffer by inject<ShufflePlayBuffer>()
|
||||
private val downloader by inject<Downloader>()
|
||||
private val localMediaPlayer by inject<LocalMediaPlayer>()
|
||||
private val mediaSessionHandler by inject<MediaSessionHandler>()
|
||||
|
||||
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<Int>()
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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<Context>()
|
||||
|
||||
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<DownloadFile>,
|
||||
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<DownloadFile>,
|
||||
referencedList: Iterable<DownloadFile>,
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<MediaSessionCompat.Token> =
|
||||
PublishSubject.create()
|
||||
val mediaSessionTokenObservable: Observable<MediaSessionCompat.Token> =
|
||||
mediaSessionTokenPublisher.observeOn(AndroidSchedulers.mainThread())
|
||||
.replay(1)
|
||||
.autoConnect(0)
|
||||
|
||||
val mediaButtonEventPublisher: PublishSubject<KeyEvent> =
|
||||
companion object {
|
||||
|
||||
private fun mainThread() = AndroidSchedulers.from(Looper.getMainLooper())
|
||||
|
||||
var activeServerChangePublisher: PublishSubject<Int> =
|
||||
PublishSubject.create()
|
||||
val mediaButtonEventObservable: Observable<KeyEvent> =
|
||||
mediaButtonEventPublisher.observeOn(AndroidSchedulers.mainThread())
|
||||
var activeServerChangeObservable: Observable<Int> =
|
||||
activeServerChangePublisher.observeOn(mainThread())
|
||||
|
||||
val themeChangedEventPublisher: PublishSubject<Unit> =
|
||||
PublishSubject.create()
|
||||
val themeChangedEventObservable: Observable<Unit> =
|
||||
themeChangedEventPublisher.observeOn(AndroidSchedulers.mainThread())
|
||||
themeChangedEventPublisher.observeOn(mainThread())
|
||||
|
||||
val musicFolderChangedEventPublisher: PublishSubject<String> =
|
||||
PublishSubject.create()
|
||||
val musicFolderChangedEventObservable: Observable<String> =
|
||||
musicFolderChangedEventPublisher.observeOn(AndroidSchedulers.mainThread())
|
||||
musicFolderChangedEventPublisher.observeOn(mainThread())
|
||||
|
||||
val playerStatePublisher: PublishSubject<StateWithTrack> =
|
||||
PublishSubject.create()
|
||||
val playerStateObservable: Observable<StateWithTrack> =
|
||||
playerStatePublisher.observeOn(AndroidSchedulers.mainThread())
|
||||
playerStatePublisher.observeOn(mainThread())
|
||||
.replay(1)
|
||||
.autoConnect(0)
|
||||
val throttledPlayerStateObservable: Observable<StateWithTrack> =
|
||||
playerStatePublisher.observeOn(mainThread())
|
||||
.replay(1)
|
||||
.autoConnect(0)
|
||||
.throttleLatest(300, TimeUnit.MILLISECONDS)
|
||||
|
||||
val playlistPublisher: PublishSubject<List<DownloadFile>> =
|
||||
PublishSubject.create()
|
||||
val playlistObservable: Observable<List<DownloadFile>> =
|
||||
playlistPublisher.observeOn(AndroidSchedulers.mainThread())
|
||||
playlistPublisher.observeOn(mainThread())
|
||||
.replay(1)
|
||||
.autoConnect(0)
|
||||
|
||||
val playbackPositionPublisher: PublishSubject<Int> =
|
||||
PublishSubject.create()
|
||||
val playbackPositionObservable: Observable<Int> =
|
||||
playbackPositionPublisher.observeOn(AndroidSchedulers.mainThread())
|
||||
.throttleFirst(1, TimeUnit.SECONDS)
|
||||
val throttledPlaylistObservable: Observable<List<DownloadFile>> =
|
||||
playlistPublisher.observeOn(mainThread())
|
||||
.replay(1)
|
||||
.autoConnect(0)
|
||||
.throttleLatest(300, TimeUnit.MILLISECONDS)
|
||||
|
||||
// Commands
|
||||
val dismissNowPlayingCommandPublisher: PublishSubject<Unit> =
|
||||
PublishSubject.create()
|
||||
val dismissNowPlayingCommandObservable: Observable<Unit> =
|
||||
dismissNowPlayingCommandPublisher.observeOn(AndroidSchedulers.mainThread())
|
||||
dismissNowPlayingCommandPublisher.observeOn(mainThread())
|
||||
|
||||
val playFromMediaIdCommandPublisher: PublishSubject<Pair<String?, Bundle?>> =
|
||||
val shutdownCommandPublisher: PublishSubject<Unit> =
|
||||
PublishSubject.create()
|
||||
val playFromMediaIdCommandObservable: Observable<Pair<String?, Bundle?>> =
|
||||
playFromMediaIdCommandPublisher.observeOn(AndroidSchedulers.mainThread())
|
||||
val shutdownCommandObservable: Observable<Unit> =
|
||||
shutdownCommandPublisher.observeOn(mainThread())
|
||||
|
||||
val playFromSearchCommandPublisher: PublishSubject<Pair<String?, Bundle?>> =
|
||||
val stopCommandPublisher: PublishSubject<Unit> =
|
||||
PublishSubject.create()
|
||||
val playFromSearchCommandObservable: Observable<Pair<String?, Bundle?>> =
|
||||
playFromSearchCommandPublisher.observeOn(AndroidSchedulers.mainThread())
|
||||
|
||||
val skipToQueueItemCommandPublisher: PublishSubject<Long> =
|
||||
PublishSubject.create()
|
||||
val skipToQueueItemCommandObservable: Observable<Long> =
|
||||
skipToQueueItemCommandPublisher.observeOn(AndroidSchedulers.mainThread())
|
||||
|
||||
fun releaseMediaSessionToken() {
|
||||
mediaSessionTokenPublisher = PublishSubject.create()
|
||||
}
|
||||
val stopCommandObservable: Observable<Unit> =
|
||||
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) {
|
||||
|
|
|
@ -34,20 +34,23 @@ class DownloadHandler(
|
|||
autoPlay: Boolean,
|
||||
playNext: Boolean,
|
||||
shuffle: Boolean,
|
||||
songs: List<Track?>
|
||||
songs: List<Track>,
|
||||
) {
|
||||
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<Track>) {
|
||||
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 &&
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)}"
|
||||
|
|
|
@ -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<Context>()
|
||||
|
||||
private var referenceCount: Int = 0
|
||||
private var cachedPlaylist: List<DownloadFile>? = 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<DownloadFile>) {
|
||||
cachedPlaylist = playlist
|
||||
setMediaSessionQueue(playlist)
|
||||
}
|
||||
|
||||
private fun setMediaSessionQueue(playlist: List<DownloadFile>) {
|
||||
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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -1,9 +0,0 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFF"
|
||||
android:pathData="M9,13.75c-2.34,0 -7,1.17 -7,3.5L2,19h14v-1.75c0,-2.33 -4.66,-3.5 -7,-3.5zM4.34,17c0.84,-0.58 2.87,-1.25 4.66,-1.25s3.82,0.67 4.66,1.25L4.34,17zM9,12c1.93,0 3.5,-1.57 3.5,-3.5S10.93,5 9,5 5.5,6.57 5.5,8.5 7.07,12 9,12zM9,7c0.83,0 1.5,0.67 1.5,1.5S9.83,10 9,10s-1.5,-0.67 -1.5,-1.5S8.17,7 9,7zM16.04,13.81c1.16,0.84 1.96,1.96 1.96,3.44L18,19h4v-1.75c0,-2.02 -3.5,-3.17 -5.96,-3.44zM15,12c1.93,0 3.5,-1.57 3.5,-3.5S16.93,5 15,5c-0.54,0 -1.04,0.13 -1.5,0.35 0.63,0.89 1,1.98 1,3.15s-0.37,2.26 -1,3.15c0.46,0.22 0.96,0.35 1.5,0.35z"/>
|
||||
</vector>
|
|
@ -1,9 +0,0 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFF"
|
||||
android:pathData="M22,6h-5v8.18C16.69,14.07 16.35,14 16,14c-1.66,0 -3,1.34 -3,3s1.34,3 3,3s3,-1.34 3,-3V8h3V6zM15,6H3v2h12V6zM15,10H3v2h12V10zM11,14H3v2h8V14z"/>
|
||||
</vector>
|
|
@ -1,9 +0,0 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFF"
|
||||
android:pathData="M8,12 L20,4v16z"/>
|
||||
</vector>
|
|
@ -61,10 +61,7 @@
|
|||
<string name="download.menu_screen_on">Obrazovka zapnuta</string>
|
||||
<string name="download.menu_show_album">Zobrazit album</string>
|
||||
<string name="download.menu_shuffle">Náhodně</string>
|
||||
<string name="download.menu_shuffle_notification">Playlist byl náhodně zamíchán</string>
|
||||
<string name="download.menu_visualizer">Vizualizér</string>
|
||||
<string name="download.playerstate_buffering">Načítám</string>
|
||||
<string name="download.playerstate_downloading">Stahuji - %s</string>
|
||||
<string name="download.playerstate_playing_shuffle">Přehrávám mix</string>
|
||||
<string name="download.playlist_done">Playlist úspěšně uložen.</string>
|
||||
<string name="download.playlist_error">Chyba ukládání playlistu, zkuste později.</string>
|
||||
|
@ -95,7 +92,6 @@
|
|||
<string name="main.genres_title">Žánry</string>
|
||||
<string name="main.music">Hudba</string>
|
||||
<string name="main.offline">Bez připojení</string>
|
||||
<string name="main.shuffle">Náhodné přehrávání</string>
|
||||
<string name="main.songs_random">Náhodné</string>
|
||||
<string name="main.songs_starred">Označené hvězdičkou</string>
|
||||
<string name="main.songs_title">Skladby</string>
|
||||
|
@ -106,26 +102,19 @@
|
|||
<string name="menu.deleted_playlist">Smazaný playlist %s</string>
|
||||
<string name="menu.deleted_playlist_error">Chyba smazání playlistu %s</string>
|
||||
<string name="menu.exit">Ukončit</string>
|
||||
<string name="menu.navigation">Navigace</string>
|
||||
<string name="menu.settings">Nastavení</string>
|
||||
<string name="menu.refresh">Obnovit</string>
|
||||
<string name="music_library.label">Knihovna médií</string>
|
||||
<string name="music_library.label_offline">Offline média</string>
|
||||
<string name="music_service.retry">Došlo k chybě sítě. Pokus %1$d z %2$d.</string>
|
||||
<string name="parser.artist_count">Dostupných %d umělců.</string>
|
||||
<string name="parser.reading">Načítání dat serveru.</string>
|
||||
<string name="parser.reading_done">Načítání ze serveru. Hotovo!</string>
|
||||
<string name="playlist.label">Playlisty</string>
|
||||
<string name="playlist.update_info">Aktualizovat informace</string>
|
||||
<string name="playlist.updated_info">Aktualizované informace playlistu pro %s</string>
|
||||
<string name="playlist.updated_info_error">Chyba aktualizace informací playlistu %s</string>
|
||||
<string name="progress.wait">Chvilku strpení…</string>
|
||||
<string name="search.albums">Alba</string>
|
||||
<string name="search.artists">Umělci</string>
|
||||
<string name="search.label">Vyhledávání</string>
|
||||
<string name="search.more">Zobrazit více</string>
|
||||
<string name="search.no_match">Nenalezeno, zkuste znovu</string>
|
||||
<string name="search.search">Kliknout pro vyhledání</string>
|
||||
<string name="search.songs">Skladby</string>
|
||||
<string name="search.title">Hledat</string>
|
||||
<string name="select_album.empty">Média nenalezena</string>
|
||||
|
@ -135,7 +124,6 @@
|
|||
<string name="select_artist.folder">Vybrat adresář</string>
|
||||
<string name="select_genre.empty">Žánry nenalezeny</string>
|
||||
<string name="select_playlist.empty">Žádné uložené playlisty na serveru</string>
|
||||
<string name="service.connecting">Kontaktuji server, chvilku strpení.</string>
|
||||
<string name="settings.appearance_title">Vzhled</string>
|
||||
<string name="settings.buffer_length">Délka bufferu</string>
|
||||
<string name="settings.buffer_length_0">Vypnuto</string>
|
||||
|
@ -175,8 +163,6 @@
|
|||
<string name="settings.chat_refresh">Interval obnovení chatu</string>
|
||||
<string name="settings.clear_bookmark">Zahodit záložku</string>
|
||||
<string name="settings.clear_bookmark_summary">Zahodit záložku po dokončení přehrávání skladby</string>
|
||||
<string name="settings.clear_playlist">Zahození playlistu</string>
|
||||
<string name="settings.clear_playlist_summary">Zahodit playlist po dokončení přehrávání všech skladeb</string>
|
||||
<string name="settings.clear_search_history">Vyčistit historii vyhledávání</string>
|
||||
<string name="settings.connection_failure">Chyba připojení.</string>
|
||||
<string name="settings.default_albums">Výchozí alba</string>
|
||||
|
@ -193,14 +179,11 @@
|
|||
<string name="settings.disc_sort">Řadit skladby podle čísla CD</string>
|
||||
<string name="settings.disc_sort_summary">Řadit seznam skladeb dle čísla CD a čísla skladby</string>
|
||||
<string name="settings.display_bitrate_summary">Připojovat jméno umělce, bitrate a příponu souboru</string>
|
||||
<string name="settings.gapless_playback">Přehrávání bez pauz</string>
|
||||
<string name="settings.gapless_playback_summary">Zapnout přehrávání bez pauz</string>
|
||||
<string name="settings.hide_media_summary">Skrýt hudební soubory před ostatními aplikacemi.</string>
|
||||
<string name="settings.hide_media_title">Skrýt před ostatními</string>
|
||||
<string name="settings.hide_media_toast">Nabyde účinnosti při příštím skenování hudby systému Android.</string>
|
||||
<string name="settings.increment_time">Interval přeskočení</string>
|
||||
<string name="settings.invalid_url">Zadejte funkční adresu URL.</string>
|
||||
<string name="settings.invalid_username">Zadejte správné uživatelské jméno (bez mezer za jménem).</string>
|
||||
<string name="settings.max_albums">Maximum alb</string>
|
||||
<string name="settings.max_artists">Maximum umělců</string>
|
||||
<string name="settings.max_bitrate_112">112 Kbps</string>
|
||||
|
@ -257,25 +240,13 @@
|
|||
<string name="settings.search_75">75</string>
|
||||
<string name="settings.search_history_cleared">Historie hledání vyčištěna</string>
|
||||
<string name="settings.search_title">Nastavení vyhledávání</string>
|
||||
<string name="settings.send_bluetooth_notification_summary">Odesílat upozornění přehrávání přes bluetooth</string>
|
||||
<string name="settings.send_bluetooth_notification">Odesílat bluetooth upozornění</string>
|
||||
<string name="settings.send_bluetooth_album_art_summary">Odesílat obrázky alb přes bluetooth (může způsobit selhávání bluetooth upozornění)</string>
|
||||
<string name="settings.send_bluetooth_album_art">Obrázky alb přes bluetooth</string>
|
||||
<string name="settings.server_manage_servers">Spravovat servery</string>
|
||||
<string name="settings.server_address">Adresa serveru</string>
|
||||
<string name="settings.server_name">Název</string>
|
||||
<string name="settings.server_password">Heslo</string>
|
||||
<string name="settings.server_remove_server">Vzdálený server</string>
|
||||
<string name="settings.server_scaling_summary">Stahovat škálované obrázky ze serveru místo plné velikosti (šetří přenos dat)</string>
|
||||
<string name="settings.server_scaling_title">Škálování obrázků alb na serveru</string>
|
||||
<string name="settings.server_unused">Nepoužitý</string>
|
||||
<string name="settings.server_username">Uživatelské jméno</string>
|
||||
<string name="settings.show_lockscreen_controls">Zobrazit ovládání na zamknuté obrazovce</string>
|
||||
<string name="settings.show_lockscreen_controls_summary">Zobrazí ovládání přehrávače na zamknuté obrazovce</string>
|
||||
<string name="settings.show_notification">Zobrazení upozornění</string>
|
||||
<string name="settings.show_notification_always">Vždy zobrazovat upozornění</string>
|
||||
<string name="settings.show_notification_always_summary">Vždy zobrazovat upozornění přehrávané skladby při vytvoření playlistu</string>
|
||||
<string name="settings.show_notification_summary">Zobrazovat přehrávanou skladbu ve stavovém panelu</string>
|
||||
<string name="settings.show_now_playing">Zobrazovat přehrávanou skladbu</string>
|
||||
<string name="settings.show_now_playing_summary">Zobrazovat přehrávanou skladbu v aktivitách</string>
|
||||
<string name="settings.show_track_number">Zobrazovat číslo skladby</string>
|
||||
|
@ -346,7 +317,6 @@
|
|||
<string name="settings.playback.bluetooth_all">Všechny bluetooth přístroje</string>
|
||||
<string name="settings.playback.bluetooth_a2dp">Pouze audio (A2DP) přístroje</string>
|
||||
<string name="settings.playback.bluetooth_disabled">Vypnuto</string>
|
||||
<string name="settings.playback.single_button_bluetooth_device_summary">Povolení tohoto nastavení může pomoci zlepšit funkci spuštění/pozastavení přehrávání na starších bluetooth přístrojích</string>
|
||||
<string name="settings.debug.title">Možnosti ladění aplikace</string>
|
||||
<string name="settings.debug.log_to_file">Zapisovat logy ladění do souboru</string>
|
||||
<string name="settings.debug.log_path">Soubory logů jsou dostupné v %1$s/%2$s</string>
|
||||
|
@ -372,12 +342,6 @@
|
|||
<item quantity="many">%d skladeb</item>
|
||||
<item quantity="other">%d skladeb</item>
|
||||
</plurals>
|
||||
<plurals name="select_album_donate_dialog_n_trial_days_left">
|
||||
<item quantity="one">Zbývá %d den zkušební doby</item>
|
||||
<item quantity="few">Zbývají %d dny zkušební doby</item>
|
||||
<item quantity="many">Zbývá %d dní zkušební doby</item>
|
||||
<item quantity="other">Zbývá %d dní zkušební doby</item>
|
||||
</plurals>
|
||||
|
||||
<!-- Subsonic api errors -->
|
||||
<string name="api.subsonic.generic">Obecná api chyba: %1$s</string>
|
||||
|
|
|
@ -41,11 +41,8 @@
|
|||
<string name="common.name">Name</string>
|
||||
<string name="common.ok">OK</string>
|
||||
<string name="common.pin">Anheften</string>
|
||||
<string name="common.pause">Pause</string>
|
||||
<string name="common.play">Abspielen</string>
|
||||
<string name="common.play_last">Zuletzt spielen</string>
|
||||
<string name="common.play_next">Als nächstes spielen</string>
|
||||
<string name="common.play_previous">Vorheriges abspielen</string>
|
||||
<string name="common.play_now">Jetzt spielen</string>
|
||||
<string name="common.play_shuffled">Zufällig spielen</string>
|
||||
<string name="common.public">Öffentlich</string>
|
||||
|
@ -75,10 +72,7 @@
|
|||
<string name="download.menu_screen_on">Bildschirm an</string>
|
||||
<string name="download.menu_show_album">Album anzeigen</string>
|
||||
<string name="download.menu_shuffle">Mischen</string>
|
||||
<string name="download.menu_shuffle_notification">Die Wiedergabeliste wurde gemischt</string>
|
||||
<string name="download.menu_visualizer">Grafik</string>
|
||||
<string name="download.playerstate_buffering">Zwischenspeichern</string>
|
||||
<string name="download.playerstate_downloading">Herunterladen - %s</string>
|
||||
<string name="download.playerstate_playing_shuffle">Wiedergabeliste mischen</string>
|
||||
<string name="download.playlist_done">Die Wiedergabeliste wurde gespeichert</string>
|
||||
<string name="download.playlist_error">Konnte die Wiedergabeliste nicht speichern, bitte später erneut versuchen.</string>
|
||||
|
@ -125,7 +119,6 @@
|
|||
<string name="main.music">Musik</string>
|
||||
<string name="main.offline">Offline</string>
|
||||
<string name="main.setup_server">%s - Server einrichten</string>
|
||||
<string name="main.shuffle">Gemischte Wiedergabe</string>
|
||||
<string name="main.songs_random">Zufällig</string>
|
||||
<string name="main.songs_starred">Mit Stern</string>
|
||||
<string name="main.songs_title">Titel</string>
|
||||
|
@ -139,26 +132,19 @@
|
|||
<string name="menu.deleted_playlist_error">Löschen der Wiedergabeliste %s ist fehlgeschlagen</string>
|
||||
<string name="menu.downloads">Downloads</string>
|
||||
<string name="menu.exit">Beenden</string>
|
||||
<string name="menu.navigation">Navigation</string>
|
||||
<string name="menu.settings">Einstellungen</string>
|
||||
<string name="menu.refresh">Aktualisierung</string>
|
||||
<string name="music_library.label">Medienbibliothek</string>
|
||||
<string name="music_library.label_offline">Offline Medien</string>
|
||||
<string name="music_service.retry">Netzwerkfehler. Neuer Versuch %1$d von %2$d.</string>
|
||||
<string name="parser.artist_count">%d Künstler*innen gefunden</string>
|
||||
<string name="parser.reading">Lese vom Server.</string>
|
||||
<string name="parser.reading_done">Lese vom Server. Fertig!</string>
|
||||
<string name="playlist.label">Wiedergabelisten</string>
|
||||
<string name="playlist.update_info">Aktualisierungs-Informationen</string>
|
||||
<string name="playlist.updated_info">Wiedergabeliste für %s aktualisiert</string>
|
||||
<string name="playlist.updated_info_error">Aktualisierung der Wiedergabeliste %s ist fehlgeschlagen</string>
|
||||
<string name="progress.wait">Bitte warten…</string>
|
||||
<string name="search.albums">Alben</string>
|
||||
<string name="search.artists">Künstler*innen</string>
|
||||
<string name="search.label">Suche</string>
|
||||
<string name="search.more">Zeige mehr</string>
|
||||
<string name="search.no_match">Keine Treffer, bitte erneut versuchen</string>
|
||||
<string name="search.search">Neue Suche</string>
|
||||
<string name="search.songs">Titel</string>
|
||||
<string name="search.title">Suche</string>
|
||||
<string name="select_album.empty">Keine Medien gefunden</string>
|
||||
|
@ -170,7 +156,6 @@
|
|||
<string name="select_artist.folder">Ordner wählen</string>
|
||||
<string name="select_genre.empty">Keine Genres gefunden</string>
|
||||
<string name="select_playlist.empty">Keine Wiedergabelisten auf dem Server</string>
|
||||
<string name="service.connecting">Kontaktiere Server, bitte warten.</string>
|
||||
<string name="settings.appearance_title">Aussehen</string>
|
||||
<string name="settings.buffer_length">Puffer-Länge</string>
|
||||
<string name="settings.buffer_length_0">Deaktiviert</string>
|
||||
|
@ -211,8 +196,6 @@
|
|||
<string name="settings.chat_refresh">Chat Aktualisierungsintervall</string>
|
||||
<string name="settings.clear_bookmark">Lesezeichen löschen</string>
|
||||
<string name="settings.clear_bookmark_summary">Lesezeichen nach Wiedergabe löschen</string>
|
||||
<string name="settings.clear_playlist">Wiedergabeliste löschen</string>
|
||||
<string name="settings.clear_playlist_summary">Wiedergableliste nach Wiedergabe aller Titel löschen</string>
|
||||
<string name="settings.clear_search_history">Suchverlauf löschen</string>
|
||||
<string name="settings.connection_failure">Verbindungsfehler</string>
|
||||
<string name="settings.default_albums">Anzahl der Alben</string>
|
||||
|
@ -232,14 +215,11 @@
|
|||
<string name="settings.display_bitrate_summary">Bitrate und Dateityp hinter der Künstler*in anzeigen</string>
|
||||
<string name="settings.download_transition">Zeige Aktuelle Wiedergabe bei Play</string>
|
||||
<string name="settings.download_transition_summary">Zeige Aktuelle Wiedergabe nach dem Start der Wiedergabe in der Medienansicht</string>
|
||||
<string name="settings.gapless_playback">Lückenlose Wiedergabe</string>
|
||||
<string name="settings.gapless_playback_summary">Lückenlose Wiedergabe aktivieren</string>
|
||||
<string name="settings.hide_media_summary">Musikdateien vor anderen Apps verbergen</string>
|
||||
<string name="settings.hide_media_title">Vor anderen verbergen</string>
|
||||
<string name="settings.hide_media_toast">Wird beim nächsten Durchsuchen nach Musik durch Android wirksam.</string>
|
||||
<string name="settings.increment_time">Sprunglänge</string>
|
||||
<string name="settings.invalid_url">Bitte eine gültige URL angeben.</string>
|
||||
<string name="settings.invalid_username">Bitte einen gültigen Benutzernamen eingeben (ohne führende Leerzeichen).</string>
|
||||
<string name="settings.max_albums">Max. Anzahl der Alben</string>
|
||||
<string name="settings.max_artists">Max. Anzahl der Künstler*innen</string>
|
||||
<string name="settings.max_bitrate_112">112 Kbps</string>
|
||||
|
@ -300,28 +280,14 @@
|
|||
<string name="settings.search_75">75</string>
|
||||
<string name="settings.search_history_cleared">Suchhistorie gelöscht</string>
|
||||
<string name="settings.search_title">Sucheinstellungen</string>
|
||||
<string name="settings.send_bluetooth_notification_summary">Wiedergabe-Benachrichtigungen über Bluetooth senden</string>
|
||||
<string name="settings.send_bluetooth_notification">Bluetooth-Benachrichtigung</string>
|
||||
<string name="settings.send_bluetooth_album_art_summary">Albumcover über Bluetooth versenden (kann dazu führen, dass Bluetooth-Benachrichtigungen fehlschlagen)</string>
|
||||
<string name="settings.send_bluetooth_album_art">Album Cover über Bluetooth</string>
|
||||
<string name="settings.disable_send_now_playing_list_summary">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</string>
|
||||
<string name="settings.disable_send_now_playing_list">Deaktiviere senden der aktuellen Wiedergabeliste</string>
|
||||
<string name="settings.server_manage_servers">Server verwalten</string>
|
||||
<string name="settings.server_address">Server Adresse</string>
|
||||
<string name="settings.server_name">Name</string>
|
||||
<string name="settings.server_password">Kennwort</string>
|
||||
<string name="settings.server_remove_server">Server entfernen</string>
|
||||
<string name="settings.server_scaling_summary">Skalierte Cover vom Server laden (spart Bandbreite)</string>
|
||||
<string name="settings.server_scaling_title">Serverseitige Skalierung der Cover</string>
|
||||
<string name="settings.server_unused">Unbenutzt</string>
|
||||
<string name="settings.server_username">Benutzername</string>
|
||||
<string name="settings.server_color">Server Farbe</string>
|
||||
<string name="settings.show_lockscreen_controls">Steuerelemente auf Sperrbildschirm</string>
|
||||
<string name="settings.show_lockscreen_controls_summary">Wiedergabeelemente auf dem Sperrbildschirm anzeigen</string>
|
||||
<string name="settings.show_notification">Benachrichtigungen anzeigen</string>
|
||||
<string name="settings.show_notification_always">Immer Benachrichtigungen zeigen</string>
|
||||
<string name="settings.show_notification_always_summary">Benachrichtigung beim Abspielen immer anzeigen, wenn Einträge in der Wiedergabeliste sind</string>
|
||||
<string name="settings.show_notification_summary">Abspielbenachrichtigung in der Statusleiste anzeigen</string>
|
||||
<string name="settings.show_now_playing">Aktuellen Titel anzeigen</string>
|
||||
<string name="settings.show_now_playing_summary">Aktuellen Titel in allen Aktivitäten anzeigen</string>
|
||||
<string name="settings.show_track_number">Titelnummer anzeigen</string>
|
||||
|
@ -401,8 +367,6 @@
|
|||
<string name="settings.playback.bluetooth_all">Alle Bluetooth Geräte</string>
|
||||
<string name="settings.playback.bluetooth_a2dp">Nur Audio (A2DP) Geräte</string>
|
||||
<string name="settings.playback.bluetooth_disabled">Deaktiviert</string>
|
||||
<string name="settings.playback.single_button_bluetooth_device">Bluetooth Gerät mit einer Play/Pause Taste</string>
|
||||
<string name="settings.playback.single_button_bluetooth_device_summary">Dies kann bei älteren Bluetooth Geräten helfen, wenn Play/Pause nicht richtig funktioniert</string>
|
||||
<string name="settings.debug.title">Debug Optionen</string>
|
||||
<string name="settings.debug.log_to_file">Schreibe Debug Log in Datei</string>
|
||||
<string name="settings.debug.log_path">Die Log Dateien sind unter %1$s/%2$s verfügbar</string>
|
||||
|
@ -455,10 +419,6 @@
|
|||
<item quantity="one">%d Titel nach aktuellen Titel hinzugefügt</item>
|
||||
<item quantity="other">%d Titel nach aktuellen Titel hinzugefügt</item>
|
||||
</plurals>
|
||||
<plurals name="select_album_donate_dialog_n_trial_days_left">
|
||||
<item quantity="one">%d Tag Testphase übrig</item>
|
||||
<item quantity="other">%d Tage Testphase übrig</item>
|
||||
</plurals>
|
||||
|
||||
<!-- Subsonic api errors -->
|
||||
<string name="api.subsonic.generic">Allgemeiner API Fehler: %1$s</string>
|
||||
|
|
|
@ -41,11 +41,8 @@
|
|||
<string name="common.name">Nombre</string>
|
||||
<string name="common.ok">OK</string>
|
||||
<string name="common.pin">Anclar</string>
|
||||
<string name="common.pause">Pausar</string>
|
||||
<string name="common.play">Reproducir</string>
|
||||
<string name="common.play_last">Reproducir última</string>
|
||||
<string name="common.play_next">Reproducir a continuación</string>
|
||||
<string name="common.play_previous">Reproducir anterior </string>
|
||||
<string name="common.play_now">Reproducir ahora</string>
|
||||
<string name="common.play_shuffled">Reproducción aleatoria</string>
|
||||
<string name="common.public">Public</string>
|
||||
|
@ -75,10 +72,7 @@
|
|||
<string name="download.menu_screen_on">Pantalla encendida</string>
|
||||
<string name="download.menu_show_album">Mostrar Álbum</string>
|
||||
<string name="download.menu_shuffle">Aleatorio</string>
|
||||
<string name="download.menu_shuffle_notification">Lista de reproducción en modo aleatorio</string>
|
||||
<string name="download.menu_visualizer">Visualizador</string>
|
||||
<string name="download.playerstate_buffering">Almacenando en el buffer</string>
|
||||
<string name="download.playerstate_downloading">Descargando - %s</string>
|
||||
<string name="download.playerstate_playing_shuffle">Reproduciendo en modo aleatorio</string>
|
||||
<string name="download.playlist_done">Lista de reproducción guardada con éxito.</string>
|
||||
<string name="download.playlist_error">Fallo al guardar la lista de reproducción, por favor reinténtalo mas tarde.</string>
|
||||
|
@ -125,7 +119,6 @@
|
|||
<string name="main.music">Música</string>
|
||||
<string name="main.offline">Sin conexión</string>
|
||||
<string name="main.setup_server">%s - Configurar servidor</string>
|
||||
<string name="main.shuffle">Reproducción aleatoria</string>
|
||||
<string name="main.songs_random">Aleatorio</string>
|
||||
<string name="main.songs_starred">Me gusta</string>
|
||||
<string name="main.songs_title">Canciones</string>
|
||||
|
@ -139,26 +132,19 @@
|
|||
<string name="menu.deleted_playlist_error">Fallo al eliminar la lista de reproducción %s</string>
|
||||
<string name="menu.downloads">Descargas</string>
|
||||
<string name="menu.exit">Salir</string>
|
||||
<string name="menu.navigation">Navegación</string>
|
||||
<string name="menu.settings">Configuración</string>
|
||||
<string name="menu.refresh">Actualizar</string>
|
||||
<string name="music_library.label">Biblioteca de medios</string>
|
||||
<string name="music_library.label_offline">Medios sin conexión</string>
|
||||
<string name="music_service.retry">Se ha producido un error de red. Reintento %1$d de %2$d.</string>
|
||||
<string name="parser.artist_count">Obtenido(s) %d artista(s).</string>
|
||||
<string name="parser.reading">Leyendo del servidor.</string>
|
||||
<string name="parser.reading_done">Leyendo del servidor. ¡Hecho!</string>
|
||||
<string name="playlist.label">Listas de reproducción</string>
|
||||
<string name="playlist.update_info">Actualizar Información</string>
|
||||
<string name="playlist.updated_info">Actualizada la información de la lista de reproducción para %s</string>
|
||||
<string name="playlist.updated_info_error">Fallo al actualizar la información de la lista de reproducción para %s</string>
|
||||
<string name="progress.wait">Por favor espere…</string>
|
||||
<string name="search.albums">Álbumes</string>
|
||||
<string name="search.artists">Artistas</string>
|
||||
<string name="search.label">Buscar</string>
|
||||
<string name="search.more">Mostrar mas</string>
|
||||
<string name="search.no_match">Sin resultados, por favor inténtalo de nuevo</string>
|
||||
<string name="search.search">Haz click para buscar</string>
|
||||
<string name="search.songs">Canciones</string>
|
||||
<string name="search.title">Buscar</string>
|
||||
<string name="select_album.empty">No se han encontrado medios</string>
|
||||
|
@ -170,7 +156,6 @@
|
|||
<string name="select_artist.folder">Seleccionar la carpeta</string>
|
||||
<string name="select_genre.empty">No se han encontrado géneros</string>
|
||||
<string name="select_playlist.empty">No hay listas de reproducción almacenadas en el servidor</string>
|
||||
<string name="service.connecting">Contactando con el servidor, por favor espera.</string>
|
||||
<string name="settings.appearance_title">Apariencia</string>
|
||||
<string name="settings.buffer_length">Duración del Buffer</string>
|
||||
<string name="settings.buffer_length_0">Deshabilitado</string>
|
||||
|
@ -211,8 +196,6 @@
|
|||
<string name="settings.chat_refresh">Intervalo de refresco del Chat</string>
|
||||
<string name="settings.clear_bookmark">Limpiar marcador</string>
|
||||
<string name="settings.clear_bookmark_summary">Limpiar marcador tras la finalización de la reproducción de una canción</string>
|
||||
<string name="settings.clear_playlist">Limpiar lista de reproducción</string>
|
||||
<string name="settings.clear_playlist_summary">Limpiar la lista de reproducción tras la finalización de la reproducción de todas las canciones</string>
|
||||
<string name="settings.clear_search_history">Limpiar el historial de búsqueda</string>
|
||||
<string name="settings.connection_failure">Fallo de conexión.</string>
|
||||
<string name="settings.default_albums">Álbumes predeterminados</string>
|
||||
|
@ -232,14 +215,11 @@
|
|||
<string name="settings.display_bitrate_summary">Añadir el nombre del artista con la tasa de bits y la extensión del archivo</string>
|
||||
<string name="settings.download_transition">Mostrar reproduciendo ahora al reproducir</string>
|
||||
<string name="settings.download_transition_summary">Cambiar a reproduciendo ahora después de iniciar la reproducción en la vista multimedia</string>
|
||||
<string name="settings.gapless_playback">Reproducción sin pausas</string>
|
||||
<string name="settings.gapless_playback_summary">Activa la reproducción sin pausas</string>
|
||||
<string name="settings.hide_media_summary">Oculta los archivos de música desde otras aplicaciones.</string>
|
||||
<string name="settings.hide_media_title">Ocultar desde otras</string>
|
||||
<string name="settings.hide_media_toast">Tiene efecto la próxima vez que Android escanee la música de tu dispositivo.</string>
|
||||
<string name="settings.increment_time">Intervalo de salto</string>
|
||||
<string name="settings.invalid_url">Por favor especifica una URL válida.</string>
|
||||
<string name="settings.invalid_username">Por favor especifica un nombre de usuario válido (sin espacios al final).</string>
|
||||
<string name="settings.max_albums">Máximo de Álbumes</string>
|
||||
<string name="settings.max_artists">Máximo de Artistas</string>
|
||||
<string name="settings.max_bitrate_112">112 Kbps</string>
|
||||
|
@ -300,28 +280,14 @@
|
|||
<string name="settings.search_75">75</string>
|
||||
<string name="settings.search_history_cleared">Se ha limpiado el historial de búsqueda</string>
|
||||
<string name="settings.search_title">Configuración de la búsqueda</string>
|
||||
<string name="settings.send_bluetooth_notification_summary">Enviar notificaciones de reproducción vía Bluetooth</string>
|
||||
<string name="settings.send_bluetooth_notification">Enviar notificaciones Bluetooth</string>
|
||||
<string name="settings.send_bluetooth_album_art_summary">Enviar la carátula del álbum vía Bluetooth (Puede causar que las notificaciones Bluetooth fallen)</string>
|
||||
<string name="settings.send_bluetooth_album_art">Carátula del Álbum vía Bluetooth</string>
|
||||
<string name="settings.disable_send_now_playing_list_summary">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</string>
|
||||
<string name="settings.disable_send_now_playing_list">Desactivar el envío de la lista de reproducción actual</string>
|
||||
<string name="settings.server_manage_servers">Administrar servidores</string>
|
||||
<string name="settings.server_address">Dirección del servidor</string>
|
||||
<string name="settings.server_name">Nombre</string>
|
||||
<string name="settings.server_password">Contraseña</string>
|
||||
<string name="settings.server_remove_server">Quitar servidor</string>
|
||||
<string name="settings.server_scaling_summary">Descarga imágenes escaladas del servidor en lugar del tamaño completo (salva ancho de banda)</string>
|
||||
<string name="settings.server_scaling_title">Escalado de caratulas en el servidor</string>
|
||||
<string name="settings.server_unused">Sin usar</string>
|
||||
<string name="settings.server_username">Nombre de usuario</string>
|
||||
<string name="settings.server_color">Color del servidor</string>
|
||||
<string name="settings.show_lockscreen_controls">Mostrar controles en la pantalla de bloqueo</string>
|
||||
<string name="settings.show_lockscreen_controls_summary">Mostrar controles de reproducción en la pantalla de bloqueo</string>
|
||||
<string name="settings.show_notification">Mostrar notificación</string>
|
||||
<string name="settings.show_notification_always">Mostrar siempre la notificación</string>
|
||||
<string name="settings.show_notification_always_summary">Mostrar siempre la notificación de reproduciendo ahora cuando la lista de reproducción contiene datos</string>
|
||||
<string name="settings.show_notification_summary">Mostrar la notificación de reproduciendo ahora en la barra de estado</string>
|
||||
<string name="settings.show_now_playing">Mostrar reproduciendo ahora</string>
|
||||
<string name="settings.show_now_playing_summary">Mostrar la pista que se esta reproduciendo en todas las actividades</string>
|
||||
<string name="settings.show_track_number">Mostrar número de pista</string>
|
||||
|
@ -401,8 +367,6 @@
|
|||
<string name="settings.playback.bluetooth_all">Todos los dispositivos Bluetooth</string>
|
||||
<string name="settings.playback.bluetooth_a2dp">Solo dispositivos de audio (A2DP)</string>
|
||||
<string name="settings.playback.bluetooth_disabled">Deshabilitado</string>
|
||||
<string name="settings.playback.single_button_bluetooth_device">Dispositivo Bluetooth con solo un único botón Reproducir / Pausa</string>
|
||||
<string name="settings.playback.single_button_bluetooth_device_summary">Habilitar esto puede ayudar con los dispositivos Bluetooth más antiguos cuando la reproducción / pausa no funciona correctamente</string>
|
||||
<string name="settings.debug.title">Opciones de depuración</string>
|
||||
<string name="settings.debug.log_to_file">Escribir registro de depuración en un archivo</string>
|
||||
<string name="settings.debug.log_path">Los archivos de registro están disponibles en %1$s/%2$s</string>
|
||||
|
@ -458,10 +422,6 @@
|
|||
<item quantity="one">%d canción insertada después de la canción actual</item>
|
||||
<item quantity="other">%d canciones insertadas después de la canción actual.</item>
|
||||
</plurals>
|
||||
<plurals name="select_album_donate_dialog_n_trial_days_left">
|
||||
<item quantity="one">Queda %d día de periodo de prueba</item>
|
||||
<item quantity="other">Quedan %d días de periodo de prueba</item>
|
||||
</plurals>
|
||||
|
||||
<!-- Subsonic api errors -->
|
||||
<string name="api.subsonic.generic">Error genérico de api: %1$s</string>
|
||||
|
|
|
@ -41,11 +41,8 @@
|
|||
<string name="common.name">Nom</string>
|
||||
<string name="common.ok">OK</string>
|
||||
<string name="common.pin">Épingler</string>
|
||||
<string name="common.pause">Pause</string>
|
||||
<string name="common.play">Lecture</string>
|
||||
<string name="common.play_last">Jouer en dernier</string>
|
||||
<string name="common.play_next">Jouer à la suite</string>
|
||||
<string name="common.play_previous">Lire le précédent</string>
|
||||
<string name="common.play_now">Jouer maintenant</string>
|
||||
<string name="common.play_shuffled">Jouer aléatoirement</string>
|
||||
<string name="common.public">Public</string>
|
||||
|
@ -75,10 +72,7 @@
|
|||
<string name="download.menu_screen_on">Sur l\'écran</string>
|
||||
<string name="download.menu_show_album">Afficher l\'album</string>
|
||||
<string name="download.menu_shuffle">Aléatoire</string>
|
||||
<string name="download.menu_shuffle_notification">Playlist aléatoire</string>
|
||||
<string name="download.menu_visualizer">Visualiseur</string>
|
||||
<string name="download.playerstate_buffering">Mise en mémoire</string>
|
||||
<string name="download.playerstate_downloading">Téléchargement - %s</string>
|
||||
<string name="download.playerstate_playing_shuffle">En lecture aléatoire</string>
|
||||
<string name="download.playlist_done">Playlist enregistrée avec succès !</string>
|
||||
<string name="download.playlist_error">Échec de l\'enregistrement de la playlist, veuillez réessayer plus tard.</string>
|
||||
|
@ -110,7 +104,6 @@
|
|||
<string name="main.music">Musique</string>
|
||||
<string name="main.offline">Hors-ligne</string>
|
||||
<string name="main.setup_server">%s - Configurer le serveur</string>
|
||||
<string name="main.shuffle">Lecture aléatoire</string>
|
||||
<string name="main.songs_random">Aléatoire</string>
|
||||
<string name="main.songs_starred">Favoris</string>
|
||||
<string name="main.songs_title">Titres</string>
|
||||
|
@ -124,26 +117,19 @@
|
|||
<string name="menu.deleted_playlist_error">Échec de suppression de la playlist %s</string>
|
||||
<string name="menu.downloads">Téléchargements</string>
|
||||
<string name="menu.exit">Quitter</string>
|
||||
<string name="menu.navigation">Navigation</string>
|
||||
<string name="menu.settings">Paramètres</string>
|
||||
<string name="menu.refresh">Rafraichir</string>
|
||||
<string name="music_library.label">Bibliothèque musicale</string>
|
||||
<string name="music_library.label_offline">Musique hors-ligne</string>
|
||||
<string name="music_service.retry">Une erreur de réseau s\'est produite. Tentative %1$d sur %2$d.</string>
|
||||
<string name="parser.artist_count">%d artistes récupérés.</string>
|
||||
<string name="parser.reading">Lecture du serveur.</string>
|
||||
<string name="parser.reading_done">Lecture du serveur. Terminé !</string>
|
||||
<string name="playlist.label">Playlists</string>
|
||||
<string name="playlist.update_info">Mise à jour des informations</string>
|
||||
<string name="playlist.updated_info">Informations de la playlist %s mises à jour</string>
|
||||
<string name="playlist.updated_info_error">Échec de mise à jour des informations de la playlist %s</string>
|
||||
<string name="progress.wait">Veuillez patienter…</string>
|
||||
<string name="search.albums">Albums</string>
|
||||
<string name="search.artists">Artistes</string>
|
||||
<string name="search.label">Recherche</string>
|
||||
<string name="search.more">Afficher plus</string>
|
||||
<string name="search.no_match">Aucun résultat, veuillez essayer à nouveau</string>
|
||||
<string name="search.search">Cliquer pour rechercher</string>
|
||||
<string name="search.songs">Titres</string>
|
||||
<string name="search.title">Recherche</string>
|
||||
<string name="select_album.empty">Aucun titre trouvé</string>
|
||||
|
@ -154,7 +140,6 @@
|
|||
<string name="select_artist.folder">Sélectionner le dossier</string>
|
||||
<string name="select_genre.empty">Aucun genre trouvé</string>
|
||||
<string name="select_playlist.empty">Aucune playlist sur le serveur</string>
|
||||
<string name="service.connecting">Contact du serveur, veuillez patienter.</string>
|
||||
<string name="settings.appearance_title">Apparence</string>
|
||||
<string name="settings.buffer_length">Taille de la mémoire tampon</string>
|
||||
<string name="settings.buffer_length_0">Désactivé</string>
|
||||
|
@ -195,8 +180,6 @@
|
|||
<string name="settings.chat_refresh">Délai de rafraichissement du salon de discussion</string>
|
||||
<string name="settings.clear_bookmark">Effacer le signet</string>
|
||||
<string name="settings.clear_bookmark_summary">Effacer le signet à la fin de la lecture d\'un titre</string>
|
||||
<string name="settings.clear_playlist">Effacer la playlist</string>
|
||||
<string name="settings.clear_playlist_summary">Effacer la playlist à la fin de la lecture de tous les titres</string>
|
||||
<string name="settings.clear_search_history">Effacer l\'historique des recherches</string>
|
||||
<string name="settings.connection_failure">Échec de la connexion</string>
|
||||
<string name="settings.default_albums">Albums par défaut</string>
|
||||
|
@ -214,14 +197,11 @@
|
|||
<string name="settings.disc_sort_summary">Trier la liste des titres par numéro de disques/pistes</string>
|
||||
<string name="settings.display_bitrate">Afficher le débit et l’extension de fichier</string>
|
||||
<string name="settings.display_bitrate_summary">Ajouter le nom d\'artiste, débit et suffixe du fichier</string>
|
||||
<string name="settings.gapless_playback">Lecture sans interruption</string>
|
||||
<string name="settings.gapless_playback_summary">Activer la lecture sans interruption</string>
|
||||
<string name="settings.hide_media_summary">Masquer les fichiers musicaux et les couvertures d\'album aux autres applis (Galerie, Musique, etc.)</string>
|
||||
<string name="settings.hide_media_title">Masquer aux autres</string>
|
||||
<string name="settings.hide_media_toast">Prendra effet la prochaine fois qu\'Android recensera les médias disponibles sur l\'appareil.</string>
|
||||
<string name="settings.increment_time">Intervalle de saut</string>
|
||||
<string name="settings.invalid_url">Veuillez spécifier une URL valide.</string>
|
||||
<string name="settings.invalid_username">Veuillez spécifier un nom d\'utilisateur valide (sans espace à la fin).</string>
|
||||
<string name="settings.max_albums">Albums maximum</string>
|
||||
<string name="settings.max_artists">Artistes maximum</string>
|
||||
<string name="settings.max_bitrate_112">112 kbit/s</string>
|
||||
|
@ -280,28 +260,14 @@
|
|||
<string name="settings.search_75">75</string>
|
||||
<string name="settings.search_history_cleared">Historique des recherches effacé</string>
|
||||
<string name="settings.search_title">Paramètres de recherche</string>
|
||||
<string name="settings.send_bluetooth_notification_summary">Envoyer des notifications de lecture via Bluetooth</string>
|
||||
<string name="settings.send_bluetooth_notification">Envoyer une notification Bluetooth</string>
|
||||
<string name="settings.send_bluetooth_album_art_summary">Envoyer la pochette de l\'album via Bluetooth (peut causer l\'échec des notifications Bluetooth)</string>
|
||||
<string name="settings.send_bluetooth_album_art">Pochette de l\'album via Bluetooth</string>
|
||||
<string name="settings.disable_send_now_playing_list_summary">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.</string>
|
||||
<string name="settings.disable_send_now_playing_list">Désactiver l’envoi de la liste de lecture</string>
|
||||
<string name="settings.server_manage_servers">Gérer les serveurs</string>
|
||||
<string name="settings.server_address">Adresse du serveur</string>
|
||||
<string name="settings.server_name">Nom</string>
|
||||
<string name="settings.server_password">Mot de passe</string>
|
||||
<string name="settings.server_remove_server">Supprimer le serveur</string>
|
||||
<string name="settings.server_scaling_summary">Télécharger sur le serveur des images réduites au lieu des images grand format (bande passante réduite)</string>
|
||||
<string name="settings.server_scaling_title">Mise à l\'échelle des pochettes d\'album sur le serveur</string>
|
||||
<string name="settings.server_unused">Inutilisé</string>
|
||||
<string name="settings.server_username">Nom d\'utilisateur</string>
|
||||
<string name="settings.server_color">Couleur du serveur</string>
|
||||
<string name="settings.show_lockscreen_controls">Boutons de contrôle sur l\'écran de verrouillage</string>
|
||||
<string name="settings.show_lockscreen_controls_summary">Afficher les contrôles de lecture sur l\'écran de verrouillage</string>
|
||||
<string name="settings.show_notification">Notifications</string>
|
||||
<string name="settings.show_notification_always">Toujours afficher les notifications</string>
|
||||
<string name="settings.show_notification_always_summary">Toujours afficher la notification de lecture en cours lorsque la liste de lecture est remplie</string>
|
||||
<string name="settings.show_notification_summary">Afficher la notification d\'un nouveau titre en lecture dans la barre d\'état</string>
|
||||
<string name="settings.show_now_playing">Montrer la lecture en cours</string>
|
||||
<string name="settings.show_now_playing_summary">Afficher les pistes en cours de lecture dans les autres activités d\'Ultrasonic</string>
|
||||
<string name="settings.show_track_number">Afficher le numéro du titre</string>
|
||||
|
@ -378,7 +344,6 @@
|
|||
<string name="settings.playback.bluetooth_all">Tous les appareils Bluetooth</string>
|
||||
<string name="settings.playback.bluetooth_a2dp">Seulement les appareils audio (A2DP)</string>
|
||||
<string name="settings.playback.bluetooth_disabled">Désactivé</string>
|
||||
<string name="settings.playback.single_button_bluetooth_device_summary">Activer cela peut aider sur les anciens appareils Bluetooth lorsque Lecture/Pause ne fonctionne pas correctement</string>
|
||||
<string name="settings.debug.title">Paramètres de debug</string>
|
||||
<string name="settings.debug.log_to_file">Enregistrer les logs de debug dans des fichiers</string>
|
||||
<string name="settings.debug.log_path">Les fichiers de log sont disponibles dans %1$s/%2$s</string>
|
||||
|
@ -410,10 +375,6 @@
|
|||
<item quantity="one">%d titre</item>
|
||||
<item quantity="other">%d titres</item>
|
||||
</plurals>
|
||||
<plurals name="select_album_donate_dialog_n_trial_days_left">
|
||||
<item quantity="one">%d jour restant à la période d\'essai</item>
|
||||
<item quantity="other">%d jours restant à la période d\'essai</item>
|
||||
</plurals>
|
||||
|
||||
<!-- Subsonic api errors -->
|
||||
<string name="api.subsonic.generic">Erreur de l\'API générique : %1$s</string>
|
||||
|
|
|
@ -39,11 +39,8 @@
|
|||
<string name="common.name">Név</string>
|
||||
<string name="common.ok">OK</string>
|
||||
<string name="common.pin">Tárolás (Megőrzés az eszközön)</string>
|
||||
<string name="common.pause">Szünet</string>
|
||||
<string name="common.play">Lejátszás</string>
|
||||
<string name="common.play_last">Lejátszás (Utolsóként)</string>
|
||||
<string name="common.play_next">Lejátszás (Következőként)</string>
|
||||
<string name="common.play_previous">Előző lejátszása</string>
|
||||
<string name="common.play_now">Lejátszás</string>
|
||||
<string name="common.play_shuffled">Véletlen sorrendű lejátszás</string>
|
||||
<string name="common.public">Nyilvános</string>
|
||||
|
@ -70,10 +67,7 @@
|
|||
<string name="download.menu_screen_on">Kijelző be</string>
|
||||
<string name="download.menu_show_album">Ugrás az albumhoz</string>
|
||||
<string name="download.menu_shuffle">Véletlen sorrendű</string>
|
||||
<string name="download.menu_shuffle_notification">Véletlen sorrendű lejátszás</string>
|
||||
<string name="download.menu_visualizer">Visualizer</string>
|
||||
<string name="download.playerstate_buffering">Pufferelés</string>
|
||||
<string name="download.playerstate_downloading">Letöltés - %s</string>
|
||||
<string name="download.playerstate_playing_shuffle">Véletlen sorrendű</string>
|
||||
<string name="download.playlist_done">Lejátszási lista mentése sikeres.</string>
|
||||
<string name="download.playlist_error">Lejátszási lista mentése sikertelen, próbálja később!</string>
|
||||
|
@ -104,7 +98,6 @@
|
|||
<string name="main.genres_title">Műfajok</string>
|
||||
<string name="main.music">Zenék</string>
|
||||
<string name="main.offline">Kapcsolat nélküli</string>
|
||||
<string name="main.shuffle">Véletlen sorrendű</string>
|
||||
<string name="main.songs_random">Véletlenszerű</string>
|
||||
<string name="main.songs_starred">Csillaggal megjelölt</string>
|
||||
<string name="main.songs_title">Dalok</string>
|
||||
|
@ -115,26 +108,19 @@
|
|||
<string name="menu.deleted_playlist">Törölt lejátszási lista %s</string>
|
||||
<string name="menu.deleted_playlist_error">Lejátszási lista törlése sikertelen %s</string>
|
||||
<string name="menu.exit">Kilépés</string>
|
||||
<string name="menu.navigation">Navigáció</string>
|
||||
<string name="menu.settings">Beállítások</string>
|
||||
<string name="menu.refresh">Frissítés</string>
|
||||
<string name="music_library.label">Mediakönyvtár</string>
|
||||
<string name="music_library.label_offline">Kapcsolat nélküli médiák</string>
|
||||
<string name="music_service.retry">Hálózati hiba történt! Újrapróbálkozás %1$d - %2$d.</string>
|
||||
<string name="parser.artist_count">%d előadó található a médiakönyvtárban.</string>
|
||||
<string name="parser.reading">Olvasás a kiszolgálóról…</string>
|
||||
<string name="parser.reading_done">Olvasás a kiszolgálóról… Kész!</string>
|
||||
<string name="playlist.label">Lejátszási listák</string>
|
||||
<string name="playlist.update_info">Módosítás</string>
|
||||
<string name="playlist.updated_info">Módosított lejátszási lista %s</string>
|
||||
<string name="playlist.updated_info_error">Lejátszási lista módosítása sikertelen %s</string>
|
||||
<string name="progress.wait">Kérem várjon!…</string>
|
||||
<string name="search.albums">Albumok</string>
|
||||
<string name="search.artists">Előadók</string>
|
||||
<string name="search.label">Keresés</string>
|
||||
<string name="search.more">Továbbiak</string>
|
||||
<string name="search.no_match">Nincs találat, próbálja újra!</string>
|
||||
<string name="search.search">Érintse meg a kereséshez</string>
|
||||
<string name="search.songs">Dalok</string>
|
||||
<string name="search.title">Keresés</string>
|
||||
<string name="select_album.empty">Nem található média!</string>
|
||||
|
@ -144,7 +130,6 @@
|
|||
<string name="select_artist.folder">Mappa kiválasztása</string>
|
||||
<string name="select_genre.empty">Műfajok nem találhatók!</string>
|
||||
<string name="select_playlist.empty">Nincs mentett lejátszási lista a kiszolgálón.</string>
|
||||
<string name="service.connecting">Csatlakozás a kiszolgálóhoz, kérem várjon!</string>
|
||||
<string name="settings.appearance_title">Megjelenés</string>
|
||||
<string name="settings.buffer_length">Pufferméret</string>
|
||||
<string name="settings.buffer_length_0">Letiltva</string>
|
||||
|
@ -184,8 +169,6 @@
|
|||
<string name="settings.chat_refresh">Csevegés frissítési gyakorisága</string>
|
||||
<string name="settings.clear_bookmark">Könyvjelző törlése</string>
|
||||
<string name="settings.clear_bookmark_summary">Könyvjelző törlése a dal lejátszása után.</string>
|
||||
<string name="settings.clear_playlist">Várólista törlése</string>
|
||||
<string name="settings.clear_playlist_summary">Várólista törlése az összes dal lejátszása után.</string>
|
||||
<string name="settings.clear_search_history">Keresési előzmények törlése</string>
|
||||
<string name="settings.connection_failure">Csatlakozási hiba!</string>
|
||||
<string name="settings.default_albums">Albumok találati száma</string>
|
||||
|
@ -202,14 +185,11 @@
|
|||
<string name="settings.disc_sort">Dalok rendezése albumok szerint</string>
|
||||
<string name="settings.disc_sort_summary">Dalok rendezése albumsorszám és dalsorszám szerint.</string>
|
||||
<string name="settings.display_bitrate_summary">Bitráta és fájlkiterjesztés megjelenítése az előadónév mellett.</string>
|
||||
<string name="settings.gapless_playback">Egybefüggő lejátszás</string>
|
||||
<string name="settings.gapless_playback_summary">Kihagyás (dalszünet) nélküli egybefüggő lejátszás (Gapless).</string>
|
||||
<string name="settings.hide_media_summary">Zenefájlok elrejtése egyéb alkalmazások elől.</string>
|
||||
<string name="settings.hide_media_title">Elrejtés</string>
|
||||
<string name="settings.hide_media_toast">A következő alkalomtól lép életbe, amikor az Android zenefájlokat keres a telefonon.</string>
|
||||
<string name="settings.increment_time">Ugrás időintervalluma</string>
|
||||
<string name="settings.invalid_url">Adjon meg egy érvényes URL-t!</string>
|
||||
<string name="settings.invalid_username">Adjon meg egy érvényes felhasználónevet (szóközt nem tartalmazhat)!</string>
|
||||
<string name="settings.max_albums">Albumok max. találati száma</string>
|
||||
<string name="settings.max_artists">Előadók max. találati száma</string>
|
||||
<string name="settings.max_bitrate_112">112 Kbps</string>
|
||||
|
@ -268,25 +248,13 @@
|
|||
<string name="settings.search_75">75</string>
|
||||
<string name="settings.search_history_cleared">Keresési előzmények törölve.</string>
|
||||
<string name="settings.search_title">Keresés beállításai</string>
|
||||
<string name="settings.send_bluetooth_notification_summary">Lejátszási értesítések küldése Bluetooth-on.</string>
|
||||
<string name="settings.send_bluetooth_notification">Bluetooth értesítések küldése</string>
|
||||
<string name="settings.send_bluetooth_album_art_summary">Albumborító küldése Bluetooth-on (Problémát okozhat a Bluetooth értesítéseknél)</string>
|
||||
<string name="settings.send_bluetooth_album_art">Albumborító Bluetooth-on</string>
|
||||
<string name="settings.server_manage_servers">Kiszolgálók kezelése</string>
|
||||
<string name="settings.server_address">Kiszolgáló címe</string>
|
||||
<string name="settings.server_name">Név</string>
|
||||
<string name="settings.server_password">Jelszó</string>
|
||||
<string name="settings.server_remove_server">Kiszolgáló eltávolítása</string>
|
||||
<string name="settings.server_scaling_summary">Teljes méretű helyett átméretezett képek letöltése a kiszolgálóról (sávszélesség-takarékos).</string>
|
||||
<string name="settings.server_scaling_title">Albumborító átméretezés (Kiszolgáló-oldali)</string>
|
||||
<string name="settings.server_unused">Kiszolgáló</string>
|
||||
<string name="settings.server_username">Felhasználónév</string>
|
||||
<string name="settings.show_lockscreen_controls">Képernyőzár kezelése</string>
|
||||
<string name="settings.show_lockscreen_controls_summary">Lejátszó-kezelőpanel megjelenítése a képernyőzáron.</string>
|
||||
<string name="settings.show_notification">Értesítések megjelenítése</string>
|
||||
<string name="settings.show_notification_always">Állandó kijelzés</string>
|
||||
<string name="settings.show_notification_always_summary">Lejátszás jelzése az értesítési sávon, míg a várólista aktív.</string>
|
||||
<string name="settings.show_notification_summary">Lejátszás jelzése az értesítési sávon.</string>
|
||||
<string name="settings.show_now_playing">Lejátszó-kezelőpanel</string>
|
||||
<string name="settings.show_now_playing_summary">Lejátszó-kezelőpanel megjelenítése minden oldalon.</string>
|
||||
<string name="settings.show_track_number">Sorszám megjelenítése</string>
|
||||
|
@ -357,7 +325,6 @@
|
|||
<string name="settings.playback.bluetooth_all">Minden Bluetooth eszköz</string>
|
||||
<string name="settings.playback.bluetooth_a2dp">Csak audio (A2DP) eszközök</string>
|
||||
<string name="settings.playback.bluetooth_disabled">Kikapcsolva</string>
|
||||
<string name="settings.playback.single_button_bluetooth_device_summary">Régebbi Bluetooth eszközök esetén segíthet, ha a Lejátszás/Szünet nem működik megfelelően</string>
|
||||
<string name="settings.debug.title">Hibakeresési lehetőségek</string>
|
||||
<string name="settings.debug.log_to_file">Hibakeresési napló írása fájlba</string>
|
||||
<string name="settings.debug.log_path">A naplófájlok elérhetőek a következő helyen: %1$s/%2$s</string>
|
||||
|
@ -381,10 +348,6 @@
|
|||
<item quantity="one">%d dal</item>
|
||||
<item quantity="other">%d dal</item>
|
||||
</plurals>
|
||||
<plurals name="select_album_donate_dialog_n_trial_days_left">
|
||||
<item quantity="one">%d nap van hátra a próba időszakból.</item>
|
||||
<item quantity="other">%d nap van hátra a próba időszakból.</item>
|
||||
</plurals>
|
||||
|
||||
<!-- Subsonic api errors -->
|
||||
<string name="api.subsonic.generic">Általános api hiba: %1$s</string>
|
||||
|
|
|
@ -58,10 +58,7 @@
|
|||
<string name="download.menu_screen_on">Schermo acceso</string>
|
||||
<string name="download.menu_show_album">Visualizza Album</string>
|
||||
<string name="download.menu_shuffle">Casuale</string>
|
||||
<string name="download.menu_shuffle_notification">Playlist casuale</string>
|
||||
<string name="download.menu_visualizer">Visualizzatore</string>
|
||||
<string name="download.playerstate_buffering">Buffering</string>
|
||||
<string name="download.playerstate_downloading">In scaricamento - %s</string>
|
||||
<string name="download.playerstate_playing_shuffle">Riproduzione casuale</string>
|
||||
<string name="download.playlist_done">Playlist salvata con successo </string>
|
||||
<string name="download.playlist_error">Impossibile salvare la playlist, riprovare più tardi.</string>
|
||||
|
@ -92,7 +89,6 @@
|
|||
<string name="main.genres_title">Generi</string>
|
||||
<string name="main.music">Musica</string>
|
||||
<string name="main.offline">Disconnesso</string>
|
||||
<string name="main.shuffle">Riproduzione casuale</string>
|
||||
<string name="main.songs_random">Casuale</string>
|
||||
<string name="main.songs_starred">Preferiti</string>
|
||||
<string name="main.songs_title">Canzoni</string>
|
||||
|
@ -103,25 +99,18 @@
|
|||
<string name="menu.deleted_playlist">Playlist %s eliminata</string>
|
||||
<string name="menu.deleted_playlist_error">Impossibile eliminare la playlist %s</string>
|
||||
<string name="menu.exit">Esci</string>
|
||||
<string name="menu.navigation">Navigazione</string>
|
||||
<string name="menu.settings">Impostazioni</string>
|
||||
<string name="music_library.label">Libreria</string>
|
||||
<string name="music_library.label_offline">Media Offline</string>
|
||||
<string name="music_service.retry">Problema di rete. Tentativo %1$d di %2$d.</string>
|
||||
<string name="parser.artist_count">Ottenuti%d Artisti.</string>
|
||||
<string name="parser.reading">Lettura dal server.</string>
|
||||
<string name="parser.reading_done">Lettura dal server. Completato!</string>
|
||||
<string name="playlist.label">Playlist</string>
|
||||
<string name="playlist.update_info">Aggiorna Informazioni</string>
|
||||
<string name="playlist.updated_info">Aggiorna informazioni playlist per %s</string>
|
||||
<string name="playlist.updated_info_error">Impossibile aggiornare informazioni playlist per %s</string>
|
||||
<string name="progress.wait">Attendere, per favore…</string>
|
||||
<string name="search.albums">Album</string>
|
||||
<string name="search.artists">Artisti</string>
|
||||
<string name="search.label">Cerca</string>
|
||||
<string name="search.more">Mostra di più</string>
|
||||
<string name="search.no_match">Nessun risultato, riprova per favore</string>
|
||||
<string name="search.search">Selezione per cercare</string>
|
||||
<string name="search.songs">Canzoni</string>
|
||||
<string name="search.title">Cerca</string>
|
||||
<string name="select_album.empty">Nessun media trovato</string>
|
||||
|
@ -131,7 +120,6 @@
|
|||
<string name="select_artist.folder">Seleziona cartella</string>
|
||||
<string name="select_genre.empty">Nessun genere trovato</string>
|
||||
<string name="select_playlist.empty">Nessuna playlist salvata sul server</string>
|
||||
<string name="service.connecting">Server contattato, attendere.</string>
|
||||
<string name="settings.appearance_title">Aspetto</string>
|
||||
<string name="settings.buffer_length">Lunghezza buffer</string>
|
||||
<string name="settings.buffer_length_0">Disabilitato</string>
|
||||
|
@ -171,8 +159,6 @@
|
|||
<string name="settings.chat_refresh">Intervallo Aggiornamento Chat</string>
|
||||
<string name="settings.clear_bookmark">Pulisci Segnalibro</string>
|
||||
<string name="settings.clear_bookmark_summary">Pulisci segnalibro al completamento della riproduzione di una canzone</string>
|
||||
<string name="settings.clear_playlist">Pulisci Playlist</string>
|
||||
<string name="settings.clear_playlist_summary">Pulisci playlist al completamento della riproduzione di tutte le canzoni</string>
|
||||
<string name="settings.clear_search_history">Pulisci Storico Ricerca</string>
|
||||
<string name="settings.connection_failure">Errore connessione.</string>
|
||||
<string name="settings.default_albums">Album predefiniti</string>
|
||||
|
@ -189,13 +175,10 @@
|
|||
<string name="settings.disc_sort">Ordina Canzoni secondo Disco</string>
|
||||
<string name="settings.disc_sort_summary">Ordina lista canzoni secondo il numero disco e traccia</string>
|
||||
<string name="settings.display_bitrate_summary">Aggiungi nome artista con bitrare ed estensione file</string>
|
||||
<string name="settings.gapless_playback">Riproduzione Ininterrotta</string>
|
||||
<string name="settings.gapless_playback_summary">Abilita riproduzione ininterrotta</string>
|
||||
<string name="settings.hide_media_summary">Nascondi file musicali di altre app</string>
|
||||
<string name="settings.hide_media_title">Nascondi Da Altro</string>
|
||||
<string name="settings.hide_media_toast">Effettivo alla prossima scansione Android per file musicali sul telefono.</string>
|
||||
<string name="settings.invalid_url">Specifica un URL valido per favore.</string>
|
||||
<string name="settings.invalid_username">Per favore specifica un nome utente valido (senza spazi)</string>
|
||||
<string name="settings.max_albums">N° Max Album</string>
|
||||
<string name="settings.max_artists">N° Max Artisti</string>
|
||||
<string name="settings.max_bitrate_112">112 Kbps</string>
|
||||
|
@ -251,24 +234,12 @@
|
|||
<string name="settings.search_75">75</string>
|
||||
<string name="settings.search_history_cleared">Storico ricerche eliminato</string>
|
||||
<string name="settings.search_title">Impostazioni ricerca</string>
|
||||
<string name="settings.send_bluetooth_notification_summary">Invia notifiche di riproduzione via Bluetooth</string>
|
||||
<string name="settings.send_bluetooth_notification">Invia notifica Bluetooth</string>
|
||||
<string name="settings.send_bluetooth_album_art_summary">Invia copertine degli album tramite Bluetooth (potrebbe causare errori nelle notifiche)</string>
|
||||
<string name="settings.send_bluetooth_album_art">Copertine Album tramite Bluetooth</string>
|
||||
<string name="settings.server_address">Indirizzo Server</string>
|
||||
<string name="settings.server_name">Nome</string>
|
||||
<string name="settings.server_password">Password</string>
|
||||
<string name="settings.server_remove_server">Elimina Server</string>
|
||||
<string name="settings.server_scaling_summary">Scarica dal server le immagini ridimensionate (risparmia larghezza di banda)</string>
|
||||
<string name="settings.server_scaling_title">Ridimensionamento copertine Album lato server</string>
|
||||
<string name="settings.server_unused">Inutilizzato</string>
|
||||
<string name="settings.server_username">Username</string>
|
||||
<string name="settings.show_lockscreen_controls">Mostra i controlli del blocco schermo</string>
|
||||
<string name="settings.show_lockscreen_controls_summary">Mostra i controlli di riproduzione sulla schermata di blocco</string>
|
||||
<string name="settings.show_notification">Mostra notifica</string>
|
||||
<string name="settings.show_notification_always">Mostra sempre notifica</string>
|
||||
<string name="settings.show_notification_always_summary">Mostra sempre la notifica In Riproduzione quando viene popolata la playlist</string>
|
||||
<string name="settings.show_notification_summary">Mostra la notifica In Riproduzione nella barra di stato</string>
|
||||
<string name="settings.show_now_playing">Mostra In Riproduzione</string>
|
||||
<string name="settings.show_now_playing_summary">Mostra la traccia attualmente in riproduzione in tutte le attività</string>
|
||||
<string name="settings.show_track_number">Visualizza numero traccia</string>
|
||||
|
|
|
@ -41,11 +41,8 @@
|
|||
<string name="common.name">Naam</string>
|
||||
<string name="common.ok">Oké</string>
|
||||
<string name="common.pin">Vastmaken</string>
|
||||
<string name="common.pause">Pauzeren</string>
|
||||
<string name="common.play">Afspelen</string>
|
||||
<string name="common.play_last">Laatste afspelen</string>
|
||||
<string name="common.play_next">Volgende afspelen</string>
|
||||
<string name="common.play_previous">Vorige afspelen</string>
|
||||
<string name="common.play_now">Nu afspelen</string>
|
||||
<string name="common.play_shuffled">Willekeurig afspelen</string>
|
||||
<string name="common.public">Openbaar</string>
|
||||
|
@ -75,10 +72,7 @@
|
|||
<string name="download.menu_screen_on">Scherm aan</string>
|
||||
<string name="download.menu_show_album">Album tonen</string>
|
||||
<string name="download.menu_shuffle">Willekeurig</string>
|
||||
<string name="download.menu_shuffle_notification">Afspeellijst wordt willekeurig afgespeeld</string>
|
||||
<string name="download.menu_visualizer">Visualisatie</string>
|
||||
<string name="download.playerstate_buffering">Bezig met bufferen</string>
|
||||
<string name="download.playerstate_downloading">Bezig met downloaden - %s</string>
|
||||
<string name="download.playerstate_playing_shuffle">Bezig met willekeurig afspelen</string>
|
||||
<string name="download.playlist_done">Afspeellijst is opgeslagen.</string>
|
||||
<string name="download.playlist_error">Afspeellijst kan niet worden opgeslagen. Probeer het later opnieuw.</string>
|
||||
|
@ -125,7 +119,6 @@
|
|||
<string name="main.music">Muziek</string>
|
||||
<string name="main.offline">Offline</string>
|
||||
<string name="main.setup_server">%s - Server instellen</string>
|
||||
<string name="main.shuffle">Willekeurig afspelen</string>
|
||||
<string name="main.songs_random">Willekeurig</string>
|
||||
<string name="main.songs_starred">Favorieten</string>
|
||||
<string name="main.songs_title">Nummers</string>
|
||||
|
@ -139,26 +132,19 @@
|
|||
<string name="menu.deleted_playlist_error">Afspeellijst %s kan niet worden verwijderd</string>
|
||||
<string name="menu.downloads">Downloads</string>
|
||||
<string name="menu.exit">Afsluiten</string>
|
||||
<string name="menu.navigation">Navigatie</string>
|
||||
<string name="menu.settings">Instellingen</string>
|
||||
<string name="menu.refresh">Verversen</string>
|
||||
<string name="music_library.label">Mediabibliotheek</string>
|
||||
<string name="music_library.label_offline">Offline media</string>
|
||||
<string name="music_service.retry">Er is een netwerkfout opgetreden. Bezig met opnieuw proberen; poging %1$d van %2$d.</string>
|
||||
<string name="parser.artist_count">%d artiesten opgehaald.</string>
|
||||
<string name="parser.reading">Bezig met uitlezen van server…</string>
|
||||
<string name="parser.reading_done">Klaar!</string>
|
||||
<string name="playlist.label">Afspeellijsten</string>
|
||||
<string name="playlist.update_info">Informatie bijwerken</string>
|
||||
<string name="playlist.updated_info">Afspeellijstinformatie bijgewerkt voor %s</string>
|
||||
<string name="playlist.updated_info_error">Kan afspeellijstinformatie voor %s niet bijwerken</string>
|
||||
<string name="progress.wait">Even geduld…</string>
|
||||
<string name="search.albums">Albums</string>
|
||||
<string name="search.artists">Artiesten</string>
|
||||
<string name="search.label">Zoeken</string>
|
||||
<string name="search.more">Meer tonen</string>
|
||||
<string name="search.no_match">Geen overeenkomsten; probeer het opnieuw</string>
|
||||
<string name="search.search">Druk om te zoeken</string>
|
||||
<string name="search.songs">Nummers</string>
|
||||
<string name="search.title">Zoeken</string>
|
||||
<string name="select_album.empty">Geen media gevonden</string>
|
||||
|
@ -170,7 +156,6 @@
|
|||
<string name="select_artist.folder">Map kiezen</string>
|
||||
<string name="select_genre.empty">Geen genres gevonden</string>
|
||||
<string name="select_playlist.empty">Geen opgeslagen afspeellijsten op server</string>
|
||||
<string name="service.connecting">Bezig met verbinden met server; even geduld…</string>
|
||||
<string name="settings.appearance_title">Uiterlijk</string>
|
||||
<string name="settings.buffer_length">Bufferduur</string>
|
||||
<string name="settings.buffer_length_0">Uitgeschakeld</string>
|
||||
|
@ -211,8 +196,6 @@
|
|||
<string name="settings.chat_refresh">Chat-ververstussenpoos</string>
|
||||
<string name="settings.clear_bookmark">Bladwijzer verwijderen</string>
|
||||
<string name="settings.clear_bookmark_summary">Bladwijzer verwijderen nadat nummer is afgespeeld</string>
|
||||
<string name="settings.clear_playlist">Afspeellijst wissen</string>
|
||||
<string name="settings.clear_playlist_summary">Afspeellijst wissen nadat alle nummers zijn afgespeeld</string>
|
||||
<string name="settings.clear_search_history">Zoekgeschiedenis wissen</string>
|
||||
<string name="settings.connection_failure">Verbindingsfout.</string>
|
||||
<string name="settings.default_albums">Standaardalbums</string>
|
||||
|
@ -232,14 +215,11 @@
|
|||
<string name="settings.display_bitrate_summary">Bitsnelheid en bestandsextensie toevoegen aan artiestennaam</string>
|
||||
<string name="settings.download_transition">Nu aan het afspelen tonen op afspeelscherm</string>
|
||||
<string name="settings.download_transition_summary">Toon ‘Nu aan het afspelen’ in de mediaweergave</string>
|
||||
<string name="settings.gapless_playback">Naadloze overgang</string>
|
||||
<string name="settings.gapless_playback_summary">Naadloze overgang tussen nummers inschakelen</string>
|
||||
<string name="settings.hide_media_summary">Muziekbestanden verbergen voor andere apps.</string>
|
||||
<string name="settings.hide_media_title">Verbergen voor andere apps</string>
|
||||
<string name="settings.hide_media_toast">Dit wordt toegepast bij de volgende keer dat Android je muziek doorzoekt.</string>
|
||||
<string name="settings.increment_time">Overslaantussenpoos</string>
|
||||
<string name="settings.invalid_url">Geef een geldige URL op.</string>
|
||||
<string name="settings.invalid_username">Geef een geldige gebruikersnaam op (geen spaties erachter).</string>
|
||||
<string name="settings.max_albums">Max. aantal albums</string>
|
||||
<string name="settings.max_artists">Max. aantal artiesten</string>
|
||||
<string name="settings.max_bitrate_112">112 Kbps</string>
|
||||
|
@ -300,28 +280,14 @@
|
|||
<string name="settings.search_75">75</string>
|
||||
<string name="settings.search_history_cleared">Zoekgeschiedenis gewist</string>
|
||||
<string name="settings.search_title">Zoekinstellingen</string>
|
||||
<string name="settings.send_bluetooth_notification_summary">Afspeelmeldingen sturen via bluetooth</string>
|
||||
<string name="settings.send_bluetooth_notification">Bluetoothmelding sturen</string>
|
||||
<string name="settings.send_bluetooth_album_art_summary">Albumhoezen versturen via bluetooth (dit kan leiden tot mislukte bluetoothmeldingen)</string>
|
||||
<string name="settings.send_bluetooth_album_art">Albumhoezen versturen via bluetooth</string>
|
||||
<string name="settings.disable_send_now_playing_list_summary">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.</string>
|
||||
<string name="settings.disable_send_now_playing_list">‘Nu aan het afspelen’-lijst niet delen</string>
|
||||
<string name="settings.server_manage_servers">Manage Servers</string>
|
||||
<string name="settings.server_address">Serveradres</string>
|
||||
<string name="settings.server_name">Naam</string>
|
||||
<string name="settings.server_password">Wachtwoord</string>
|
||||
<string name="settings.server_remove_server">Server verwijderen</string>
|
||||
<string name="settings.server_scaling_summary">Verkleinde afbeeldingen ophalen van server in plaats van volledige (bespaart bandbreedte)</string>
|
||||
<string name="settings.server_scaling_title">Verkleinde afbeeldingen ophalen van server</string>
|
||||
<string name="settings.server_unused">Ongebruikt</string>
|
||||
<string name="settings.server_username">Gebruikersnaam</string>
|
||||
<string name="settings.server_color">Serverkleur</string>
|
||||
<string name="settings.show_lockscreen_controls">Vergrendelschermbediening tonen</string>
|
||||
<string name="settings.show_lockscreen_controls_summary">Toont afspeelbediening op het vergrendelscherm</string>
|
||||
<string name="settings.show_notification">Melding tonen</string>
|
||||
<string name="settings.show_notification_always">Altijd melding tonen</string>
|
||||
<string name="settings.show_notification_always_summary">Altijd een \"nu aan het afspelen\"-melding tonen tijdens het samenstellen van een afspeellijst</string>
|
||||
<string name="settings.show_notification_summary">\"Nu aan het afspelen\"-melding tonen op de statusbalk</string>
|
||||
<string name="settings.show_now_playing">\"Nu aan het afspelen\"-melding tonen</string>
|
||||
<string name="settings.show_now_playing_summary">Toont het momenteel afspelende nummer in alle activiteiten</string>
|
||||
<string name="settings.show_track_number">Itemnummer tonen</string>
|
||||
|
@ -401,8 +367,6 @@
|
|||
<string name="settings.playback.bluetooth_all">Alle bluetoothapparaten</string>
|
||||
<string name="settings.playback.bluetooth_a2dp">Alleen audio-apparaten (AD2P)</string>
|
||||
<string name="settings.playback.bluetooth_disabled">Uitgeschakeld</string>
|
||||
<string name="settings.playback.single_button_bluetooth_device">Bluetoothapparaat met één afspeel- en pauzeknop</string>
|
||||
<string name="settings.playback.single_button_bluetooth_device_summary">Schakel dit in bij gebruik van oudere bluetoothapparaten om de afspeel- en pauzeerknop goed te laten werken</string>
|
||||
<string name="settings.debug.title">Foutopsporingsopties</string>
|
||||
<string name="settings.debug.log_to_file">Foutopsporingslogboek bijhouden</string>
|
||||
<string name="settings.debug.log_path">De logboeken worden opgeslagen in %1$s/%2$s</string>
|
||||
|
@ -458,10 +422,6 @@
|
|||
<item quantity="one">%d nummer ingevoegd na het huidige nummer</item>
|
||||
<item quantity="other">%d nummers ingevoegd na het huidige nummer</item>
|
||||
</plurals>
|
||||
<plurals name="select_album_donate_dialog_n_trial_days_left">
|
||||
<item quantity="one">Nog %d dag over van de proefperiode</item>
|
||||
<item quantity="other">Nog %d dagen over van de proefperiode</item>
|
||||
</plurals>
|
||||
|
||||
<!-- Subsonic api errors -->
|
||||
<string name="api.subsonic.generic">Algemene API-fout: %1$s</string>
|
||||
|
|
|
@ -61,10 +61,7 @@
|
|||
<string name="download.menu_screen_on">Ekran włączony</string>
|
||||
<string name="download.menu_show_album">Wyświetl album</string>
|
||||
<string name="download.menu_shuffle">Wymieszaj</string>
|
||||
<string name="download.menu_shuffle_notification">Playlista została wymieszana</string>
|
||||
<string name="download.menu_visualizer">Efekt wizualny</string>
|
||||
<string name="download.playerstate_buffering">Buforowanie</string>
|
||||
<string name="download.playerstate_downloading">Pobieranie - %s</string>
|
||||
<string name="download.playerstate_playing_shuffle">Odtwarzanie losowe</string>
|
||||
<string name="download.playlist_done">Playlista została zapisana.</string>
|
||||
<string name="download.playlist_error">Błąd zapisu playlisty. Proszę spróbować później.</string>
|
||||
|
@ -95,7 +92,6 @@
|
|||
<string name="main.genres_title">Gatunki</string>
|
||||
<string name="main.music">Muzyka</string>
|
||||
<string name="main.offline">Offline</string>
|
||||
<string name="main.shuffle">Losowo</string>
|
||||
<string name="main.songs_random">Losowe</string>
|
||||
<string name="main.songs_starred">Ulubione</string>
|
||||
<string name="main.songs_title">Utwory</string>
|
||||
|
@ -106,26 +102,19 @@
|
|||
<string name="menu.deleted_playlist">Usunięto playlistę %s</string>
|
||||
<string name="menu.deleted_playlist_error">Usunięcie playlisty %s nie powiodło się</string>
|
||||
<string name="menu.exit">Zakończ</string>
|
||||
<string name="menu.navigation">Nawigacja</string>
|
||||
<string name="menu.settings">Ustawienia</string>
|
||||
<string name="menu.refresh">Refresh</string>
|
||||
<string name="music_library.label">Biblioteka mediów</string>
|
||||
<string name="music_library.label_offline">Media offline</string>
|
||||
<string name="music_service.retry">Wystąpił błąd sieci. Ponawiam %1$d z %2$d.</string>
|
||||
<string name="parser.artist_count">Znaleziono %d artystów.</string>
|
||||
<string name="parser.reading">Trwa odczyt z serwera.</string>
|
||||
<string name="parser.reading_done">Odczyt z serwera zakończony!</string>
|
||||
<string name="playlist.label">Playlisty</string>
|
||||
<string name="playlist.update_info">Aktualizacja informacji</string>
|
||||
<string name="playlist.updated_info">Zaktualizowano informacje dla playlisty %s</string>
|
||||
<string name="playlist.updated_info_error">Błąc podczas aktualizacji playlisty %s</string>
|
||||
<string name="progress.wait">Proszę czekać…</string>
|
||||
<string name="search.albums">Albumy</string>
|
||||
<string name="search.artists">Artyści</string>
|
||||
<string name="search.label">Wyszukaj</string>
|
||||
<string name="search.more">Wyświetl więcej</string>
|
||||
<string name="search.no_match">Brak wyników, proszę spróbować ponownie</string>
|
||||
<string name="search.search">Kliknij, aby wyszukać</string>
|
||||
<string name="search.songs">Utwory</string>
|
||||
<string name="search.title">Wyszukiwanie</string>
|
||||
<string name="select_album.empty">Brak mediów</string>
|
||||
|
@ -135,7 +124,6 @@
|
|||
<string name="select_artist.folder">Wybierz folder</string>
|
||||
<string name="select_genre.empty">Brak gatunków</string>
|
||||
<string name="select_playlist.empty">Brak zapisanych playlist na serwerze</string>
|
||||
<string name="service.connecting">Trwa łączenie z serwerem, proszę czekać.</string>
|
||||
<string name="settings.appearance_title">Wygląd</string>
|
||||
<string name="settings.buffer_length">Wielkość bufora</string>
|
||||
<string name="settings.buffer_length_0">Wyłączone</string>
|
||||
|
@ -175,8 +163,6 @@
|
|||
<string name="settings.chat_refresh">Okres odświeżania czatu</string>
|
||||
<string name="settings.clear_bookmark">Czyszczenie zakładek</string>
|
||||
<string name="settings.clear_bookmark_summary">Czyść zakładkę po zakończeniu odtwarzania utworu</string>
|
||||
<string name="settings.clear_playlist">Czyszczenie playlist</string>
|
||||
<string name="settings.clear_playlist_summary">Czyść playlistę po zakończeniu odtwarzania wszystkich utworów</string>
|
||||
<string name="settings.clear_search_history">Wyczyść historię wyszukiwania</string>
|
||||
<string name="settings.connection_failure">Błąd połączenia.</string>
|
||||
<string name="settings.default_albums">Domyślna ilość wyników - albumy</string>
|
||||
|
@ -193,14 +179,11 @@
|
|||
<string name="settings.disc_sort">Sortuj utwory wg dysku</string>
|
||||
<string name="settings.disc_sort_summary">Sortuje listę utworów wg numeru dysku i numeru utworu</string>
|
||||
<string name="settings.display_bitrate_summary">Dołącza bitrate i typ pliku do nazwy artysty</string>
|
||||
<string name="settings.gapless_playback">Odtwarzanie bez przerw</string>
|
||||
<string name="settings.gapless_playback_summary">Włącz odtwarzanie bez przerw między utworami</string>
|
||||
<string name="settings.hide_media_summary">Ukrywa pliki muzyczne przed innymi aplikacjami.</string>
|
||||
<string name="settings.hide_media_title">Ukryj pliki</string>
|
||||
<string name="settings.hide_media_toast">Efekt widoczny będzie po następnym skanowaniu muzyki przez system Android</string>
|
||||
<string name="settings.increment_time">Skok przewijania</string>
|
||||
<string name="settings.invalid_url">Proszę wprowadzić prawidłowy URL</string>
|
||||
<string name="settings.invalid_username">Proszę wprowadzić prawidłową nazwę użytkownika (bez spacji na końcu)</string>
|
||||
<string name="settings.max_albums">Maksymalna ilość wyników - albumy</string>
|
||||
<string name="settings.max_artists">Maksymalna ilość wyników - artyści</string>
|
||||
<string name="settings.max_bitrate_112">112 Kbps</string>
|
||||
|
@ -257,25 +240,13 @@
|
|||
<string name="settings.search_75">75</string>
|
||||
<string name="settings.search_history_cleared">Wyczyść historię wyszukiwania</string>
|
||||
<string name="settings.search_title">Ustawienia wyszukiwania</string>
|
||||
<string name="settings.send_bluetooth_notification_summary">Wysyła powiadomienia o odtwarzaniu przez Bluetooth</string>
|
||||
<string name="settings.send_bluetooth_notification">Wysyłaj powiadomienia Bluetooth</string>
|
||||
<string name="settings.send_bluetooth_album_art_summary">Wysyła okładki przez Bluetooth (może powodować problemy z powiadomieniami)</string>
|
||||
<string name="settings.send_bluetooth_album_art">Okładki przez Bluetooth</string>
|
||||
<string name="settings.server_manage_servers">Manage Servers</string>
|
||||
<string name="settings.server_address">Adres serwera</string>
|
||||
<string name="settings.server_name">Nazwa</string>
|
||||
<string name="settings.server_password">Hasło</string>
|
||||
<string name="settings.server_remove_server">Usuń serwer</string>
|
||||
<string name="settings.server_scaling_summary">Pobiera przeskalowane obrazy z serwera zamiast pełnego rozmiaru (oszczędza ilość przesyłanych danych)</string>
|
||||
<string name="settings.server_scaling_title">Skalowanie okładek po stronie serwera</string>
|
||||
<string name="settings.server_unused">Bez nazwy</string>
|
||||
<string name="settings.server_username">Nazwa użytkownika</string>
|
||||
<string name="settings.show_lockscreen_controls">Wyświetlaj na ekranie blokady</string>
|
||||
<string name="settings.show_lockscreen_controls_summary">Wyświetla widżet odtwarzacza na ekranie blokady</string>
|
||||
<string name="settings.show_notification">Wyświetlaj powiadomienia</string>
|
||||
<string name="settings.show_notification_always">Zawsze wyświetlaj powiadomienia</string>
|
||||
<string name="settings.show_notification_always_summary">Zawsze wyświetla powiadomienia o odtwarzanych utworach, gdy playlista jest wypełniona</string>
|
||||
<string name="settings.show_notification_summary">Wyświetla powiadomienia o odtwarzanym utworze na pasku statusu</string>
|
||||
<string name="settings.show_now_playing">Wyświetlaj powiadomienia o utworach</string>
|
||||
<string name="settings.show_now_playing_summary">Wyświetla bieżący utwór we wszystkich aktywnościach</string>
|
||||
<string name="settings.show_track_number">Wyświetlaj numer utworu</string>
|
||||
|
@ -361,12 +332,6 @@
|
|||
<item quantity="many">%d utworów</item>
|
||||
<item quantity="other">%d utworów</item>
|
||||
</plurals>
|
||||
<plurals name="select_album_donate_dialog_n_trial_days_left">
|
||||
<item quantity="one">%d dzień pozostał do zakończenia okresu próbnego</item>
|
||||
<item quantity="few">%d dni pozostały do zakończenia okresu próbnego</item>
|
||||
<item quantity="many">%d dni pozostało do zakończenia okresu próbnego</item>
|
||||
<item quantity="other">%d dni pozostało do zakończenia okresu próbnego</item>
|
||||
</plurals>
|
||||
|
||||
<!-- Subsonic api errors -->
|
||||
<string name="api.subsonic.generic">Ogólny błąd interfejsu API: %1$s</string>
|
||||
|
|
|
@ -41,11 +41,8 @@
|
|||
<string name="common.name">Nome</string>
|
||||
<string name="common.ok">OK</string>
|
||||
<string name="common.pin">Fixar</string>
|
||||
<string name="common.pause">Pausar</string>
|
||||
<string name="common.play">Tocar</string>
|
||||
<string name="common.play_last">Tocar por Último</string>
|
||||
<string name="common.play_next">Tocar na Próxima</string>
|
||||
<string name="common.play_previous">Tocar a Anterior</string>
|
||||
<string name="common.play_now">Tocar Agora</string>
|
||||
<string name="common.play_shuffled">Tocar Aleatoriamente</string>
|
||||
<string name="common.public">Público</string>
|
||||
|
@ -73,10 +70,7 @@
|
|||
<string name="download.menu_screen_on">Tela Ligada</string>
|
||||
<string name="download.menu_show_album">Mostrar Álbum</string>
|
||||
<string name="download.menu_shuffle">Misturar</string>
|
||||
<string name="download.menu_shuffle_notification">Playlist foi misturada</string>
|
||||
<string name="download.menu_visualizer">Visualizador</string>
|
||||
<string name="download.playerstate_buffering">Armazenando</string>
|
||||
<string name="download.playerstate_downloading">Baixando - %s</string>
|
||||
<string name="download.playerstate_playing_shuffle">Tocando misturado</string>
|
||||
<string name="download.playlist_done">Playlist salva com sucesso.</string>
|
||||
<string name="download.playlist_error">Falha ao salvar a playlist, Tente mais tarde.</string>
|
||||
|
@ -107,7 +101,6 @@
|
|||
<string name="main.genres_title">Gêneros</string>
|
||||
<string name="main.music">Música</string>
|
||||
<string name="main.offline">Offline</string>
|
||||
<string name="main.shuffle">Misturar Músicas</string>
|
||||
<string name="main.songs_random">Aleatórias</string>
|
||||
<string name="main.songs_starred">Favoritas</string>
|
||||
<string name="main.songs_title">Músicas</string>
|
||||
|
@ -121,26 +114,19 @@
|
|||
<string name="menu.deleted_playlist_error">Falha ao excluir a playlist %s</string>
|
||||
<string name="menu.downloads">Downloads</string>
|
||||
<string name="menu.exit">Sair</string>
|
||||
<string name="menu.navigation">Navegação</string>
|
||||
<string name="menu.settings">Configurações</string>
|
||||
<string name="menu.refresh">Atualizar</string>
|
||||
<string name="music_library.label">Biblioteca de Mídia</string>
|
||||
<string name="music_library.label_offline">Mídia Offline</string>
|
||||
<string name="music_service.retry">Ocorreu um erro de rede. Tentativa %1$d de %2$d.</string>
|
||||
<string name="parser.artist_count">Obtive %d Artistas.</string>
|
||||
<string name="parser.reading">Lendo do servidor.</string>
|
||||
<string name="parser.reading_done">Lendo do servidor. Pronto!</string>
|
||||
<string name="playlist.label">Playlists</string>
|
||||
<string name="playlist.update_info">Atualizar Informação</string>
|
||||
<string name="playlist.updated_info">Informação da playlist atualizada para %s</string>
|
||||
<string name="playlist.updated_info_error">Falha ao atualizar a informação da playlist para %s</string>
|
||||
<string name="progress.wait">Por favor aguarde…</string>
|
||||
<string name="search.albums">Álbuns</string>
|
||||
<string name="search.artists">Artistas</string>
|
||||
<string name="search.label">Pesquisar</string>
|
||||
<string name="search.more">Mostrar Mais</string>
|
||||
<string name="search.no_match">Nada coincide, tente novamente</string>
|
||||
<string name="search.search">Clique para pesquisar</string>
|
||||
<string name="search.songs">Músicas</string>
|
||||
<string name="search.title">Pesquisar</string>
|
||||
<string name="select_album.empty">Nenhuma mídia encontrada</string>
|
||||
|
@ -150,7 +136,6 @@
|
|||
<string name="select_artist.folder">Selecionar Pasta</string>
|
||||
<string name="select_genre.empty">Nenhum gênero encontrado</string>
|
||||
<string name="select_playlist.empty">Não existe nenhuma playlist no servidor</string>
|
||||
<string name="service.connecting">Contactando o servidor, por favor aguarde.</string>
|
||||
<string name="settings.appearance_title">Aparência</string>
|
||||
<string name="settings.buffer_length">Tamanho do Buffer</string>
|
||||
<string name="settings.buffer_length_0">Desativado</string>
|
||||
|
@ -190,8 +175,6 @@
|
|||
<string name="settings.chat_refresh">Intervalo de Atualização do Chat</string>
|
||||
<string name="settings.clear_bookmark">Limpar Favoritos</string>
|
||||
<string name="settings.clear_bookmark_summary">Limpar favoritos após terminar de tocar a música</string>
|
||||
<string name="settings.clear_playlist">Limpar Playlist</string>
|
||||
<string name="settings.clear_playlist_summary">Limpar a playlist após terminar de tocar todas as músicas</string>
|
||||
<string name="settings.clear_search_history">Limpar Histórico de Pesquisas</string>
|
||||
<string name="settings.connection_failure">Falha na conexão.</string>
|
||||
<string name="settings.default_albums">Álbuns Padrões</string>
|
||||
|
@ -209,14 +192,11 @@
|
|||
<string name="settings.disc_sort_summary">Classificar músicas pelo número do álbum e faixas</string>
|
||||
<string name="settings.display_bitrate">Mostrar Bitrate se Sufixo do Arquivo</string>
|
||||
<string name="settings.display_bitrate_summary">Adicionar o nome do artista com a taxa de bits e sufixo do arquivo</string>
|
||||
<string name="settings.gapless_playback">Reprodução sem Interrupção</string>
|
||||
<string name="settings.gapless_playback_summary">Ativar reprodução sem interrupção</string>
|
||||
<string name="settings.hide_media_summary">Esconder arquivos de músicas de outros aplicativos</string>
|
||||
<string name="settings.hide_media_title">Esconder de Outros</string>
|
||||
<string name="settings.hide_media_toast">Será efetivado na próxima vez que o Android procurar por músicas em seu celular.</string>
|
||||
<string name="settings.increment_time">Intervalo de Salto</string>
|
||||
<string name="settings.invalid_url">Especifique uma URL válida.</string>
|
||||
<string name="settings.invalid_username">Especifique um nome de usuário válido (sem espaços).</string>
|
||||
<string name="settings.max_albums">Máximo de Álbuns</string>
|
||||
<string name="settings.max_artists">Máximo de Artistas</string>
|
||||
<string name="settings.max_bitrate_112">112 Kbps</string>
|
||||
|
@ -275,27 +255,13 @@
|
|||
<string name="settings.search_75">75</string>
|
||||
<string name="settings.search_history_cleared">Histórico de pesquisas apagado</string>
|
||||
<string name="settings.search_title">Configurações de Pesquisa</string>
|
||||
<string name="settings.send_bluetooth_notification_summary">Enviar notificações de reprodução via Bluetooth</string>
|
||||
<string name="settings.send_bluetooth_notification">Notificações via Bluetooth</string>
|
||||
<string name="settings.send_bluetooth_album_art_summary">Enviar a arte do álbum via Bluetooth (Pode causar falhas nas notificações do Bluetooth)</string>
|
||||
<string name="settings.send_bluetooth_album_art">Arte do Álbum via Bluetooth</string>
|
||||
<string name="settings.disable_send_now_playing_list_summary">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</string>
|
||||
<string name="settings.disable_send_now_playing_list">Desativar Envio da Lista Tocando Agora</string>
|
||||
<string name="settings.server_manage_servers">Gerenciar Servidores</string>
|
||||
<string name="settings.server_address">Endereço do Servidor</string>
|
||||
<string name="settings.server_name">Nome</string>
|
||||
<string name="settings.server_password">Senha</string>
|
||||
<string name="settings.server_remove_server">Excluir Servidor</string>
|
||||
<string name="settings.server_scaling_summary">Baixar imagens reduzidas do servidor ao invés do tamanho completo (economiza banda)</string>
|
||||
<string name="settings.server_scaling_title">Reduzir Arte dos Álbuns</string>
|
||||
<string name="settings.server_unused">Não usado</string>
|
||||
<string name="settings.server_username">Login</string>
|
||||
<string name="settings.show_lockscreen_controls">Controles na Tela de Bloqueio</string>
|
||||
<string name="settings.show_lockscreen_controls_summary">Mostrar controles de reprodução na tela de bloqueio</string>
|
||||
<string name="settings.show_notification">Mostrar Notificações</string>
|
||||
<string name="settings.show_notification_always">Sempre Mostrar Notificações</string>
|
||||
<string name="settings.show_notification_always_summary">Sempre mostrar a reprodução atual quando uma playlist é preenchida</string>
|
||||
<string name="settings.show_notification_summary">Mostrar a notificação de reprodução atual na barra de status</string>
|
||||
<string name="settings.show_now_playing">Mostrar Reprodução Atual</string>
|
||||
<string name="settings.show_now_playing_summary">Mostrar a faixa tocada atualmente em todas as atividades</string>
|
||||
<string name="settings.show_track_number">Mostrar o Número da Faixa</string>
|
||||
|
@ -370,7 +336,6 @@
|
|||
<string name="settings.playback.bluetooth_all">Todos os dispositivos Bluetooth</string>
|
||||
<string name="settings.playback.bluetooth_a2dp">Somente dispositivos de áudio (A2DP)</string>
|
||||
<string name="settings.playback.bluetooth_disabled">Desativado</string>
|
||||
<string name="settings.playback.single_button_bluetooth_device_summary">Ativar isso pode ajudar com dispositivos Bluetooth mais antigos quando Reproduzir/Pausar não funciona corretamente</string>
|
||||
<string name="settings.debug.title">Opções de Depuração</string>
|
||||
<string name="settings.debug.log_to_file">Log de Depuração em Arquivo</string>
|
||||
<string name="settings.debug.log_path">Os arquivos com log estão disponíveis em %1$s/%2$s</string>
|
||||
|
@ -399,10 +364,6 @@
|
|||
<item quantity="one">%d música</item>
|
||||
<item quantity="other">%d músicas</item>
|
||||
</plurals>
|
||||
<plurals name="select_album_donate_dialog_n_trial_days_left">
|
||||
<item quantity="one">%d dia restante do período de teste</item>
|
||||
<item quantity="other">%d dias restantes do período de teste</item>
|
||||
</plurals>
|
||||
|
||||
<!-- Subsonic api errors -->
|
||||
<string name="api.subsonic.generic">Erro de api genérico: %1$s</string>
|
||||
|
|
|
@ -61,10 +61,7 @@
|
|||
<string name="download.menu_screen_on">Ecrã Ligado</string>
|
||||
<string name="download.menu_show_album">Mostrar Álbum</string>
|
||||
<string name="download.menu_shuffle">Misturar</string>
|
||||
<string name="download.menu_shuffle_notification">Playlist foi misturada</string>
|
||||
<string name="download.menu_visualizer">Visualizador</string>
|
||||
<string name="download.playerstate_buffering">Armazenando</string>
|
||||
<string name="download.playerstate_downloading">Descarregando - %s</string>
|
||||
<string name="download.playerstate_playing_shuffle">Tocando misturado</string>
|
||||
<string name="download.playlist_done">Playlist salva com sucesso.</string>
|
||||
<string name="download.playlist_error">Falha ao salvar a playlist, Tente mais tarde.</string>
|
||||
|
@ -95,7 +92,6 @@
|
|||
<string name="main.genres_title">Gêneros</string>
|
||||
<string name="main.music">Música</string>
|
||||
<string name="main.offline">Offline</string>
|
||||
<string name="main.shuffle">Misturar Músicas</string>
|
||||
<string name="main.songs_random">Aleatórias</string>
|
||||
<string name="main.songs_starred">Favoritas</string>
|
||||
<string name="main.songs_title">Músicas</string>
|
||||
|
@ -106,26 +102,19 @@
|
|||
<string name="menu.deleted_playlist">Playlist apagada %s</string>
|
||||
<string name="menu.deleted_playlist_error">Falha ao apagar a playlist %s</string>
|
||||
<string name="menu.exit">Sair</string>
|
||||
<string name="menu.navigation">Navegação</string>
|
||||
<string name="menu.settings">Configurações</string>
|
||||
<string name="menu.refresh">Refresh</string>
|
||||
<string name="music_library.label">Biblioteca de Mídia</string>
|
||||
<string name="music_library.label_offline">Mídia Offline</string>
|
||||
<string name="music_service.retry">Ocorreu um erro de rede. Tentativa %1$d de %2$d.</string>
|
||||
<string name="parser.artist_count">Obtive %d Artistas.</string>
|
||||
<string name="parser.reading">Lendo do servidor.</string>
|
||||
<string name="parser.reading_done">Lendo do servidor. Pronto!</string>
|
||||
<string name="playlist.label">Playlists</string>
|
||||
<string name="playlist.update_info">Atualizar Informação</string>
|
||||
<string name="playlist.updated_info">Informação da playlist atualizada para %s</string>
|
||||
<string name="playlist.updated_info_error">Falha ao atualizar a informação da playlist para %s</string>
|
||||
<string name="progress.wait">Por favor aguarde…</string>
|
||||
<string name="search.albums">Álbuns</string>
|
||||
<string name="search.artists">Artistas</string>
|
||||
<string name="search.label">Pesquisar</string>
|
||||
<string name="search.more">Mostrar Mais</string>
|
||||
<string name="search.no_match">Nada coincide, tente novamente</string>
|
||||
<string name="search.search">Clique para pesquisar</string>
|
||||
<string name="search.songs">Músicas</string>
|
||||
<string name="search.title">Pesquisar</string>
|
||||
<string name="select_album.empty">Nenhuma mídia encontrada</string>
|
||||
|
@ -135,7 +124,6 @@
|
|||
<string name="select_artist.folder">Selecionar Pasta</string>
|
||||
<string name="select_genre.empty">Nenhum gênero encontrado</string>
|
||||
<string name="select_playlist.empty">Não existe nenhuma playlist no servidor</string>
|
||||
<string name="service.connecting">Contactando o servidor, por favor aguarde.</string>
|
||||
<string name="settings.appearance_title">Aparência</string>
|
||||
<string name="settings.buffer_length">Tamanho do Buffer</string>
|
||||
<string name="settings.buffer_length_0">Disabilitando</string>
|
||||
|
@ -175,8 +163,6 @@
|
|||
<string name="settings.chat_refresh">Intervalo de Atualização do Chat</string>
|
||||
<string name="settings.clear_bookmark">Limpar Favoritos</string>
|
||||
<string name="settings.clear_bookmark_summary">Limpar favoritos após terminar de tocar a música</string>
|
||||
<string name="settings.clear_playlist">Limpar Playlist</string>
|
||||
<string name="settings.clear_playlist_summary">Limpar a playlist após terminar de tocar todas as músicas</string>
|
||||
<string name="settings.clear_search_history">Limpar Histórico de Pesquisas</string>
|
||||
<string name="settings.connection_failure">Falha na conexão.</string>
|
||||
<string name="settings.default_albums">Álbuns Padrões</string>
|
||||
|
@ -193,14 +179,11 @@
|
|||
<string name="settings.disc_sort">Classificar Músicas por Álbum</string>
|
||||
<string name="settings.disc_sort_summary">Classificar músicas pelo número do álbum e faixas.</string>
|
||||
<string name="settings.display_bitrate_summary">Adiciona o nome do artista com a taxa de bits e sufixo do ficheiro</string>
|
||||
<string name="settings.gapless_playback">Reprodução sem Interrupção</string>
|
||||
<string name="settings.gapless_playback_summary">Habilita reprodução sem interrupção</string>
|
||||
<string name="settings.hide_media_summary">Esconder músicas de outros aplicativos.</string>
|
||||
<string name="settings.hide_media_title">Esconder de Outros</string>
|
||||
<string name="settings.hide_media_toast">Será realizado na próxima vez que o Android procurar por músicas em seu telemóvel.</string>
|
||||
<string name="settings.increment_time">Intervalo de Salto</string>
|
||||
<string name="settings.invalid_url">Especifique uma URL válida.</string>
|
||||
<string name="settings.invalid_username">Especifique um nome de usuário válido (sem espaços).</string>
|
||||
<string name="settings.max_albums">Máximo de Álbuns</string>
|
||||
<string name="settings.max_artists">Máximo de Artistas</string>
|
||||
<string name="settings.max_bitrate_112">112 Kbps</string>
|
||||
|
@ -257,25 +240,13 @@
|
|||
<string name="settings.search_75">75</string>
|
||||
<string name="settings.search_history_cleared">Histórico de pesquisas apagado</string>
|
||||
<string name="settings.search_title">Configurações de Pesquisa</string>
|
||||
<string name="settings.send_bluetooth_notification_summary">Envia notificações de reprodução via Bluetooth</string>
|
||||
<string name="settings.send_bluetooth_notification">Notificações via Bluetooth</string>
|
||||
<string name="settings.send_bluetooth_album_art_summary">Envia a arte do álbum via Bluetooth (Pode causar falhas nas notificações do Bluetooth)</string>
|
||||
<string name="settings.send_bluetooth_album_art">Arte do Álbum via Bluetooth</string>
|
||||
<string name="settings.server_manage_servers">Manage Servers</string>
|
||||
<string name="settings.server_address">Endereço do Servidor</string>
|
||||
<string name="settings.server_name">Nome</string>
|
||||
<string name="settings.server_password">Senha</string>
|
||||
<string name="settings.server_remove_server">Apagar Servidor</string>
|
||||
<string name="settings.server_scaling_summary">Descarrega imagens reduzidas do servidor ao invés do tamanho completo (economiza banda)</string>
|
||||
<string name="settings.server_scaling_title">Reduzir Arte dos Álbuns</string>
|
||||
<string name="settings.server_unused">Não usado</string>
|
||||
<string name="settings.server_username">Login</string>
|
||||
<string name="settings.show_lockscreen_controls">Controles no Ecrã de Bloqueio</string>
|
||||
<string name="settings.show_lockscreen_controls_summary">Mostra controles de reprodução no ecrã de bloqueio</string>
|
||||
<string name="settings.show_notification">Mostrar Notificações</string>
|
||||
<string name="settings.show_notification_always">Sempre Mostrar Notificações</string>
|
||||
<string name="settings.show_notification_always_summary">Sempre mostrar a reprodução atual quando uma playlist é preenchida</string>
|
||||
<string name="settings.show_notification_summary">Mostra a notificação de reprodução atual na barra de estado</string>
|
||||
<string name="settings.show_now_playing">Mostrar Reprodução Atual</string>
|
||||
<string name="settings.show_now_playing_summary">Mostrar a faixa tocada atualmente em todas as atividades</string>
|
||||
<string name="settings.show_track_number">Mostrar o Número da Faixa</string>
|
||||
|
@ -359,10 +330,6 @@
|
|||
<item quantity="one">%d música</item>
|
||||
<item quantity="other">%d músicas</item>
|
||||
</plurals>
|
||||
<plurals name="select_album_donate_dialog_n_trial_days_left">
|
||||
<item quantity="one">%d dia restante do período de teste</item>
|
||||
<item quantity="other">%d dias restantes do período de teste</item>
|
||||
</plurals>
|
||||
|
||||
<!-- Subsonic api errors -->
|
||||
<string name="api.subsonic.generic">Erro de api genérico: %1$s</string>
|
||||
|
|
|
@ -41,11 +41,8 @@
|
|||
<string name="common.name">Имя</string>
|
||||
<string name="common.ok">Ок</string>
|
||||
<string name="common.pin">Пин</string>
|
||||
<string name="common.pause">Пауза</string>
|
||||
<string name="common.play">Воспроизведение</string>
|
||||
<string name="common.play_last">Воспроизвести последний</string>
|
||||
<string name="common.play_next">Воспроизвести следующий</string>
|
||||
<string name="common.play_previous">Воспроизвести предыдущий</string>
|
||||
<string name="common.play_now">Воспроизвести сейчас</string>
|
||||
<string name="common.play_shuffled">Играть в случайном порядке</string>
|
||||
<string name="common.public">Публичный</string>
|
||||
|
@ -75,10 +72,7 @@
|
|||
<string name="download.menu_screen_on">Включение дисплея</string>
|
||||
<string name="download.menu_show_album">Показать альбом</string>
|
||||
<string name="download.menu_shuffle">Случайное воспроизведение</string>
|
||||
<string name="download.menu_shuffle_notification">Плейлист в случайном порядке</string>
|
||||
<string name="download.menu_visualizer">Визуализатор</string>
|
||||
<string name="download.playerstate_buffering">Буферизация</string>
|
||||
<string name="download.playerstate_downloading">Загрузка - %s</string>
|
||||
<string name="download.playerstate_playing_shuffle">Игра в случайном порядке</string>
|
||||
<string name="download.playlist_done">Плейлист был успешно сохранен.</string>
|
||||
<string name="download.playlist_error">Не удалось сохранить плейлист, попробуйте позже.</string>
|
||||
|
@ -119,7 +113,6 @@
|
|||
<string name="main.genres_title">Жанры</string>
|
||||
<string name="main.music">Музыка</string>
|
||||
<string name="main.offline">Не в сети</string>
|
||||
<string name="main.shuffle">Играть в случайном порядке</string>
|
||||
<string name="main.songs_random">Случайный</string>
|
||||
<string name="main.songs_starred">Отмеченные</string>
|
||||
<string name="main.songs_title">Песни</string>
|
||||
|
@ -133,26 +126,19 @@
|
|||
<string name="menu.deleted_playlist_error">Не удалось удалить плейлист %s</string>
|
||||
<string name="menu.downloads">Загрузки</string>
|
||||
<string name="menu.exit">Выход</string>
|
||||
<string name="menu.navigation">Навигация</string>
|
||||
<string name="menu.settings">Настройки</string>
|
||||
<string name="menu.refresh">Обновить</string>
|
||||
<string name="music_library.label">Медиа библиотека</string>
|
||||
<string name="music_library.label_offline">Медиа Оффлайн</string>
|
||||
<string name="music_service.retry">Произошла ошибка сети. Повторная %1$d из %2$d.</string>
|
||||
<string name="parser.artist_count">Получил %d исполнители.</string>
|
||||
<string name="parser.reading">Чтение с сервера</string>
|
||||
<string name="parser.reading_done">Чтение с сервера. Готово!</string>
|
||||
<string name="playlist.label">Плейлисты</string>
|
||||
<string name="playlist.update_info">Обновление информации</string>
|
||||
<string name="playlist.updated_info">Обновлена информация о плейлисте для %s</string>
|
||||
<string name="playlist.updated_info_error">Не удалось обновить информацию о плейлисте для %s</string>
|
||||
<string name="progress.wait">Пожалуйста, подождите#8230;</string>
|
||||
<string name="search.albums">Альбомы</string>
|
||||
<string name="search.artists">Исполнители</string>
|
||||
<string name="search.label">Поиск</string>
|
||||
<string name="search.more">Показать еще</string>
|
||||
<string name="search.no_match">Нет совпадений, пожалуйста попробуйте еще раз</string>
|
||||
<string name="search.search">Нажми для поиска</string>
|
||||
<string name="search.songs">Песни</string>
|
||||
<string name="search.title">Поиск</string>
|
||||
<string name="select_album.empty">Медиа не найдена</string>
|
||||
|
@ -162,7 +148,6 @@
|
|||
<string name="select_artist.folder">Выбрать папку</string>
|
||||
<string name="select_genre.empty">Жанры не найдены</string>
|
||||
<string name="select_playlist.empty">Нет сохраненных плейлистов на сервере</string>
|
||||
<string name="service.connecting">Свяжитесь с сервером, пожалуйста, подождите.</string>
|
||||
<string name="settings.appearance_title">Появление</string>
|
||||
<string name="settings.buffer_length">Размер буфера</string>
|
||||
<string name="settings.buffer_length_0">Отключить</string>
|
||||
|
@ -202,8 +187,6 @@
|
|||
<string name="settings.chat_refresh">Интервал обновления чата</string>
|
||||
<string name="settings.clear_bookmark">Очистить закладку</string>
|
||||
<string name="settings.clear_bookmark_summary">Очистить закладку после завершения воспроизведения песни</string>
|
||||
<string name="settings.clear_playlist">Очистить плейлист</string>
|
||||
<string name="settings.clear_playlist_summary">Очистите плейлист после завершения воспроизведения всех песен</string>
|
||||
<string name="settings.clear_search_history">Очистить историю поиска</string>
|
||||
<string name="settings.connection_failure">Ошибка подключения.</string>
|
||||
<string name="settings.default_albums">Альбомы по умолчанию</string>
|
||||
|
@ -220,14 +203,11 @@
|
|||
<string name="settings.disc_sort">Время кэша каталогов</string>
|
||||
<string name="settings.disc_sort_summary">Сортировать список песен по номеру диска и треку</string>
|
||||
<string name="settings.display_bitrate_summary">Добавить имя исполнителя с битрейтом и суффиксом файла</string>
|
||||
<string name="settings.gapless_playback">Воспроизведение без промежутка</string>
|
||||
<string name="settings.gapless_playback_summary">Включить воспроизведение без паузы</string>
|
||||
<string name="settings.hide_media_summary">Включить воспроизведение без паузы</string>
|
||||
<string name="settings.hide_media_title">Скрыть от других</string>
|
||||
<string name="settings.hide_media_toast">Вступает в силу в следующий раз, Android сканирует ваш телефон на предмет музыки</string>
|
||||
<string name="settings.increment_time">Пропустить интервал</string>
|
||||
<string name="settings.invalid_url">Пожалуйста, укажите действительный URL.</string>
|
||||
<string name="settings.invalid_username">Пожалуйста, укажите правильное имя пользователя (без пробелов).</string>
|
||||
<string name="settings.max_albums">Максимум альбомов</string>
|
||||
<string name="settings.max_artists">Максимум исполнителей</string>
|
||||
<string name="settings.max_bitrate_112">112 Kbps</string>
|
||||
|
@ -286,25 +266,13 @@
|
|||
<string name="settings.search_75">75</string>
|
||||
<string name="settings.search_history_cleared">История поиска очищена</string>
|
||||
<string name="settings.search_title">Настройки поиска</string>
|
||||
<string name="settings.send_bluetooth_notification_summary">Отправлять уведомления о воспроизведении через Bluetooth</string>
|
||||
<string name="settings.send_bluetooth_notification">Отправить уведомление Bluetooth</string>
|
||||
<string name="settings.send_bluetooth_album_art_summary">Отправить обложку альбома через Bluetooth (может привести к сбою уведомлений Bluetooth)</string>
|
||||
<string name="settings.send_bluetooth_album_art">Обложка альбома через Bluetooth</string>
|
||||
<string name="settings.server_manage_servers">Управление серверами</string>
|
||||
<string name="settings.server_address">Адрес сервера</string>
|
||||
<string name="settings.server_name">Имя</string>
|
||||
<string name="settings.server_password">Пароль</string>
|
||||
<string name="settings.server_remove_server">Удалить сервер</string>
|
||||
<string name="settings.server_scaling_summary">Загрузка масштабированных изображений с сервера вместо полноразмерного (экономит трафик)</string>
|
||||
<string name="settings.server_scaling_title">Серверное масштабирование обложек альбомов</string>
|
||||
<string name="settings.server_unused">Неиспользуемый</string>
|
||||
<string name="settings.server_username">Имя пользователя</string>
|
||||
<string name="settings.show_lockscreen_controls">Показать блокировку экрана</string>
|
||||
<string name="settings.show_lockscreen_controls_summary">Показать элементы управления воспроизведением на экране блокировки</string>
|
||||
<string name="settings.show_notification">Показывать уведомления</string>
|
||||
<string name="settings.show_notification_always">Всегда показывать уведомления</string>
|
||||
<string name="settings.show_notification_always_summary">Всегда показывать воспроизведение, когда плейлист заполнен</string>
|
||||
<string name="settings.show_notification_summary">Показать уведомление о воспроизведении в строке состояния</string>
|
||||
<string name="settings.show_now_playing">Показать что сейчас играет</string>
|
||||
<string name="settings.show_now_playing_summary">Показать текущий воспроизводимый трек во всех активностях</string>
|
||||
<string name="settings.show_track_number">Показать номер трека</string>
|
||||
|
@ -375,7 +343,6 @@
|
|||
<string name="settings.playback.bluetooth_all">Все устройства Bluetooth</string>
|
||||
<string name="settings.playback.bluetooth_a2dp">Только аудио (A2DP) устройства</string>
|
||||
<string name="settings.playback.bluetooth_disabled">Отключено</string>
|
||||
<string name="settings.playback.single_button_bluetooth_device_summary">Включение этого может помочь со старыми устройствами Bluetooth, когда Воспроизведение/Пауза работает некорректно.</string>
|
||||
<string name="settings.debug.title">Настройки отладки</string>
|
||||
<string name="settings.debug.log_to_file">Записать журнал отладки в файл</string>
|
||||
<string name="settings.debug.log_path">Файлы журнала доступны по адресу %1$s/%2$s</string>
|
||||
|
@ -404,12 +371,6 @@
|
|||
<item quantity="many">%d песен</item>
|
||||
<item quantity="other">%d песен</item>
|
||||
</plurals>
|
||||
<plurals name="select_album_donate_dialog_n_trial_days_left">
|
||||
<item quantity="one">Остался %d день пробного периода</item>
|
||||
<item quantity="few">Осталось %d дня пробного периода</item>
|
||||
<item quantity="many">Осталось %d дней пробного периода</item>
|
||||
<item quantity="other">Осталось %d дней пробного периода</item>
|
||||
</plurals>
|
||||
|
||||
<!-- Subsonic api errors -->
|
||||
<string name="api.subsonic.generic">Общая ошибка API: %1$s</string>
|
||||
|
|
|
@ -41,11 +41,8 @@
|
|||
<string name="common.name">名称</string>
|
||||
<string name="common.ok">确定</string>
|
||||
<string name="common.pin">固定</string>
|
||||
<string name="common.pause">暂停</string>
|
||||
<string name="common.play">播放</string>
|
||||
<string name="common.play_last">最后一首</string>
|
||||
<string name="common.play_next">下一首</string>
|
||||
<string name="common.play_previous">上一首</string>
|
||||
<string name="common.play_now">现在播放</string>
|
||||
<string name="common.play_shuffled">随机播放</string>
|
||||
<string name="common.public">公开</string>
|
||||
|
@ -75,10 +72,7 @@
|
|||
<string name="download.menu_screen_on">开启屏幕常亮</string>
|
||||
<string name="download.menu_show_album">显示专辑</string>
|
||||
<string name="download.menu_shuffle">随机</string>
|
||||
<string name="download.menu_shuffle_notification">已随机排列播放列表</string>
|
||||
<string name="download.menu_visualizer">可视化</string>
|
||||
<string name="download.playerstate_buffering">缓冲中</string>
|
||||
<string name="download.playerstate_downloading">下载中 - %s</string>
|
||||
<string name="download.playerstate_playing_shuffle">随机播放</string>
|
||||
<string name="download.playlist_done">已成功保存播放列表。</string>
|
||||
<string name="download.playlist_error">保存播放列表失败,请重试。</string>
|
||||
|
@ -110,7 +104,6 @@
|
|||
<string name="main.music">音乐</string>
|
||||
<string name="main.offline">离线</string>
|
||||
<string name="main.setup_server">%s - 已设置服务器</string>
|
||||
<string name="main.shuffle">随机播放</string>
|
||||
<string name="main.songs_random">随机</string>
|
||||
<string name="main.songs_starred">收藏夹</string>
|
||||
<string name="main.songs_title">歌曲</string>
|
||||
|
@ -124,26 +117,19 @@
|
|||
<string name="menu.deleted_playlist_error">播放列表删除失败%s</string>
|
||||
<string name="menu.downloads">下载</string>
|
||||
<string name="menu.exit">退出</string>
|
||||
<string name="menu.navigation">导航</string>
|
||||
<string name="menu.settings">设置</string>
|
||||
<string name="menu.refresh">刷新</string>
|
||||
<string name="music_library.label">媒体库</string>
|
||||
<string name="music_library.label_offline">离线媒体</string>
|
||||
<string name="music_service.retry">发生网络错误,正在重试 %1$d of %2$d.</string>
|
||||
<string name="parser.artist_count">有 %d 位艺术家。</string>
|
||||
<string name="parser.reading">正在加载服务器。</string>
|
||||
<string name="parser.reading_done">正在加载服务器。完成!</string>
|
||||
<string name="playlist.label">播放列表</string>
|
||||
<string name="playlist.update_info">更新信息</string>
|
||||
<string name="playlist.updated_info">已更新此播放列表信息 - %s</string>
|
||||
<string name="playlist.updated_info_error">更新播放列表信息失败 - %s</string>
|
||||
<string name="progress.wait">请稍等…</string>
|
||||
<string name="search.albums">专辑</string>
|
||||
<string name="search.artists">艺人</string>
|
||||
<string name="search.label">搜索</string>
|
||||
<string name="search.more">显示更多</string>
|
||||
<string name="search.no_match">没有匹配的结果,请重试</string>
|
||||
<string name="search.search">点击搜索</string>
|
||||
<string name="search.songs">歌曲</string>
|
||||
<string name="search.title">搜索</string>
|
||||
<string name="select_album.empty">找不到歌曲</string>
|
||||
|
@ -154,7 +140,6 @@
|
|||
<string name="select_artist.folder">选择文件夹</string>
|
||||
<string name="select_genre.empty">找不到流派</string>
|
||||
<string name="select_playlist.empty">服务器上没有保存的播放列表</string>
|
||||
<string name="service.connecting">服务器连接中,请稍等。</string>
|
||||
<string name="settings.appearance_title">外观</string>
|
||||
<string name="settings.buffer_length">缓冲长度</string>
|
||||
<string name="settings.buffer_length_0">已禁用</string>
|
||||
|
@ -195,8 +180,6 @@
|
|||
<string name="settings.chat_refresh">聊天消息刷新时间间隔</string>
|
||||
<string name="settings.clear_bookmark">清空书签</string>
|
||||
<string name="settings.clear_bookmark_summary">歌曲播放完毕后清除书签</string>
|
||||
<string name="settings.clear_playlist">清空播放列表</string>
|
||||
<string name="settings.clear_playlist_summary">所有歌曲播放完毕后清空播放列表</string>
|
||||
<string name="settings.clear_search_history">清空搜索历史</string>
|
||||
<string name="settings.connection_failure">连接失败</string>
|
||||
<string name="settings.default_albums">默认专辑</string>
|
||||
|
@ -214,14 +197,11 @@
|
|||
<string name="settings.disc_sort_summary">按光盘编号和曲目编号对歌曲列表进行排序</string>
|
||||
<string name="settings.display_bitrate">展示比特率和文件后缀</string>
|
||||
<string name="settings.display_bitrate_summary">在艺术家姓名后追加比特率和文件后缀</string>
|
||||
<string name="settings.gapless_playback">无缝播放</string>
|
||||
<string name="settings.gapless_playback_summary">启用无缝播放</string>
|
||||
<string name="settings.hide_media_summary">隐藏来自其他应用的音乐</string>
|
||||
<string name="settings.hide_media_title">隐藏其他来源</string>
|
||||
<string name="settings.hide_media_toast">在安卓系统下次扫描音乐时生效。</string>
|
||||
<string name="settings.increment_time">快进间隔</string>
|
||||
<string name="settings.invalid_url">请填写有效的URL。</string>
|
||||
<string name="settings.invalid_username">请填写有效用户名 (请去除尾部空格)。</string>
|
||||
<string name="settings.max_albums">最大专辑</string>
|
||||
<string name="settings.max_artists">最大艺术家</string>
|
||||
<string name="settings.max_bitrate_112">112 Kbps</string>
|
||||
|
@ -280,28 +260,14 @@
|
|||
<string name="settings.search_75">75</string>
|
||||
<string name="settings.search_history_cleared">搜索记录已清除</string>
|
||||
<string name="settings.search_title">搜索设置</string>
|
||||
<string name="settings.send_bluetooth_notification_summary">通过蓝牙发送播放通知</string>
|
||||
<string name="settings.send_bluetooth_notification">发送蓝牙通知</string>
|
||||
<string name="settings.send_bluetooth_album_art_summary">通过蓝牙发送专辑封面(可能导致蓝牙通知失败)</string>
|
||||
<string name="settings.send_bluetooth_album_art">通过蓝牙发送专辑封面</string>
|
||||
<string name="settings.disable_send_now_playing_list_summary">现在播放列表不会发送到已连接的设备。 当前曲目显示未更新时,这可能会恢复AVRCP 1.3的设备的兼容性。</string>
|
||||
<string name="settings.disable_send_now_playing_list">禁用发送正在播放列表</string>
|
||||
<string name="settings.server_manage_servers">管理服务器</string>
|
||||
<string name="settings.server_address">服务器地址</string>
|
||||
<string name="settings.server_name">名称</string>
|
||||
<string name="settings.server_password">密码</string>
|
||||
<string name="settings.server_remove_server">删除服务器</string>
|
||||
<string name="settings.server_scaling_summary">从服务器下载缩放图像而不是全尺寸(节省数据流量)</string>
|
||||
<string name="settings.server_scaling_title">服务器端专辑图片缩放</string>
|
||||
<string name="settings.server_unused">未启用</string>
|
||||
<string name="settings.server_username">用户名</string>
|
||||
<string name="settings.server_color">服务器颜色</string>
|
||||
<string name="settings.show_lockscreen_controls">锁屏显示控制器</string>
|
||||
<string name="settings.show_lockscreen_controls_summary">在锁定屏幕上显示播放控件</string>
|
||||
<string name="settings.show_notification">显示通知</string>
|
||||
<string name="settings.show_notification_always">总是显示通知</string>
|
||||
<string name="settings.show_notification_always_summary">当播放列表有音乐时,总是在通知栏显示播放信息</string>
|
||||
<string name="settings.show_notification_summary">在状态栏中显示正在播放通知</string>
|
||||
<string name="settings.show_now_playing">显示正在播放</string>
|
||||
<string name="settings.show_now_playing_summary">在所有活动页面显示正在播放信息</string>
|
||||
<string name="settings.show_track_number">显示曲目编号</string>
|
||||
|
@ -378,7 +344,6 @@
|
|||
<string name="settings.playback.bluetooth_all">所有蓝牙设备</string>
|
||||
<string name="settings.playback.bluetooth_a2dp">仅音频 (A2DP) 设备</string>
|
||||
<string name="settings.playback.bluetooth_disabled">已禁用</string>
|
||||
<string name="settings.playback.single_button_bluetooth_device_summary">当播放/暂停无法正常工作时,启用此功能可能对较旧的蓝牙设备有所帮助</string>
|
||||
<string name="settings.debug.title">调试选项</string>
|
||||
<string name="settings.debug.log_to_file">将调试日志写入文件</string>
|
||||
<string name="settings.debug.log_path">日志文件可在 %1$s/%2$s 获取</string>
|
||||
|
@ -427,9 +392,6 @@
|
|||
<plurals name="select_album_n_songs_play_next">
|
||||
<item quantity="other">在当前歌曲之后插入了 %d 首歌曲。</item>
|
||||
</plurals>
|
||||
<plurals name="select_album_donate_dialog_n_trial_days_left">
|
||||
<item quantity="other">试用期还剩 %d 天</item>
|
||||
</plurals>
|
||||
|
||||
<!-- Subsonic api errors -->
|
||||
<string name="api.subsonic.generic">一般api错误: %1$s</string>
|
||||
|
|
|
@ -1,4 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="settings.playback.resume_play_on_headphones_plug" translatable="false">playback.resume_play_on_headphones_plug</string>
|
||||
</resources>
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="setting_keys.resume_play_on_headphones_plug" translatable="false">playback.resume_play_on_headphones_plug</string>
|
||||
</resources>
|
|
@ -41,11 +41,8 @@
|
|||
<string name="common.name">Name</string>
|
||||
<string name="common.ok">OK</string>
|
||||
<string name="common.pin">Pin</string>
|
||||
<string name="common.pause">Pause</string>
|
||||
<string name="common.play">Play</string>
|
||||
<string name="common.play_last">Play Last</string>
|
||||
<string name="common.play_next">Play Next</string>
|
||||
<string name="common.play_previous">Play Previous</string>
|
||||
<string name="common.play_now">Play Now</string>
|
||||
<string name="common.play_shuffled">Play Shuffled</string>
|
||||
<string name="common.public">Public</string>
|
||||
|
@ -75,10 +72,10 @@
|
|||
<string name="download.menu_screen_on">Screen On</string>
|
||||
<string name="download.menu_show_album">Show Album</string>
|
||||
<string name="download.menu_shuffle">Shuffle</string>
|
||||
<string name="download.menu_shuffle_notification">Playlist was shuffled</string>
|
||||
<string name="download.menu_shuffle_on">Shuffle mode enabled</string>
|
||||
<string name="download.menu_shuffle_off">Shuffle mode disabled</string>
|
||||
<string name="download.menu_visualizer">Visualizer</string>
|
||||
<string name="download.playerstate_buffering">Buffering</string>
|
||||
<string name="download.playerstate_downloading">Downloading - %s</string>
|
||||
<string name="download.playerstate_loading">Buffering …</string>
|
||||
<string name="download.playerstate_playing_shuffle">Playing shuffle</string>
|
||||
<string name="download.playlist_done">Playlist was successfully saved.</string>
|
||||
<string name="download.playlist_error">Failed to save playlist, please try later.</string>
|
||||
|
@ -125,7 +122,6 @@
|
|||
<string name="main.music">Music</string>
|
||||
<string name="main.offline">Offline</string>
|
||||
<string name="main.setup_server">%s - Set up Server</string>
|
||||
<string name="main.shuffle">Shuffle Play</string>
|
||||
<string name="main.songs_random">Random</string>
|
||||
<string name="main.songs_starred">Starred</string>
|
||||
<string name="main.songs_title">Songs</string>
|
||||
|
@ -139,26 +135,19 @@
|
|||
<string name="menu.deleted_playlist_error">Failed to delete playlist %s</string>
|
||||
<string name="menu.downloads">Downloads</string>
|
||||
<string name="menu.exit">Exit</string>
|
||||
<string name="menu.navigation">Navigation</string>
|
||||
<string name="menu.settings">Settings</string>
|
||||
<string name="menu.refresh">Refresh</string>
|
||||
<string name="music_library.label">Media Library</string>
|
||||
<string name="music_library.label_offline">Offline Media</string>
|
||||
<string name="music_service.retry">A network error occurred. Retrying %1$d of %2$d.</string>
|
||||
<string name="parser.artist_count">Got %d Artists.</string>
|
||||
<string name="parser.reading">Reading from server.</string>
|
||||
<string name="parser.reading_done">Reading from server. Done!</string>
|
||||
<string name="playlist.label">Playlists</string>
|
||||
<string name="playlist.update_info">Update Information</string>
|
||||
<string name="playlist.updated_info">Updated playlist information for %s</string>
|
||||
<string name="playlist.updated_info_error">Failed to update playlist information for %s</string>
|
||||
<string name="progress.wait">Please wait…</string>
|
||||
<string name="search.albums">Albums</string>
|
||||
<string name="search.artists">Artists</string>
|
||||
<string name="search.label">Search</string>
|
||||
<string name="search.more">Show More</string>
|
||||
<string name="search.no_match">No matches, please try again</string>
|
||||
<string name="search.search">Click to search</string>
|
||||
<string name="search.songs">Songs</string>
|
||||
<string name="search.title">Search</string>
|
||||
<string name="select_album.empty">No media found</string>
|
||||
|
@ -170,9 +159,6 @@
|
|||
<string name="select_artist.folder">Select Folder</string>
|
||||
<string name="select_genre.empty">No genres found</string>
|
||||
<string name="select_playlist.empty">No saved playlists on server</string>
|
||||
<string name="service.connecting">Contacting server, please wait.</string>
|
||||
<string name="settings.allow_self_signed_certificate" translatable="false">allowSelfSignedCertificate</string>
|
||||
<string name="settings.enable_ldap_user_support" translatable="false">enableLdapUserSupport</string>
|
||||
<string name="settings.appearance_title">Appearance</string>
|
||||
<string name="settings.buffer_length">Buffer Length</string>
|
||||
<string name="settings.buffer_length_0">Disabled</string>
|
||||
|
@ -213,8 +199,6 @@
|
|||
<string name="settings.chat_refresh">Chat Refresh Interval</string>
|
||||
<string name="settings.clear_bookmark">Clear Bookmark</string>
|
||||
<string name="settings.clear_bookmark_summary">Clear bookmark upon completion of playback of a song</string>
|
||||
<string name="settings.clear_playlist">Clear Playlist</string>
|
||||
<string name="settings.clear_playlist_summary">Clear the playlist upon completion of playback of all songs</string>
|
||||
<string name="settings.clear_search_history">Clear Search History</string>
|
||||
<string name="settings.connection_failure">Connection failure.</string>
|
||||
<string name="settings.default_albums">Default Albums</string>
|
||||
|
@ -234,14 +218,11 @@
|
|||
<string name="settings.display_bitrate_summary">Append artist name with bitrate and file suffix</string>
|
||||
<string name="settings.download_transition">Show Now Playing on Play</string>
|
||||
<string name="settings.download_transition_summary">Switch to Now Playing after starting playback in media view</string>
|
||||
<string name="settings.gapless_playback">Gapless Playback</string>
|
||||
<string name="settings.gapless_playback_summary">Enable gapless playback</string>
|
||||
<string name="settings.hide_media_summary">Hide music files from other apps.</string>
|
||||
<string name="settings.hide_media_title">Hide From Other</string>
|
||||
<string name="settings.hide_media_toast">Takes effect next time Android scans your phone for music.</string>
|
||||
<string name="settings.increment_time">Skip Interval</string>
|
||||
<string name="settings.invalid_url">Please specify a valid URL.</string>
|
||||
<string name="settings.invalid_username">Please specify a valid username (no trailing spaces).</string>
|
||||
<string name="settings.max_albums">Max Albums</string>
|
||||
<string name="settings.max_artists">Max Artists</string>
|
||||
<string name="settings.max_bitrate_112">112 Kbps</string>
|
||||
|
@ -275,6 +256,13 @@
|
|||
<string name="settings.override_language">Override the language</string>
|
||||
<string name="settings.override_language_summary">You need to restart the app after changing the language</string>
|
||||
<string name="settings.playback_control_title">Playback Control Settings</string>
|
||||
<string name="settings.playback.resume_on_bluetooth_device">Resume when a Bluetooth device is connected</string>
|
||||
<string name="settings.playback.pause_on_bluetooth_device">Pause when a Bluetooth device is disconnected</string>
|
||||
<string name="settings.playback.bluetooth_all">All Bluetooth devices</string>
|
||||
<string name="settings.playback.bluetooth_a2dp">Only audio (A2DP) devices</string>
|
||||
<string name="settings.playback.bluetooth_disabled">Disabled</string>
|
||||
<string name="settings.playback.resume_play_on_headphones_plug.title">Resume on headphones insertion</string>
|
||||
<string name="settings.playback.resume_play_on_headphones_plug.summary">App will resume paused playback on wired headphones insertion into device.</string>
|
||||
<string name="settings.preload">Songs To Preload</string>
|
||||
<string name="settings.preload_1">1 song</string>
|
||||
<string name="settings.preload_10">10 songs</string>
|
||||
|
@ -282,8 +270,6 @@
|
|||
<string name="settings.preload_3">3 songs</string>
|
||||
<string name="settings.preload_5">5 songs</string>
|
||||
<string name="settings.preload_unlimited">Unlimited</string>
|
||||
<string name="settings.playback.resume_play_on_headphones_plug.title">Resume on headphones insertion</string>
|
||||
<string name="settings.playback.resume_play_on_headphones_plug.summary">App will resume paused playback on wired headphones insertion into device.</string>
|
||||
<string name="settings.scrobble_summary">Remember to set up your user and password in the Scrobble service(s) on the server</string>
|
||||
<string name="settings.scrobble_title">Scrobble my plays</string>
|
||||
<string name="settings.search_1">1</string>
|
||||
|
@ -302,28 +288,14 @@
|
|||
<string name="settings.search_75">75</string>
|
||||
<string name="settings.search_history_cleared">Search history cleared</string>
|
||||
<string name="settings.search_title">Search Settings</string>
|
||||
<string name="settings.send_bluetooth_notification_summary">Send playback notifications via Bluetooth</string>
|
||||
<string name="settings.send_bluetooth_notification">Send Bluetooth Notification</string>
|
||||
<string name="settings.send_bluetooth_album_art_summary">Send album art over Bluetooth (May cause Bluetooth notifications to fail)</string>
|
||||
<string name="settings.send_bluetooth_album_art">Album Art Over Bluetooth</string>
|
||||
<string name="settings.disable_send_now_playing_list_summary">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</string>
|
||||
<string name="settings.disable_send_now_playing_list">Disable sending of Now Playing List</string>
|
||||
<string name="settings.server_manage_servers">Manage Servers</string>
|
||||
<string name="settings.server_address">Server Address</string>
|
||||
<string name="settings.server_name">Name</string>
|
||||
<string name="settings.server_password">Password</string>
|
||||
<string name="settings.server_remove_server">Remove Server</string>
|
||||
<string name="settings.server_scaling_summary">Download scaled images from the server instead of full size (saves bandwidth)</string>
|
||||
<string name="settings.server_scaling_title">Server-Side Album Art Scaling</string>
|
||||
<string name="settings.server_unused">Unused</string>
|
||||
<string name="settings.server_username">Username</string>
|
||||
<string name="settings.server_color">Server color</string>
|
||||
<string name="settings.show_lockscreen_controls">Show Lock Screen Controls</string>
|
||||
<string name="settings.show_lockscreen_controls_summary">Show playback controls on the lock screen</string>
|
||||
<string name="settings.show_notification">Show Notification</string>
|
||||
<string name="settings.show_notification_always">Always Show Notification</string>
|
||||
<string name="settings.show_notification_always_summary">Always show now playing notification when playlist is populated</string>
|
||||
<string name="settings.show_notification_summary">Show now playing notification in the status bar</string>
|
||||
<string name="settings.show_now_playing">Show Now Playing</string>
|
||||
<string name="settings.show_now_playing_summary">Show currently playing track in all activities</string>
|
||||
<string name="settings.show_track_number">Show Track Number</string>
|
||||
|
@ -399,14 +371,6 @@
|
|||
<string name="download.menu_show_artist">Show Artist</string>
|
||||
<string name="albumArt">albumArt</string>
|
||||
<string name="common_multiple_years">Multiple Years</string>
|
||||
<string name="settings.server_address_unset" translatable="false">http://example.com</string>
|
||||
<string name="settings.playback.resume_on_bluetooth_device">Resume when a Bluetooth device is connected</string>
|
||||
<string name="settings.playback.pause_on_bluetooth_device">Pause when a Bluetooth device is disconnected</string>
|
||||
<string name="settings.playback.bluetooth_all">All Bluetooth devices</string>
|
||||
<string name="settings.playback.bluetooth_a2dp">Only audio (A2DP) devices</string>
|
||||
<string name="settings.playback.bluetooth_disabled">Disabled</string>
|
||||
<string name="settings.playback.single_button_bluetooth_device">Bluetooth device with a single Play/Pause button</string>
|
||||
<string name="settings.playback.single_button_bluetooth_device_summary">Enabling this may help with older Bluetooth devices when Play/Pause doesn\'t work correctly</string>
|
||||
<string name="settings.debug.title">Debug options</string>
|
||||
<string name="settings.debug.log_to_file">Write debug log to file</string>
|
||||
<string name="settings.debug.log_path">The log files are available at %1$s/%2$s</string>
|
||||
|
@ -465,10 +429,6 @@
|
|||
<item quantity="one">%d song inserted after current song</item>
|
||||
<item quantity="other">%d songs inserted after current song</item>
|
||||
</plurals>
|
||||
<plurals name="select_album_donate_dialog_n_trial_days_left">
|
||||
<item quantity="one">%d day left of trial period</item>
|
||||
<item quantity="other">%d days left of trial period</item>
|
||||
</plurals>
|
||||
|
||||
<!-- Subsonic api errors -->
|
||||
<string name="api.subsonic.generic">Generic api error: %1$s</string>
|
||||
|
@ -486,4 +446,8 @@
|
|||
<string name="settings.five_star_rating_title">Use five star rating for songs</string>
|
||||
<string name="settings.five_star_rating_description">Use five star rating system for songs instead of simply starring/unstarring items.</string>
|
||||
|
||||
<string name="settings.use_hw_offload_title">Use hardware playback (experimental)</string>
|
||||
<string name="settings.use_hw_offload_description">Try to play the media using the media decoder chip on your phone. This can improve battery usage.</string>
|
||||
|
||||
|
||||
</resources>
|
||||
|
|
|
@ -77,18 +77,6 @@
|
|||
a:summary="@string/settings.download_transition_summary"
|
||||
a:title="@string/settings.download_transition"
|
||||
app:iconSpaceReserved="false"/>
|
||||
<CheckBoxPreference
|
||||
a:defaultValue="false"
|
||||
a:key="gaplessPlayback"
|
||||
a:summary="@string/settings.gapless_playback_summary"
|
||||
a:title="@string/settings.gapless_playback"
|
||||
app:iconSpaceReserved="false"/>
|
||||
<CheckBoxPreference
|
||||
a:defaultValue="false"
|
||||
a:key="clearPlaylist"
|
||||
a:summary="@string/settings.clear_playlist_summary"
|
||||
a:title="@string/settings.clear_playlist"
|
||||
app:iconSpaceReserved="false"/>
|
||||
<CheckBoxPreference
|
||||
a:defaultValue="false"
|
||||
a:key="clearBookmark"
|
||||
|
@ -104,7 +92,7 @@
|
|||
app:iconSpaceReserved="false"/>
|
||||
<CheckBoxPreference
|
||||
a:defaultValue="false"
|
||||
a:key="@string/settings.playback.resume_play_on_headphones_plug"
|
||||
a:key="@string/setting_keys.resume_play_on_headphones_plug"
|
||||
a:title="@string/settings.playback.resume_play_on_headphones_plug.title"
|
||||
a:summary="@string/settings.playback.resume_play_on_headphones_plug.summary"
|
||||
app:iconSpaceReserved="false"/>
|
||||
|
@ -116,18 +104,18 @@
|
|||
a:key="pauseOnBluetoothDevice"
|
||||
a:title="@string/settings.playback.pause_on_bluetooth_device"
|
||||
app:iconSpaceReserved="false"/>
|
||||
<CheckBoxPreference
|
||||
a:defaultValue="false"
|
||||
a:key="singleButtonPlayPause"
|
||||
a:summary="@string/settings.playback.single_button_bluetooth_device_summary"
|
||||
a:title="@string/settings.playback.single_button_bluetooth_device"
|
||||
app:iconSpaceReserved="false"/>
|
||||
<CheckBoxPreference
|
||||
a:defaultValue="false"
|
||||
a:key="use_five_star_rating"
|
||||
a:summary="@string/settings.five_star_rating_description"
|
||||
a:title="@string/settings.five_star_rating_title"
|
||||
app:iconSpaceReserved="false" />
|
||||
<CheckBoxPreference
|
||||
a:defaultValue="false"
|
||||
a:key="use_hw_offload"
|
||||
a:summary="@string/settings.use_hw_offload_description"
|
||||
a:title="@string/settings.use_hw_offload_title"
|
||||
app:iconSpaceReserved="false" />
|
||||
</PreferenceCategory>
|
||||
<PreferenceCategory
|
||||
a:title="@string/settings.notifications_title"
|
||||
|
@ -139,42 +127,6 @@
|
|||
a:summary="@string/settings.show_now_playing_summary"
|
||||
a:title="@string/settings.show_now_playing"
|
||||
app:iconSpaceReserved="false"/>
|
||||
<CheckBoxPreference
|
||||
a:defaultValue="true"
|
||||
a:key="showNotification"
|
||||
a:summary="@string/settings.show_notification_summary"
|
||||
a:title="@string/settings.show_notification"
|
||||
app:iconSpaceReserved="false"/>
|
||||
<CheckBoxPreference
|
||||
a:defaultValue="true"
|
||||
a:key="alwaysShowNotification"
|
||||
a:summary="@string/settings.show_notification_always_summary"
|
||||
a:title="@string/settings.show_notification_always"
|
||||
app:iconSpaceReserved="false"/>
|
||||
<CheckBoxPreference
|
||||
a:defaultValue="true"
|
||||
a:key="showLockScreen"
|
||||
a:summary="@string/settings.show_lockscreen_controls_summary"
|
||||
a:title="@string/settings.show_lockscreen_controls"
|
||||
app:iconSpaceReserved="false"/>
|
||||
<CheckBoxPreference
|
||||
a:defaultValue="true"
|
||||
a:key="sendBluetoothNotifications"
|
||||
a:summary="@string/settings.send_bluetooth_notification_summary"
|
||||
a:title="@string/settings.send_bluetooth_notification"
|
||||
app:iconSpaceReserved="false"/>
|
||||
<CheckBoxPreference
|
||||
a:defaultValue="false"
|
||||
a:key="sendBluetoothAlbumArt"
|
||||
a:summary="@string/settings.send_bluetooth_album_art_summary"
|
||||
a:title="@string/settings.send_bluetooth_album_art"
|
||||
app:iconSpaceReserved="false"/>
|
||||
<CheckBoxPreference
|
||||
a:defaultValue="false"
|
||||
a:key="disableNowPlayingListSending"
|
||||
a:summary="@string/settings.disable_send_now_playing_list_summary"
|
||||
a:title="@string/settings.disable_send_now_playing_list"
|
||||
app:iconSpaceReserved="false"/>
|
||||
</PreferenceCategory>
|
||||
<PreferenceCategory
|
||||
a:title="@string/settings.sharing_title"
|
||||
|
|
Loading…
Reference in New Issue