Merge pull request #726 from ultrasonic/media3-flat

Implement Media3
This commit is contained in:
tzugen 2022-06-16 13:47:36 +02:00 committed by GitHub
commit d8b5b774ee
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
96 changed files with 3801 additions and 6507 deletions

View File

@ -1,12 +0,0 @@
package org.moire.ultrasonic.domain
enum class PlayerState {
IDLE,
DOWNLOADING,
PREPARING,
PREPARED,
STARTED,
STOPPED,
PAUSED,
COMPLETED
}

View File

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

View File

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

View File

@ -2,41 +2,24 @@
<SmellBaseline>
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>ComplexCondition:DownloadHandler.kt$DownloadHandler.&lt;no name provided>$!append &amp;&amp; !playNext &amp;&amp; !unpin &amp;&amp; !background</ID>
<ID>ComplexMethod:DownloadFile.kt$DownloadFile.DownloadTask$override fun execute()</ID>
<ID>ImplicitDefaultLocale:EditServerFragment.kt$EditServerFragment.&lt;no name provided>$String.format( "%s %s", resources.getString(R.string.settings_connection_failure), getErrorMessage(error) )</ID>
<ID>ImplicitDefaultLocale: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&lt;ServerSetting>, private val model: ServerSettingsModel, private val activeServerProvider: ActiveServerProvider, private val manageMode: Boolean, private val serverDeletedCallback: ((Int) -> Unit), private val serverEditRequestedCallback: ((Int) -> Unit) )</ID>
<ID>LongParameterList:ServerRowAdapter.kt$ServerRowAdapter$( private var context: Context, passedData: Array&lt;ServerSetting>, 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.&lt;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>

View File

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

View File

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

View File

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

View File

@ -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&apos;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="&quot;lizensiert&quot; is a common misspelling; did you mean &quot;lizenziert&quot; ?"
errorLine1=" &lt;string name=&quot;settings.testing_unlicensed&quot;>Verbindung OK, Server nicht lizensiert.&lt;/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 (&quot;Artists&quot;): This should probably be a plural rather than a string"
errorLine1=" &lt;string name=&quot;parser.artist_count&quot;>Got %d Artists.&lt;/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=" &lt;receiver android:name=&quot;.receiver.UltrasonicIntentReceiver&quot;>"
errorLine1=" &lt;receiver android:name=&quot;.receiver.UltrasonicIntentReceiver&quot;"
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=" &lt;service android:name=&quot;.playback.PlaybackService&quot;"
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 \&#xA;available to other apps, and `false` otherwise."
errorLine1=" &lt;receiver android:name=&quot;.receiver.MediaButtonIntentReceiver&quot;>"
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 \&#xA;available to other apps, and `false` otherwise."
errorLine1=" &lt;receiver android:name=&quot;.receiver.UltrasonicIntentReceiver&quot;>"
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 \&#xA;available to other apps, and `false` otherwise."
errorLine1=" &lt;receiver android:name=&quot;.receiver.BluetoothIntentReceiver&quot;>"
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 \&#xA;available to other apps, and `false` otherwise."
errorLine1=" &lt;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 \&#xA;available to other apps, and `false` otherwise."
errorLine1=" &lt;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 \&#xA;available to other apps, and `false` otherwise."
errorLine1=" &lt;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 \&#xA;available to other apps, and `false` otherwise."
errorLine1=" &lt;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="&lt;vector xmlns:android=&quot;http://schemas.android.com/apk/res/android&quot;"
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="&lt;vector xmlns:android=&quot;http://schemas.android.com/apk/res/android&quot;"
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=" &lt;string name=&quot;main.shuffle&quot;>Shuffle Play&lt;/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=" &lt;string name=&quot;menu.navigation&quot;>Navigation&lt;/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=" &lt;string name=&quot;music_service.retry&quot;>A network error occurred. Retrying %1$d of %2$d.&lt;/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=" &lt;string name=&quot;parser.artist_count&quot;>Got %d Artists.&lt;/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=" &lt;string name=&quot;parser.reading&quot;>Reading from server.&lt;/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=" &lt;string name=&quot;parser.reading_done&quot;>Reading from server. Done!&lt;/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=" &lt;string name=&quot;progress.wait&quot;>Please wait&amp;#8230;&lt;/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=" &lt;string name=&quot;search.search&quot;>Click to search&lt;/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=" &lt;string name=&quot;service.connecting&quot;>Contacting server, please wait.&lt;/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=" &lt;string name=&quot;settings.allow_self_signed_certificate&quot; translatable=&quot;false&quot;>allowSelfSignedCertificate&lt;/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=" &lt;string name=&quot;settings.enable_ldap_user_support&quot; translatable=&quot;false&quot;>enableLdapUserSupport&lt;/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=" &lt;string name=&quot;settings.invalid_username&quot;>Please specify a valid username (no trailing spaces).&lt;/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=" &lt;string name=&quot;settings.server_remove_server&quot;>Remove Server&lt;/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=" &lt;string name=&quot;settings.server_unused&quot;>Unused&lt;/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=" &lt;string name=&quot;settings.server_address_unset&quot; translatable=&quot;false&quot;>http://example.com&lt;/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=" &lt;plurals name=&quot;select_album_donate_dialog_n_trial_days_left&quot;>"
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=" &lt;ImageView a:id=&quot;@+id/help_back&quot;"
errorLine2=" ~~~~~~~~~">
<location
file="src/main/res/layout/help.xml"
line="12"
column="10"/>
</issue>
<issue
id="ContentDescription"
message="Missing `contentDescription` attribute on image"
errorLine1=" &lt;ImageView a:id=&quot;@+id/help_stop&quot;"
errorLine2=" ~~~~~~~~~">
<location
file="src/main/res/layout/help.xml"
line="18"
column="10"/>
</issue>
<issue
id="ContentDescription"
message="Missing `contentDescription` attribute on image"
errorLine1=" &lt;ImageView a:id=&quot;@+id/help_forward&quot;"
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=" &lt;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"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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)
}
/**

View File

@ -11,7 +11,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase
*/
@Database(
entities = [ServerSetting::class],
version = 4,
version = 5,
exportSchema = true
)
abstract class AppDatabase : RoomDatabase() {

View File

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

View File

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

View File

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

View File

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

View File

@ -54,7 +54,7 @@ class DownloadsFragment : MultiListFragment<DownloadFile>() {
viewAdapter.register(
TrackViewBinder(
{ },
{ _, _ -> },
{ _, _ -> true },
checkable = false,
draggable = false,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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í&#8230;</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>

View File

@ -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&#8230;</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>

View File

@ -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&#8230;</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>

View File

@ -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&#8230;</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 lextension 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 laffichage de la piste actuelle nest pas mise à jour.</string>
<string name="settings.disable_send_now_playing_list">Désactiver lenvoi 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>

View File

@ -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&#8230;</string>
<string name="parser.reading_done">Olvasás a kiszolgálóról&#8230; 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!&#8230;</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>

View File

@ -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&#8230;</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>

View File

@ -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&#8230;</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>

View File

@ -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ć&#8230;</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>

View File

@ -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&#8230;</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>

View File

@ -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&#8230;</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>

View File

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

View File

@ -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">请稍等&#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>
@ -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>

View File

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

View File

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

View File

@ -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&#8230;</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>

View File

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