commit
d8b5b774ee
|
@ -1,12 +0,0 @@
|
||||||
package org.moire.ultrasonic.domain
|
|
||||||
|
|
||||||
enum class PlayerState {
|
|
||||||
IDLE,
|
|
||||||
DOWNLOADING,
|
|
||||||
PREPARING,
|
|
||||||
PREPARED,
|
|
||||||
STARTED,
|
|
||||||
STOPPED,
|
|
||||||
PAUSED,
|
|
||||||
COMPLETED
|
|
||||||
}
|
|
|
@ -1,15 +0,0 @@
|
||||||
package org.moire.ultrasonic.domain
|
|
||||||
|
|
||||||
enum class RepeatMode {
|
|
||||||
OFF {
|
|
||||||
override operator fun next(): RepeatMode = ALL
|
|
||||||
},
|
|
||||||
ALL {
|
|
||||||
override operator fun next(): RepeatMode = SINGLE
|
|
||||||
},
|
|
||||||
SINGLE {
|
|
||||||
override operator fun next(): RepeatMode = OFF
|
|
||||||
};
|
|
||||||
|
|
||||||
abstract operator fun next(): RepeatMode
|
|
||||||
}
|
|
|
@ -122,7 +122,7 @@ class SubsonicAPIClient(
|
||||||
|
|
||||||
private fun OkHttpClient.Builder.addLogging() {
|
private fun OkHttpClient.Builder.addLogging() {
|
||||||
val loggingInterceptor = HttpLoggingInterceptor(okLogger)
|
val loggingInterceptor = HttpLoggingInterceptor(okLogger)
|
||||||
loggingInterceptor.level = HttpLoggingInterceptor.Level.BODY
|
loggingInterceptor.level = HttpLoggingInterceptor.Level.HEADERS
|
||||||
this.addInterceptor(loggingInterceptor)
|
this.addInterceptor(loggingInterceptor)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -2,41 +2,24 @@
|
||||||
<SmellBaseline>
|
<SmellBaseline>
|
||||||
<ManuallySuppressedIssues/>
|
<ManuallySuppressedIssues/>
|
||||||
<CurrentIssues>
|
<CurrentIssues>
|
||||||
<ID>ComplexCondition:DownloadHandler.kt$DownloadHandler.<no name provided>$!append && !playNext && !unpin && !background</ID>
|
|
||||||
<ID>ComplexMethod:DownloadFile.kt$DownloadFile.DownloadTask$override fun execute()</ID>
|
|
||||||
<ID>ImplicitDefaultLocale:EditServerFragment.kt$EditServerFragment.<no name provided>$String.format( "%s %s", resources.getString(R.string.settings_connection_failure), getErrorMessage(error) )</ID>
|
<ID>ImplicitDefaultLocale:EditServerFragment.kt$EditServerFragment.<no name provided>$String.format( "%s %s", resources.getString(R.string.settings_connection_failure), getErrorMessage(error) )</ID>
|
||||||
<ID>ImplicitDefaultLocale:FileLoggerTree.kt$FileLoggerTree$String.format("Failed to write log to %s", file)</ID>
|
<ID>ImplicitDefaultLocale:FileLoggerTree.kt$FileLoggerTree$String.format("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("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: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>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: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: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>LongMethod:ShareHandler.kt$ShareHandler$private fun showDialog( fragment: Fragment, shareDetails: ShareDetails, swipe: SwipeRefreshLayout?, cancellationToken: CancellationToken )</ID>
|
||||||
<ID>LongParameterList:ServerRowAdapter.kt$ServerRowAdapter$( private var context: Context, private var data: Array<ServerSetting>, private val model: ServerSettingsModel, private val activeServerProvider: ActiveServerProvider, private val manageMode: Boolean, private val serverDeletedCallback: ((Int) -> Unit), private val serverEditRequestedCallback: ((Int) -> Unit) )</ID>
|
<ID>LongParameterList:ServerRowAdapter.kt$ServerRowAdapter$( private var context: Context, passedData: Array<ServerSetting>, private val model: ServerSettingsModel, private val activeServerProvider: ActiveServerProvider, private val manageMode: Boolean, private val serverDeletedCallback: ((Int) -> Unit), private val serverEditRequestedCallback: ((Int) -> Unit) )</ID>
|
||||||
<ID>MagicNumber:ActiveServerProvider.kt$ActiveServerProvider$8192</ID>
|
<ID>MagicNumber:ActiveServerProvider.kt$ActiveServerProvider$8192</ID>
|
||||||
<ID>MagicNumber:LocalMediaPlayer.kt$LocalMediaPlayer.<no name provided>$60000</ID>
|
<ID>MagicNumber:JukeboxMediaPlayer.kt$JukeboxMediaPlayer$0.05f</ID>
|
||||||
<ID>MagicNumber:LocalMediaPlayer.kt$LocalMediaPlayer.BufferTask$100000</ID>
|
<ID>MagicNumber:JukeboxMediaPlayer.kt$JukeboxMediaPlayer$50</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:RESTMusicService.kt$RESTMusicService$206</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: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:FileLoggerTree.kt$FileLoggerTree$x: Throwable</ID>
|
||||||
<ID>TooGenericExceptionCaught:LocalMediaPlayer.kt$LocalMediaPlayer$ex: Exception</ID>
|
<ID>TooGenericExceptionCaught:JukeboxMediaPlayer.kt$JukeboxMediaPlayer$x: Throwable</ID>
|
||||||
<ID>TooGenericExceptionCaught:LocalMediaPlayer.kt$LocalMediaPlayer$exception: Throwable</ID>
|
<ID>TooGenericExceptionCaught:JukeboxMediaPlayer.kt$JukeboxMediaPlayer.TaskQueue$x: Throwable</ID>
|
||||||
<ID>TooGenericExceptionCaught:LocalMediaPlayer.kt$LocalMediaPlayer$x: Exception</ID>
|
<ID>TooGenericExceptionThrown:Downloader.kt$Downloader.DownloadTask$throw RuntimeException( String.format( Locale.ROOT, "Download of '%s' was cancelled", downloadFile.track ) )</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>TooManyFunctions:RESTMusicService.kt$RESTMusicService : MusicService</ID>
|
<ID>TooManyFunctions:RESTMusicService.kt$RESTMusicService : MusicService</ID>
|
||||||
<ID>UtilityClassWithPublicConstructor:FragmentTitle.kt$FragmentTitle</ID>
|
<ID>UtilityClassWithPublicConstructor:FragmentTitle.kt$FragmentTitle</ID>
|
||||||
</CurrentIssues>
|
</CurrentIssues>
|
||||||
|
|
|
@ -70,7 +70,7 @@ style:
|
||||||
excludeImportStatements: false
|
excludeImportStatements: false
|
||||||
MagicNumber:
|
MagicNumber:
|
||||||
# 100 common in percentage, 1000 in milliseconds
|
# 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
|
ignoreEnums: true
|
||||||
ignorePropertyDeclaration: true
|
ignorePropertyDeclaration: true
|
||||||
UnnecessaryAbstractClass:
|
UnnecessaryAbstractClass:
|
||||||
|
|
|
@ -11,6 +11,7 @@ detekt = "1.19.0"
|
||||||
jacoco = "0.8.7"
|
jacoco = "0.8.7"
|
||||||
preferences = "1.1.1"
|
preferences = "1.1.1"
|
||||||
media = "1.3.1"
|
media = "1.3.1"
|
||||||
|
media3 = "1.0.0-alpha03"
|
||||||
|
|
||||||
androidSupport = "28.0.0"
|
androidSupport = "28.0.0"
|
||||||
androidLegacySupport = "1.0.0"
|
androidLegacySupport = "1.0.0"
|
||||||
|
@ -20,6 +21,7 @@ multidex = "2.0.1"
|
||||||
room = "2.4.0"
|
room = "2.4.0"
|
||||||
kotlin = "1.6.10"
|
kotlin = "1.6.10"
|
||||||
kotlinxCoroutines = "1.6.0-native-mt"
|
kotlinxCoroutines = "1.6.0-native-mt"
|
||||||
|
kotlinxGuava = "1.6.0"
|
||||||
viewModelKtx = "2.3.0"
|
viewModelKtx = "2.3.0"
|
||||||
|
|
||||||
retrofit = "2.9.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" }
|
navigationFeature = { module = "androidx.navigation:navigation-dynamic-features-fragment", version.ref = "navigation" }
|
||||||
preferences = { module = "androidx.preference:preference", version.ref = "preferences" }
|
preferences = { module = "androidx.preference:preference", version.ref = "preferences" }
|
||||||
media = { module = "androidx.media:media", version.ref = "media" }
|
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" }
|
kotlinStdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" }
|
||||||
kotlinReflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" }
|
kotlinReflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" }
|
||||||
kotlinxCoroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" }
|
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" }
|
retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" }
|
||||||
gsonConverter = { module = "com.squareup.retrofit2:converter-gson", version.ref = "retrofit" }
|
gsonConverter = { module = "com.squareup.retrofit2:converter-gson", version.ref = "retrofit" }
|
||||||
jacksonConverter = { module = "com.squareup.retrofit2:converter-jackson", version.ref = "retrofit" }
|
jacksonConverter = { module = "com.squareup.retrofit2:converter-jackson", version.ref = "retrofit" }
|
||||||
|
|
|
@ -100,6 +100,9 @@ dependencies {
|
||||||
implementation libs.constraintLayout
|
implementation libs.constraintLayout
|
||||||
implementation libs.preferences
|
implementation libs.preferences
|
||||||
implementation libs.media
|
implementation libs.media
|
||||||
|
implementation libs.media3exoplayer
|
||||||
|
implementation libs.media3session
|
||||||
|
implementation libs.media3okhttp
|
||||||
|
|
||||||
implementation libs.navigationFragment
|
implementation libs.navigationFragment
|
||||||
implementation libs.navigationUi
|
implementation libs.navigationUi
|
||||||
|
@ -109,6 +112,7 @@ dependencies {
|
||||||
|
|
||||||
implementation libs.kotlinStdlib
|
implementation libs.kotlinStdlib
|
||||||
implementation libs.kotlinxCoroutines
|
implementation libs.kotlinxCoroutines
|
||||||
|
implementation libs.kotlinxGuava
|
||||||
implementation libs.koinAndroid
|
implementation libs.koinAndroid
|
||||||
implementation libs.okhttpLogging
|
implementation libs.okhttpLogging
|
||||||
implementation libs.fastScroll
|
implementation libs.fastScroll
|
||||||
|
|
|
@ -1,37 +1,15 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?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
|
<issue
|
||||||
id="InflateParams"
|
id="InflateParams"
|
||||||
message="Avoid passing `null` as the view root (needed to resolve layout parameters on the inflated layout's root element)"
|
message="Avoid passing `null` as the view root (needed to resolve layout parameters on the inflated layout's root element)"
|
||||||
errorLine1=" View view = inflater.inflate(R.layout.jukebox_volume, null);"
|
errorLine1=" val view = inflater.inflate(R.layout.jukebox_volume, null)"
|
||||||
errorLine2=" ~~~~">
|
errorLine2=" ~~~~">
|
||||||
<location
|
<location
|
||||||
file="src/main/java/org/moire/ultrasonic/service/JukeboxMediaPlayer.java"
|
file="src/main/kotlin/org/moire/ultrasonic/service/JukeboxMediaPlayer.kt"
|
||||||
line="477"
|
line="331"
|
||||||
column="58"/>
|
column="66"/>
|
||||||
</issue>
|
|
||||||
|
|
||||||
<issue
|
|
||||||
id="Typos"
|
|
||||||
message=""lizensiert" is a common misspelling; did you mean "lizenziert" ?"
|
|
||||||
errorLine1=" <string name="settings.testing_unlicensed">Verbindung OK, Server nicht lizensiert.</string>"
|
|
||||||
errorLine2=" ^">
|
|
||||||
<location
|
|
||||||
file="src/main/res/values-de/strings.xml"
|
|
||||||
line="289"
|
|
||||||
column="76"/>
|
|
||||||
</issue>
|
|
||||||
|
|
||||||
<issue
|
|
||||||
id="PluralsCandidate"
|
|
||||||
message="Formatting %d followed by words ("Artists"): This should probably be a plural rather than a string"
|
|
||||||
errorLine1=" <string name="parser.artist_count">Got %d Artists.</string>"
|
|
||||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
|
||||||
<location
|
|
||||||
file="src/main/res/values/strings.xml"
|
|
||||||
line="134"
|
|
||||||
column="5"/>
|
|
||||||
</issue>
|
</issue>
|
||||||
|
|
||||||
<issue
|
<issue
|
||||||
|
@ -41,7 +19,7 @@
|
||||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||||
<location
|
<location
|
||||||
file="src/main/res/values/strings.xml"
|
file="src/main/res/values/strings.xml"
|
||||||
line="151"
|
line="154"
|
||||||
column="5"/>
|
column="5"/>
|
||||||
</issue>
|
</issue>
|
||||||
|
|
||||||
|
@ -56,17 +34,6 @@
|
||||||
column="73"/>
|
column="73"/>
|
||||||
</issue>
|
</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
|
<issue
|
||||||
id="TrustAllX509TrustManager"
|
id="TrustAllX509TrustManager"
|
||||||
message="`checkClientTrusted` is empty, which could cause insecure network traffic due to trusting arbitrary TLS/SSL certificates presented by peers">
|
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=" ~~~~~~~~">
|
errorLine2=" ~~~~~~~~">
|
||||||
<location
|
<location
|
||||||
file="src/main/AndroidManifest.xml"
|
file="src/main/AndroidManifest.xml"
|
||||||
line="146"
|
line="151"
|
||||||
column="10"/>
|
column="10"/>
|
||||||
</issue>
|
</issue>
|
||||||
|
|
||||||
<issue
|
<issue
|
||||||
id="ExportedReceiver"
|
id="ExportedReceiver"
|
||||||
message="Exported receiver does not require permission"
|
message="Exported receiver does not require permission"
|
||||||
errorLine1=" <receiver android:name=".receiver.UltrasonicIntentReceiver">"
|
errorLine1=" <receiver android:name=".receiver.UltrasonicIntentReceiver""
|
||||||
errorLine2=" ~~~~~~~~">
|
errorLine2=" ~~~~~~~~">
|
||||||
<location
|
<location
|
||||||
file="src/main/AndroidManifest.xml"
|
file="src/main/AndroidManifest.xml"
|
||||||
line="81"
|
line="75"
|
||||||
|
column="10"/>
|
||||||
|
</issue>
|
||||||
|
|
||||||
|
<issue
|
||||||
|
id="ExportedService"
|
||||||
|
message="Exported service does not require permission"
|
||||||
|
errorLine1=" <service android:name=".playback.PlaybackService""
|
||||||
|
errorLine2=" ~~~~~~~">
|
||||||
|
<location
|
||||||
|
file="src/main/AndroidManifest.xml"
|
||||||
|
line="65"
|
||||||
column="10"/>
|
column="10"/>
|
||||||
</issue>
|
</issue>
|
||||||
|
|
||||||
|
@ -114,171 +92,6 @@
|
||||||
column="10"/>
|
column="10"/>
|
||||||
</issue>
|
</issue>
|
||||||
|
|
||||||
<issue
|
|
||||||
id="IntentFilterExportedReceiver"
|
|
||||||
message="As of Android 12, `android:exported` must be set; use `true` to make the activity \
available to other apps, and `false` otherwise."
|
|
||||||
errorLine1=" <receiver android:name=".receiver.MediaButtonIntentReceiver">"
|
|
||||||
errorLine2=" ~~~~~~~~">
|
|
||||||
<location
|
|
||||||
file="src/main/AndroidManifest.xml"
|
|
||||||
line="76"
|
|
||||||
column="10"/>
|
|
||||||
</issue>
|
|
||||||
|
|
||||||
<issue
|
|
||||||
id="IntentFilterExportedReceiver"
|
|
||||||
message="As of Android 12, `android:exported` must be set; use `true` to make the activity \
available to other apps, and `false` otherwise."
|
|
||||||
errorLine1=" <receiver android:name=".receiver.UltrasonicIntentReceiver">"
|
|
||||||
errorLine2=" ~~~~~~~~">
|
|
||||||
<location
|
|
||||||
file="src/main/AndroidManifest.xml"
|
|
||||||
line="81"
|
|
||||||
column="10"/>
|
|
||||||
</issue>
|
|
||||||
|
|
||||||
<issue
|
|
||||||
id="IntentFilterExportedReceiver"
|
|
||||||
message="As of Android 12, `android:exported` must be set; use `true` to make the activity \
available to other apps, and `false` otherwise."
|
|
||||||
errorLine1=" <receiver android:name=".receiver.BluetoothIntentReceiver">"
|
|
||||||
errorLine2=" ~~~~~~~~">
|
|
||||||
<location
|
|
||||||
file="src/main/AndroidManifest.xml"
|
|
||||||
line="93"
|
|
||||||
column="10"/>
|
|
||||||
</issue>
|
|
||||||
|
|
||||||
<issue
|
|
||||||
id="IntentFilterExportedReceiver"
|
|
||||||
message="As of Android 12, `android:exported` must be set; use `true` to make the activity \
available to other apps, and `false` otherwise."
|
|
||||||
errorLine1=" <receiver"
|
|
||||||
errorLine2=" ~~~~~~~~">
|
|
||||||
<location
|
|
||||||
file="src/main/AndroidManifest.xml"
|
|
||||||
line="101"
|
|
||||||
column="10"/>
|
|
||||||
</issue>
|
|
||||||
|
|
||||||
<issue
|
|
||||||
id="IntentFilterExportedReceiver"
|
|
||||||
message="As of Android 12, `android:exported` must be set; use `true` to make the activity \
available to other apps, and `false` otherwise."
|
|
||||||
errorLine1=" <receiver"
|
|
||||||
errorLine2=" ~~~~~~~~">
|
|
||||||
<location
|
|
||||||
file="src/main/AndroidManifest.xml"
|
|
||||||
line="112"
|
|
||||||
column="10"/>
|
|
||||||
</issue>
|
|
||||||
|
|
||||||
<issue
|
|
||||||
id="IntentFilterExportedReceiver"
|
|
||||||
message="As of Android 12, `android:exported` must be set; use `true` to make the activity \
available to other apps, and `false` otherwise."
|
|
||||||
errorLine1=" <receiver"
|
|
||||||
errorLine2=" ~~~~~~~~">
|
|
||||||
<location
|
|
||||||
file="src/main/AndroidManifest.xml"
|
|
||||||
line="123"
|
|
||||||
column="10"/>
|
|
||||||
</issue>
|
|
||||||
|
|
||||||
<issue
|
|
||||||
id="IntentFilterExportedReceiver"
|
|
||||||
message="As of Android 12, `android:exported` must be set; use `true` to make the activity \
available to other apps, and `false` otherwise."
|
|
||||||
errorLine1=" <receiver"
|
|
||||||
errorLine2=" ~~~~~~~~">
|
|
||||||
<location
|
|
||||||
file="src/main/AndroidManifest.xml"
|
|
||||||
line="134"
|
|
||||||
column="10"/>
|
|
||||||
</issue>
|
|
||||||
|
|
||||||
<issue
|
|
||||||
id="UnspecifiedImmutableFlag"
|
|
||||||
message="Missing `PendingIntent` mutability flag"
|
|
||||||
errorLine1=" return PendingIntent.getActivity(this, 0, intent, flags)"
|
|
||||||
errorLine2=" ~~~~~">
|
|
||||||
<location
|
|
||||||
file="src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerService.kt"
|
|
||||||
line="708"
|
|
||||||
column="59"/>
|
|
||||||
</issue>
|
|
||||||
|
|
||||||
<issue
|
|
||||||
id="UnspecifiedImmutableFlag"
|
|
||||||
message="Missing `PendingIntent` mutability flag"
|
|
||||||
errorLine1=" PendingIntent.FLAG_CANCEL_CURRENT"
|
|
||||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
|
||||||
<location
|
|
||||||
file="src/main/kotlin/org/moire/ultrasonic/util/MediaSessionHandler.kt"
|
|
||||||
line="323"
|
|
||||||
column="13"/>
|
|
||||||
</issue>
|
|
||||||
|
|
||||||
<issue
|
|
||||||
id="UnspecifiedImmutableFlag"
|
|
||||||
message="Missing `PendingIntent` mutability flag"
|
|
||||||
errorLine1=" PendingIntent pendingIntent = PendingIntent.getActivity(context, 10, intent, PendingIntent.FLAG_UPDATE_CURRENT);"
|
|
||||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
|
||||||
<location
|
|
||||||
file="src/main/java/org/moire/ultrasonic/provider/UltrasonicAppWidgetProvider.java"
|
|
||||||
line="198"
|
|
||||||
column="80"/>
|
|
||||||
</issue>
|
|
||||||
|
|
||||||
<issue
|
|
||||||
id="UnspecifiedImmutableFlag"
|
|
||||||
message="Missing `PendingIntent` mutability flag"
|
|
||||||
errorLine1=" pendingIntent = PendingIntent.getBroadcast(context, 11, intent, 0);"
|
|
||||||
errorLine2=" ~">
|
|
||||||
<location
|
|
||||||
file="src/main/java/org/moire/ultrasonic/provider/UltrasonicAppWidgetProvider.java"
|
|
||||||
line="206"
|
|
||||||
column="67"/>
|
|
||||||
</issue>
|
|
||||||
|
|
||||||
<issue
|
|
||||||
id="UnspecifiedImmutableFlag"
|
|
||||||
message="Missing `PendingIntent` mutability flag"
|
|
||||||
errorLine1=" pendingIntent = PendingIntent.getBroadcast(context, 12, intent, 0);"
|
|
||||||
errorLine2=" ~">
|
|
||||||
<location
|
|
||||||
file="src/main/java/org/moire/ultrasonic/provider/UltrasonicAppWidgetProvider.java"
|
|
||||||
line="212"
|
|
||||||
column="67"/>
|
|
||||||
</issue>
|
|
||||||
|
|
||||||
<issue
|
|
||||||
id="UnspecifiedImmutableFlag"
|
|
||||||
message="Missing `PendingIntent` mutability flag"
|
|
||||||
errorLine1=" pendingIntent = PendingIntent.getBroadcast(context, 13, intent, 0);"
|
|
||||||
errorLine2=" ~">
|
|
||||||
<location
|
|
||||||
file="src/main/java/org/moire/ultrasonic/provider/UltrasonicAppWidgetProvider.java"
|
|
||||||
line="218"
|
|
||||||
column="67"/>
|
|
||||||
</issue>
|
|
||||||
|
|
||||||
<issue
|
|
||||||
id="UnspecifiedImmutableFlag"
|
|
||||||
message="Missing `PendingIntent` mutability flag"
|
|
||||||
errorLine1=" return PendingIntent.getBroadcast(context, requestCode, intent, flags)"
|
|
||||||
errorLine2=" ~~~~~">
|
|
||||||
<location
|
|
||||||
file="src/main/kotlin/org/moire/ultrasonic/util/Util.kt"
|
|
||||||
line="891"
|
|
||||||
column="73"/>
|
|
||||||
</issue>
|
|
||||||
|
|
||||||
<issue
|
|
||||||
id="NotifyDataSetChanged"
|
|
||||||
message="It will always be more efficient to use more specific change events if you can. Rely on `notifyDataSetChanged` as a last resort."
|
|
||||||
errorLine1=" viewAdapter.notifyDataSetChanged()"
|
|
||||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
|
||||||
<location
|
|
||||||
file="src/main/kotlin/org/moire/ultrasonic/fragment/PlayerFragment.kt"
|
|
||||||
line="908"
|
|
||||||
column="21"/>
|
|
||||||
</issue>
|
|
||||||
|
|
||||||
<issue
|
<issue
|
||||||
id="ObsoleteLayoutParam"
|
id="ObsoleteLayoutParam"
|
||||||
message="Invalid layout param in a `LinearLayout`: `layout_above`"
|
message="Invalid layout param in a `LinearLayout`: `layout_above`"
|
||||||
|
@ -345,6 +158,17 @@
|
||||||
column="5"/>
|
column="5"/>
|
||||||
</issue>
|
</issue>
|
||||||
|
|
||||||
|
<issue
|
||||||
|
id="UnusedResources"
|
||||||
|
message="The resource `R.drawable.ic_baseline_close` appears to be unused"
|
||||||
|
errorLine1="<vector xmlns:android="http://schemas.android.com/apk/res/android""
|
||||||
|
errorLine2="^">
|
||||||
|
<location
|
||||||
|
file="src/main/res/drawable/ic_baseline_close.xml"
|
||||||
|
line="1"
|
||||||
|
column="1"/>
|
||||||
|
</issue>
|
||||||
|
|
||||||
<issue
|
<issue
|
||||||
id="UnusedResources"
|
id="UnusedResources"
|
||||||
message="The resource `R.drawable.ic_menu_arrow` appears to be unused"
|
message="The resource `R.drawable.ic_menu_arrow` appears to be unused"
|
||||||
|
@ -356,193 +180,6 @@
|
||||||
column="1"/>
|
column="1"/>
|
||||||
</issue>
|
</issue>
|
||||||
|
|
||||||
<issue
|
|
||||||
id="UnusedResources"
|
|
||||||
message="The resource `R.drawable.menu_arrow` appears to be unused"
|
|
||||||
errorLine1="<vector xmlns:android="http://schemas.android.com/apk/res/android""
|
|
||||||
errorLine2="^">
|
|
||||||
<location
|
|
||||||
file="src/main/res/drawable/menu_arrow.xml"
|
|
||||||
line="1"
|
|
||||||
column="1"/>
|
|
||||||
</issue>
|
|
||||||
|
|
||||||
<issue
|
|
||||||
id="UnusedResources"
|
|
||||||
message="The resource `R.string.main_shuffle` appears to be unused"
|
|
||||||
errorLine1=" <string name="main.shuffle">Shuffle Play</string>"
|
|
||||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~">
|
|
||||||
<location
|
|
||||||
file="src/main/res/values/strings.xml"
|
|
||||||
line="114"
|
|
||||||
column="13"/>
|
|
||||||
</issue>
|
|
||||||
|
|
||||||
<issue
|
|
||||||
id="UnusedResources"
|
|
||||||
message="The resource `R.string.menu_navigation` appears to be unused"
|
|
||||||
errorLine1=" <string name="menu.navigation">Navigation</string>"
|
|
||||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~">
|
|
||||||
<location
|
|
||||||
file="src/main/res/values/strings.xml"
|
|
||||||
line="128"
|
|
||||||
column="13"/>
|
|
||||||
</issue>
|
|
||||||
|
|
||||||
<issue
|
|
||||||
id="UnusedResources"
|
|
||||||
message="The resource `R.string.music_service_retry` appears to be unused"
|
|
||||||
errorLine1=" <string name="music_service.retry">A network error occurred. Retrying %1$d of %2$d.</string>"
|
|
||||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
|
||||||
<location
|
|
||||||
file="src/main/res/values/strings.xml"
|
|
||||||
line="133"
|
|
||||||
column="13"/>
|
|
||||||
</issue>
|
|
||||||
|
|
||||||
<issue
|
|
||||||
id="UnusedResources"
|
|
||||||
message="The resource `R.string.parser_artist_count` appears to be unused"
|
|
||||||
errorLine1=" <string name="parser.artist_count">Got %d Artists.</string>"
|
|
||||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
|
||||||
<location
|
|
||||||
file="src/main/res/values/strings.xml"
|
|
||||||
line="134"
|
|
||||||
column="13"/>
|
|
||||||
</issue>
|
|
||||||
|
|
||||||
<issue
|
|
||||||
id="UnusedResources"
|
|
||||||
message="The resource `R.string.parser_reading` appears to be unused"
|
|
||||||
errorLine1=" <string name="parser.reading">Reading from server.</string>"
|
|
||||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~">
|
|
||||||
<location
|
|
||||||
file="src/main/res/values/strings.xml"
|
|
||||||
line="135"
|
|
||||||
column="13"/>
|
|
||||||
</issue>
|
|
||||||
|
|
||||||
<issue
|
|
||||||
id="UnusedResources"
|
|
||||||
message="The resource `R.string.parser_reading_done` appears to be unused"
|
|
||||||
errorLine1=" <string name="parser.reading_done">Reading from server. Done!</string>"
|
|
||||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
|
||||||
<location
|
|
||||||
file="src/main/res/values/strings.xml"
|
|
||||||
line="136"
|
|
||||||
column="13"/>
|
|
||||||
</issue>
|
|
||||||
|
|
||||||
<issue
|
|
||||||
id="UnusedResources"
|
|
||||||
message="The resource `R.string.progress_wait` appears to be unused"
|
|
||||||
errorLine1=" <string name="progress.wait">Please wait&#8230;</string>"
|
|
||||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
|
|
||||||
<location
|
|
||||||
file="src/main/res/values/strings.xml"
|
|
||||||
line="141"
|
|
||||||
column="13"/>
|
|
||||||
</issue>
|
|
||||||
|
|
||||||
<issue
|
|
||||||
id="UnusedResources"
|
|
||||||
message="The resource `R.string.search_search` appears to be unused"
|
|
||||||
errorLine1=" <string name="search.search">Click to search</string>"
|
|
||||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
|
|
||||||
<location
|
|
||||||
file="src/main/res/values/strings.xml"
|
|
||||||
line="147"
|
|
||||||
column="13"/>
|
|
||||||
</issue>
|
|
||||||
|
|
||||||
<issue
|
|
||||||
id="UnusedResources"
|
|
||||||
message="The resource `R.string.service_connecting` appears to be unused"
|
|
||||||
errorLine1=" <string name="service.connecting">Contacting server, please wait.</string>"
|
|
||||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~">
|
|
||||||
<location
|
|
||||||
file="src/main/res/values/strings.xml"
|
|
||||||
line="159"
|
|
||||||
column="13"/>
|
|
||||||
</issue>
|
|
||||||
|
|
||||||
<issue
|
|
||||||
id="UnusedResources"
|
|
||||||
message="The resource `R.string.settings_allow_self_signed_certificate` appears to be unused"
|
|
||||||
errorLine1=" <string name="settings.allow_self_signed_certificate" translatable="false">allowSelfSignedCertificate</string>"
|
|
||||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
|
||||||
<location
|
|
||||||
file="src/main/res/values/strings.xml"
|
|
||||||
line="160"
|
|
||||||
column="13"/>
|
|
||||||
</issue>
|
|
||||||
|
|
||||||
<issue
|
|
||||||
id="UnusedResources"
|
|
||||||
message="The resource `R.string.settings_enable_ldap_user_support` appears to be unused"
|
|
||||||
errorLine1=" <string name="settings.enable_ldap_user_support" translatable="false">enableLdapUserSupport</string>"
|
|
||||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
|
||||||
<location
|
|
||||||
file="src/main/res/values/strings.xml"
|
|
||||||
line="161"
|
|
||||||
column="13"/>
|
|
||||||
</issue>
|
|
||||||
|
|
||||||
<issue
|
|
||||||
id="UnusedResources"
|
|
||||||
message="The resource `R.string.settings_invalid_username` appears to be unused"
|
|
||||||
errorLine1=" <string name="settings.invalid_username">Please specify a valid username (no trailing spaces).</string>"
|
|
||||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
|
||||||
<location
|
|
||||||
file="src/main/res/values/strings.xml"
|
|
||||||
line="230"
|
|
||||||
column="13"/>
|
|
||||||
</issue>
|
|
||||||
|
|
||||||
<issue
|
|
||||||
id="UnusedResources"
|
|
||||||
message="The resource `R.string.settings_server_remove_server` appears to be unused"
|
|
||||||
errorLine1=" <string name="settings.server_remove_server">Remove Server</string>"
|
|
||||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
|
||||||
<location
|
|
||||||
file="src/main/res/values/strings.xml"
|
|
||||||
line="299"
|
|
||||||
column="13"/>
|
|
||||||
</issue>
|
|
||||||
|
|
||||||
<issue
|
|
||||||
id="UnusedResources"
|
|
||||||
message="The resource `R.string.settings_server_unused` appears to be unused"
|
|
||||||
errorLine1=" <string name="settings.server_unused">Unused</string>"
|
|
||||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
|
||||||
<location
|
|
||||||
file="src/main/res/values/strings.xml"
|
|
||||||
line="302"
|
|
||||||
column="13"/>
|
|
||||||
</issue>
|
|
||||||
|
|
||||||
<issue
|
|
||||||
id="UnusedResources"
|
|
||||||
message="The resource `R.string.settings_server_address_unset` appears to be unused"
|
|
||||||
errorLine1=" <string name="settings.server_address_unset" translatable="false">http://example.com</string>"
|
|
||||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
|
||||||
<location
|
|
||||||
file="src/main/res/values/strings.xml"
|
|
||||||
line="387"
|
|
||||||
column="13"/>
|
|
||||||
</issue>
|
|
||||||
|
|
||||||
<issue
|
|
||||||
id="UnusedResources"
|
|
||||||
message="The resource `R.plurals.select_album_donate_dialog_n_trial_days_left` appears to be unused"
|
|
||||||
errorLine1=" <plurals name="select_album_donate_dialog_n_trial_days_left">"
|
|
||||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
|
||||||
<location
|
|
||||||
file="src/main/res/values/strings.xml"
|
|
||||||
line="447"
|
|
||||||
column="14"/>
|
|
||||||
</issue>
|
|
||||||
|
|
||||||
<issue
|
<issue
|
||||||
id="IconDuplicates"
|
id="IconDuplicates"
|
||||||
message="The following unrelated icon files have identical contents: list_pressed_holo_dark.9.png, list_pressed_holo_light.9.png">
|
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"/>
|
column="6"/>
|
||||||
</issue>
|
</issue>
|
||||||
|
|
||||||
<issue
|
|
||||||
id="ContentDescription"
|
|
||||||
message="Missing `contentDescription` attribute on image"
|
|
||||||
errorLine1=" <ImageView a:id="@+id/help_back""
|
|
||||||
errorLine2=" ~~~~~~~~~">
|
|
||||||
<location
|
|
||||||
file="src/main/res/layout/help.xml"
|
|
||||||
line="12"
|
|
||||||
column="10"/>
|
|
||||||
</issue>
|
|
||||||
|
|
||||||
<issue
|
|
||||||
id="ContentDescription"
|
|
||||||
message="Missing `contentDescription` attribute on image"
|
|
||||||
errorLine1=" <ImageView a:id="@+id/help_stop""
|
|
||||||
errorLine2=" ~~~~~~~~~">
|
|
||||||
<location
|
|
||||||
file="src/main/res/layout/help.xml"
|
|
||||||
line="18"
|
|
||||||
column="10"/>
|
|
||||||
</issue>
|
|
||||||
|
|
||||||
<issue
|
|
||||||
id="ContentDescription"
|
|
||||||
message="Missing `contentDescription` attribute on image"
|
|
||||||
errorLine1=" <ImageView a:id="@+id/help_forward""
|
|
||||||
errorLine2=" ~~~~~~~~~">
|
|
||||||
<location
|
|
||||||
file="src/main/res/layout/help.xml"
|
|
||||||
line="24"
|
|
||||||
column="10"/>
|
|
||||||
</issue>
|
|
||||||
|
|
||||||
<issue
|
<issue
|
||||||
id="LabelFor"
|
id="LabelFor"
|
||||||
message="Missing accessibility label: provide either a view with an `android:labelFor` that references this view or provide an `android:hint`"
|
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"/>
|
column="13"/>
|
||||||
</issue>
|
</issue>
|
||||||
|
|
||||||
<issue
|
|
||||||
id="RelativeOverlap"
|
|
||||||
message="`LinearLayout-3` can overlap `LinearLayout-1` if LinearLayout-1, LinearLayout-3 grow due to localized text expansion"
|
|
||||||
errorLine1=" <LinearLayout"
|
|
||||||
errorLine2=" ~~~~~~~~~~~~">
|
|
||||||
<location
|
|
||||||
file="src/main/res/layout/player_media_info.xml"
|
|
||||||
line="52"
|
|
||||||
column="6"/>
|
|
||||||
</issue>
|
|
||||||
|
|
||||||
<issue
|
<issue
|
||||||
id="RelativeOverlap"
|
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"
|
message="`@id/current_playing_duration` can overlap `@id/current_playing_position` if @string/util.no_time, @string/util.no_time grow due to localized text expansion"
|
||||||
|
|
|
@ -56,28 +56,24 @@
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
<service
|
<service
|
||||||
android:name=".service.MediaPlayerService"
|
android:name=".service.DownloadService"
|
||||||
android:label="Ultrasonic Media Player Service"
|
android:label="Ultrasonic Media Player Service"
|
||||||
android:exported="false">
|
android:exported="false">
|
||||||
</service>
|
</service>
|
||||||
|
|
||||||
<service
|
<!-- TODO: Check if it works with exported=false as well -->
|
||||||
tools:ignore="ExportedService"
|
<service android:name=".playback.PlaybackService"
|
||||||
android:name=".service.AutoMediaBrowserService"
|
|
||||||
android:label="@string/common.appname"
|
android:label="@string/common.appname"
|
||||||
android:exported="true">
|
android:exported="true">
|
||||||
|
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
|
<action android:name="androidx.media3.session.MediaLibraryService" />
|
||||||
<action android:name="android.media.browse.MediaBrowserService" />
|
<action android:name="android.media.browse.MediaBrowserService" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</service>
|
</service>
|
||||||
|
|
||||||
<receiver android:name=".receiver.MediaButtonIntentReceiver">
|
<receiver android:name=".receiver.UltrasonicIntentReceiver"
|
||||||
<intent-filter android:priority="2147483647">
|
android:exported="true">
|
||||||
<action android:name="android.intent.action.MEDIA_BUTTON"/>
|
|
||||||
</intent-filter>
|
|
||||||
</receiver>
|
|
||||||
<receiver android:name=".receiver.UltrasonicIntentReceiver">
|
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="org.moire.ultrasonic.CMD_TOGGLEPAUSE"/>
|
<action android:name="org.moire.ultrasonic.CMD_TOGGLEPAUSE"/>
|
||||||
<action android:name="org.moire.ultrasonic.CMD_PLAY"/>
|
<action android:name="org.moire.ultrasonic.CMD_PLAY"/>
|
||||||
|
@ -89,7 +85,8 @@
|
||||||
<action android:name="org.moire.ultrasonic.CMD_PROCESS_KEYCODE"/>
|
<action android:name="org.moire.ultrasonic.CMD_PROCESS_KEYCODE"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</receiver>
|
</receiver>
|
||||||
<receiver android:name=".receiver.BluetoothIntentReceiver">
|
<receiver android:name=".receiver.BluetoothIntentReceiver"
|
||||||
|
android:exported="true">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.bluetooth.device.action.ACL_CONNECTED"/>
|
<action android:name="android.bluetooth.device.action.ACL_CONNECTED"/>
|
||||||
<action android:name="android.bluetooth.device.action.ACL_DISCONNECTED"/>
|
<action android:name="android.bluetooth.device.action.ACL_DISCONNECTED"/>
|
||||||
|
@ -99,7 +96,8 @@
|
||||||
</receiver>
|
</receiver>
|
||||||
<receiver
|
<receiver
|
||||||
android:name=".provider.UltrasonicAppWidgetProvider4X1"
|
android:name=".provider.UltrasonicAppWidgetProvider4X1"
|
||||||
android:label="Ultrasonic (4x1)">
|
android:label="Ultrasonic (4x1)"
|
||||||
|
android:exported="false">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
@ -110,7 +108,8 @@
|
||||||
</receiver>
|
</receiver>
|
||||||
<receiver
|
<receiver
|
||||||
android:name=".provider.UltrasonicAppWidgetProvider4X2"
|
android:name=".provider.UltrasonicAppWidgetProvider4X2"
|
||||||
android:label="Ultrasonic (4x2)">
|
android:label="Ultrasonic (4x2)"
|
||||||
|
android:exported="false">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
@ -121,7 +120,8 @@
|
||||||
</receiver>
|
</receiver>
|
||||||
<receiver
|
<receiver
|
||||||
android:name=".provider.UltrasonicAppWidgetProvider4X3"
|
android:name=".provider.UltrasonicAppWidgetProvider4X3"
|
||||||
android:label="Ultrasonic (4x3)">
|
android:label="Ultrasonic (4x3)"
|
||||||
|
android:exported="false">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
@ -132,7 +132,8 @@
|
||||||
</receiver>
|
</receiver>
|
||||||
<receiver
|
<receiver
|
||||||
android:name=".provider.UltrasonicAppWidgetProvider4X4"
|
android:name=".provider.UltrasonicAppWidgetProvider4X4"
|
||||||
android:label="Ultrasonic (4x4)">
|
android:label="Ultrasonic (4x4)"
|
||||||
|
android:exported="false">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
@ -141,18 +142,16 @@
|
||||||
android:name="android.appwidget.provider"
|
android:name="android.appwidget.provider"
|
||||||
android:resource="@xml/appwidget_info_4x4"/>
|
android:resource="@xml/appwidget_info_4x4"/>
|
||||||
</receiver>
|
</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
|
<provider
|
||||||
android:name=".provider.SearchSuggestionProvider"
|
android:name=".provider.SearchSuggestionProvider"
|
||||||
android:authorities="org.moire.ultrasonic.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>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|
|
@ -1,221 +0,0 @@
|
||||||
package org.moire.ultrasonic.provider;
|
|
||||||
|
|
||||||
import android.app.PendingIntent;
|
|
||||||
import android.appwidget.AppWidgetManager;
|
|
||||||
import android.appwidget.AppWidgetProvider;
|
|
||||||
import android.content.ComponentName;
|
|
||||||
import android.content.Context;
|
|
||||||
import android.content.Intent;
|
|
||||||
import android.content.res.Resources;
|
|
||||||
import android.graphics.Bitmap;
|
|
||||||
import android.os.Environment;
|
|
||||||
import android.view.KeyEvent;
|
|
||||||
import android.widget.RemoteViews;
|
|
||||||
|
|
||||||
import org.moire.ultrasonic.R;
|
|
||||||
import org.moire.ultrasonic.activity.NavigationActivity;
|
|
||||||
import org.moire.ultrasonic.domain.Track;
|
|
||||||
import org.moire.ultrasonic.imageloader.BitmapUtils;
|
|
||||||
import org.moire.ultrasonic.receiver.MediaButtonIntentReceiver;
|
|
||||||
import org.moire.ultrasonic.service.MediaPlayerController;
|
|
||||||
import org.moire.ultrasonic.util.Constants;
|
|
||||||
|
|
||||||
import timber.log.Timber;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Widget Provider for the Ultrasonic Widgets
|
|
||||||
*/
|
|
||||||
public class UltrasonicAppWidgetProvider extends AppWidgetProvider
|
|
||||||
{
|
|
||||||
protected int layoutId;
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onUpdate(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds)
|
|
||||||
{
|
|
||||||
defaultAppWidget(context, appWidgetIds);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize given widgets to default state, where we launch Ultrasonic on default click
|
|
||||||
* and hide actions if service not running.
|
|
||||||
*/
|
|
||||||
private void defaultAppWidget(Context context, int[] appWidgetIds)
|
|
||||||
{
|
|
||||||
final Resources res = context.getResources();
|
|
||||||
final RemoteViews views = new RemoteViews(context.getPackageName(), this.layoutId);
|
|
||||||
|
|
||||||
views.setTextViewText(R.id.title, null);
|
|
||||||
views.setTextViewText(R.id.album, null);
|
|
||||||
views.setTextViewText(R.id.artist, res.getText(R.string.widget_initial_text));
|
|
||||||
|
|
||||||
linkButtons(context, views, false);
|
|
||||||
pushUpdate(context, appWidgetIds, views);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void pushUpdate(Context context, int[] appWidgetIds, RemoteViews views)
|
|
||||||
{
|
|
||||||
// Update specific list of appWidgetIds if given, otherwise default to all
|
|
||||||
final AppWidgetManager manager = AppWidgetManager.getInstance(context);
|
|
||||||
|
|
||||||
if (manager != null)
|
|
||||||
{
|
|
||||||
if (appWidgetIds != null)
|
|
||||||
{
|
|
||||||
manager.updateAppWidget(appWidgetIds, views);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
manager.updateAppWidget(new ComponentName(context, this.getClass()), views);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle a change notification coming over from {@link MediaPlayerController}
|
|
||||||
*/
|
|
||||||
public void notifyChange(Context context, Track currentSong, boolean playing, boolean setAlbum)
|
|
||||||
{
|
|
||||||
if (hasInstances(context))
|
|
||||||
{
|
|
||||||
performUpdate(context, currentSong, playing, setAlbum);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check against {@link AppWidgetManager} if there are any instances of this widget.
|
|
||||||
*/
|
|
||||||
private boolean hasInstances(Context context)
|
|
||||||
{
|
|
||||||
AppWidgetManager manager = AppWidgetManager.getInstance(context);
|
|
||||||
|
|
||||||
if (manager != null)
|
|
||||||
{
|
|
||||||
int[] appWidgetIds = manager.getAppWidgetIds(new ComponentName(context, getClass()));
|
|
||||||
return (appWidgetIds.length > 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update all active widget instances by pushing changes
|
|
||||||
*/
|
|
||||||
private void performUpdate(Context context, Track currentSong, boolean playing, boolean setAlbum)
|
|
||||||
{
|
|
||||||
final Resources res = context.getResources();
|
|
||||||
final RemoteViews views = new RemoteViews(context.getPackageName(), this.layoutId);
|
|
||||||
|
|
||||||
String title = currentSong == null ? null : currentSong.getTitle();
|
|
||||||
String artist = currentSong == null ? null : currentSong.getArtist();
|
|
||||||
String album = currentSong == null ? null : currentSong.getAlbum();
|
|
||||||
CharSequence errorState = null;
|
|
||||||
|
|
||||||
// Show error message?
|
|
||||||
String status = Environment.getExternalStorageState();
|
|
||||||
if (status.equals(Environment.MEDIA_SHARED) || status.equals(Environment.MEDIA_UNMOUNTED))
|
|
||||||
{
|
|
||||||
errorState = res.getText(R.string.widget_sdcard_busy);
|
|
||||||
}
|
|
||||||
else if (status.equals(Environment.MEDIA_REMOVED))
|
|
||||||
{
|
|
||||||
errorState = res.getText(R.string.widget_sdcard_missing);
|
|
||||||
}
|
|
||||||
else if (currentSong == null)
|
|
||||||
{
|
|
||||||
errorState = res.getText(R.string.widget_initial_text);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (errorState != null)
|
|
||||||
{
|
|
||||||
// Show error state to user
|
|
||||||
views.setTextViewText(R.id.title, null);
|
|
||||||
views.setTextViewText(R.id.artist, errorState);
|
|
||||||
if (setAlbum)
|
|
||||||
{
|
|
||||||
views.setTextViewText(R.id.album, null);
|
|
||||||
}
|
|
||||||
views.setImageViewResource(R.id.appwidget_coverart, R.drawable.unknown_album);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// No error, so show normal titles
|
|
||||||
views.setTextViewText(R.id.title, title);
|
|
||||||
views.setTextViewText(R.id.artist, artist);
|
|
||||||
if (setAlbum)
|
|
||||||
{
|
|
||||||
views.setTextViewText(R.id.album, album);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set correct drawable for pause state
|
|
||||||
if (playing)
|
|
||||||
{
|
|
||||||
views.setImageViewResource(R.id.control_play, R.drawable.media_pause_normal_dark);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
views.setImageViewResource(R.id.control_play, R.drawable.media_start_normal_dark);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set the cover art
|
|
||||||
try
|
|
||||||
{
|
|
||||||
Bitmap bitmap = currentSong == null ? null : BitmapUtils.Companion.getAlbumArtBitmapFromDisk(currentSong, 240);
|
|
||||||
|
|
||||||
if (bitmap == null)
|
|
||||||
{
|
|
||||||
// Set default cover art
|
|
||||||
views.setImageViewResource(R.id.appwidget_coverart, R.drawable.unknown_album);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
views.setImageViewBitmap(R.id.appwidget_coverart, bitmap);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception x)
|
|
||||||
{
|
|
||||||
Timber.e(x, "Failed to load cover art");
|
|
||||||
views.setImageViewResource(R.id.appwidget_coverart, R.drawable.unknown_album);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Link actions buttons to intents
|
|
||||||
linkButtons(context, views, currentSong != null);
|
|
||||||
|
|
||||||
pushUpdate(context, null, views);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Link up various button actions using {@link PendingIntent}.
|
|
||||||
*/
|
|
||||||
private static void linkButtons(Context context, RemoteViews views, boolean playerActive)
|
|
||||||
{
|
|
||||||
Intent intent = new Intent(context, NavigationActivity.class).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP);
|
|
||||||
if (playerActive)
|
|
||||||
intent.putExtra(Constants.INTENT_SHOW_PLAYER, true);
|
|
||||||
|
|
||||||
intent.setAction("android.intent.action.MAIN");
|
|
||||||
intent.addCategory("android.intent.category.LAUNCHER");
|
|
||||||
PendingIntent pendingIntent = PendingIntent.getActivity(context, 10, intent, PendingIntent.FLAG_UPDATE_CURRENT);
|
|
||||||
views.setOnClickPendingIntent(R.id.appwidget_coverart, pendingIntent);
|
|
||||||
views.setOnClickPendingIntent(R.id.appwidget_top, pendingIntent);
|
|
||||||
|
|
||||||
// Emulate media button clicks.
|
|
||||||
intent = new Intent(Constants.CMD_PROCESS_KEYCODE);
|
|
||||||
intent.setComponent(new ComponentName(context, MediaButtonIntentReceiver.class));
|
|
||||||
intent.putExtra(Intent.EXTRA_KEY_EVENT, new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE));
|
|
||||||
pendingIntent = PendingIntent.getBroadcast(context, 11, intent, 0);
|
|
||||||
views.setOnClickPendingIntent(R.id.control_play, pendingIntent);
|
|
||||||
|
|
||||||
intent = new Intent(Constants.CMD_PROCESS_KEYCODE);
|
|
||||||
intent.setComponent(new ComponentName(context, MediaButtonIntentReceiver.class));
|
|
||||||
intent.putExtra(Intent.EXTRA_KEY_EVENT, new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_NEXT));
|
|
||||||
pendingIntent = PendingIntent.getBroadcast(context, 12, intent, 0);
|
|
||||||
views.setOnClickPendingIntent(R.id.control_next, pendingIntent);
|
|
||||||
|
|
||||||
intent = new Intent(Constants.CMD_PROCESS_KEYCODE);
|
|
||||||
intent.setComponent(new ComponentName(context, MediaButtonIntentReceiver.class));
|
|
||||||
intent.putExtra(Intent.EXTRA_KEY_EVENT, new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_PREVIOUS));
|
|
||||||
pendingIntent = PendingIntent.getBroadcast(context, 13, intent, 0);
|
|
||||||
views.setOnClickPendingIntent(R.id.control_previous, pendingIntent);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,42 +0,0 @@
|
||||||
/*
|
|
||||||
This file is part of Subsonic.
|
|
||||||
|
|
||||||
Subsonic is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU General Public License as published by
|
|
||||||
the Free Software Foundation, either version 3 of the License, or
|
|
||||||
(at your option) any later version.
|
|
||||||
|
|
||||||
Subsonic is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU General Public License
|
|
||||||
along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
Copyright 2010 (C) Sindre Mehus
|
|
||||||
*/
|
|
||||||
package org.moire.ultrasonic.provider;
|
|
||||||
|
|
||||||
import org.moire.ultrasonic.R;
|
|
||||||
|
|
||||||
public class UltrasonicAppWidgetProvider4X1 extends UltrasonicAppWidgetProvider
|
|
||||||
{
|
|
||||||
|
|
||||||
public UltrasonicAppWidgetProvider4X1()
|
|
||||||
{
|
|
||||||
super();
|
|
||||||
this.layoutId = R.layout.appwidget4x1;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static UltrasonicAppWidgetProvider4X1 instance;
|
|
||||||
|
|
||||||
public static synchronized UltrasonicAppWidgetProvider4X1 getInstance()
|
|
||||||
{
|
|
||||||
if (instance == null)
|
|
||||||
{
|
|
||||||
instance = new UltrasonicAppWidgetProvider4X1();
|
|
||||||
}
|
|
||||||
return instance;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,42 +0,0 @@
|
||||||
/*
|
|
||||||
This file is part of Subsonic.
|
|
||||||
|
|
||||||
Subsonic is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU General Public License as published by
|
|
||||||
the Free Software Foundation, either version 3 of the License, or
|
|
||||||
(at your option) any later version.
|
|
||||||
|
|
||||||
Subsonic is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU General Public License
|
|
||||||
along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
Copyright 2010 (C) Sindre Mehus
|
|
||||||
*/
|
|
||||||
package org.moire.ultrasonic.provider;
|
|
||||||
|
|
||||||
import org.moire.ultrasonic.R;
|
|
||||||
|
|
||||||
public class UltrasonicAppWidgetProvider4X2 extends UltrasonicAppWidgetProvider
|
|
||||||
{
|
|
||||||
|
|
||||||
public UltrasonicAppWidgetProvider4X2()
|
|
||||||
{
|
|
||||||
super();
|
|
||||||
this.layoutId = R.layout.appwidget4x2;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static UltrasonicAppWidgetProvider4X2 instance;
|
|
||||||
|
|
||||||
public static synchronized UltrasonicAppWidgetProvider4X2 getInstance()
|
|
||||||
{
|
|
||||||
if (instance == null)
|
|
||||||
{
|
|
||||||
instance = new UltrasonicAppWidgetProvider4X2();
|
|
||||||
}
|
|
||||||
return instance;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,42 +0,0 @@
|
||||||
/*
|
|
||||||
This file is part of Subsonic.
|
|
||||||
|
|
||||||
Subsonic is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU General Public License as published by
|
|
||||||
the Free Software Foundation, either version 3 of the License, or
|
|
||||||
(at your option) any later version.
|
|
||||||
|
|
||||||
Subsonic is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU General Public License
|
|
||||||
along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
Copyright 2010 (C) Sindre Mehus
|
|
||||||
*/
|
|
||||||
package org.moire.ultrasonic.provider;
|
|
||||||
|
|
||||||
import org.moire.ultrasonic.R;
|
|
||||||
|
|
||||||
public class UltrasonicAppWidgetProvider4X3 extends UltrasonicAppWidgetProvider
|
|
||||||
{
|
|
||||||
|
|
||||||
public UltrasonicAppWidgetProvider4X3()
|
|
||||||
{
|
|
||||||
super();
|
|
||||||
this.layoutId = R.layout.appwidget4x3;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static UltrasonicAppWidgetProvider4X3 instance;
|
|
||||||
|
|
||||||
public static synchronized UltrasonicAppWidgetProvider4X3 getInstance()
|
|
||||||
{
|
|
||||||
if (instance == null)
|
|
||||||
{
|
|
||||||
instance = new UltrasonicAppWidgetProvider4X3();
|
|
||||||
}
|
|
||||||
return instance;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,42 +0,0 @@
|
||||||
/*
|
|
||||||
This file is part of Subsonic.
|
|
||||||
|
|
||||||
Subsonic is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU General Public License as published by
|
|
||||||
the Free Software Foundation, either version 3 of the License, or
|
|
||||||
(at your option) any later version.
|
|
||||||
|
|
||||||
Subsonic is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU General Public License
|
|
||||||
along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
Copyright 2010 (C) Sindre Mehus
|
|
||||||
*/
|
|
||||||
package org.moire.ultrasonic.provider;
|
|
||||||
|
|
||||||
import org.moire.ultrasonic.R;
|
|
||||||
|
|
||||||
public class UltrasonicAppWidgetProvider4X4 extends UltrasonicAppWidgetProvider
|
|
||||||
{
|
|
||||||
|
|
||||||
public UltrasonicAppWidgetProvider4X4()
|
|
||||||
{
|
|
||||||
super();
|
|
||||||
this.layoutId = R.layout.appwidget4x4;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static UltrasonicAppWidgetProvider4X4 instance;
|
|
||||||
|
|
||||||
public static synchronized UltrasonicAppWidgetProvider4X4 getInstance()
|
|
||||||
{
|
|
||||||
if (instance == null)
|
|
||||||
{
|
|
||||||
instance = new UltrasonicAppWidgetProvider4X4();
|
|
||||||
}
|
|
||||||
return instance;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,57 +0,0 @@
|
||||||
package org.moire.ultrasonic.receiver;
|
|
||||||
|
|
||||||
import static org.koin.java.KoinJavaComponent.inject;
|
|
||||||
|
|
||||||
import android.content.BroadcastReceiver;
|
|
||||||
import android.content.Context;
|
|
||||||
import android.content.Intent;
|
|
||||||
|
|
||||||
import org.moire.ultrasonic.domain.Track;
|
|
||||||
import org.moire.ultrasonic.service.MediaPlayerController;
|
|
||||||
|
|
||||||
import kotlin.Lazy;
|
|
||||||
|
|
||||||
public class A2dpIntentReceiver extends BroadcastReceiver
|
|
||||||
{
|
|
||||||
private static final String PLAYSTATUS_RESPONSE = "com.android.music.playstatusresponse";
|
|
||||||
private Lazy<MediaPlayerController> mediaPlayerControllerLazy = inject(MediaPlayerController.class);
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onReceive(Context context, Intent intent)
|
|
||||||
{
|
|
||||||
if (mediaPlayerControllerLazy.getValue().getCurrentPlaying() == null) return;
|
|
||||||
|
|
||||||
Track song = mediaPlayerControllerLazy.getValue().getCurrentPlaying().getTrack();
|
|
||||||
if (song == null) return;
|
|
||||||
|
|
||||||
Intent avrcpIntent = new Intent(PLAYSTATUS_RESPONSE);
|
|
||||||
|
|
||||||
Integer duration = song.getDuration();
|
|
||||||
int playerPosition = mediaPlayerControllerLazy.getValue().getPlayerPosition();
|
|
||||||
int listSize = mediaPlayerControllerLazy.getValue().getPlaylistSize();
|
|
||||||
|
|
||||||
if (duration != null)
|
|
||||||
{
|
|
||||||
avrcpIntent.putExtra("duration", (long) duration);
|
|
||||||
}
|
|
||||||
|
|
||||||
avrcpIntent.putExtra("position", (long) playerPosition);
|
|
||||||
avrcpIntent.putExtra("ListSize", (long) listSize);
|
|
||||||
|
|
||||||
switch (mediaPlayerControllerLazy.getValue().getPlayerState())
|
|
||||||
{
|
|
||||||
case STARTED:
|
|
||||||
avrcpIntent.putExtra("playing", true);
|
|
||||||
break;
|
|
||||||
case STOPPED:
|
|
||||||
case PAUSED:
|
|
||||||
case COMPLETED:
|
|
||||||
avrcpIntent.putExtra("playing", false);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
context.sendBroadcast(avrcpIntent);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,83 +0,0 @@
|
||||||
/*
|
|
||||||
This file is part of Subsonic.
|
|
||||||
|
|
||||||
Subsonic is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU General Public License as published by
|
|
||||||
the Free Software Foundation, either version 3 of the License, or
|
|
||||||
(at your option) any later version.
|
|
||||||
|
|
||||||
Subsonic is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU General Public License
|
|
||||||
along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
Copyright 2010 (C) Sindre Mehus
|
|
||||||
*/
|
|
||||||
package org.moire.ultrasonic.receiver;
|
|
||||||
|
|
||||||
import android.content.BroadcastReceiver;
|
|
||||||
import android.content.Context;
|
|
||||||
import android.content.Intent;
|
|
||||||
import android.os.Bundle;
|
|
||||||
import android.os.Parcelable;
|
|
||||||
import timber.log.Timber;
|
|
||||||
|
|
||||||
import org.moire.ultrasonic.service.MediaPlayerLifecycleSupport;
|
|
||||||
import org.moire.ultrasonic.util.Constants;
|
|
||||||
import org.moire.ultrasonic.util.Settings;
|
|
||||||
import org.moire.ultrasonic.util.Util;
|
|
||||||
|
|
||||||
import kotlin.Lazy;
|
|
||||||
|
|
||||||
import static org.koin.java.KoinJavaComponent.inject;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @author Sindre Mehus
|
|
||||||
*/
|
|
||||||
public class MediaButtonIntentReceiver extends BroadcastReceiver
|
|
||||||
{
|
|
||||||
private Lazy<MediaPlayerLifecycleSupport> lifecycleSupport = inject(MediaPlayerLifecycleSupport.class);
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onReceive(Context context, Intent intent)
|
|
||||||
{
|
|
||||||
String intentAction = intent.getAction();
|
|
||||||
|
|
||||||
// If media button are turned off and we received a media button, exit
|
|
||||||
if (!Settings.getMediaButtonsEnabled() && Intent.ACTION_MEDIA_BUTTON.equals(intentAction))
|
|
||||||
return;
|
|
||||||
|
|
||||||
// Only process media buttons and CMD_PROCESS_KEYCODE, which is received from the widgets
|
|
||||||
if (!Intent.ACTION_MEDIA_BUTTON.equals(intentAction) &&
|
|
||||||
!Constants.CMD_PROCESS_KEYCODE.equals(intentAction)) return;
|
|
||||||
|
|
||||||
Bundle extras = intent.getExtras();
|
|
||||||
|
|
||||||
if (extras == null)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Parcelable event = (Parcelable) extras.get(Intent.EXTRA_KEY_EVENT);
|
|
||||||
Timber.i("Got MEDIA_BUTTON key event: %s", event);
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
Intent serviceIntent = new Intent(Constants.CMD_PROCESS_KEYCODE);
|
|
||||||
serviceIntent.putExtra(Intent.EXTRA_KEY_EVENT, event);
|
|
||||||
lifecycleSupport.getValue().receiveIntent(serviceIntent);
|
|
||||||
|
|
||||||
if (isOrderedBroadcast())
|
|
||||||
{
|
|
||||||
abortBroadcast();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception x)
|
|
||||||
{
|
|
||||||
// Ignored.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,489 +0,0 @@
|
||||||
/*
|
|
||||||
This file is part of Subsonic.
|
|
||||||
|
|
||||||
Subsonic is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU General Public License as published by
|
|
||||||
the Free Software Foundation, either version 3 of the License, or
|
|
||||||
(at your option) any later version.
|
|
||||||
|
|
||||||
Subsonic is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU General Public License
|
|
||||||
along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
Copyright 2009 (C) Sindre Mehus
|
|
||||||
*/
|
|
||||||
package org.moire.ultrasonic.service;
|
|
||||||
|
|
||||||
import android.content.Context;
|
|
||||||
import android.os.Handler;
|
|
||||||
import timber.log.Timber;
|
|
||||||
import android.view.Gravity;
|
|
||||||
import android.view.LayoutInflater;
|
|
||||||
import android.view.View;
|
|
||||||
import android.widget.ProgressBar;
|
|
||||||
import android.widget.Toast;
|
|
||||||
|
|
||||||
import org.jetbrains.annotations.NotNull;
|
|
||||||
import org.moire.ultrasonic.R;
|
|
||||||
import org.moire.ultrasonic.api.subsonic.ApiNotSupportedException;
|
|
||||||
import org.moire.ultrasonic.api.subsonic.SubsonicRESTException;
|
|
||||||
import org.moire.ultrasonic.app.UApp;
|
|
||||||
import org.moire.ultrasonic.data.ActiveServerProvider;
|
|
||||||
import org.moire.ultrasonic.domain.JukeboxStatus;
|
|
||||||
import org.moire.ultrasonic.domain.PlayerState;
|
|
||||||
import org.moire.ultrasonic.util.Util;
|
|
||||||
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.Iterator;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.concurrent.Executors;
|
|
||||||
import java.util.concurrent.LinkedBlockingQueue;
|
|
||||||
import java.util.concurrent.ScheduledExecutorService;
|
|
||||||
import java.util.concurrent.ScheduledFuture;
|
|
||||||
import java.util.concurrent.TimeUnit;
|
|
||||||
import java.util.concurrent.atomic.AtomicBoolean;
|
|
||||||
import java.util.concurrent.atomic.AtomicLong;
|
|
||||||
|
|
||||||
import kotlin.Lazy;
|
|
||||||
|
|
||||||
import static org.koin.java.KoinJavaComponent.inject;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Provides an asynchronous interface to the remote jukebox on the Subsonic server.
|
|
||||||
*
|
|
||||||
* @author Sindre Mehus
|
|
||||||
* @version $Id$
|
|
||||||
*/
|
|
||||||
public class JukeboxMediaPlayer
|
|
||||||
{
|
|
||||||
private static final long STATUS_UPDATE_INTERVAL_SECONDS = 5L;
|
|
||||||
|
|
||||||
private final TaskQueue tasks = new TaskQueue();
|
|
||||||
private final ScheduledExecutorService executorService = Executors.newSingleThreadScheduledExecutor();
|
|
||||||
private ScheduledFuture<?> statusUpdateFuture;
|
|
||||||
private final AtomicLong timeOfLastUpdate = new AtomicLong();
|
|
||||||
private JukeboxStatus jukeboxStatus;
|
|
||||||
private float gain = 0.5f;
|
|
||||||
private VolumeToast volumeToast;
|
|
||||||
private final AtomicBoolean running = new AtomicBoolean();
|
|
||||||
private Thread serviceThread;
|
|
||||||
private boolean enabled = false;
|
|
||||||
|
|
||||||
// TODO: These create circular references, try to refactor
|
|
||||||
private final Lazy<MediaPlayerController> mediaPlayerControllerLazy = inject(MediaPlayerController.class);
|
|
||||||
private final Downloader downloader;
|
|
||||||
|
|
||||||
// TODO: Report warning if queue fills up.
|
|
||||||
// TODO: Create shutdown method?
|
|
||||||
// TODO: Disable repeat.
|
|
||||||
// TODO: Persist RC state?
|
|
||||||
// TODO: Minimize status updates.
|
|
||||||
|
|
||||||
public JukeboxMediaPlayer(Downloader downloader)
|
|
||||||
{
|
|
||||||
this.downloader = downloader;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void startJukeboxService()
|
|
||||||
{
|
|
||||||
if (running.get())
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
running.set(true);
|
|
||||||
startProcessTasks();
|
|
||||||
Timber.d("Started Jukebox Service");
|
|
||||||
}
|
|
||||||
|
|
||||||
public void stopJukeboxService()
|
|
||||||
{
|
|
||||||
running.set(false);
|
|
||||||
Util.sleepQuietly(1000);
|
|
||||||
|
|
||||||
if (serviceThread != null)
|
|
||||||
{
|
|
||||||
serviceThread.interrupt();
|
|
||||||
}
|
|
||||||
Timber.d("Stopped Jukebox Service");
|
|
||||||
}
|
|
||||||
|
|
||||||
private void startProcessTasks()
|
|
||||||
{
|
|
||||||
serviceThread = new Thread()
|
|
||||||
{
|
|
||||||
@Override
|
|
||||||
public void run()
|
|
||||||
{
|
|
||||||
processTasks();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
serviceThread.start();
|
|
||||||
}
|
|
||||||
|
|
||||||
private synchronized void startStatusUpdate()
|
|
||||||
{
|
|
||||||
stopStatusUpdate();
|
|
||||||
|
|
||||||
Runnable updateTask = new Runnable()
|
|
||||||
{
|
|
||||||
@Override
|
|
||||||
public void run()
|
|
||||||
{
|
|
||||||
tasks.remove(GetStatus.class);
|
|
||||||
tasks.add(new GetStatus());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
statusUpdateFuture = executorService.scheduleWithFixedDelay(updateTask, STATUS_UPDATE_INTERVAL_SECONDS, STATUS_UPDATE_INTERVAL_SECONDS, TimeUnit.SECONDS);
|
|
||||||
}
|
|
||||||
|
|
||||||
private synchronized void stopStatusUpdate()
|
|
||||||
{
|
|
||||||
if (statusUpdateFuture != null)
|
|
||||||
{
|
|
||||||
statusUpdateFuture.cancel(false);
|
|
||||||
statusUpdateFuture = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void processTasks()
|
|
||||||
{
|
|
||||||
while (running.get())
|
|
||||||
{
|
|
||||||
JukeboxTask task = null;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (!ActiveServerProvider.Companion.isOffline())
|
|
||||||
{
|
|
||||||
task = tasks.take();
|
|
||||||
JukeboxStatus status = task.execute();
|
|
||||||
onStatusUpdate(status);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (InterruptedException ignored)
|
|
||||||
{
|
|
||||||
|
|
||||||
}
|
|
||||||
catch (Throwable x)
|
|
||||||
{
|
|
||||||
onError(task, x);
|
|
||||||
}
|
|
||||||
|
|
||||||
Util.sleepQuietly(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void onStatusUpdate(JukeboxStatus jukeboxStatus)
|
|
||||||
{
|
|
||||||
timeOfLastUpdate.set(System.currentTimeMillis());
|
|
||||||
this.jukeboxStatus = jukeboxStatus;
|
|
||||||
|
|
||||||
// Track change?
|
|
||||||
Integer index = jukeboxStatus.getCurrentPlayingIndex();
|
|
||||||
|
|
||||||
if (index != null && index != -1 && index != downloader.getCurrentPlayingIndex())
|
|
||||||
{
|
|
||||||
mediaPlayerControllerLazy.getValue().setCurrentPlaying(index);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void onError(JukeboxTask task, Throwable x)
|
|
||||||
{
|
|
||||||
if (x instanceof ApiNotSupportedException && !(task instanceof Stop))
|
|
||||||
{
|
|
||||||
disableJukeboxOnError(x, R.string.download_jukebox_server_too_old);
|
|
||||||
}
|
|
||||||
else if (x instanceof OfflineException && !(task instanceof Stop))
|
|
||||||
{
|
|
||||||
disableJukeboxOnError(x, R.string.download_jukebox_offline);
|
|
||||||
}
|
|
||||||
else if (x instanceof SubsonicRESTException && ((SubsonicRESTException) x).getCode() == 50 && !(task instanceof Stop))
|
|
||||||
{
|
|
||||||
disableJukeboxOnError(x, R.string.download_jukebox_not_authorized);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Timber.e(x, "Failed to process jukebox task");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void disableJukeboxOnError(Throwable x, final int resourceId)
|
|
||||||
{
|
|
||||||
Timber.w(x.toString());
|
|
||||||
Context context = UApp.Companion.applicationContext();
|
|
||||||
new Handler().post(() -> Util.toast(context, resourceId, false));
|
|
||||||
|
|
||||||
mediaPlayerControllerLazy.getValue().setJukeboxEnabled(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void updatePlaylist()
|
|
||||||
{
|
|
||||||
if (!enabled) return;
|
|
||||||
|
|
||||||
tasks.remove(Skip.class);
|
|
||||||
tasks.remove(Stop.class);
|
|
||||||
tasks.remove(Start.class);
|
|
||||||
|
|
||||||
List<String> ids = new ArrayList<>();
|
|
||||||
for (DownloadFile file : downloader.getAll())
|
|
||||||
{
|
|
||||||
ids.add(file.getTrack().getId());
|
|
||||||
}
|
|
||||||
|
|
||||||
tasks.add(new SetPlaylist(ids));
|
|
||||||
}
|
|
||||||
|
|
||||||
public void skip(final int index, final int offsetSeconds)
|
|
||||||
{
|
|
||||||
tasks.remove(Skip.class);
|
|
||||||
tasks.remove(Stop.class);
|
|
||||||
tasks.remove(Start.class);
|
|
||||||
|
|
||||||
startStatusUpdate();
|
|
||||||
|
|
||||||
if (jukeboxStatus != null)
|
|
||||||
{
|
|
||||||
jukeboxStatus.setPositionSeconds(offsetSeconds);
|
|
||||||
}
|
|
||||||
|
|
||||||
tasks.add(new Skip(index, offsetSeconds));
|
|
||||||
mediaPlayerControllerLazy.getValue().setPlayerState(PlayerState.STARTED);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void stop()
|
|
||||||
{
|
|
||||||
tasks.remove(Stop.class);
|
|
||||||
tasks.remove(Start.class);
|
|
||||||
|
|
||||||
stopStatusUpdate();
|
|
||||||
|
|
||||||
tasks.add(new Stop());
|
|
||||||
}
|
|
||||||
|
|
||||||
public void start()
|
|
||||||
{
|
|
||||||
tasks.remove(Stop.class);
|
|
||||||
tasks.remove(Start.class);
|
|
||||||
|
|
||||||
startStatusUpdate();
|
|
||||||
tasks.add(new Start());
|
|
||||||
}
|
|
||||||
|
|
||||||
public synchronized void adjustVolume(boolean up)
|
|
||||||
{
|
|
||||||
float delta = up ? 0.05f : -0.05f;
|
|
||||||
gain += delta;
|
|
||||||
gain = Math.max(gain, 0.0f);
|
|
||||||
gain = Math.min(gain, 1.0f);
|
|
||||||
|
|
||||||
tasks.remove(SetGain.class);
|
|
||||||
tasks.add(new SetGain(gain));
|
|
||||||
|
|
||||||
Context context = UApp.Companion.applicationContext();
|
|
||||||
if (volumeToast == null) volumeToast = new VolumeToast(context);
|
|
||||||
|
|
||||||
volumeToast.setVolume(gain);
|
|
||||||
}
|
|
||||||
|
|
||||||
private MusicService getMusicService()
|
|
||||||
{
|
|
||||||
return MusicServiceFactory.getMusicService();
|
|
||||||
}
|
|
||||||
|
|
||||||
public int getPositionSeconds()
|
|
||||||
{
|
|
||||||
if (jukeboxStatus == null || jukeboxStatus.getPositionSeconds() == null || timeOfLastUpdate.get() == 0)
|
|
||||||
{
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (jukeboxStatus.isPlaying())
|
|
||||||
{
|
|
||||||
int secondsSinceLastUpdate = (int) ((System.currentTimeMillis() - timeOfLastUpdate.get()) / 1000L);
|
|
||||||
return jukeboxStatus.getPositionSeconds() + secondsSinceLastUpdate;
|
|
||||||
}
|
|
||||||
|
|
||||||
return jukeboxStatus.getPositionSeconds();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setEnabled(boolean enabled)
|
|
||||||
{
|
|
||||||
Timber.d("Jukebox Service setting enabled to %b", enabled);
|
|
||||||
this.enabled = enabled;
|
|
||||||
|
|
||||||
tasks.clear();
|
|
||||||
if (enabled)
|
|
||||||
{
|
|
||||||
updatePlaylist();
|
|
||||||
}
|
|
||||||
|
|
||||||
stop();
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean isEnabled()
|
|
||||||
{
|
|
||||||
return enabled;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static class TaskQueue
|
|
||||||
{
|
|
||||||
private final LinkedBlockingQueue<JukeboxTask> queue = new LinkedBlockingQueue<>();
|
|
||||||
|
|
||||||
void add(JukeboxTask jukeboxTask)
|
|
||||||
{
|
|
||||||
queue.add(jukeboxTask);
|
|
||||||
}
|
|
||||||
|
|
||||||
JukeboxTask take() throws InterruptedException
|
|
||||||
{
|
|
||||||
return queue.take();
|
|
||||||
}
|
|
||||||
|
|
||||||
void remove(Class<? extends JukeboxTask> taskClass)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
Iterator<JukeboxTask> iterator = queue.iterator();
|
|
||||||
|
|
||||||
while (iterator.hasNext())
|
|
||||||
{
|
|
||||||
JukeboxTask task = iterator.next();
|
|
||||||
|
|
||||||
if (taskClass.equals(task.getClass()))
|
|
||||||
{
|
|
||||||
iterator.remove();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Throwable x)
|
|
||||||
{
|
|
||||||
Timber.w(x, "Failed to clean-up task queue.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void clear()
|
|
||||||
{
|
|
||||||
queue.clear();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private abstract static class JukeboxTask
|
|
||||||
{
|
|
||||||
abstract JukeboxStatus execute() throws Exception;
|
|
||||||
|
|
||||||
@NotNull
|
|
||||||
@Override
|
|
||||||
public String toString()
|
|
||||||
{
|
|
||||||
return getClass().getSimpleName();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private class GetStatus extends JukeboxTask
|
|
||||||
{
|
|
||||||
@Override
|
|
||||||
JukeboxStatus execute() throws Exception
|
|
||||||
{
|
|
||||||
return getMusicService().getJukeboxStatus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private class SetPlaylist extends JukeboxTask
|
|
||||||
{
|
|
||||||
private final List<String> ids;
|
|
||||||
|
|
||||||
SetPlaylist(List<String> ids)
|
|
||||||
{
|
|
||||||
this.ids = ids;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
JukeboxStatus execute() throws Exception
|
|
||||||
{
|
|
||||||
return getMusicService().updateJukeboxPlaylist(ids);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private class Skip extends JukeboxTask
|
|
||||||
{
|
|
||||||
private final int index;
|
|
||||||
private final int offsetSeconds;
|
|
||||||
|
|
||||||
Skip(int index, int offsetSeconds)
|
|
||||||
{
|
|
||||||
this.index = index;
|
|
||||||
this.offsetSeconds = offsetSeconds;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
JukeboxStatus execute() throws Exception
|
|
||||||
{
|
|
||||||
return getMusicService().skipJukebox(index, offsetSeconds);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private class Stop extends JukeboxTask
|
|
||||||
{
|
|
||||||
@Override
|
|
||||||
JukeboxStatus execute() throws Exception
|
|
||||||
{
|
|
||||||
return getMusicService().stopJukebox();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private class Start extends JukeboxTask
|
|
||||||
{
|
|
||||||
@Override
|
|
||||||
JukeboxStatus execute() throws Exception
|
|
||||||
{
|
|
||||||
return getMusicService().startJukebox();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private class SetGain extends JukeboxTask
|
|
||||||
{
|
|
||||||
|
|
||||||
private final float gain;
|
|
||||||
|
|
||||||
private SetGain(float gain)
|
|
||||||
{
|
|
||||||
this.gain = gain;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
JukeboxStatus execute() throws Exception
|
|
||||||
{
|
|
||||||
return getMusicService().setJukeboxGain(gain);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static class VolumeToast extends Toast
|
|
||||||
{
|
|
||||||
|
|
||||||
private final ProgressBar progressBar;
|
|
||||||
|
|
||||||
public VolumeToast(Context context)
|
|
||||||
{
|
|
||||||
super(context);
|
|
||||||
setDuration(Toast.LENGTH_SHORT);
|
|
||||||
LayoutInflater inflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
|
|
||||||
View view = inflater.inflate(R.layout.jukebox_volume, null);
|
|
||||||
progressBar = (ProgressBar) view.findViewById(R.id.jukebox_volume_progress_bar);
|
|
||||||
setView(view);
|
|
||||||
setGravity(Gravity.TOP, 0, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setVolume(float volume)
|
|
||||||
{
|
|
||||||
progressBar.setProgress(Math.round(100 * volume));
|
|
||||||
show();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,125 +0,0 @@
|
||||||
/*
|
|
||||||
This file is part of Subsonic.
|
|
||||||
|
|
||||||
Subsonic is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU General Public License as published by
|
|
||||||
the Free Software Foundation, either version 3 of the License, or
|
|
||||||
(at your option) any later version.
|
|
||||||
|
|
||||||
Subsonic is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU General Public License
|
|
||||||
along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
Copyright 2009 (C) Sindre Mehus
|
|
||||||
*/
|
|
||||||
package org.moire.ultrasonic.util;
|
|
||||||
|
|
||||||
import org.moire.ultrasonic.data.ActiveServerProvider;
|
|
||||||
import org.moire.ultrasonic.domain.MusicDirectory;
|
|
||||||
import org.moire.ultrasonic.domain.Track;
|
|
||||||
import org.moire.ultrasonic.service.MusicService;
|
|
||||||
import org.moire.ultrasonic.service.MusicServiceFactory;
|
|
||||||
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.concurrent.Executors;
|
|
||||||
import java.util.concurrent.ScheduledExecutorService;
|
|
||||||
import java.util.concurrent.TimeUnit;
|
|
||||||
|
|
||||||
import timber.log.Timber;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @author Sindre Mehus
|
|
||||||
* @version $Id$
|
|
||||||
*/
|
|
||||||
public class ShufflePlayBuffer
|
|
||||||
{
|
|
||||||
private static final int CAPACITY = 50;
|
|
||||||
private static final int REFILL_THRESHOLD = 40;
|
|
||||||
|
|
||||||
private final List<Track> buffer = new ArrayList<>();
|
|
||||||
private ScheduledExecutorService executorService;
|
|
||||||
private int currentServer;
|
|
||||||
|
|
||||||
public boolean isEnabled = false;
|
|
||||||
|
|
||||||
public ShufflePlayBuffer()
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public void onCreate()
|
|
||||||
{
|
|
||||||
executorService = Executors.newSingleThreadScheduledExecutor();
|
|
||||||
Runnable runnable = this::refill;
|
|
||||||
executorService.scheduleWithFixedDelay(runnable, 1, 10, TimeUnit.SECONDS);
|
|
||||||
Timber.i("ShufflePlayBuffer created");
|
|
||||||
}
|
|
||||||
|
|
||||||
public void onDestroy()
|
|
||||||
{
|
|
||||||
executorService.shutdown();
|
|
||||||
Timber.i("ShufflePlayBuffer destroyed");
|
|
||||||
}
|
|
||||||
|
|
||||||
public List<Track> get(int size)
|
|
||||||
{
|
|
||||||
clearBufferIfNecessary();
|
|
||||||
|
|
||||||
List<Track> result = new ArrayList<>(size);
|
|
||||||
synchronized (buffer)
|
|
||||||
{
|
|
||||||
while (!buffer.isEmpty() && result.size() < size)
|
|
||||||
{
|
|
||||||
result.add(buffer.remove(buffer.size() - 1));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Timber.i("Taking %d songs from shuffle play buffer. %d remaining.", result.size(), buffer.size());
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void refill()
|
|
||||||
{
|
|
||||||
if (!isEnabled) return;
|
|
||||||
|
|
||||||
// Check if active server has changed.
|
|
||||||
clearBufferIfNecessary();
|
|
||||||
|
|
||||||
if (buffer.size() > REFILL_THRESHOLD || (!Util.isNetworkConnected() && !ActiveServerProvider.Companion.isOffline()))
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
MusicService service = MusicServiceFactory.getMusicService();
|
|
||||||
int n = CAPACITY - buffer.size();
|
|
||||||
MusicDirectory songs = service.getRandomSongs(n);
|
|
||||||
|
|
||||||
synchronized (buffer)
|
|
||||||
{
|
|
||||||
buffer.addAll(songs.getTracks());
|
|
||||||
Timber.i("Refilled shuffle play buffer with %d songs.", songs.getTracks().size());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception x)
|
|
||||||
{
|
|
||||||
Timber.w(x, "Failed to refill shuffle play buffer.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void clearBufferIfNecessary()
|
|
||||||
{
|
|
||||||
synchronized (buffer)
|
|
||||||
{
|
|
||||||
if (currentServer != ActiveServerProvider.Companion.getActiveServerId())
|
|
||||||
{
|
|
||||||
currentServer = ActiveServerProvider.Companion.getActiveServerId();
|
|
||||||
buffer.clear();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,290 +0,0 @@
|
||||||
package org.moire.ultrasonic.util;
|
|
||||||
|
|
||||||
import org.moire.ultrasonic.domain.Track;
|
|
||||||
import org.moire.ultrasonic.service.DownloadFile;
|
|
||||||
import org.moire.ultrasonic.service.Supplier;
|
|
||||||
|
|
||||||
import java.io.BufferedOutputStream;
|
|
||||||
import java.io.BufferedReader;
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.io.InputStream;
|
|
||||||
import java.io.InputStreamReader;
|
|
||||||
import java.io.OutputStream;
|
|
||||||
import java.io.UnsupportedEncodingException;
|
|
||||||
import java.net.InetAddress;
|
|
||||||
import java.net.ServerSocket;
|
|
||||||
import java.net.Socket;
|
|
||||||
import java.net.SocketException;
|
|
||||||
import java.net.SocketTimeoutException;
|
|
||||||
import java.net.URLDecoder;
|
|
||||||
import java.net.UnknownHostException;
|
|
||||||
import java.util.StringTokenizer;
|
|
||||||
|
|
||||||
import timber.log.Timber;
|
|
||||||
|
|
||||||
public class StreamProxy implements Runnable
|
|
||||||
{
|
|
||||||
private Thread thread;
|
|
||||||
private boolean isRunning;
|
|
||||||
private ServerSocket socket;
|
|
||||||
private int port;
|
|
||||||
private Supplier<DownloadFile> currentPlaying;
|
|
||||||
|
|
||||||
public StreamProxy(Supplier<DownloadFile> currentPlaying)
|
|
||||||
{
|
|
||||||
|
|
||||||
// Create listening socket
|
|
||||||
try
|
|
||||||
{
|
|
||||||
socket = new ServerSocket(0, 0, InetAddress.getByAddress(new byte[]{127, 0, 0, 1}));
|
|
||||||
socket.setSoTimeout(5000);
|
|
||||||
port = socket.getLocalPort();
|
|
||||||
this.currentPlaying = currentPlaying;
|
|
||||||
}
|
|
||||||
catch (UnknownHostException e)
|
|
||||||
{ // impossible
|
|
||||||
}
|
|
||||||
catch (IOException e)
|
|
||||||
{
|
|
||||||
Timber.e(e, "IOException initializing server");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public int getPort()
|
|
||||||
{
|
|
||||||
return port;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void start()
|
|
||||||
{
|
|
||||||
thread = new Thread(this);
|
|
||||||
thread.start();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void stop()
|
|
||||||
{
|
|
||||||
isRunning = false;
|
|
||||||
thread.interrupt();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void run()
|
|
||||||
{
|
|
||||||
isRunning = true;
|
|
||||||
while (isRunning)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
Socket client = socket.accept();
|
|
||||||
if (client == null)
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
Timber.i("Client connected");
|
|
||||||
|
|
||||||
StreamToMediaPlayerTask task = new StreamToMediaPlayerTask(client);
|
|
||||||
if (task.processRequest())
|
|
||||||
{
|
|
||||||
new Thread(task).start();
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
catch (SocketTimeoutException e)
|
|
||||||
{
|
|
||||||
// Do nothing
|
|
||||||
}
|
|
||||||
catch (IOException e)
|
|
||||||
{
|
|
||||||
Timber.e(e, "Error connecting to client");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Timber.i("Proxy interrupted. Shutting down.");
|
|
||||||
}
|
|
||||||
|
|
||||||
private class StreamToMediaPlayerTask implements Runnable {
|
|
||||||
String localPath;
|
|
||||||
Socket client;
|
|
||||||
int cbSkip;
|
|
||||||
|
|
||||||
StreamToMediaPlayerTask(Socket client) {
|
|
||||||
this.client = client;
|
|
||||||
}
|
|
||||||
|
|
||||||
private String readRequest() {
|
|
||||||
InputStream is;
|
|
||||||
String firstLine;
|
|
||||||
try {
|
|
||||||
is = client.getInputStream();
|
|
||||||
BufferedReader reader = new BufferedReader(new InputStreamReader(is), 8192);
|
|
||||||
firstLine = reader.readLine();
|
|
||||||
} catch (IOException e) {
|
|
||||||
Timber.e(e, "Error parsing request");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (firstLine == null) {
|
|
||||||
Timber.i("Proxy client closed connection without a request.");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
StringTokenizer st = new StringTokenizer(firstLine);
|
|
||||||
st.nextToken(); // method
|
|
||||||
String uri = st.nextToken();
|
|
||||||
String realUri = uri.substring(1);
|
|
||||||
Timber.i(realUri);
|
|
||||||
|
|
||||||
return realUri;
|
|
||||||
}
|
|
||||||
|
|
||||||
boolean processRequest() {
|
|
||||||
final String uri = readRequest();
|
|
||||||
if (uri == null || uri.isEmpty()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read HTTP headers
|
|
||||||
Timber.i("Processing request: %s", uri);
|
|
||||||
|
|
||||||
try {
|
|
||||||
localPath = URLDecoder.decode(uri, Constants.UTF_8);
|
|
||||||
} catch (UnsupportedEncodingException e) {
|
|
||||||
Timber.e(e, "Unsupported encoding");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
Timber.i("Processing request for file %s", localPath);
|
|
||||||
if (Storage.INSTANCE.isPathExists(localPath)) return true;
|
|
||||||
|
|
||||||
// Usually the .partial file will be requested here, but sometimes it has already
|
|
||||||
// been renamed, so check if it is completed since
|
|
||||||
String saveFileName = FileUtil.INSTANCE.getSaveFile(localPath);
|
|
||||||
String completeFileName = FileUtil.INSTANCE.getCompleteFile(saveFileName);
|
|
||||||
|
|
||||||
if (Storage.INSTANCE.isPathExists(saveFileName)) {
|
|
||||||
localPath = saveFileName;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Storage.INSTANCE.isPathExists(completeFileName)) {
|
|
||||||
localPath = completeFileName;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
Timber.e("File %s does not exist", localPath);
|
|
||||||
return false;
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void run()
|
|
||||||
{
|
|
||||||
Timber.i("Streaming song in background");
|
|
||||||
DownloadFile downloadFile = currentPlaying == null? null : currentPlaying.get();
|
|
||||||
Track song = downloadFile.getTrack();
|
|
||||||
long fileSize = downloadFile.getBitRate() * ((song.getDuration() != null) ? song.getDuration() : 0) * 1000 / 8;
|
|
||||||
Timber.i("Streaming fileSize: %d", fileSize);
|
|
||||||
|
|
||||||
// Create HTTP header
|
|
||||||
String headers = "HTTP/1.0 200 OK\r\n";
|
|
||||||
headers += "Content-Type: application/octet-stream\r\n";
|
|
||||||
headers += "Connection: close\r\n";
|
|
||||||
headers += "\r\n";
|
|
||||||
|
|
||||||
long cbToSend = fileSize - cbSkip;
|
|
||||||
OutputStream output = null;
|
|
||||||
byte[] buff = new byte[64 * 1024];
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
output = new BufferedOutputStream(client.getOutputStream(), 32 * 1024);
|
|
||||||
output.write(headers.getBytes());
|
|
||||||
|
|
||||||
if (!downloadFile.isWorkDone())
|
|
||||||
{
|
|
||||||
// Loop as long as there's stuff to send
|
|
||||||
while (isRunning && !client.isClosed())
|
|
||||||
{
|
|
||||||
// See if there's more to send
|
|
||||||
String file = downloadFile.isCompleteFileAvailable() ? downloadFile.getCompleteOrSaveFile() : downloadFile.getPartialFile();
|
|
||||||
int cbSentThisBatch = 0;
|
|
||||||
|
|
||||||
AbstractFile storageFile = Storage.INSTANCE.getFromPath(file);
|
|
||||||
if (storageFile != null)
|
|
||||||
{
|
|
||||||
InputStream input = storageFile.getFileInputStream();
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
long skip = input.skip(cbSkip);
|
|
||||||
int cbToSendThisBatch = input.available();
|
|
||||||
|
|
||||||
while (cbToSendThisBatch > 0)
|
|
||||||
{
|
|
||||||
int cbToRead = Math.min(cbToSendThisBatch, buff.length);
|
|
||||||
int cbRead = input.read(buff, 0, cbToRead);
|
|
||||||
|
|
||||||
if (cbRead == -1)
|
|
||||||
{
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
cbToSendThisBatch -= cbRead;
|
|
||||||
cbToSend -= cbRead;
|
|
||||||
output.write(buff, 0, cbRead);
|
|
||||||
output.flush();
|
|
||||||
cbSkip += cbRead;
|
|
||||||
cbSentThisBatch += cbRead;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
input.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Done regardless of whether or not it thinks it is
|
|
||||||
if (downloadFile.isWorkDone() && cbSkip >= file.length())
|
|
||||||
{
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we did nothing this batch, block for a second
|
|
||||||
if (cbSentThisBatch == 0)
|
|
||||||
{
|
|
||||||
Timber.d("Blocking until more data appears (%d)", cbToSend);
|
|
||||||
Util.sleepQuietly(1000L);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Timber.w("Requesting data for completely downloaded file");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (SocketException socketException)
|
|
||||||
{
|
|
||||||
Timber.e("SocketException() thrown, proxy client has probably closed. This can exit harmlessly");
|
|
||||||
}
|
|
||||||
catch (Exception e)
|
|
||||||
{
|
|
||||||
Timber.e("Exception thrown from streaming task:");
|
|
||||||
Timber.e("%s : %s", e.getClass().getName(), e.getLocalizedMessage());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cleanup
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (output != null)
|
|
||||||
{
|
|
||||||
output.close();
|
|
||||||
}
|
|
||||||
client.close();
|
|
||||||
}
|
|
||||||
catch (IOException e)
|
|
||||||
{
|
|
||||||
Timber.e("IOException while cleaning up streaming task:");
|
|
||||||
Timber.e("%s : %s", e.getClass().getName(), e.getLocalizedMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -18,6 +18,8 @@
|
||||||
*/
|
*/
|
||||||
package org.moire.ultrasonic.view;
|
package org.moire.ultrasonic.view;
|
||||||
|
|
||||||
|
import static org.koin.java.KoinJavaComponent.inject;
|
||||||
|
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import android.graphics.Canvas;
|
import android.graphics.Canvas;
|
||||||
import android.graphics.Color;
|
import android.graphics.Color;
|
||||||
|
@ -29,14 +31,11 @@ import androidx.lifecycle.LifecycleOwner;
|
||||||
import androidx.lifecycle.Observer;
|
import androidx.lifecycle.Observer;
|
||||||
|
|
||||||
import org.moire.ultrasonic.audiofx.VisualizerController;
|
import org.moire.ultrasonic.audiofx.VisualizerController;
|
||||||
import org.moire.ultrasonic.domain.PlayerState;
|
|
||||||
import org.moire.ultrasonic.service.MediaPlayerController;
|
import org.moire.ultrasonic.service.MediaPlayerController;
|
||||||
|
|
||||||
import kotlin.Lazy;
|
import kotlin.Lazy;
|
||||||
import timber.log.Timber;
|
import timber.log.Timber;
|
||||||
|
|
||||||
import static org.koin.java.KoinJavaComponent.inject;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A simple class that draws waveform data received from a
|
* A simple class that draws waveform data received from a
|
||||||
* {@link Visualizer.OnDataCaptureListener#onWaveFormDataCapture}
|
* {@link Visualizer.OnDataCaptureListener#onWaveFormDataCapture}
|
||||||
|
@ -130,7 +129,7 @@ public class VisualizerView extends View
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mediaPlayerControllerLazy.getValue().getPlayerState() != PlayerState.STARTED)
|
if (!mediaPlayerControllerLazy.getValue().isPlaying())
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
/*
|
/*
|
||||||
* NavigationActivity.kt
|
* NavigationActivity.kt
|
||||||
* Copyright (C) 2009-2021 Ultrasonic developers
|
* Copyright (C) 2009-2022 Ultrasonic developers
|
||||||
*
|
*
|
||||||
* Distributed under terms of the GNU GPLv3 license.
|
* Distributed under terms of the GNU GPLv3 license.
|
||||||
*/
|
*/
|
||||||
|
@ -27,6 +27,9 @@ import androidx.core.content.ContextCompat
|
||||||
import androidx.core.view.GravityCompat
|
import androidx.core.view.GravityCompat
|
||||||
import androidx.drawerlayout.widget.DrawerLayout
|
import androidx.drawerlayout.widget.DrawerLayout
|
||||||
import androidx.fragment.app.FragmentContainerView
|
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.NavController
|
||||||
import androidx.navigation.findNavController
|
import androidx.navigation.findNavController
|
||||||
import androidx.navigation.fragment.NavHostFragment
|
import androidx.navigation.fragment.NavHostFragment
|
||||||
|
@ -38,20 +41,20 @@ import androidx.navigation.ui.setupWithNavController
|
||||||
import androidx.preference.PreferenceManager
|
import androidx.preference.PreferenceManager
|
||||||
import com.google.android.material.button.MaterialButton
|
import com.google.android.material.button.MaterialButton
|
||||||
import com.google.android.material.navigation.NavigationView
|
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.android.ext.android.inject
|
||||||
import org.koin.androidx.viewmodel.ext.android.viewModel
|
import org.koin.androidx.viewmodel.ext.android.viewModel
|
||||||
import org.moire.ultrasonic.R
|
import org.moire.ultrasonic.R
|
||||||
|
import org.moire.ultrasonic.app.UApp
|
||||||
import org.moire.ultrasonic.data.ActiveServerProvider
|
import org.moire.ultrasonic.data.ActiveServerProvider
|
||||||
import org.moire.ultrasonic.data.ServerSettingDao
|
import org.moire.ultrasonic.data.ServerSettingDao
|
||||||
import org.moire.ultrasonic.domain.PlayerState
|
|
||||||
import org.moire.ultrasonic.fragment.OnBackPressedHandler
|
import org.moire.ultrasonic.fragment.OnBackPressedHandler
|
||||||
import org.moire.ultrasonic.model.ServerSettingsModel
|
import org.moire.ultrasonic.model.ServerSettingsModel
|
||||||
import org.moire.ultrasonic.provider.SearchSuggestionProvider
|
import org.moire.ultrasonic.provider.SearchSuggestionProvider
|
||||||
import org.moire.ultrasonic.service.DownloadFile
|
|
||||||
import org.moire.ultrasonic.service.MediaPlayerController
|
import org.moire.ultrasonic.service.MediaPlayerController
|
||||||
import org.moire.ultrasonic.service.MediaPlayerLifecycleSupport
|
import org.moire.ultrasonic.service.MediaPlayerLifecycleSupport
|
||||||
import org.moire.ultrasonic.service.RxBus
|
import org.moire.ultrasonic.service.RxBus
|
||||||
|
import org.moire.ultrasonic.service.plusAssign
|
||||||
import org.moire.ultrasonic.subsonic.ImageLoaderProvider
|
import org.moire.ultrasonic.subsonic.ImageLoaderProvider
|
||||||
import org.moire.ultrasonic.util.Constants
|
import org.moire.ultrasonic.util.Constants
|
||||||
import org.moire.ultrasonic.util.InfoDialog
|
import org.moire.ultrasonic.util.InfoDialog
|
||||||
|
@ -64,7 +67,7 @@ import org.moire.ultrasonic.util.Util
|
||||||
import timber.log.Timber
|
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")
|
@Suppress("TooManyFunctions")
|
||||||
class NavigationActivity : AppCompatActivity() {
|
class NavigationActivity : AppCompatActivity() {
|
||||||
|
@ -81,8 +84,8 @@ class NavigationActivity : AppCompatActivity() {
|
||||||
private var headerBackgroundImage: ImageView? = null
|
private var headerBackgroundImage: ImageView? = null
|
||||||
|
|
||||||
private lateinit var appBarConfiguration: AppBarConfiguration
|
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 serverSettingsModel: ServerSettingsModel by viewModel()
|
||||||
private val lifecycleSupport: MediaPlayerLifecycleSupport by inject()
|
private val lifecycleSupport: MediaPlayerLifecycleSupport by inject()
|
||||||
|
@ -96,6 +99,16 @@ class NavigationActivity : AppCompatActivity() {
|
||||||
private var cachedServerCount: Int = 0
|
private var cachedServerCount: Int = 0
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
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()
|
setUncaughtExceptionHandler()
|
||||||
Util.applyTheme(this)
|
Util.applyTheme(this)
|
||||||
|
|
||||||
|
@ -179,25 +192,25 @@ class NavigationActivity : AppCompatActivity() {
|
||||||
hideNowPlaying()
|
hideNowPlaying()
|
||||||
}
|
}
|
||||||
|
|
||||||
playerStateSubscription = RxBus.playerStateObservable.subscribe {
|
rxBusSubscription += RxBus.playerStateObservable.subscribe {
|
||||||
if (it.state === PlayerState.STARTED || it.state === PlayerState.PAUSED)
|
if (it.state == STATE_READY)
|
||||||
showNowPlaying()
|
showNowPlaying()
|
||||||
else
|
else
|
||||||
hideNowPlaying()
|
hideNowPlaying()
|
||||||
}
|
}
|
||||||
|
|
||||||
themeChangedEventSubscription = RxBus.themeChangedEventObservable.subscribe {
|
rxBusSubscription += RxBus.themeChangedEventObservable.subscribe {
|
||||||
recreate()
|
recreate()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
rxBusSubscription += RxBus.activeServerChangeObservable.subscribe {
|
||||||
|
updateNavigationHeaderForServer()
|
||||||
|
}
|
||||||
|
|
||||||
serverRepository.liveServerCount().observe(this) { count ->
|
serverRepository.liveServerCount().observe(this) { count ->
|
||||||
cachedServerCount = count ?: 0
|
cachedServerCount = count ?: 0
|
||||||
updateNavigationHeaderForServer()
|
updateNavigationHeaderForServer()
|
||||||
}
|
}
|
||||||
|
|
||||||
ActiveServerProvider.liveActiveServerId.observe(this) {
|
|
||||||
updateNavigationHeaderForServer()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateNavigationHeaderForServer() {
|
private fun updateNavigationHeaderForServer() {
|
||||||
|
@ -223,6 +236,7 @@ class NavigationActivity : AppCompatActivity() {
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
|
Timber.d("onResume called")
|
||||||
super.onResume()
|
super.onResume()
|
||||||
|
|
||||||
Storage.reset()
|
Storage.reset()
|
||||||
|
@ -236,10 +250,11 @@ class NavigationActivity : AppCompatActivity() {
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
super.onDestroy()
|
Timber.d("onDestroy called")
|
||||||
themeChangedEventSubscription?.dispose()
|
rxBusSubscription.dispose()
|
||||||
playerStateSubscription?.dispose()
|
|
||||||
imageLoaderProvider.clearImageLoader()
|
imageLoaderProvider.clearImageLoader()
|
||||||
|
UApp.instance!!.shutdownKoin()
|
||||||
|
super.onDestroy()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
|
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
|
||||||
|
@ -364,8 +379,13 @@ class NavigationActivity : AppCompatActivity() {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun exit() {
|
private fun exit() {
|
||||||
|
Timber.d("User choose to exit the app")
|
||||||
|
|
||||||
|
// Broadcast that the service is being shutdown
|
||||||
|
RxBus.stopCommandPublisher.onNext(Unit)
|
||||||
|
|
||||||
lifecycleSupport.onDestroy()
|
lifecycleSupport.onDestroy()
|
||||||
finish()
|
finishAndRemoveTask()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun showWelcomeDialog() {
|
private fun showWelcomeDialog() {
|
||||||
|
@ -414,10 +434,10 @@ class NavigationActivity : AppCompatActivity() {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (nowPlayingView != null) {
|
if (nowPlayingView != null) {
|
||||||
val playerState: PlayerState = mediaPlayerController.playerState
|
val playerState: Int = mediaPlayerController.playbackState
|
||||||
if (playerState == PlayerState.PAUSED || playerState == PlayerState.STARTED) {
|
if (playerState == STATE_BUFFERING || playerState == STATE_READY) {
|
||||||
val file: DownloadFile? = mediaPlayerController.currentPlaying
|
val item: MediaItem? = mediaPlayerController.currentMediaItem
|
||||||
if (file != null) {
|
if (item != null) {
|
||||||
nowPlayingView?.visibility = View.VISIBLE
|
nowPlayingView?.visibility = View.VISIBLE
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -30,7 +30,7 @@ import org.moire.ultrasonic.util.Util
|
||||||
*/
|
*/
|
||||||
internal class ServerRowAdapter(
|
internal class ServerRowAdapter(
|
||||||
private var context: Context,
|
private var context: Context,
|
||||||
private var data: Array<ServerSetting>,
|
passedData: Array<ServerSetting>,
|
||||||
private val model: ServerSettingsModel,
|
private val model: ServerSettingsModel,
|
||||||
private val activeServerProvider: ActiveServerProvider,
|
private val activeServerProvider: ActiveServerProvider,
|
||||||
private val manageMode: Boolean,
|
private val manageMode: Boolean,
|
||||||
|
@ -38,6 +38,12 @@ internal class ServerRowAdapter(
|
||||||
private val serverEditRequestedCallback: ((Int) -> Unit)
|
private val serverEditRequestedCallback: ((Int) -> Unit)
|
||||||
) : BaseAdapter() {
|
) : BaseAdapter() {
|
||||||
|
|
||||||
|
private var data: MutableList<ServerSetting> = mutableListOf()
|
||||||
|
|
||||||
|
init {
|
||||||
|
setData(passedData)
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val MENU_ID_EDIT = 1
|
private const val MENU_ID_EDIT = 1
|
||||||
private const val MENU_ID_DELETE = 2
|
private const val MENU_ID_DELETE = 2
|
||||||
|
@ -49,12 +55,19 @@ internal class ServerRowAdapter(
|
||||||
context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater
|
context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater
|
||||||
|
|
||||||
fun setData(data: Array<ServerSetting>) {
|
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()
|
notifyDataSetChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getCount(): Int {
|
override fun getCount(): Int {
|
||||||
return if (manageMode) data.size else data.size + 1
|
return data.size
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getItem(position: Int): Any {
|
override fun getItem(position: Int): Any {
|
||||||
|
@ -69,11 +82,11 @@ internal class ServerRowAdapter(
|
||||||
* Creates the Row representation of a Server Setting
|
* Creates the Row representation of a Server Setting
|
||||||
*/
|
*/
|
||||||
@Suppress("LongMethod")
|
@Suppress("LongMethod")
|
||||||
override fun getView(position: Int, convertView: View?, parent: ViewGroup?): View? {
|
override fun getView(pos: Int, convertView: View?, parent: ViewGroup?): View? {
|
||||||
var index = position
|
var position = pos
|
||||||
|
|
||||||
// Skip "Offline" in manage mode
|
// Skip "Offline" in manage mode
|
||||||
if (manageMode) index++
|
if (manageMode) position++
|
||||||
|
|
||||||
var vi: View? = convertView
|
var vi: View? = convertView
|
||||||
if (vi == null) vi = inflater.inflate(R.layout.server_row, parent, false)
|
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 layout = vi?.findViewById<ConstraintLayout>(R.id.server_layout)
|
||||||
val image = vi?.findViewById<ImageView>(R.id.server_image)
|
val image = vi?.findViewById<ImageView>(R.id.server_image)
|
||||||
val serverMenu = vi?.findViewById<ImageButton>(R.id.server_menu)
|
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 = setting?.name ?: ""
|
||||||
text?.text = context.getString(R.string.main_offline)
|
description?.text = setting?.url ?: ""
|
||||||
description?.text = ""
|
if (setting == null) serverMenu?.visibility = View.INVISIBLE
|
||||||
} else {
|
|
||||||
text?.text = setting?.name ?: ""
|
|
||||||
description?.text = setting?.url ?: ""
|
|
||||||
if (setting == null) serverMenu?.visibility = View.INVISIBLE
|
|
||||||
}
|
|
||||||
|
|
||||||
val icon: Drawable?
|
val icon: Drawable?
|
||||||
val background: Drawable?
|
val background: Drawable?
|
||||||
|
|
||||||
// Configure icons for the row
|
// Configure icons for the row
|
||||||
if (index == 0) {
|
if (setting?.id == ActiveServerProvider.OFFLINE_DB_ID) {
|
||||||
serverMenu?.visibility = View.INVISIBLE
|
serverMenu?.visibility = View.INVISIBLE
|
||||||
icon = Util.getDrawableFromAttribute(context, R.attr.screen_on_off)
|
icon = Util.getDrawableFromAttribute(context, R.attr.screen_on_off)
|
||||||
background = ContextCompat.getDrawable(context, R.drawable.circle)
|
background = ContextCompat.getDrawable(context, R.drawable.circle)
|
||||||
|
@ -116,7 +124,7 @@ internal class ServerRowAdapter(
|
||||||
image?.background = background
|
image?.background = background
|
||||||
|
|
||||||
// Highlight the Active Server's row by changing its 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)
|
layout?.background = ContextCompat.getDrawable(context, R.drawable.select_ripple)
|
||||||
} else {
|
} else {
|
||||||
layout?.background = ContextCompat.getDrawable(context, R.drawable.default_ripple)
|
layout?.background = ContextCompat.getDrawable(context, R.drawable.default_ripple)
|
||||||
|
@ -128,7 +136,7 @@ internal class ServerRowAdapter(
|
||||||
R.drawable.select_ripple_circle
|
R.drawable.select_ripple_circle
|
||||||
)
|
)
|
||||||
|
|
||||||
serverMenu?.setOnClickListener { view -> serverMenuClick(view, index) }
|
serverMenu?.setOnClickListener { view -> serverMenuClick(view, position) }
|
||||||
|
|
||||||
return vi
|
return vi
|
||||||
}
|
}
|
||||||
|
@ -192,7 +200,8 @@ internal class ServerRowAdapter(
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
MENU_ID_DELETE -> {
|
MENU_ID_DELETE -> {
|
||||||
serverDeletedCallback.invoke(position)
|
val server = getItem(position) as ServerSetting
|
||||||
|
serverDeletedCallback.invoke(server.id)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
MENU_ID_UP -> {
|
MENU_ID_UP -> {
|
||||||
|
|
|
@ -17,7 +17,7 @@ import org.moire.ultrasonic.service.DownloadFile
|
||||||
import org.moire.ultrasonic.service.Downloader
|
import org.moire.ultrasonic.service.Downloader
|
||||||
|
|
||||||
class TrackViewBinder(
|
class TrackViewBinder(
|
||||||
val onItemClick: (DownloadFile) -> Unit,
|
val onItemClick: (DownloadFile, Int) -> Unit,
|
||||||
val onContextMenuClick: ((MenuItem, DownloadFile) -> Boolean)? = null,
|
val onContextMenuClick: ((MenuItem, DownloadFile) -> Boolean)? = null,
|
||||||
val checkable: Boolean,
|
val checkable: Boolean,
|
||||||
val draggable: Boolean,
|
val draggable: Boolean,
|
||||||
|
@ -29,7 +29,7 @@ class TrackViewBinder(
|
||||||
|
|
||||||
// Set our layout files
|
// Set our layout files
|
||||||
val layout = R.layout.list_item_track
|
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 downloader: Downloader by inject()
|
||||||
private val imageHelper: Utils.ImageHelper = Utils.ImageHelper(context)
|
private val imageHelper: Utils.ImageHelper = Utils.ImageHelper(context)
|
||||||
|
@ -41,15 +41,14 @@ class TrackViewBinder(
|
||||||
@SuppressLint("ClickableViewAccessibility")
|
@SuppressLint("ClickableViewAccessibility")
|
||||||
@Suppress("LongMethod")
|
@Suppress("LongMethod")
|
||||||
override fun onBindViewHolder(holder: TrackViewHolder, item: Identifiable) {
|
override fun onBindViewHolder(holder: TrackViewHolder, item: Identifiable) {
|
||||||
val downloadFile: DownloadFile?
|
|
||||||
val diffAdapter = adapter as BaseAdapter<*>
|
val diffAdapter = adapter as BaseAdapter<*>
|
||||||
|
|
||||||
when (item) {
|
val downloadFile: DownloadFile = when (item) {
|
||||||
is Track -> {
|
is Track -> {
|
||||||
downloadFile = downloader.getDownloadFileForSong(item)
|
downloader.getDownloadFileForSong(item)
|
||||||
}
|
}
|
||||||
is DownloadFile -> {
|
is DownloadFile -> {
|
||||||
downloadFile = item
|
item
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
return
|
return
|
||||||
|
@ -90,7 +89,7 @@ class TrackViewBinder(
|
||||||
val nowChecked = !holder.check.isChecked
|
val nowChecked = !holder.check.isChecked
|
||||||
holder.isChecked = nowChecked
|
holder.isChecked = nowChecked
|
||||||
} else {
|
} else {
|
||||||
onItemClick(downloadFile)
|
onItemClick(downloadFile, holder.bindingAdapterPosition)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -103,41 +102,37 @@ class TrackViewBinder(
|
||||||
|
|
||||||
// Notify the adapter of selection changes
|
// Notify the adapter of selection changes
|
||||||
holder.observableChecked.observe(
|
holder.observableChecked.observe(
|
||||||
lifecycleOwner,
|
lifecycleOwner
|
||||||
{ isCheckedNow ->
|
) { isCheckedNow ->
|
||||||
if (isCheckedNow) {
|
if (isCheckedNow) {
|
||||||
diffAdapter.notifySelected(holder.entry!!.longId)
|
diffAdapter.notifySelected(holder.entry!!.longId)
|
||||||
} else {
|
} else {
|
||||||
diffAdapter.notifyUnselected(holder.entry!!.longId)
|
diffAdapter.notifyUnselected(holder.entry!!.longId)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
}
|
||||||
|
|
||||||
// Listen to changes in selection status and update ourselves
|
// Listen to changes in selection status and update ourselves
|
||||||
diffAdapter.selectionRevision.observe(
|
diffAdapter.selectionRevision.observe(
|
||||||
lifecycleOwner,
|
lifecycleOwner
|
||||||
{
|
) {
|
||||||
val newStatus = diffAdapter.isSelected(item.longId)
|
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
|
// Observe download status
|
||||||
downloadFile.status.observe(
|
downloadFile.status.observe(
|
||||||
lifecycleOwner,
|
lifecycleOwner
|
||||||
{
|
) {
|
||||||
holder.updateStatus(it)
|
holder.updateStatus(it)
|
||||||
diffAdapter.notifyChanged()
|
diffAdapter.notifyChanged()
|
||||||
}
|
}
|
||||||
)
|
|
||||||
|
|
||||||
downloadFile.progress.observe(
|
downloadFile.progress.observe(
|
||||||
lifecycleOwner,
|
lifecycleOwner
|
||||||
{
|
) {
|
||||||
holder.updateProgress(it)
|
holder.updateProgress(it)
|
||||||
}
|
}
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onViewRecycled(holder: TrackViewHolder) {
|
override fun onViewRecycled(holder: TrackViewHolder) {
|
||||||
|
|
|
@ -109,7 +109,7 @@ class TrackViewHolder(val view: View) : RecyclerView.ViewHolder(view), Checkable
|
||||||
}
|
}
|
||||||
|
|
||||||
rxSubscription = RxBus.playerStateObservable.subscribe {
|
rxSubscription = RxBus.playerStateObservable.subscribe {
|
||||||
setPlayIcon(it.track == downloadFile)
|
setPlayIcon(it.index == bindingAdapterPosition && it.track == downloadFile)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -2,8 +2,12 @@ package org.moire.ultrasonic.app
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.multidex.MultiDexApplication
|
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.android.ext.koin.androidContext
|
||||||
import org.koin.core.context.startKoin
|
import org.koin.core.context.startKoin
|
||||||
|
import org.koin.core.context.stopKoin
|
||||||
import org.koin.core.logger.Level
|
import org.koin.core.logger.Level
|
||||||
import org.moire.ultrasonic.BuildConfig
|
import org.moire.ultrasonic.BuildConfig
|
||||||
import org.moire.ultrasonic.di.appPermanentStorage
|
import org.moire.ultrasonic.di.appPermanentStorage
|
||||||
|
@ -23,22 +27,39 @@ import timber.log.Timber.DebugTree
|
||||||
|
|
||||||
class UApp : MultiDexApplication() {
|
class UApp : MultiDexApplication() {
|
||||||
|
|
||||||
|
private var ioScope = CoroutineScope(Dispatchers.IO)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
instance = this
|
instance = this
|
||||||
|
// if (BuildConfig.DEBUG)
|
||||||
|
// StrictMode.enableDefaults()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var initiated = false
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
|
initiated = true
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
|
|
||||||
if (BuildConfig.DEBUG) {
|
if (BuildConfig.DEBUG) {
|
||||||
Timber.plant(DebugTree())
|
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 {
|
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.ERROR))
|
||||||
// logger(TimberKoinLogger(Level.INFO))
|
// logger(TimberKoinLogger(Level.INFO))
|
||||||
|
|
||||||
|
@ -55,8 +76,13 @@ class UApp : MultiDexApplication() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal fun shutdownKoin() {
|
||||||
|
stopKoin()
|
||||||
|
initiated = false
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private var instance: UApp? = null
|
var instance: UApp? = null
|
||||||
|
|
||||||
fun applicationContext(): Context {
|
fun applicationContext(): Context {
|
||||||
return instance!!.applicationContext
|
return instance!!.applicationContext
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
package org.moire.ultrasonic.data
|
package org.moire.ultrasonic.data
|
||||||
|
|
||||||
import androidx.lifecycle.MutableLiveData
|
|
||||||
import androidx.room.Room
|
import androidx.room.Room
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
@ -11,6 +10,7 @@ import org.moire.ultrasonic.R
|
||||||
import org.moire.ultrasonic.app.UApp
|
import org.moire.ultrasonic.app.UApp
|
||||||
import org.moire.ultrasonic.di.DB_FILENAME
|
import org.moire.ultrasonic.di.DB_FILENAME
|
||||||
import org.moire.ultrasonic.service.MusicServiceFactory.resetMusicService
|
import org.moire.ultrasonic.service.MusicServiceFactory.resetMusicService
|
||||||
|
import org.moire.ultrasonic.service.RxBus
|
||||||
import org.moire.ultrasonic.util.Constants
|
import org.moire.ultrasonic.util.Constants
|
||||||
import org.moire.ultrasonic.util.Settings
|
import org.moire.ultrasonic.util.Settings
|
||||||
import org.moire.ultrasonic.util.Util
|
import org.moire.ultrasonic.util.Util
|
||||||
|
@ -52,12 +52,32 @@ class ActiveServerProvider(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to Offline
|
// Fallback to Offline
|
||||||
setActiveServerId(OFFLINE_DB_ID)
|
setActiveServerById(OFFLINE_DB_ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
return OFFLINE_DB
|
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
|
* 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
|
* @param index: The index of the Active Server in the Server Selector List
|
||||||
|
@ -66,13 +86,13 @@ class ActiveServerProvider(
|
||||||
Timber.d("setActiveServerByIndex $index")
|
Timber.d("setActiveServerByIndex $index")
|
||||||
if (index <= OFFLINE_DB_INDEX) {
|
if (index <= OFFLINE_DB_INDEX) {
|
||||||
// Offline mode is selected
|
// Offline mode is selected
|
||||||
setActiveServerId(OFFLINE_DB_ID)
|
setActiveServerById(OFFLINE_DB_ID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
launch {
|
launch {
|
||||||
val serverId = repository.findByIndex(index)?.id ?: 0
|
val serverId = repository.findByIndex(index)?.id ?: 0
|
||||||
setActiveServerId(serverId)
|
setActiveServerById(serverId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -180,8 +200,6 @@ class ActiveServerProvider(
|
||||||
minimumApiVersion = null
|
minimumApiVersion = null
|
||||||
)
|
)
|
||||||
|
|
||||||
val liveActiveServerId: MutableLiveData<Int> = MutableLiveData(getActiveServerId())
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Queries if the Active Server is the "Offline" mode of Ultrasonic
|
* Queries if the Active Server is the "Offline" mode of Ultrasonic
|
||||||
* @return True, if the "Offline" mode is selected
|
* @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()
|
resetMusicService()
|
||||||
|
|
||||||
Settings.activeServer = serverId
|
Settings.activeServer = serverId
|
||||||
liveActiveServerId.postValue(serverId)
|
|
||||||
|
Timber.i("setActiveServerById done, new id: %s", serverId)
|
||||||
|
RxBus.activeServerChangePublisher.onNext(serverId)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -11,7 +11,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase
|
||||||
*/
|
*/
|
||||||
@Database(
|
@Database(
|
||||||
entities = [ServerSetting::class],
|
entities = [ServerSetting::class],
|
||||||
version = 4,
|
version = 5,
|
||||||
exportSchema = true
|
exportSchema = true
|
||||||
)
|
)
|
||||||
abstract class AppDatabase : RoomDatabase() {
|
abstract class AppDatabase : RoomDatabase() {
|
||||||
|
|
|
@ -19,7 +19,8 @@ import androidx.room.PrimaryKey
|
||||||
*/
|
*/
|
||||||
@Entity
|
@Entity
|
||||||
data class ServerSetting(
|
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 = "index") var index: Int,
|
||||||
@ColumnInfo(name = "name") var name: String,
|
@ColumnInfo(name = "name") var name: String,
|
||||||
@ColumnInfo(name = "url") var url: String,
|
@ColumnInfo(name = "url") var url: String,
|
||||||
|
@ -37,6 +38,6 @@ data class ServerSetting(
|
||||||
@ColumnInfo(name = "podcastSupport") var podcastSupport: Boolean? = null
|
@ColumnInfo(name = "podcastSupport") var podcastSupport: Boolean? = null
|
||||||
) {
|
) {
|
||||||
constructor() : this (
|
constructor() : this (
|
||||||
-1, 0, "", "", null, "", "", false, false, false, null, null
|
0, 0, "", "", null, "", "", false, false, false, null, null
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -69,12 +69,6 @@ interface ServerSettingDao {
|
||||||
@Query("SELECT COUNT(*) FROM serverSetting")
|
@Query("SELECT COUNT(*) FROM serverSetting")
|
||||||
fun liveServerCount(): LiveData<Int?>
|
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
|
* Retrieves the greatest value of the Index column in the table
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -4,7 +4,6 @@ import org.koin.android.ext.koin.androidContext
|
||||||
import org.koin.dsl.module
|
import org.koin.dsl.module
|
||||||
import org.moire.ultrasonic.data.ActiveServerProvider
|
import org.moire.ultrasonic.data.ActiveServerProvider
|
||||||
import org.moire.ultrasonic.subsonic.ImageLoaderProvider
|
import org.moire.ultrasonic.subsonic.ImageLoaderProvider
|
||||||
import org.moire.ultrasonic.util.MediaSessionHandler
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This Koin module contains the registration of general classes needed for Ultrasonic
|
* 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 {
|
val applicationModule = module {
|
||||||
single { ActiveServerProvider(get()) }
|
single { ActiveServerProvider(get()) }
|
||||||
single { ImageLoaderProvider(androidContext()) }
|
single { ImageLoaderProvider(androidContext()) }
|
||||||
single { MediaSessionHandler() }
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,15 +1,13 @@
|
||||||
package org.moire.ultrasonic.di
|
package org.moire.ultrasonic.di
|
||||||
|
|
||||||
import org.koin.dsl.module
|
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.Downloader
|
||||||
import org.moire.ultrasonic.service.ExternalStorageMonitor
|
import org.moire.ultrasonic.service.ExternalStorageMonitor
|
||||||
import org.moire.ultrasonic.service.JukeboxMediaPlayer
|
import org.moire.ultrasonic.service.JukeboxMediaPlayer
|
||||||
import org.moire.ultrasonic.service.LocalMediaPlayer
|
|
||||||
import org.moire.ultrasonic.service.MediaPlayerController
|
import org.moire.ultrasonic.service.MediaPlayerController
|
||||||
import org.moire.ultrasonic.service.MediaPlayerLifecycleSupport
|
import org.moire.ultrasonic.service.MediaPlayerLifecycleSupport
|
||||||
import org.moire.ultrasonic.service.PlaybackStateSerializer
|
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
|
* This Koin module contains the registration of classes related to the media player
|
||||||
|
@ -19,10 +17,8 @@ val mediaPlayerModule = module {
|
||||||
single { MediaPlayerLifecycleSupport() }
|
single { MediaPlayerLifecycleSupport() }
|
||||||
single { PlaybackStateSerializer() }
|
single { PlaybackStateSerializer() }
|
||||||
single { ExternalStorageMonitor() }
|
single { ExternalStorageMonitor() }
|
||||||
single { ShufflePlayBuffer() }
|
single { LegacyPlaylistManager() }
|
||||||
single { Downloader(get(), get(), get()) }
|
single { Downloader(get(), get()) }
|
||||||
single { LocalMediaPlayer() }
|
|
||||||
single { AudioFocusHandler(get()) }
|
|
||||||
|
|
||||||
// TODO Ideally this can be cleaned up when all circular references are removed.
|
// TODO Ideally this can be cleaned up when all circular references are removed.
|
||||||
single { MediaPlayerController(get(), get(), get(), get(), get()) }
|
single { MediaPlayerController(get(), get(), get(), get(), get()) }
|
||||||
|
|
|
@ -54,7 +54,7 @@ class DownloadsFragment : MultiListFragment<DownloadFile>() {
|
||||||
|
|
||||||
viewAdapter.register(
|
viewAdapter.register(
|
||||||
TrackViewBinder(
|
TrackViewBinder(
|
||||||
{ },
|
{ _, _ -> },
|
||||||
{ _, _ -> true },
|
{ _, _ -> true },
|
||||||
checkable = false,
|
checkable = false,
|
||||||
draggable = false,
|
draggable = false,
|
||||||
|
|
|
@ -22,7 +22,6 @@ import java.lang.Exception
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
import org.koin.android.ext.android.inject
|
import org.koin.android.ext.android.inject
|
||||||
import org.moire.ultrasonic.R
|
import org.moire.ultrasonic.R
|
||||||
import org.moire.ultrasonic.domain.PlayerState
|
|
||||||
import org.moire.ultrasonic.service.MediaPlayerController
|
import org.moire.ultrasonic.service.MediaPlayerController
|
||||||
import org.moire.ultrasonic.service.RxBus
|
import org.moire.ultrasonic.service.RxBus
|
||||||
import org.moire.ultrasonic.subsonic.ImageLoaderProvider
|
import org.moire.ultrasonic.subsonic.ImageLoaderProvider
|
||||||
|
@ -47,7 +46,7 @@ class NowPlayingFragment : Fragment() {
|
||||||
private var nowPlayingTrack: TextView? = null
|
private var nowPlayingTrack: TextView? = null
|
||||||
private var nowPlayingArtist: 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 mediaPlayerController: MediaPlayerController by inject()
|
||||||
private val imageLoader: ImageLoaderProvider by inject()
|
private val imageLoader: ImageLoaderProvider by inject()
|
||||||
|
|
||||||
|
@ -69,8 +68,7 @@ class NowPlayingFragment : Fragment() {
|
||||||
nowPlayingAlbumArtImage = view.findViewById(R.id.now_playing_image)
|
nowPlayingAlbumArtImage = view.findViewById(R.id.now_playing_image)
|
||||||
nowPlayingTrack = view.findViewById(R.id.now_playing_trackname)
|
nowPlayingTrack = view.findViewById(R.id.now_playing_trackname)
|
||||||
nowPlayingArtist = view.findViewById(R.id.now_playing_artist)
|
nowPlayingArtist = view.findViewById(R.id.now_playing_artist)
|
||||||
playerStateSubscription =
|
rxBusSubscription = RxBus.playerStateObservable.subscribe { update() }
|
||||||
RxBus.playerStateObservable.subscribe { update() }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
|
@ -80,29 +78,27 @@ class NowPlayingFragment : Fragment() {
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
playerStateSubscription!!.dispose()
|
rxBusSubscription!!.dispose()
|
||||||
}
|
}
|
||||||
|
|
||||||
@SuppressLint("ClickableViewAccessibility")
|
@SuppressLint("ClickableViewAccessibility")
|
||||||
private fun update() {
|
private fun update() {
|
||||||
try {
|
try {
|
||||||
val playerState = mediaPlayerController.playerState
|
if (mediaPlayerController.isPlaying) {
|
||||||
|
|
||||||
if (playerState === PlayerState.PAUSED) {
|
|
||||||
playButton!!.setImageDrawable(
|
|
||||||
getDrawableFromAttribute(
|
|
||||||
requireContext(), R.attr.media_play
|
|
||||||
)
|
|
||||||
)
|
|
||||||
} else if (playerState === PlayerState.STARTED) {
|
|
||||||
playButton!!.setImageDrawable(
|
playButton!!.setImageDrawable(
|
||||||
getDrawableFromAttribute(
|
getDrawableFromAttribute(
|
||||||
requireContext(), R.attr.media_pause
|
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) {
|
if (file != null) {
|
||||||
val song = file.track
|
val song = file.track
|
||||||
|
@ -137,6 +133,7 @@ class NowPlayingFragment : Fragment() {
|
||||||
.navigate(R.id.trackCollectionFragment, bundle)
|
.navigate(R.id.trackCollectionFragment, bundle)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
requireView().setOnTouchListener { _: View?, event: MotionEvent ->
|
requireView().setOnTouchListener { _: View?, event: MotionEvent ->
|
||||||
handleOnTouch(event)
|
handleOnTouch(event)
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,6 +13,7 @@ import android.graphics.Point
|
||||||
import android.graphics.drawable.Drawable
|
import android.graphics.drawable.Drawable
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
import android.view.ContextMenu
|
import android.view.ContextMenu
|
||||||
import android.view.ContextMenu.ContextMenuInfo
|
import android.view.ContextMenu.ContextMenuInfo
|
||||||
import android.view.GestureDetector
|
import android.view.GestureDetector
|
||||||
|
@ -35,22 +36,23 @@ import android.widget.TextView
|
||||||
import android.widget.ViewFlipper
|
import android.widget.ViewFlipper
|
||||||
import androidx.core.view.isVisible
|
import androidx.core.view.isVisible
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.media3.common.Player
|
||||||
|
import androidx.media3.common.Timeline
|
||||||
import androidx.navigation.Navigation
|
import androidx.navigation.Navigation
|
||||||
import androidx.recyclerview.widget.ItemTouchHelper
|
import androidx.recyclerview.widget.ItemTouchHelper
|
||||||
import androidx.recyclerview.widget.ItemTouchHelper.ACTION_STATE_DRAG
|
import androidx.recyclerview.widget.ItemTouchHelper.ACTION_STATE_DRAG
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
import androidx.recyclerview.widget.LinearSmoothScroller
|
import androidx.recyclerview.widget.LinearSmoothScroller
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import io.reactivex.rxjava3.disposables.Disposable
|
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||||
import java.text.DateFormat
|
import java.text.DateFormat
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.ArrayList
|
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import java.util.concurrent.CancellationException
|
|
||||||
import java.util.concurrent.Executors
|
import java.util.concurrent.Executors
|
||||||
import java.util.concurrent.ScheduledExecutorService
|
import java.util.concurrent.ScheduledExecutorService
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
import kotlin.coroutines.cancellation.CancellationException
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
import kotlin.math.max
|
import kotlin.math.max
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
@ -66,15 +68,13 @@ import org.moire.ultrasonic.audiofx.EqualizerController
|
||||||
import org.moire.ultrasonic.audiofx.VisualizerController
|
import org.moire.ultrasonic.audiofx.VisualizerController
|
||||||
import org.moire.ultrasonic.data.ActiveServerProvider.Companion.isOffline
|
import org.moire.ultrasonic.data.ActiveServerProvider.Companion.isOffline
|
||||||
import org.moire.ultrasonic.domain.Identifiable
|
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.domain.Track
|
||||||
import org.moire.ultrasonic.fragment.FragmentTitle.Companion.setTitle
|
import org.moire.ultrasonic.fragment.FragmentTitle.Companion.setTitle
|
||||||
import org.moire.ultrasonic.service.DownloadFile
|
import org.moire.ultrasonic.service.DownloadFile
|
||||||
import org.moire.ultrasonic.service.LocalMediaPlayer
|
|
||||||
import org.moire.ultrasonic.service.MediaPlayerController
|
import org.moire.ultrasonic.service.MediaPlayerController
|
||||||
import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService
|
import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService
|
||||||
import org.moire.ultrasonic.service.RxBus
|
import org.moire.ultrasonic.service.RxBus
|
||||||
|
import org.moire.ultrasonic.service.plusAssign
|
||||||
import org.moire.ultrasonic.subsonic.ImageLoaderProvider
|
import org.moire.ultrasonic.subsonic.ImageLoaderProvider
|
||||||
import org.moire.ultrasonic.subsonic.NetworkAndStorageChecker
|
import org.moire.ultrasonic.subsonic.NetworkAndStorageChecker
|
||||||
import org.moire.ultrasonic.subsonic.ShareHandler
|
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
|
* Contains the Music Player screen of Ultrasonic with playback controls and the playlist
|
||||||
|
* TODO: Add timeline lister -> updateProgressBar().
|
||||||
*/
|
*/
|
||||||
@Suppress("LargeClass", "TooManyFunctions", "MagicNumber")
|
@Suppress("LargeClass", "TooManyFunctions", "MagicNumber")
|
||||||
class PlayerFragment :
|
class PlayerFragment :
|
||||||
|
@ -113,14 +114,13 @@ class PlayerFragment :
|
||||||
// Data & Services
|
// Data & Services
|
||||||
private val networkAndStorageChecker: NetworkAndStorageChecker by inject()
|
private val networkAndStorageChecker: NetworkAndStorageChecker by inject()
|
||||||
private val mediaPlayerController: MediaPlayerController by inject()
|
private val mediaPlayerController: MediaPlayerController by inject()
|
||||||
private val localMediaPlayer: LocalMediaPlayer by inject()
|
|
||||||
private val shareHandler: ShareHandler by inject()
|
private val shareHandler: ShareHandler by inject()
|
||||||
private val imageLoaderProvider: ImageLoaderProvider by inject()
|
private val imageLoaderProvider: ImageLoaderProvider by inject()
|
||||||
private lateinit var executorService: ScheduledExecutorService
|
|
||||||
private var currentPlaying: DownloadFile? = null
|
private var currentPlaying: DownloadFile? = null
|
||||||
private var currentSong: Track? = null
|
private var currentSong: Track? = null
|
||||||
private lateinit var viewManager: LinearLayoutManager
|
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)
|
private var ioScope = CoroutineScope(Dispatchers.IO)
|
||||||
|
|
||||||
// Views and UI Elements
|
// Views and UI Elements
|
||||||
|
@ -148,7 +148,8 @@ class PlayerFragment :
|
||||||
private lateinit var durationTextView: TextView
|
private lateinit var durationTextView: TextView
|
||||||
private lateinit var pauseButton: View
|
private lateinit var pauseButton: View
|
||||||
private lateinit var stopButton: 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 repeatButton: ImageView
|
||||||
private lateinit var hollowStar: Drawable
|
private lateinit var hollowStar: Drawable
|
||||||
private lateinit var fullStar: Drawable
|
private lateinit var fullStar: Drawable
|
||||||
|
@ -189,7 +190,7 @@ class PlayerFragment :
|
||||||
|
|
||||||
pauseButton = view.findViewById(R.id.button_pause)
|
pauseButton = view.findViewById(R.id.button_pause)
|
||||||
stopButton = view.findViewById(R.id.button_stop)
|
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)
|
repeatButton = view.findViewById(R.id.button_repeat)
|
||||||
visualizerViewLayout = view.findViewById(R.id.current_playing_visualizer_layout)
|
visualizerViewLayout = view.findViewById(R.id.current_playing_visualizer_layout)
|
||||||
fiveStar1ImageView = view.findViewById(R.id.song_five_star_1)
|
fiveStar1ImageView = view.findViewById(R.id.song_five_star_1)
|
||||||
|
@ -216,18 +217,13 @@ class PlayerFragment :
|
||||||
swipeVelocity = swipeDistance
|
swipeVelocity = swipeDistance
|
||||||
gestureScanner = GestureDetector(context, this)
|
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)
|
findViews(view)
|
||||||
val previousButton: AutoRepeatButton = view.findViewById(R.id.button_previous)
|
val previousButton: AutoRepeatButton = view.findViewById(R.id.button_previous)
|
||||||
val nextButton: AutoRepeatButton = view.findViewById(R.id.button_next)
|
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)
|
val ratingLinearLayout = view.findViewById<LinearLayout>(R.id.song_rating)
|
||||||
if (!useFiveStarRating) ratingLinearLayout.isVisible = false
|
if (!useFiveStarRating) ratingLinearLayout.isVisible = false
|
||||||
hollowStar = Util.getDrawableFromAttribute(view.context, R.attr.star_hollow)
|
hollowStar = Util.getDrawableFromAttribute(view.context, R.attr.star_hollow)
|
||||||
|
@ -291,34 +287,39 @@ class PlayerFragment :
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
startButton.setOnClickListener {
|
playButton.setOnClickListener {
|
||||||
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
|
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
|
||||||
launch(CommunicationError.getHandler(context)) {
|
launch(CommunicationError.getHandler(context)) {
|
||||||
start()
|
mediaPlayerController.play()
|
||||||
onCurrentChanged()
|
onCurrentChanged()
|
||||||
onSliderProgressChanged()
|
onSliderProgressChanged()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
shuffleButton.setOnClickListener {
|
shuffleButton.setOnClickListener {
|
||||||
mediaPlayerController.shuffle()
|
toggleShuffle()
|
||||||
Util.toast(activity, R.string.download_menu_shuffle_notification)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
repeatButton.setOnClickListener {
|
repeatButton.setOnClickListener {
|
||||||
val repeatMode = mediaPlayerController.repeatMode.next()
|
var newRepeat = mediaPlayerController.repeatMode + 1
|
||||||
mediaPlayerController.repeatMode = repeatMode
|
if (newRepeat == 3) {
|
||||||
|
newRepeat = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
mediaPlayerController.repeatMode = newRepeat
|
||||||
|
|
||||||
onPlaylistChanged()
|
onPlaylistChanged()
|
||||||
when (repeatMode) {
|
|
||||||
RepeatMode.OFF -> Util.toast(
|
when (newRepeat) {
|
||||||
|
0 -> Util.toast(
|
||||||
context, R.string.download_repeat_off
|
context, R.string.download_repeat_off
|
||||||
)
|
)
|
||||||
RepeatMode.ALL -> Util.toast(
|
1 -> Util.toast(
|
||||||
context, R.string.download_repeat_all
|
|
||||||
)
|
|
||||||
RepeatMode.SINGLE -> Util.toast(
|
|
||||||
context, R.string.download_repeat_single
|
context, R.string.download_repeat_single
|
||||||
)
|
)
|
||||||
|
2 -> Util.toast(
|
||||||
|
context, R.string.download_repeat_all
|
||||||
|
)
|
||||||
else -> {
|
else -> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -351,53 +352,67 @@ class PlayerFragment :
|
||||||
|
|
||||||
visualizerViewLayout.isVisible = false
|
visualizerViewLayout.isVisible = false
|
||||||
VisualizerController.get().observe(
|
VisualizerController.get().observe(
|
||||||
requireActivity(),
|
requireActivity()
|
||||||
{ visualizerController ->
|
) { visualizerController ->
|
||||||
if (visualizerController != null) {
|
if (visualizerController != null) {
|
||||||
Timber.d("VisualizerController Observer.onChanged received controller")
|
Timber.d("VisualizerController Observer.onChanged received controller")
|
||||||
visualizerView = VisualizerView(context)
|
visualizerView = VisualizerView(context)
|
||||||
visualizerViewLayout.addView(
|
visualizerViewLayout.addView(
|
||||||
visualizerView,
|
visualizerView,
|
||||||
LinearLayout.LayoutParams(
|
LinearLayout.LayoutParams(
|
||||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||||
LinearLayout.LayoutParams.MATCH_PARENT
|
LinearLayout.LayoutParams.MATCH_PARENT
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
|
||||||
visualizerViewLayout.isVisible = visualizerView.isActive
|
visualizerViewLayout.isVisible = visualizerView.isActive
|
||||||
|
|
||||||
visualizerView.setOnTouchListener { _, _ ->
|
visualizerView.setOnTouchListener { _, _ ->
|
||||||
visualizerView.isActive = !visualizerView.isActive
|
visualizerView.isActive = !visualizerView.isActive
|
||||||
mediaPlayerController.showVisualization = visualizerView.isActive
|
mediaPlayerController.showVisualization = visualizerView.isActive
|
||||||
true
|
true
|
||||||
}
|
|
||||||
isVisualizerAvailable = true
|
|
||||||
} else {
|
|
||||||
Timber.d("VisualizerController Observer.onChanged has no controller")
|
|
||||||
visualizerViewLayout.isVisible = false
|
|
||||||
isVisualizerAvailable = false
|
|
||||||
}
|
}
|
||||||
|
isVisualizerAvailable = true
|
||||||
|
} else {
|
||||||
|
Timber.d("VisualizerController Observer.onChanged has no controller")
|
||||||
|
visualizerViewLayout.isVisible = false
|
||||||
|
isVisualizerAvailable = false
|
||||||
}
|
}
|
||||||
)
|
}
|
||||||
|
|
||||||
EqualizerController.get().observe(
|
EqualizerController.get().observe(
|
||||||
requireActivity(),
|
requireActivity()
|
||||||
{ equalizerController ->
|
) { equalizerController ->
|
||||||
isEqualizerAvailable = if (equalizerController != null) {
|
isEqualizerAvailable = if (equalizerController != null) {
|
||||||
Timber.d("EqualizerController Observer.onChanged received controller")
|
Timber.d("EqualizerController Observer.onChanged received controller")
|
||||||
true
|
true
|
||||||
} else {
|
} else {
|
||||||
Timber.d("EqualizerController Observer.onChanged has no controller")
|
Timber.d("EqualizerController Observer.onChanged has no controller")
|
||||||
false
|
false
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
}
|
||||||
|
|
||||||
// Observe playlist changes and update the UI
|
// Observe playlist changes and update the UI
|
||||||
rxBusSubscription = RxBus.playlistObservable.subscribe {
|
rxBusSubscription += RxBus.playlistObservable.subscribe {
|
||||||
onPlaylistChanged()
|
// 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
|
// Query the Jukebox state in an IO Context
|
||||||
ioScope.launch(CommunicationError.getHandler(context)) {
|
ioScope.launch(CommunicationError.getHandler(context)) {
|
||||||
try {
|
try {
|
||||||
|
@ -410,18 +425,68 @@ class PlayerFragment :
|
||||||
view.setOnTouchListener { _, event -> gestureScanner.onTouchEvent(event) }
|
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() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
if (mediaPlayerController.currentPlaying == null) {
|
if (mediaPlayerController.currentPlayingLegacy == null) {
|
||||||
playlistFlipper.displayedChild = 1
|
playlistFlipper.displayedChild = 1
|
||||||
} else {
|
} else {
|
||||||
// Download list and Album art must be updated when Resumed
|
// Download list and Album art must be updated when resumed
|
||||||
onPlaylistChanged()
|
onPlaylistChanged()
|
||||||
onCurrentChanged()
|
onCurrentChanged()
|
||||||
}
|
}
|
||||||
val handler = Handler()
|
|
||||||
|
|
||||||
// TODO Use Rx for Update instead of polling!
|
val handler = Handler(Looper.getMainLooper())
|
||||||
val runnable = Runnable { handler.post { update(cancellationToken) } }
|
val runnable = Runnable { handler.post { update(cancellationToken) } }
|
||||||
executorService = Executors.newSingleThreadScheduledExecutor()
|
executorService = Executors.newSingleThreadScheduledExecutor()
|
||||||
executorService.scheduleWithFixedDelay(runnable, 0L, 500L, TimeUnit.MILLISECONDS)
|
executorService.scheduleWithFixedDelay(runnable, 0L, 500L, TimeUnit.MILLISECONDS)
|
||||||
|
@ -441,7 +506,7 @@ class PlayerFragment :
|
||||||
|
|
||||||
// Scroll to current playing.
|
// Scroll to current playing.
|
||||||
private fun scrollToCurrent() {
|
private fun scrollToCurrent() {
|
||||||
val index = mediaPlayerController.playList.indexOf(currentPlaying)
|
val index = mediaPlayerController.currentMediaItemIndex
|
||||||
|
|
||||||
if (index != -1) {
|
if (index != -1) {
|
||||||
val smoothScroller = LinearSmoothScroller(context)
|
val smoothScroller = LinearSmoothScroller(context)
|
||||||
|
@ -459,7 +524,7 @@ class PlayerFragment :
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroyView() {
|
override fun onDestroyView() {
|
||||||
rxBusSubscription?.dispose()
|
rxBusSubscription.dispose()
|
||||||
cancel("CoroutineScope cancelled because the view was destroyed")
|
cancel("CoroutineScope cancelled because the view was destroyed")
|
||||||
cancellationToken.cancel()
|
cancellationToken.cancel()
|
||||||
super.onDestroyView()
|
super.onDestroyView()
|
||||||
|
@ -504,7 +569,7 @@ class PlayerFragment :
|
||||||
visualizerMenuItem.isVisible = isVisualizerAvailable
|
visualizerMenuItem.isVisible = isVisualizerAvailable
|
||||||
}
|
}
|
||||||
val mediaPlayerController = mediaPlayerController
|
val mediaPlayerController = mediaPlayerController
|
||||||
val downloadFile = mediaPlayerController.currentPlaying
|
val downloadFile = mediaPlayerController.currentPlayingLegacy
|
||||||
|
|
||||||
if (downloadFile != null) {
|
if (downloadFile != null) {
|
||||||
currentSong = downloadFile.track
|
currentSong = downloadFile.track
|
||||||
|
@ -615,7 +680,6 @@ class PlayerFragment :
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
R.id.menu_remove -> {
|
R.id.menu_remove -> {
|
||||||
mediaPlayerController.removeFromPlaylist(song!!)
|
|
||||||
onPlaylistChanged()
|
onPlaylistChanged()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
@ -631,8 +695,7 @@ class PlayerFragment :
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
R.id.menu_shuffle -> {
|
R.id.menu_shuffle -> {
|
||||||
mediaPlayerController.shuffle()
|
toggleShuffle()
|
||||||
Util.toast(context, R.string.download_menu_shuffle_notification)
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
R.id.menu_item_equalizer -> {
|
R.id.menu_item_equalizer -> {
|
||||||
|
@ -768,10 +831,10 @@ class PlayerFragment :
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun update(cancel: CancellationToken?) {
|
private fun update(cancel: CancellationToken? = null) {
|
||||||
if (cancel!!.isCancellationRequested) return
|
if (cancel?.isCancellationRequested == true) return
|
||||||
val mediaPlayerController = mediaPlayerController
|
val mediaPlayerController = mediaPlayerController
|
||||||
if (currentPlaying != mediaPlayerController.currentPlaying) {
|
if (currentPlaying != mediaPlayerController.currentPlayingLegacy) {
|
||||||
onCurrentChanged()
|
onCurrentChanged()
|
||||||
}
|
}
|
||||||
onSliderProgressChanged()
|
onSliderProgressChanged()
|
||||||
|
@ -822,24 +885,6 @@ class PlayerFragment :
|
||||||
scrollToCurrent()
|
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() {
|
private fun initPlaylistDisplay() {
|
||||||
// Create a View Manager
|
// Create a View Manager
|
||||||
viewManager = LinearLayoutManager(this.context)
|
viewManager = LinearLayoutManager(this.context)
|
||||||
|
@ -852,17 +897,17 @@ class PlayerFragment :
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create listener
|
// Create listener
|
||||||
val listener: ((DownloadFile) -> Unit) = { file ->
|
val clickHandler: ((DownloadFile, Int) -> Unit) = { _, pos ->
|
||||||
val list = mediaPlayerController.playList
|
mediaPlayerController.seekTo(pos, 0)
|
||||||
val index = list.indexOf(file)
|
mediaPlayerController.prepare()
|
||||||
mediaPlayerController.play(index)
|
mediaPlayerController.play()
|
||||||
onCurrentChanged()
|
onCurrentChanged()
|
||||||
onSliderProgressChanged()
|
onSliderProgressChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
viewAdapter.register(
|
viewAdapter.register(
|
||||||
TrackViewBinder(
|
TrackViewBinder(
|
||||||
onItemClick = listener,
|
onItemClick = clickHandler,
|
||||||
checkable = false,
|
checkable = false,
|
||||||
draggable = true,
|
draggable = true,
|
||||||
context = requireContext(),
|
context = requireContext(),
|
||||||
|
@ -874,68 +919,65 @@ class PlayerFragment :
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
dragTouchHelper = ItemTouchHelper(object : ItemTouchHelper.SimpleCallback(
|
val callback = object : ItemTouchHelper.SimpleCallback(
|
||||||
ItemTouchHelper.UP or ItemTouchHelper.DOWN,
|
ItemTouchHelper.UP or ItemTouchHelper.DOWN,
|
||||||
ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT
|
ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT
|
||||||
) {
|
) {
|
||||||
|
override fun onMove(
|
||||||
|
recyclerView: RecyclerView,
|
||||||
|
viewHolder: RecyclerView.ViewHolder,
|
||||||
|
target: RecyclerView.ViewHolder
|
||||||
|
): Boolean {
|
||||||
|
|
||||||
override fun onMove(
|
val from = viewHolder.bindingAdapterPosition
|
||||||
recyclerView: RecyclerView,
|
val to = target.bindingAdapterPosition
|
||||||
viewHolder: RecyclerView.ViewHolder,
|
|
||||||
target: RecyclerView.ViewHolder
|
|
||||||
): Boolean {
|
|
||||||
|
|
||||||
val from = viewHolder.bindingAdapterPosition
|
// Move it in the data set
|
||||||
val to = target.bindingAdapterPosition
|
mediaPlayerController.moveItemInPlaylist(from, to)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// Move it in the data set
|
// Swipe to delete from playlist
|
||||||
mediaPlayerController.moveItemInPlaylist(from, to)
|
@SuppressLint("NotifyDataSetChanged")
|
||||||
viewAdapter.submitList(mediaPlayerController.playList)
|
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
|
Util.toast(context, songRemoved)
|
||||||
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
|
}
|
||||||
val pos = viewHolder.bindingAdapterPosition
|
|
||||||
val file = mediaPlayerController.playList[pos]
|
|
||||||
mediaPlayerController.removeFromPlaylist(file)
|
|
||||||
|
|
||||||
val songRemoved = String.format(
|
override fun onSelectedChanged(
|
||||||
resources.getString(R.string.download_song_removed),
|
viewHolder: RecyclerView.ViewHolder?,
|
||||||
file.track.title
|
actionState: Int
|
||||||
)
|
) {
|
||||||
Util.toast(context, songRemoved)
|
super.onSelectedChanged(viewHolder, actionState)
|
||||||
|
|
||||||
viewAdapter.submitList(mediaPlayerController.playList)
|
if (actionState == ACTION_STATE_DRAG) {
|
||||||
viewAdapter.notifyDataSetChanged()
|
viewHolder?.itemView?.alpha = ALPHA_DEACTIVATED
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
|
||||||
|
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)
|
dragTouchHelper.attachToRecyclerView(playlistView)
|
||||||
}
|
}
|
||||||
|
@ -949,33 +991,16 @@ class PlayerFragment :
|
||||||
|
|
||||||
emptyTextView.isVisible = list.isEmpty()
|
emptyTextView.isVisible = list.isEmpty()
|
||||||
|
|
||||||
when (mediaPlayerController.repeatMode) {
|
updateRepeatButtonState(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 -> {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onCurrentChanged() {
|
private fun onCurrentChanged() {
|
||||||
currentPlaying = mediaPlayerController.currentPlaying
|
currentPlaying = mediaPlayerController.currentPlayingLegacy
|
||||||
|
|
||||||
scrollToCurrent()
|
scrollToCurrent()
|
||||||
val totalDuration = mediaPlayerController.playListDuration
|
val totalDuration = mediaPlayerController.playListDuration
|
||||||
val totalSongs = mediaPlayerController.playlistSize.toLong()
|
val totalSongs = mediaPlayerController.playlistSize.toLong()
|
||||||
val currentSongIndex = mediaPlayerController.currentPlayingNumberOnPlaylist + 1
|
val currentSongIndex = mediaPlayerController.currentMediaItemIndex + 1
|
||||||
val duration = Util.formatTotalDuration(totalDuration)
|
val duration = Util.formatTotalDuration(totalDuration)
|
||||||
val trackFormat =
|
val trackFormat =
|
||||||
String.format(Locale.getDefault(), "%d / %d", currentSongIndex, totalSongs)
|
String.format(Locale.getDefault(), "%d / %d", currentSongIndex, totalSongs)
|
||||||
|
@ -992,7 +1017,7 @@ class PlayerFragment :
|
||||||
genreTextView.isVisible =
|
genreTextView.isVisible =
|
||||||
(currentSong!!.genre != null && currentSong!!.genre!!.isNotBlank())
|
(currentSong!!.genre != null && currentSong!!.genre!!.isNotBlank())
|
||||||
|
|
||||||
var bitRate: String = ""
|
var bitRate = ""
|
||||||
if (currentSong!!.bitRate != null && currentSong!!.bitRate!! > 0)
|
if (currentSong!!.bitRate != null && currentSong!!.bitRate!! > 0)
|
||||||
bitRate = String.format(
|
bitRate = String.format(
|
||||||
Util.appContext().getString(R.string.song_details_kbps),
|
Util.appContext().getString(R.string.song_details_kbps),
|
||||||
|
@ -1027,14 +1052,15 @@ class PlayerFragment :
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suppress("LongMethod", "ComplexMethod")
|
@Suppress("LongMethod")
|
||||||
@Synchronized
|
@Synchronized
|
||||||
private fun onSliderProgressChanged() {
|
private fun onSliderProgressChanged() {
|
||||||
|
|
||||||
val isJukeboxEnabled: Boolean = mediaPlayerController.isJukeboxEnabled
|
val isJukeboxEnabled: Boolean = mediaPlayerController.isJukeboxEnabled
|
||||||
val millisPlayed: Int = max(0, mediaPlayerController.playerPosition)
|
val millisPlayed: Int = max(0, mediaPlayerController.playerPosition)
|
||||||
val duration: Int = mediaPlayerController.playerDuration
|
val duration: Int = mediaPlayerController.playerDuration
|
||||||
val playerState: PlayerState = mediaPlayerController.playerState
|
val playbackState: Int = mediaPlayerController.playbackState
|
||||||
|
val isPlaying = mediaPlayerController.isPlaying
|
||||||
|
|
||||||
if (cancellationToken.isCancellationRequested) return
|
if (cancellationToken.isCancellationRequested) return
|
||||||
if (currentPlaying != null) {
|
if (currentPlaying != null) {
|
||||||
|
@ -1043,7 +1069,7 @@ class PlayerFragment :
|
||||||
progressBar.max =
|
progressBar.max =
|
||||||
if (duration == 0) 100 else duration // Work-around for apparent bug.
|
if (duration == 0) 100 else duration // Work-around for apparent bug.
|
||||||
progressBar.progress = millisPlayed
|
progressBar.progress = millisPlayed
|
||||||
progressBar.isEnabled = currentPlaying!!.isWorkDone || isJukeboxEnabled
|
progressBar.isEnabled = mediaPlayerController.isPlaying || isJukeboxEnabled
|
||||||
} else {
|
} else {
|
||||||
positionTextView.setText(R.string.util_zero_time)
|
positionTextView.setText(R.string.util_zero_time)
|
||||||
durationTextView.setText(R.string.util_no_time)
|
durationTextView.setText(R.string.util_no_time)
|
||||||
|
@ -1052,21 +1078,19 @@ class PlayerFragment :
|
||||||
progressBar.isEnabled = false
|
progressBar.isEnabled = false
|
||||||
}
|
}
|
||||||
|
|
||||||
when (playerState) {
|
val progress = mediaPlayerController.bufferedPercentage
|
||||||
PlayerState.DOWNLOADING -> {
|
|
||||||
val progress =
|
when (playbackState) {
|
||||||
if (currentPlaying != null) currentPlaying!!.progress.value!! else 0
|
Player.STATE_BUFFERING -> {
|
||||||
|
|
||||||
val downloadStatus = resources.getString(
|
val downloadStatus = resources.getString(
|
||||||
R.string.download_playerstate_downloading,
|
R.string.download_playerstate_loading
|
||||||
Util.formatPercentage(progress)
|
|
||||||
)
|
)
|
||||||
|
progressBar.secondaryProgress = progress
|
||||||
setTitle(this@PlayerFragment, downloadStatus)
|
setTitle(this@PlayerFragment, downloadStatus)
|
||||||
}
|
}
|
||||||
PlayerState.PREPARING -> setTitle(
|
Player.STATE_READY -> {
|
||||||
this@PlayerFragment,
|
progressBar.secondaryProgress = progress
|
||||||
R.string.download_playerstate_buffering
|
|
||||||
)
|
|
||||||
PlayerState.STARTED -> {
|
|
||||||
if (mediaPlayerController.isShufflePlayEnabled) {
|
if (mediaPlayerController.isShufflePlayEnabled) {
|
||||||
setTitle(
|
setTitle(
|
||||||
this@PlayerFragment,
|
this@PlayerFragment,
|
||||||
|
@ -1076,30 +1100,28 @@ class PlayerFragment :
|
||||||
setTitle(this@PlayerFragment, R.string.common_appname)
|
setTitle(this@PlayerFragment, R.string.common_appname)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
PlayerState.IDLE,
|
Player.STATE_IDLE,
|
||||||
PlayerState.PREPARED,
|
Player.STATE_ENDED,
|
||||||
PlayerState.STOPPED,
|
-> {
|
||||||
PlayerState.PAUSED,
|
|
||||||
PlayerState.COMPLETED -> {
|
|
||||||
}
|
}
|
||||||
else -> setTitle(this@PlayerFragment, R.string.common_appname)
|
else -> setTitle(this@PlayerFragment, R.string.common_appname)
|
||||||
}
|
}
|
||||||
|
|
||||||
when (playerState) {
|
when (playbackState) {
|
||||||
PlayerState.STARTED -> {
|
Player.STATE_READY -> {
|
||||||
pauseButton.isVisible = true
|
pauseButton.isVisible = isPlaying
|
||||||
stopButton.isVisible = false
|
stopButton.isVisible = false
|
||||||
startButton.isVisible = false
|
playButton.isVisible = !isPlaying
|
||||||
}
|
}
|
||||||
PlayerState.DOWNLOADING, PlayerState.PREPARING -> {
|
Player.STATE_BUFFERING -> {
|
||||||
pauseButton.isVisible = false
|
pauseButton.isVisible = false
|
||||||
stopButton.isVisible = true
|
stopButton.isVisible = true
|
||||||
startButton.isVisible = false
|
playButton.isVisible = false
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
pauseButton.isVisible = false
|
pauseButton.isVisible = false
|
||||||
stopButton.isVisible = false
|
stopButton.isVisible = false
|
||||||
startButton.isVisible = true
|
playButton.isVisible = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1238,5 +1260,7 @@ class PlayerFragment :
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val PERCENTAGE_OF_SCREEN_FOR_SWIPE = 5
|
private const val PERCENTAGE_OF_SCREEN_FOR_SWIPE = 5
|
||||||
|
private const val ALPHA_ACTIVATED = 1f
|
||||||
|
private const val ALPHA_DEACTIVATED = 0.4f
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -109,7 +109,7 @@ class SearchFragment : MultiListFragment<Identifiable>(), KoinComponent {
|
||||||
|
|
||||||
viewAdapter.register(
|
viewAdapter.register(
|
||||||
TrackViewBinder(
|
TrackViewBinder(
|
||||||
onItemClick = ::onItemClick,
|
onItemClick = { file, _ -> onItemClick(file) },
|
||||||
onContextMenuClick = ::onContextMenuItemSelected,
|
onContextMenuClick = ::onContextMenuItemSelected,
|
||||||
checkable = false,
|
checkable = false,
|
||||||
draggable = false,
|
draggable = false,
|
||||||
|
@ -303,13 +303,12 @@ class SearchFragment : MultiListFragment<Identifiable>(), KoinComponent {
|
||||||
}
|
}
|
||||||
mediaPlayerController.addToPlaylist(
|
mediaPlayerController.addToPlaylist(
|
||||||
listOf(song),
|
listOf(song),
|
||||||
save = false,
|
cachePermanently = false,
|
||||||
autoPlay = false,
|
autoPlay = false,
|
||||||
playNext = false,
|
|
||||||
shuffle = 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))
|
toast(context, resources.getQuantityString(R.plurals.select_album_n_songs_added, 1, 1))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -9,14 +9,12 @@ import android.widget.ListView
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.navigation.fragment.findNavController
|
import androidx.navigation.fragment.findNavController
|
||||||
import com.google.android.material.floatingactionbutton.FloatingActionButton
|
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.android.ext.android.inject
|
||||||
import org.koin.androidx.viewmodel.ext.android.viewModel
|
import org.koin.androidx.viewmodel.ext.android.viewModel
|
||||||
import org.moire.ultrasonic.R
|
import org.moire.ultrasonic.R
|
||||||
import org.moire.ultrasonic.adapters.ServerRowAdapter
|
import org.moire.ultrasonic.adapters.ServerRowAdapter
|
||||||
import org.moire.ultrasonic.data.ActiveServerProvider
|
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.fragment.EditServerFragment.Companion.EDIT_SERVER_INTENT_INDEX
|
||||||
import org.moire.ultrasonic.model.ServerSettingsModel
|
import org.moire.ultrasonic.model.ServerSettingsModel
|
||||||
import org.moire.ultrasonic.service.MediaPlayerController
|
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
|
* Displays the list of configured servers, they can be selected or edited
|
||||||
|
*
|
||||||
|
* TODO: Manage mode is unused. Remove it...
|
||||||
*/
|
*/
|
||||||
class ServerSelectorFragment : Fragment() {
|
class ServerSelectorFragment : Fragment() {
|
||||||
companion object {
|
companion object {
|
||||||
|
@ -34,7 +34,7 @@ class ServerSelectorFragment : Fragment() {
|
||||||
|
|
||||||
private var listView: ListView? = null
|
private var listView: ListView? = null
|
||||||
private val serverSettingsModel: ServerSettingsModel by viewModel()
|
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 val activeServerProvider: ActiveServerProvider by inject()
|
||||||
private var serverRowAdapter: ServerRowAdapter? = null
|
private var serverRowAdapter: ServerRowAdapter? = null
|
||||||
|
|
||||||
|
@ -59,6 +59,7 @@ class ServerSelectorFragment : Fragment() {
|
||||||
SERVER_SELECTOR_MANAGE_MODE,
|
SERVER_SELECTOR_MANAGE_MODE,
|
||||||
false
|
false
|
||||||
) ?: false
|
) ?: false
|
||||||
|
|
||||||
if (manageMode) {
|
if (manageMode) {
|
||||||
FragmentTitle.setTitle(this, R.string.settings_server_manage_servers)
|
FragmentTitle.setTitle(this, R.string.settings_server_manage_servers)
|
||||||
} else {
|
} else {
|
||||||
|
@ -72,31 +73,26 @@ class ServerSelectorFragment : Fragment() {
|
||||||
serverSettingsModel,
|
serverSettingsModel,
|
||||||
activeServerProvider,
|
activeServerProvider,
|
||||||
manageMode,
|
manageMode,
|
||||||
{
|
::deleteServerById,
|
||||||
i ->
|
::editServerByIndex
|
||||||
onServerDeleted(i)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
i ->
|
|
||||||
editServer(i)
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
listView?.adapter = serverRowAdapter
|
listView?.adapter = serverRowAdapter
|
||||||
|
|
||||||
listView?.onItemClickListener = AdapterView.OnItemClickListener {
|
listView?.onItemClickListener = AdapterView.OnItemClickListener { parent, _, position, _ ->
|
||||||
_, _, position, _ ->
|
|
||||||
|
val server = parent.getItemAtPosition(position) as ServerSetting
|
||||||
if (manageMode) {
|
if (manageMode) {
|
||||||
editServer(position + 1)
|
editServerByIndex(position + 1)
|
||||||
} else {
|
} else {
|
||||||
setActiveServer(position)
|
setActiveServerById(server.id)
|
||||||
findNavController().popBackStack(R.id.mainFragment, false)
|
findNavController().popBackStack(R.id.mainFragment, false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val fab = view.findViewById<FloatingActionButton>(R.id.server_add_fab)
|
val fab = view.findViewById<FloatingActionButton>(R.id.server_add_fab)
|
||||||
fab.setOnClickListener {
|
fab.setOnClickListener {
|
||||||
editServer(-1)
|
editServerByIndex(-1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -113,44 +109,37 @@ class ServerSelectorFragment : Fragment() {
|
||||||
/**
|
/**
|
||||||
* Sets the active server when a list item is clicked
|
* Sets the active server when a list item is clicked
|
||||||
*/
|
*/
|
||||||
private fun setActiveServer(index: Int) {
|
private fun setActiveServerById(id: 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
|
controller.clearIncomplete()
|
||||||
runBlocking {
|
|
||||||
withContext(Dispatchers.IO) {
|
if (activeServerProvider.getActiveServer().id != id) {
|
||||||
if (activeServerProvider.getActiveServer().index != index) {
|
ActiveServerProvider.setActiveServerById(id)
|
||||||
service.clearIncomplete()
|
|
||||||
activeServerProvider.setActiveServerByIndex(index)
|
|
||||||
service.isJukeboxEnabled =
|
|
||||||
activeServerProvider.getActiveServer().jukeboxByDefault
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Timber.i("Active server was set to: $index")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This Callback handles the deletion of a Server Setting
|
* This Callback handles the deletion of a Server Setting
|
||||||
*/
|
*/
|
||||||
private fun onServerDeleted(index: Int) {
|
private fun deleteServerById(id: Int) {
|
||||||
ErrorDialog.Builder(context)
|
ErrorDialog.Builder(context)
|
||||||
.setTitle(R.string.server_menu_delete)
|
.setTitle(R.string.server_menu_delete)
|
||||||
.setMessage(R.string.server_selector_delete_confirmation)
|
.setMessage(R.string.server_selector_delete_confirmation)
|
||||||
.setPositiveButton(R.string.common_delete) { dialog, _ ->
|
.setPositiveButton(R.string.common_delete) { dialog, _ ->
|
||||||
dialog.dismiss()
|
dialog.dismiss()
|
||||||
|
|
||||||
val activeServerIndex = activeServerProvider.getActiveServer().index
|
// Get the id of the current active server
|
||||||
val id = ActiveServerProvider.getActiveServerId()
|
val activeServerId = ActiveServerProvider.getActiveServerId()
|
||||||
|
|
||||||
// If the currently active server is deleted, go offline
|
// 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
|
// 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, _ ->
|
.setNegativeButton(R.string.common_cancel) { dialog, _ ->
|
||||||
dialog.dismiss()
|
dialog.dismiss()
|
||||||
|
@ -161,7 +150,7 @@ class ServerSelectorFragment : Fragment() {
|
||||||
/**
|
/**
|
||||||
* Starts the Edit Server Fragment to edit the details of a server
|
* 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()
|
val bundle = Bundle()
|
||||||
bundle.putInt(EDIT_SERVER_INTENT_INDEX, index)
|
bundle.putInt(EDIT_SERVER_INTENT_INDEX, index)
|
||||||
findNavController().navigate(R.id.serverSelectorToEditServer, bundle)
|
findNavController().navigate(R.id.serverSelectorToEditServer, bundle)
|
||||||
|
|
|
@ -18,12 +18,11 @@ import androidx.preference.CheckBoxPreference
|
||||||
import androidx.preference.EditTextPreference
|
import androidx.preference.EditTextPreference
|
||||||
import androidx.preference.ListPreference
|
import androidx.preference.ListPreference
|
||||||
import androidx.preference.Preference
|
import androidx.preference.Preference
|
||||||
import androidx.preference.PreferenceCategory
|
|
||||||
import androidx.preference.PreferenceFragmentCompat
|
import androidx.preference.PreferenceFragmentCompat
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import kotlin.math.ceil
|
import kotlin.math.ceil
|
||||||
import org.koin.core.component.KoinComponent
|
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.R
|
||||||
import org.moire.ultrasonic.app.UApp
|
import org.moire.ultrasonic.app.UApp
|
||||||
import org.moire.ultrasonic.fragment.FragmentTitle.Companion.setTitle
|
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.ErrorDialog
|
||||||
import org.moire.ultrasonic.util.FileUtil.ultrasonicDirectory
|
import org.moire.ultrasonic.util.FileUtil.ultrasonicDirectory
|
||||||
import org.moire.ultrasonic.util.InfoDialog
|
import org.moire.ultrasonic.util.InfoDialog
|
||||||
import org.moire.ultrasonic.util.MediaSessionHandler
|
|
||||||
import org.moire.ultrasonic.util.Settings
|
import org.moire.ultrasonic.util.Settings
|
||||||
import org.moire.ultrasonic.util.Settings.preferences
|
import org.moire.ultrasonic.util.Settings.preferences
|
||||||
import org.moire.ultrasonic.util.Settings.shareGreeting
|
import org.moire.ultrasonic.util.Settings.shareGreeting
|
||||||
|
@ -77,9 +75,6 @@ class SettingsFragment :
|
||||||
private var chatRefreshInterval: ListPreference? = null
|
private var chatRefreshInterval: ListPreference? = null
|
||||||
private var directoryCacheTime: ListPreference? = null
|
private var directoryCacheTime: ListPreference? = null
|
||||||
private var mediaButtonsEnabled: CheckBoxPreference? = 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 showArtistPicture: CheckBoxPreference? = null
|
||||||
private var sharingDefaultDescription: EditTextPreference? = null
|
private var sharingDefaultDescription: EditTextPreference? = null
|
||||||
private var sharingDefaultGreeting: EditTextPreference? = null
|
private var sharingDefaultGreeting: EditTextPreference? = null
|
||||||
|
@ -89,12 +84,7 @@ class SettingsFragment :
|
||||||
private var debugLogToFile: CheckBoxPreference? = null
|
private var debugLogToFile: CheckBoxPreference? = null
|
||||||
private var customCacheLocation: CheckBoxPreference? = null
|
private var customCacheLocation: CheckBoxPreference? = null
|
||||||
|
|
||||||
private val mediaPlayerControllerLazy = inject<MediaPlayerController>(
|
private val mediaPlayerController: MediaPlayerController by inject()
|
||||||
MediaPlayerController::class.java
|
|
||||||
)
|
|
||||||
private val mediaSessionHandler = inject<MediaSessionHandler>(
|
|
||||||
MediaSessionHandler::class.java
|
|
||||||
)
|
|
||||||
|
|
||||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||||
setPreferencesFromResource(R.xml.settings, rootKey)
|
setPreferencesFromResource(R.xml.settings, rootKey)
|
||||||
|
@ -121,10 +111,6 @@ class SettingsFragment :
|
||||||
chatRefreshInterval = findPreference(Constants.PREFERENCES_KEY_CHAT_REFRESH_INTERVAL)
|
chatRefreshInterval = findPreference(Constants.PREFERENCES_KEY_CHAT_REFRESH_INTERVAL)
|
||||||
directoryCacheTime = findPreference(Constants.PREFERENCES_KEY_DIRECTORY_CACHE_TIME)
|
directoryCacheTime = findPreference(Constants.PREFERENCES_KEY_DIRECTORY_CACHE_TIME)
|
||||||
mediaButtonsEnabled = findPreference(Constants.PREFERENCES_KEY_MEDIA_BUTTONS)
|
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 =
|
sharingDefaultDescription =
|
||||||
findPreference(Constants.PREFERENCES_KEY_DEFAULT_SHARE_DESCRIPTION)
|
findPreference(Constants.PREFERENCES_KEY_DEFAULT_SHARE_DESCRIPTION)
|
||||||
sharingDefaultGreeting = findPreference(Constants.PREFERENCES_KEY_DEFAULT_SHARE_GREETING)
|
sharingDefaultGreeting = findPreference(Constants.PREFERENCES_KEY_DEFAULT_SHARE_GREETING)
|
||||||
|
@ -137,25 +123,10 @@ class SettingsFragment :
|
||||||
showArtistPicture = findPreference(Constants.PREFERENCES_KEY_SHOW_ARTIST_PICTURE)
|
showArtistPicture = findPreference(Constants.PREFERENCES_KEY_SHOW_ARTIST_PICTURE)
|
||||||
customCacheLocation = findPreference(Constants.PREFERENCES_KEY_CUSTOM_CACHE_LOCATION)
|
customCacheLocation = findPreference(Constants.PREFERENCES_KEY_CUSTOM_CACHE_LOCATION)
|
||||||
|
|
||||||
sharingDefaultGreeting!!.text = shareGreeting
|
sharingDefaultGreeting?.text = shareGreeting
|
||||||
setupClearSearchPreference()
|
setupClearSearchPreference()
|
||||||
setupCacheLocationPreference()
|
setupCacheLocationPreference()
|
||||||
setupBluetoothDevicePreferences()
|
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?) {
|
override fun onActivityCreated(savedInstanceState: Bundle?) {
|
||||||
|
@ -221,12 +192,6 @@ class SettingsFragment :
|
||||||
Constants.PREFERENCES_KEY_HIDE_MEDIA -> {
|
Constants.PREFERENCES_KEY_HIDE_MEDIA -> {
|
||||||
setHideMedia(sharedPreferences.getBoolean(key, false))
|
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 -> {
|
Constants.PREFERENCES_KEY_DEBUG_LOG_TO_FILE -> {
|
||||||
setDebugLogToFile(sharedPreferences.getBoolean(key, false))
|
setDebugLogToFile(sharedPreferences.getBoolean(key, false))
|
||||||
}
|
}
|
||||||
|
@ -306,9 +271,7 @@ class SettingsFragment :
|
||||||
R.string.settings_playback_resume_on_bluetooth_device,
|
R.string.settings_playback_resume_on_bluetooth_device,
|
||||||
Settings.resumeOnBluetoothDevice
|
Settings.resumeOnBluetoothDevice
|
||||||
) { choice: Int ->
|
) { choice: Int ->
|
||||||
val editor = resumeOnBluetoothDevice!!.sharedPreferences.edit()
|
Settings.resumeOnBluetoothDevice = choice
|
||||||
editor.putInt(Constants.PREFERENCES_KEY_RESUME_ON_BLUETOOTH_DEVICE, choice)
|
|
||||||
editor.apply()
|
|
||||||
resumeOnBluetoothDevice!!.summary = bluetoothDevicePreferenceToString(choice)
|
resumeOnBluetoothDevice!!.summary = bluetoothDevicePreferenceToString(choice)
|
||||||
}
|
}
|
||||||
true
|
true
|
||||||
|
@ -399,23 +362,16 @@ class SettingsFragment :
|
||||||
sharingDefaultExpiration!!.summary = sharingDefaultExpiration!!.text
|
sharingDefaultExpiration!!.summary = sharingDefaultExpiration!!.text
|
||||||
sharingDefaultDescription!!.summary = sharingDefaultDescription!!.text
|
sharingDefaultDescription!!.summary = sharingDefaultDescription!!.text
|
||||||
sharingDefaultGreeting!!.summary = sharingDefaultGreeting!!.text
|
sharingDefaultGreeting!!.summary = sharingDefaultGreeting!!.text
|
||||||
if (!mediaButtonsEnabled!!.isChecked) {
|
|
||||||
lockScreenEnabled!!.isChecked = false
|
if (debugLogToFile?.isChecked == true) {
|
||||||
lockScreenEnabled!!.isEnabled = false
|
debugLogToFile?.summary = getString(
|
||||||
}
|
|
||||||
if (!sendBluetoothNotifications!!.isChecked) {
|
|
||||||
sendBluetoothAlbumArt!!.isChecked = false
|
|
||||||
sendBluetoothAlbumArt!!.isEnabled = false
|
|
||||||
}
|
|
||||||
if (debugLogToFile!!.isChecked) {
|
|
||||||
debugLogToFile!!.summary = getString(
|
|
||||||
R.string.settings_debug_log_path,
|
R.string.settings_debug_log_path,
|
||||||
ultrasonicDirectory, FileLoggerTree.FILENAME
|
ultrasonicDirectory, FileLoggerTree.FILENAME
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
debugLogToFile!!.summary = ""
|
debugLogToFile?.summary = ""
|
||||||
}
|
}
|
||||||
showArtistPicture!!.isEnabled = shouldUseId3Tags
|
showArtistPicture?.isEnabled = shouldUseId3Tags
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setHideMedia(hide: Boolean) {
|
private fun setHideMedia(hide: Boolean) {
|
||||||
|
@ -433,15 +389,6 @@ class SettingsFragment :
|
||||||
toast(activity, R.string.settings_hide_media_toast, false)
|
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) {
|
private fun setCacheLocation(path: String) {
|
||||||
if (path != "") {
|
if (path != "") {
|
||||||
val uri = Uri.parse(path)
|
val uri = Uri.parse(path)
|
||||||
|
@ -451,8 +398,8 @@ class SettingsFragment :
|
||||||
Settings.cacheLocationUri = path
|
Settings.cacheLocationUri = path
|
||||||
|
|
||||||
// Clear download queue.
|
// Clear download queue.
|
||||||
mediaPlayerControllerLazy.value.clear()
|
mediaPlayerController.clear()
|
||||||
mediaPlayerControllerLazy.value.clearCaches()
|
mediaPlayerController.clearCaches()
|
||||||
Storage.reset()
|
Storage.reset()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -122,7 +122,7 @@ open class TrackCollectionFragment : MultiListFragment<MusicDirectory.Child>() {
|
||||||
|
|
||||||
viewAdapter.register(
|
viewAdapter.register(
|
||||||
TrackViewBinder(
|
TrackViewBinder(
|
||||||
onItemClick = { onItemClick(it.track) },
|
onItemClick = { file, _ -> onItemClick(file.track) },
|
||||||
onContextMenuClick = { menu, id -> onContextMenuItemSelected(menu, id.track) },
|
onContextMenuClick = { menu, id -> onContextMenuItemSelected(menu, id.track) },
|
||||||
checkable = true,
|
checkable = true,
|
||||||
draggable = false,
|
draggable = false,
|
||||||
|
|
|
@ -12,6 +12,7 @@ import kotlinx.coroutines.runBlocking
|
||||||
import org.moire.ultrasonic.R
|
import org.moire.ultrasonic.R
|
||||||
import org.moire.ultrasonic.app.UApp
|
import org.moire.ultrasonic.app.UApp
|
||||||
import org.moire.ultrasonic.data.ActiveServerProvider
|
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.ServerSetting
|
||||||
import org.moire.ultrasonic.data.ServerSettingDao
|
import org.moire.ultrasonic.data.ServerSettingDao
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
@ -30,6 +31,8 @@ class ServerSettingsModel(
|
||||||
/**
|
/**
|
||||||
* Retrieves the list of the configured servers from the database.
|
* Retrieves the list of the configured servers from the database.
|
||||||
* This function is asynchronous, uses LiveData to provide the Setting.
|
* This function is asynchronous, uses LiveData to provide the Setting.
|
||||||
|
*
|
||||||
|
* It does not include the Offline "server".
|
||||||
*/
|
*/
|
||||||
fun getServerList(): LiveData<List<ServerSetting>> {
|
fun getServerList(): LiveData<List<ServerSetting>> {
|
||||||
// This check should run before returning any result
|
// This check should run before returning any result
|
||||||
|
@ -92,14 +95,14 @@ class ServerSettingsModel(
|
||||||
/**
|
/**
|
||||||
* Removes a Setting from the database
|
* Removes a Setting from the database
|
||||||
*/
|
*/
|
||||||
fun deleteItem(index: Int) {
|
fun deleteItemById(id: Int) {
|
||||||
if (index == 0) return
|
if (id == OFFLINE_DB_ID) return
|
||||||
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val itemToBeDeleted = repository.findByIndex(index)
|
val itemToBeDeleted = repository.findById(id)
|
||||||
if (itemToBeDeleted != null) {
|
if (itemToBeDeleted != null) {
|
||||||
repository.delete(itemToBeDeleted)
|
repository.delete(itemToBeDeleted)
|
||||||
Timber.d("deleteItem deleted index: $index")
|
Timber.d("deleteItem deleted id: $id")
|
||||||
reindexSettings()
|
reindexSettings()
|
||||||
activeServerProvider.invalidateCache()
|
activeServerProvider.invalidateCache()
|
||||||
}
|
}
|
||||||
|
@ -127,7 +130,6 @@ class ServerSettingsModel(
|
||||||
|
|
||||||
appScope.launch {
|
appScope.launch {
|
||||||
serverSetting.index = (repository.count() ?: 0) + 1
|
serverSetting.index = (repository.count() ?: 0) + 1
|
||||||
serverSetting.id = (repository.getMaxId() ?: 0) + 1
|
|
||||||
repository.insert(serverSetting)
|
repository.insert(serverSetting)
|
||||||
Timber.d("saveNewItem saved server setting: $serverSetting")
|
Timber.d("saveNewItem saved server setting: $serverSetting")
|
||||||
}
|
}
|
||||||
|
@ -142,12 +144,11 @@ class ServerSettingsModel(
|
||||||
|
|
||||||
runBlocking {
|
runBlocking {
|
||||||
demo.index = (repository.count() ?: 0) + 1
|
demo.index = (repository.count() ?: 0) + 1
|
||||||
demo.id = (repository.getMaxId() ?: 0) + 1
|
|
||||||
repository.insert(demo)
|
repository.insert(demo)
|
||||||
Timber.d("Added demo server")
|
Timber.d("Added demo server")
|
||||||
}
|
}
|
||||||
|
|
||||||
return demo.id
|
return demo.index
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -0,0 +1,336 @@
|
||||||
|
/*
|
||||||
|
* APIDataSource.kt
|
||||||
|
* Copyright (C) 2009-2022 Ultrasonic developers
|
||||||
|
*
|
||||||
|
* Distributed under terms of the GNU GPLv3 license.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.moire.ultrasonic.playback
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import androidx.media3.common.C
|
||||||
|
import androidx.media3.common.PlaybackException
|
||||||
|
import androidx.media3.common.util.Assertions
|
||||||
|
import androidx.media3.common.util.Util
|
||||||
|
import androidx.media3.datasource.BaseDataSource
|
||||||
|
import androidx.media3.datasource.DataSourceException
|
||||||
|
import androidx.media3.datasource.DataSpec
|
||||||
|
import androidx.media3.datasource.HttpDataSource
|
||||||
|
import androidx.media3.datasource.HttpDataSource.HttpDataSourceException
|
||||||
|
import androidx.media3.datasource.HttpDataSource.InvalidResponseCodeException
|
||||||
|
import androidx.media3.datasource.HttpDataSource.RequestProperties
|
||||||
|
import androidx.media3.datasource.HttpUtil
|
||||||
|
import androidx.media3.datasource.TransferListener
|
||||||
|
import com.google.common.net.HttpHeaders
|
||||||
|
import java.io.IOException
|
||||||
|
import java.io.InputStream
|
||||||
|
import java.io.InterruptedIOException
|
||||||
|
import okhttp3.Call
|
||||||
|
import okhttp3.ResponseBody
|
||||||
|
import org.moire.ultrasonic.api.subsonic.SubsonicAPIClient
|
||||||
|
import org.moire.ultrasonic.api.subsonic.response.StreamResponse
|
||||||
|
import org.moire.ultrasonic.api.subsonic.throwOnFailure
|
||||||
|
import org.moire.ultrasonic.api.subsonic.toStreamResponse
|
||||||
|
import timber.log.Timber
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An [HttpDataSource] that delegates to Square's [Call.Factory].
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* Note: HTTP request headers will be set using all parameters passed via (in order of decreasing
|
||||||
|
* priority) the `dataSpec`, [.setRequestProperty] and the default parameters used to
|
||||||
|
* construct the instance.
|
||||||
|
*/
|
||||||
|
@SuppressLint("UnsafeOptInUsageError")
|
||||||
|
@Suppress("MagicNumber")
|
||||||
|
open class APIDataSource private constructor(
|
||||||
|
subsonicAPIClient: SubsonicAPIClient
|
||||||
|
) : BaseDataSource(true),
|
||||||
|
HttpDataSource {
|
||||||
|
|
||||||
|
/** [DataSource.Factory] for [APIDataSource] instances. */
|
||||||
|
class Factory(private var subsonicAPIClient: SubsonicAPIClient) : HttpDataSource.Factory {
|
||||||
|
private val defaultRequestProperties: RequestProperties = RequestProperties()
|
||||||
|
private var transferListener: TransferListener? = null
|
||||||
|
|
||||||
|
override fun setDefaultRequestProperties(
|
||||||
|
defaultRequestProperties: Map<String, String>
|
||||||
|
): Factory {
|
||||||
|
this.defaultRequestProperties.clearAndSet(defaultRequestProperties)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the [TransferListener] that will be used.
|
||||||
|
*
|
||||||
|
* See [DataSource.addTransferListener].
|
||||||
|
*
|
||||||
|
* @param transferListener The listener that will be used.
|
||||||
|
* @return This factory.
|
||||||
|
*/
|
||||||
|
fun setTransferListener(transferListener: TransferListener?): Factory {
|
||||||
|
this.transferListener = transferListener
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setAPIClient(newClient: SubsonicAPIClient) {
|
||||||
|
this.subsonicAPIClient = newClient
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun createDataSource(): APIDataSource {
|
||||||
|
val dataSource = APIDataSource(
|
||||||
|
subsonicAPIClient
|
||||||
|
)
|
||||||
|
if (transferListener != null) {
|
||||||
|
dataSource.addTransferListener(transferListener!!)
|
||||||
|
}
|
||||||
|
return dataSource
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val subsonicAPIClient: SubsonicAPIClient = Assertions.checkNotNull(subsonicAPIClient)
|
||||||
|
private val requestProperties: RequestProperties = RequestProperties()
|
||||||
|
private var dataSpec: DataSpec? = null
|
||||||
|
private var response: retrofit2.Response<ResponseBody>? = null
|
||||||
|
private var responseByteStream: InputStream? = null
|
||||||
|
private var openedNetwork = false
|
||||||
|
private var bytesToRead: Long = 0
|
||||||
|
private var bytesRead: Long = 0
|
||||||
|
|
||||||
|
override fun getUri(): Uri? {
|
||||||
|
return when (response) {
|
||||||
|
null -> null
|
||||||
|
else -> response!!.raw().request.url.toString().toUri()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getResponseCode(): Int {
|
||||||
|
return if (response == null) -1 else response!!.code()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getResponseHeaders(): Map<String, List<String>> {
|
||||||
|
return if (response == null) emptyMap() else response!!.headers().toMultimap()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setRequestProperty(name: String, value: String) {
|
||||||
|
Assertions.checkNotNull(name)
|
||||||
|
Assertions.checkNotNull(value)
|
||||||
|
requestProperties[name] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun clearRequestProperty(name: String) {
|
||||||
|
Assertions.checkNotNull(name)
|
||||||
|
requestProperties.remove(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun clearAllRequestProperties() {
|
||||||
|
requestProperties.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("LongMethod", "NestedBlockDepth")
|
||||||
|
@Throws(HttpDataSourceException::class)
|
||||||
|
override fun open(dataSpec: DataSpec): Long {
|
||||||
|
Timber.i(
|
||||||
|
"APIDatasource: Open: %s %s %s",
|
||||||
|
dataSpec.uri,
|
||||||
|
dataSpec.position,
|
||||||
|
dataSpec.toString()
|
||||||
|
)
|
||||||
|
|
||||||
|
this.dataSpec = dataSpec
|
||||||
|
bytesRead = 0
|
||||||
|
bytesToRead = 0
|
||||||
|
|
||||||
|
transferInitializing(dataSpec)
|
||||||
|
val components = dataSpec.uri.toString().split('|')
|
||||||
|
val id = components[0]
|
||||||
|
val bitrate = components[1].toInt()
|
||||||
|
val request = subsonicAPIClient.api.stream(id, bitrate, offset = dataSpec.position)
|
||||||
|
val response: retrofit2.Response<ResponseBody>?
|
||||||
|
val streamResponse: StreamResponse
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.response = request.execute()
|
||||||
|
response = this.response
|
||||||
|
streamResponse = response!!.toStreamResponse()
|
||||||
|
responseByteStream = streamResponse.stream
|
||||||
|
} catch (e: IOException) {
|
||||||
|
throw HttpDataSourceException.createForIOException(
|
||||||
|
e, dataSpec, HttpDataSourceException.TYPE_OPEN
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
streamResponse.throwOnFailure()
|
||||||
|
|
||||||
|
val responseCode = response.code()
|
||||||
|
|
||||||
|
// Check for a valid response code.
|
||||||
|
if (!response.isSuccessful) {
|
||||||
|
if (responseCode == 416) {
|
||||||
|
val documentSize =
|
||||||
|
HttpUtil.getDocumentSize(response.headers()[HttpHeaders.CONTENT_RANGE])
|
||||||
|
if (dataSpec.position == documentSize) {
|
||||||
|
openedNetwork = true
|
||||||
|
transferStarted(dataSpec)
|
||||||
|
return if (dataSpec.length != C.LENGTH_UNSET.toLong()) dataSpec.length else 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val errorResponseBody: ByteArray = try {
|
||||||
|
Util.toByteArray(Assertions.checkNotNull(responseByteStream))
|
||||||
|
} catch (ignore: IOException) {
|
||||||
|
Util.EMPTY_BYTE_ARRAY
|
||||||
|
}
|
||||||
|
val headers = response.headers().toMultimap()
|
||||||
|
closeConnectionQuietly()
|
||||||
|
val cause: IOException? =
|
||||||
|
if (responseCode == 416) DataSourceException(
|
||||||
|
PlaybackException.ERROR_CODE_IO_READ_POSITION_OUT_OF_RANGE
|
||||||
|
) else null
|
||||||
|
throw InvalidResponseCodeException(
|
||||||
|
responseCode, response.message(), cause, headers, dataSpec, errorResponseBody
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we requested a range starting from a non-zero position and received a 200 rather than a
|
||||||
|
// 206, then the server does not support partial requests. We'll need to manually skip to the
|
||||||
|
// requested position.
|
||||||
|
val bytesToSkip =
|
||||||
|
if (responseCode == 200 && dataSpec.position != 0L) dataSpec.position else 0
|
||||||
|
|
||||||
|
// Determine the length of the data to be read, after skipping.
|
||||||
|
bytesToRead = if (dataSpec.length != C.LENGTH_UNSET.toLong()) {
|
||||||
|
dataSpec.length
|
||||||
|
} else {
|
||||||
|
val contentLength = response.body()!!.contentLength()
|
||||||
|
if (contentLength != -1L) contentLength - bytesToSkip else C.LENGTH_UNSET.toLong()
|
||||||
|
}
|
||||||
|
openedNetwork = true
|
||||||
|
transferStarted(dataSpec)
|
||||||
|
try {
|
||||||
|
skipFully(bytesToSkip, dataSpec)
|
||||||
|
} catch (e: HttpDataSourceException) {
|
||||||
|
closeConnectionQuietly()
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
|
||||||
|
return bytesToRead
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(HttpDataSourceException::class)
|
||||||
|
override fun read(buffer: ByteArray, offset: Int, length: Int): Int {
|
||||||
|
// Timber.d("APIDatasource: Read: %s %s", offset, length)
|
||||||
|
return try {
|
||||||
|
readInternal(buffer, offset, length)
|
||||||
|
} catch (e: IOException) {
|
||||||
|
throw HttpDataSourceException.createForIOException(
|
||||||
|
e, Util.castNonNull(dataSpec), HttpDataSourceException.TYPE_READ
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
Timber.i("APIDatasource: Close")
|
||||||
|
if (openedNetwork) {
|
||||||
|
openedNetwork = false
|
||||||
|
transferEnded()
|
||||||
|
closeConnectionQuietly()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempts to skip the specified number of bytes in full.
|
||||||
|
*
|
||||||
|
* @param bytesToSkip The number of bytes to skip.
|
||||||
|
* @param dataSpec The [DataSpec].
|
||||||
|
* @throws HttpDataSourceException If the thread is interrupted during the operation, or an error
|
||||||
|
* occurs while reading from the source, or if the data ended before skipping the specified
|
||||||
|
* number of bytes.
|
||||||
|
*/
|
||||||
|
@Suppress("ThrowsCount")
|
||||||
|
@Throws(HttpDataSourceException::class)
|
||||||
|
private fun skipFully(bytesToSkip: Long, dataSpec: DataSpec) {
|
||||||
|
var bytesToSkip = bytesToSkip
|
||||||
|
if (bytesToSkip == 0L) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val skipBuffer = ByteArray(4096)
|
||||||
|
try {
|
||||||
|
while (bytesToSkip > 0) {
|
||||||
|
val readLength =
|
||||||
|
bytesToSkip.coerceAtMost(skipBuffer.size.toLong()).toInt()
|
||||||
|
val read = Util.castNonNull(responseByteStream).read(skipBuffer, 0, readLength)
|
||||||
|
if (Thread.currentThread().isInterrupted) {
|
||||||
|
throw InterruptedIOException()
|
||||||
|
}
|
||||||
|
if (read == -1) {
|
||||||
|
throw HttpDataSourceException(
|
||||||
|
dataSpec,
|
||||||
|
PlaybackException.ERROR_CODE_IO_READ_POSITION_OUT_OF_RANGE,
|
||||||
|
HttpDataSourceException.TYPE_OPEN
|
||||||
|
)
|
||||||
|
}
|
||||||
|
bytesToSkip -= read.toLong()
|
||||||
|
bytesTransferred(read)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
} catch (e: IOException) {
|
||||||
|
if (e is HttpDataSourceException) {
|
||||||
|
throw e
|
||||||
|
} else {
|
||||||
|
throw HttpDataSourceException(
|
||||||
|
dataSpec,
|
||||||
|
PlaybackException.ERROR_CODE_IO_UNSPECIFIED,
|
||||||
|
HttpDataSourceException.TYPE_OPEN
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads up to `length` bytes of data and stores them into `buffer`, starting at index
|
||||||
|
* `offset`.
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* This method blocks until at least one byte of data can be read, the end of the opened range
|
||||||
|
* is detected, or an exception is thrown.
|
||||||
|
*
|
||||||
|
* @param buffer The buffer into which the read data should be stored.
|
||||||
|
* @param offset The start offset into `buffer` at which data should be written.
|
||||||
|
* @param readLength The maximum number of bytes to read.
|
||||||
|
* @return The number of bytes read, or [C.RESULT_END_OF_INPUT] if the end of the opened
|
||||||
|
* range is reached.
|
||||||
|
* @throws IOException If an error occurs reading from the source.
|
||||||
|
*/
|
||||||
|
@Throws(IOException::class)
|
||||||
|
private fun readInternal(buffer: ByteArray, offset: Int, readLength: Int): Int {
|
||||||
|
var readLength = readLength
|
||||||
|
if (readLength == 0) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if (bytesToRead != C.LENGTH_UNSET.toLong()) {
|
||||||
|
val bytesRemaining = bytesToRead - bytesRead
|
||||||
|
if (bytesRemaining == 0L) {
|
||||||
|
return C.RESULT_END_OF_INPUT
|
||||||
|
}
|
||||||
|
readLength = readLength.toLong().coerceAtMost(bytesRemaining).toInt()
|
||||||
|
}
|
||||||
|
val read = Util.castNonNull(responseByteStream).read(buffer, offset, readLength)
|
||||||
|
if (read == -1) {
|
||||||
|
return C.RESULT_END_OF_INPUT
|
||||||
|
}
|
||||||
|
bytesRead += read.toLong()
|
||||||
|
bytesTransferred(read)
|
||||||
|
return read
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Closes the current connection quietly, if there is one. */
|
||||||
|
private fun closeConnectionQuietly() {
|
||||||
|
if (response != null) {
|
||||||
|
Assertions.checkNotNull(response!!.body()).close()
|
||||||
|
response = null
|
||||||
|
}
|
||||||
|
responseByteStream = null
|
||||||
|
}
|
||||||
|
}
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,220 @@
|
||||||
|
/*
|
||||||
|
* CachedDataSource.kt
|
||||||
|
* Copyright (C) 2009-2022 Ultrasonic developers
|
||||||
|
*
|
||||||
|
* Distributed under terms of the GNU GPLv3 license.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.moire.ultrasonic.playback
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import androidx.media3.common.C
|
||||||
|
import androidx.media3.common.PlaybackException
|
||||||
|
import androidx.media3.common.util.Util
|
||||||
|
import androidx.media3.datasource.BaseDataSource
|
||||||
|
import androidx.media3.datasource.DataSource
|
||||||
|
import androidx.media3.datasource.DataSpec
|
||||||
|
import androidx.media3.datasource.HttpDataSource.HttpDataSourceException
|
||||||
|
import java.io.IOException
|
||||||
|
import java.io.InputStream
|
||||||
|
import java.io.InterruptedIOException
|
||||||
|
import org.moire.ultrasonic.util.AbstractFile
|
||||||
|
import org.moire.ultrasonic.util.FileUtil
|
||||||
|
import org.moire.ultrasonic.util.Storage
|
||||||
|
import timber.log.Timber
|
||||||
|
|
||||||
|
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
|
||||||
|
class CachedDataSource(
|
||||||
|
private var upstreamDataSource: DataSource
|
||||||
|
) : BaseDataSource(true) {
|
||||||
|
|
||||||
|
class Factory(
|
||||||
|
private var upstreamDataSourceFactory: DataSource.Factory
|
||||||
|
) : DataSource.Factory {
|
||||||
|
|
||||||
|
override fun createDataSource(): CachedDataSource {
|
||||||
|
return createDataSourceInternal(
|
||||||
|
upstreamDataSourceFactory.createDataSource()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createDataSourceInternal(
|
||||||
|
upstreamDataSource: DataSource
|
||||||
|
): CachedDataSource {
|
||||||
|
return CachedDataSource(
|
||||||
|
upstreamDataSource
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var bytesToRead: Long = 0
|
||||||
|
private var bytesRead: Long = 0
|
||||||
|
private var dataSpec: DataSpec? = null
|
||||||
|
private var responseByteStream: InputStream? = null
|
||||||
|
private var openedFile = false
|
||||||
|
private var cachePath: String? = null
|
||||||
|
private var cacheFile: AbstractFile? = null
|
||||||
|
|
||||||
|
override fun open(dataSpec: DataSpec): Long {
|
||||||
|
Timber.i(
|
||||||
|
"CachedDatasource: Open: %s",
|
||||||
|
dataSpec.toString()
|
||||||
|
)
|
||||||
|
|
||||||
|
this.dataSpec = dataSpec
|
||||||
|
bytesRead = 0
|
||||||
|
bytesToRead = 0
|
||||||
|
|
||||||
|
val components = dataSpec.uri.toString().split('|')
|
||||||
|
val path = components[2]
|
||||||
|
val cacheLength = checkCache(path)
|
||||||
|
|
||||||
|
// We have found an item in the cache, return early
|
||||||
|
if (cacheLength > 0) {
|
||||||
|
transferInitializing(dataSpec)
|
||||||
|
bytesToRead = cacheLength
|
||||||
|
transferStarted(dataSpec)
|
||||||
|
skipFully(dataSpec.position, dataSpec)
|
||||||
|
return bytesToRead
|
||||||
|
}
|
||||||
|
|
||||||
|
// else forward the call to upstream
|
||||||
|
return upstreamDataSource.open(dataSpec)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("MagicNumber")
|
||||||
|
override fun read(buffer: ByteArray, offset: Int, length: Int): Int {
|
||||||
|
// if (offset > 0 || length > 4)
|
||||||
|
// Timber.d("CachedDatasource: Read: %s %s", offset, length)
|
||||||
|
return if (cachePath != null) {
|
||||||
|
try {
|
||||||
|
readInternal(buffer, offset, length)
|
||||||
|
} catch (e: IOException) {
|
||||||
|
throw HttpDataSourceException.createForIOException(
|
||||||
|
e, Util.castNonNull(dataSpec), HttpDataSourceException.TYPE_READ
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
upstreamDataSource.read(buffer, offset, length)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readInternal(buffer: ByteArray, offset: Int, readLength: Int): Int {
|
||||||
|
var readLength = readLength
|
||||||
|
if (readLength == 0) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if (bytesToRead != C.LENGTH_UNSET.toLong()) {
|
||||||
|
val bytesRemaining = bytesToRead - bytesRead
|
||||||
|
if (bytesRemaining == 0L) {
|
||||||
|
return C.RESULT_END_OF_INPUT
|
||||||
|
}
|
||||||
|
readLength = readLength.toLong().coerceAtMost(bytesRemaining).toInt()
|
||||||
|
}
|
||||||
|
val read = Util.castNonNull(responseByteStream).read(buffer, offset, readLength)
|
||||||
|
if (read == -1) {
|
||||||
|
Timber.i("CachedDatasource: EndOfInput")
|
||||||
|
return C.RESULT_END_OF_INPUT
|
||||||
|
}
|
||||||
|
bytesRead += read.toLong()
|
||||||
|
bytesTransferred(read)
|
||||||
|
return read
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempts to skip the specified number of bytes in full.
|
||||||
|
*
|
||||||
|
* @param bytesToSkip The number of bytes to skip.
|
||||||
|
* @param dataSpec The [DataSpec].
|
||||||
|
* @throws HttpDataSourceException If the thread is interrupted during the operation, or an error
|
||||||
|
* occurs while reading from the source, or if the data ended before skipping the specified
|
||||||
|
* number of bytes.
|
||||||
|
*/
|
||||||
|
@Suppress("ThrowsCount")
|
||||||
|
@Throws(HttpDataSourceException::class)
|
||||||
|
private fun skipFully(bytesToSkip: Long, dataSpec: DataSpec) {
|
||||||
|
var bytesToSkip = bytesToSkip
|
||||||
|
if (bytesToSkip == 0L) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val skipBuffer = ByteArray(4096)
|
||||||
|
try {
|
||||||
|
while (bytesToSkip > 0) {
|
||||||
|
val readLength =
|
||||||
|
bytesToSkip.coerceAtMost(skipBuffer.size.toLong()).toInt()
|
||||||
|
val read = Util.castNonNull(responseByteStream).read(skipBuffer, 0, readLength)
|
||||||
|
if (Thread.currentThread().isInterrupted) {
|
||||||
|
throw InterruptedIOException()
|
||||||
|
}
|
||||||
|
if (read == -1) {
|
||||||
|
throw HttpDataSourceException(
|
||||||
|
dataSpec,
|
||||||
|
PlaybackException.ERROR_CODE_IO_READ_POSITION_OUT_OF_RANGE,
|
||||||
|
HttpDataSourceException.TYPE_OPEN
|
||||||
|
)
|
||||||
|
}
|
||||||
|
bytesToSkip -= read.toLong()
|
||||||
|
bytesTransferred(read)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
} catch (e: IOException) {
|
||||||
|
if (e is HttpDataSourceException) {
|
||||||
|
throw e
|
||||||
|
} else {
|
||||||
|
throw HttpDataSourceException(
|
||||||
|
dataSpec,
|
||||||
|
PlaybackException.ERROR_CODE_IO_UNSPECIFIED,
|
||||||
|
HttpDataSourceException.TYPE_OPEN
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This method is called by StatsDataSource to verify that the loading succeeded,
|
||||||
|
* so its important that we return the correct value here..
|
||||||
|
*/
|
||||||
|
override fun getUri(): Uri? {
|
||||||
|
return cachePath?.toUri() ?: upstreamDataSource.uri
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
Timber.i("CachedDatasource: close %s", openedFile)
|
||||||
|
if (openedFile) {
|
||||||
|
openedFile = false
|
||||||
|
transferEnded()
|
||||||
|
responseByteStream?.close()
|
||||||
|
responseByteStream = null
|
||||||
|
} else {
|
||||||
|
upstreamDataSource.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks our cache for a matching media file
|
||||||
|
*/
|
||||||
|
private fun checkCache(path: String): Long {
|
||||||
|
var filePath: String = path
|
||||||
|
var found = Storage.isPathExists(path)
|
||||||
|
|
||||||
|
if (!found) {
|
||||||
|
filePath = FileUtil.getCompleteFile(path)
|
||||||
|
found = Storage.isPathExists(filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!found) return -1
|
||||||
|
|
||||||
|
cachePath = filePath
|
||||||
|
openedFile = true
|
||||||
|
|
||||||
|
cacheFile = Storage.getFromPath(filePath)!!
|
||||||
|
responseByteStream = cacheFile!!.getFileInputStream()
|
||||||
|
|
||||||
|
val descriptor = cacheFile!!.getDocumentFileDescriptor("r")
|
||||||
|
val length = descriptor!!.length
|
||||||
|
descriptor.close()
|
||||||
|
|
||||||
|
return length
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,110 @@
|
||||||
|
/*
|
||||||
|
* LegacyPlaylist.kt
|
||||||
|
* Copyright (C) 2009-2022 Ultrasonic developers
|
||||||
|
*
|
||||||
|
* Distributed under terms of the GNU GPLv3 license.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.moire.ultrasonic.playback
|
||||||
|
|
||||||
|
import androidx.media3.common.MediaItem
|
||||||
|
import androidx.media3.session.MediaController
|
||||||
|
import org.koin.core.component.KoinComponent
|
||||||
|
import org.koin.core.component.inject
|
||||||
|
import org.moire.ultrasonic.domain.Track
|
||||||
|
import org.moire.ultrasonic.service.DownloadFile
|
||||||
|
import org.moire.ultrasonic.service.Downloader
|
||||||
|
import org.moire.ultrasonic.service.JukeboxMediaPlayer
|
||||||
|
import org.moire.ultrasonic.service.RxBus
|
||||||
|
import org.moire.ultrasonic.util.LRUCache
|
||||||
|
import timber.log.Timber
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This class keeps a legacy playlist maintained which
|
||||||
|
* reflects the internal timeline of the Media3.Player
|
||||||
|
*/
|
||||||
|
class LegacyPlaylistManager : KoinComponent {
|
||||||
|
|
||||||
|
private val _playlist = mutableListOf<DownloadFile>()
|
||||||
|
|
||||||
|
@JvmField
|
||||||
|
var currentPlaying: DownloadFile? = null
|
||||||
|
|
||||||
|
// TODO This limits the maximum size of the playlist.
|
||||||
|
// This will be fixed when this class is refactored and removed
|
||||||
|
private val mediaItemCache = LRUCache<String, DownloadFile>(2000)
|
||||||
|
|
||||||
|
val jukeboxMediaPlayer: JukeboxMediaPlayer by inject()
|
||||||
|
val downloader: Downloader by inject()
|
||||||
|
|
||||||
|
private var playlistUpdateRevision: Long = 0
|
||||||
|
private set(value) {
|
||||||
|
field = value
|
||||||
|
RxBus.playlistPublisher.onNext(_playlist)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun rebuildPlaylist(controller: MediaController) {
|
||||||
|
_playlist.clear()
|
||||||
|
|
||||||
|
val n = controller.mediaItemCount
|
||||||
|
|
||||||
|
for (i in 0 until n) {
|
||||||
|
val item = controller.getMediaItemAt(i)
|
||||||
|
val file = mediaItemCache[item.mediaMetadata.mediaUri.toString()]
|
||||||
|
if (file != null)
|
||||||
|
_playlist.add(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
playlistUpdateRevision++
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addToCache(item: MediaItem, file: DownloadFile) {
|
||||||
|
mediaItemCache.put(item.mediaMetadata.mediaUri.toString(), file)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateCurrentPlaying(item: MediaItem?) {
|
||||||
|
currentPlaying = mediaItemCache[item?.mediaMetadata?.mediaUri.toString()]
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun clearPlaylist() {
|
||||||
|
_playlist.clear()
|
||||||
|
playlistUpdateRevision++
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onDestroy() {
|
||||||
|
clearPlaylist()
|
||||||
|
Timber.i("PlaylistManager destroyed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public facing playlist (immutable)
|
||||||
|
val playlist: List<DownloadFile>
|
||||||
|
get() = _playlist
|
||||||
|
|
||||||
|
@get:Synchronized
|
||||||
|
val playlistDuration: Long
|
||||||
|
get() {
|
||||||
|
var totalDuration: Long = 0
|
||||||
|
for (downloadFile in _playlist) {
|
||||||
|
val song = downloadFile.track
|
||||||
|
if (!song.isDirectory) {
|
||||||
|
if (song.artist != null) {
|
||||||
|
if (song.duration != null) {
|
||||||
|
totalDuration += song.duration!!.toLong()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return totalDuration
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extension function
|
||||||
|
* Gathers the download file for a given song, and modifies shouldSave if provided.
|
||||||
|
*/
|
||||||
|
fun Track.getDownloadFile(save: Boolean? = null): DownloadFile {
|
||||||
|
return downloader.getDownloadFileForSong(this).apply {
|
||||||
|
if (save != null) this.shouldSave = save
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,150 @@
|
||||||
|
/*
|
||||||
|
* MediaNotificationProvider.kt
|
||||||
|
* Copyright (C) 2009-2022 Ultrasonic developers
|
||||||
|
*
|
||||||
|
* Distributed under terms of the GNU GPLv3 license.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.moire.ultrasonic.playback
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.graphics.drawable.IconCompat
|
||||||
|
import androidx.media3.common.Player
|
||||||
|
import androidx.media3.common.util.Assertions
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import androidx.media3.common.util.Util
|
||||||
|
import androidx.media3.session.MediaController
|
||||||
|
import androidx.media3.session.MediaNotification
|
||||||
|
import androidx.media3.session.MediaNotification.ActionFactory
|
||||||
|
import org.moire.ultrasonic.R
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This is a copy of DefaultMediaNotificationProvider.java with some small changes
|
||||||
|
* I have opened a bug https://github.com/androidx/media/issues/65 to make it easier to customize
|
||||||
|
* the icons and actions without creating our own copy of this class..
|
||||||
|
*/
|
||||||
|
@UnstableApi
|
||||||
|
/* package */
|
||||||
|
internal class MediaNotificationProvider(context: Context) :
|
||||||
|
MediaNotification.Provider {
|
||||||
|
private val context: Context = context.applicationContext
|
||||||
|
private val notificationManager: NotificationManager = Assertions.checkStateNotNull(
|
||||||
|
context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
)
|
||||||
|
|
||||||
|
@Suppress("LongMethod")
|
||||||
|
override fun createNotification(
|
||||||
|
mediaController: MediaController,
|
||||||
|
actionFactory: ActionFactory,
|
||||||
|
onNotificationChangedCallback: MediaNotification.Provider.Callback
|
||||||
|
): MediaNotification {
|
||||||
|
ensureNotificationChannel()
|
||||||
|
val builder: NotificationCompat.Builder = NotificationCompat.Builder(
|
||||||
|
context,
|
||||||
|
NOTIFICATION_CHANNEL_ID
|
||||||
|
)
|
||||||
|
// Skip to previous action.
|
||||||
|
builder.addAction(
|
||||||
|
actionFactory.createMediaAction(
|
||||||
|
IconCompat.createWithResource(
|
||||||
|
context,
|
||||||
|
R.drawable.media3_notification_seek_to_previous
|
||||||
|
),
|
||||||
|
context.getString(R.string.media3_controls_seek_to_previous_description),
|
||||||
|
ActionFactory.COMMAND_SKIP_TO_PREVIOUS
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if (mediaController.playbackState == Player.STATE_ENDED ||
|
||||||
|
!mediaController.playWhenReady
|
||||||
|
) {
|
||||||
|
// Play action.
|
||||||
|
builder.addAction(
|
||||||
|
actionFactory.createMediaAction(
|
||||||
|
IconCompat.createWithResource(context, R.drawable.media3_notification_play),
|
||||||
|
context.getString(R.string.media3_controls_play_description),
|
||||||
|
ActionFactory.COMMAND_PLAY
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// Pause action.
|
||||||
|
builder.addAction(
|
||||||
|
actionFactory.createMediaAction(
|
||||||
|
IconCompat.createWithResource(context, R.drawable.media3_notification_pause),
|
||||||
|
context.getString(R.string.media3_controls_pause_description),
|
||||||
|
ActionFactory.COMMAND_PAUSE
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Skip to next action.
|
||||||
|
builder.addAction(
|
||||||
|
actionFactory.createMediaAction(
|
||||||
|
IconCompat.createWithResource(context, R.drawable.media3_notification_seek_to_next),
|
||||||
|
context.getString(R.string.media3_controls_seek_to_next_description),
|
||||||
|
ActionFactory.COMMAND_SKIP_TO_NEXT
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Set metadata info in the notification.
|
||||||
|
val metadata = mediaController.mediaMetadata
|
||||||
|
builder.setContentTitle(metadata.title).setContentText(metadata.artist)
|
||||||
|
if (metadata.artworkData != null) {
|
||||||
|
val artworkBitmap =
|
||||||
|
BitmapFactory.decodeByteArray(metadata.artworkData, 0, metadata.artworkData!!.size)
|
||||||
|
builder.setLargeIcon(artworkBitmap)
|
||||||
|
}
|
||||||
|
val mediaStyle = androidx.media.app.NotificationCompat.MediaStyle()
|
||||||
|
.setShowActionsInCompactView(0, 1, 2)
|
||||||
|
val notification: Notification = builder
|
||||||
|
.setContentIntent(mediaController.sessionActivity)
|
||||||
|
.setOnlyAlertOnce(true)
|
||||||
|
.setSmallIcon(getSmallIconResId())
|
||||||
|
.setStyle(mediaStyle)
|
||||||
|
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||||
|
.setOngoing(false)
|
||||||
|
.build()
|
||||||
|
return MediaNotification(
|
||||||
|
NOTIFICATION_ID,
|
||||||
|
notification
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun handleCustomAction(
|
||||||
|
mediaController: MediaController,
|
||||||
|
action: String,
|
||||||
|
extras: Bundle
|
||||||
|
) {
|
||||||
|
// We don't handle custom commands.
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ensureNotificationChannel() {
|
||||||
|
if (Util.SDK_INT < Build.VERSION_CODES.O ||
|
||||||
|
notificationManager.getNotificationChannel(NOTIFICATION_CHANNEL_ID) != null
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val channel = NotificationChannel(
|
||||||
|
NOTIFICATION_CHANNEL_ID,
|
||||||
|
NOTIFICATION_CHANNEL_NAME,
|
||||||
|
NotificationManager.IMPORTANCE_LOW
|
||||||
|
)
|
||||||
|
channel.setShowBadge(false)
|
||||||
|
|
||||||
|
notificationManager.createNotificationChannel(channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val NOTIFICATION_CHANNEL_ID = "org.moire.ultrasonic"
|
||||||
|
private const val NOTIFICATION_CHANNEL_NAME = "Ultrasonic background service"
|
||||||
|
private const val NOTIFICATION_ID = 3032
|
||||||
|
private fun getSmallIconResId(): Int {
|
||||||
|
return R.drawable.ic_stat_ultrasonic
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,17 @@
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
UI:
|
||||||
|
[x] Display tracks
|
||||||
|
[x] On selection: Translate Tracks to MediaItems
|
||||||
|
[x] Move playlist val to Controller: Keep it around for easier migration!!
|
||||||
|
[x] Also make a LRU Cache to help with translation between MediaItem and DownloadFile
|
||||||
|
[x] Hand MediaItems to Service
|
||||||
|
[] If wanted also hand them to Downloader.kt
|
||||||
|
[x] Service plays MediaItem through OkHttp
|
||||||
|
[x] UI needs to receive info from service
|
||||||
|
[x] Create a Cache Layer
|
||||||
|
[] Translate AutoMediaBrowserService
|
||||||
|
[] Add new shuffle icon....
|
||||||
|
|
||||||
|
convertToPlaybackStateCompatState()
|
|
@ -0,0 +1,177 @@
|
||||||
|
/*
|
||||||
|
* PlaybackService.kt
|
||||||
|
* Copyright (C) 2009-2022 Ultrasonic developers
|
||||||
|
*
|
||||||
|
* Distributed under terms of the GNU GPLv3 license.
|
||||||
|
*/
|
||||||
|
package org.moire.ultrasonic.playback
|
||||||
|
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.media3.common.AudioAttributes
|
||||||
|
import androidx.media3.common.C
|
||||||
|
import androidx.media3.common.C.CONTENT_TYPE_MUSIC
|
||||||
|
import androidx.media3.common.C.USAGE_MEDIA
|
||||||
|
import androidx.media3.common.MediaItem
|
||||||
|
import androidx.media3.datasource.DataSource
|
||||||
|
import androidx.media3.exoplayer.DefaultRenderersFactory
|
||||||
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
|
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||||
|
import androidx.media3.session.MediaLibraryService
|
||||||
|
import androidx.media3.session.MediaSession
|
||||||
|
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||||
|
import org.koin.core.component.KoinComponent
|
||||||
|
import org.koin.core.component.inject
|
||||||
|
import org.moire.ultrasonic.activity.NavigationActivity
|
||||||
|
import org.moire.ultrasonic.api.subsonic.SubsonicAPIClient
|
||||||
|
import org.moire.ultrasonic.app.UApp
|
||||||
|
import org.moire.ultrasonic.data.ActiveServerProvider
|
||||||
|
import org.moire.ultrasonic.service.RxBus
|
||||||
|
import org.moire.ultrasonic.service.plusAssign
|
||||||
|
import org.moire.ultrasonic.util.Constants
|
||||||
|
import org.moire.ultrasonic.util.Settings
|
||||||
|
import timber.log.Timber
|
||||||
|
|
||||||
|
class PlaybackService : MediaLibraryService(), KoinComponent {
|
||||||
|
private lateinit var player: ExoPlayer
|
||||||
|
private lateinit var mediaLibrarySession: MediaLibrarySession
|
||||||
|
private lateinit var apiDataSource: APIDataSource.Factory
|
||||||
|
|
||||||
|
private lateinit var librarySessionCallback: MediaLibrarySession.MediaLibrarySessionCallback
|
||||||
|
|
||||||
|
private var rxBusSubscription = CompositeDisposable()
|
||||||
|
|
||||||
|
private var isStarted = false
|
||||||
|
|
||||||
|
/*
|
||||||
|
* For some reason the LocalConfiguration of MediaItem are stripped somewhere in ExoPlayer,
|
||||||
|
* and thereby customarily it is required to rebuild it..
|
||||||
|
*/
|
||||||
|
private class CustomMediaItemFiller : MediaSession.MediaItemFiller {
|
||||||
|
override fun fillInLocalConfiguration(
|
||||||
|
session: MediaSession,
|
||||||
|
controller: MediaSession.ControllerInfo,
|
||||||
|
mediaItem: MediaItem
|
||||||
|
): MediaItem {
|
||||||
|
// Again, set the Uri, so that it will get a LocalConfiguration
|
||||||
|
return mediaItem.buildUpon()
|
||||||
|
.setUri(mediaItem.mediaMetadata.mediaUri)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
Timber.i("onCreate called")
|
||||||
|
super.onCreate()
|
||||||
|
initializeSessionAndPlayer()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getWakeModeFlag(): Int {
|
||||||
|
return if (ActiveServerProvider.isOffline()) C.WAKE_MODE_LOCAL else C.WAKE_MODE_NETWORK
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
Timber.i("onDestroy called")
|
||||||
|
releasePlayerAndSession()
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaLibrarySession {
|
||||||
|
return mediaLibrarySession
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||||
|
Timber.i("Stopping the playback because we were swiped away")
|
||||||
|
releasePlayerAndSession()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun releasePlayerAndSession() {
|
||||||
|
// Broadcast that the service is being shutdown
|
||||||
|
RxBus.stopCommandPublisher.onNext(Unit)
|
||||||
|
|
||||||
|
player.release()
|
||||||
|
mediaLibrarySession.release()
|
||||||
|
rxBusSubscription.dispose()
|
||||||
|
isStarted = false
|
||||||
|
stopForeground(true)
|
||||||
|
stopSelf()
|
||||||
|
}
|
||||||
|
|
||||||
|
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
|
||||||
|
private fun initializeSessionAndPlayer() {
|
||||||
|
if (isStarted) return
|
||||||
|
|
||||||
|
setMediaNotificationProvider(MediaNotificationProvider(UApp.applicationContext()))
|
||||||
|
|
||||||
|
val subsonicAPIClient: SubsonicAPIClient by inject()
|
||||||
|
|
||||||
|
// Create a MediaSource which passes calls through our OkHttp Stack
|
||||||
|
apiDataSource = APIDataSource.Factory(subsonicAPIClient)
|
||||||
|
val cacheDataSourceFactory: DataSource.Factory = CachedDataSource.Factory(apiDataSource)
|
||||||
|
|
||||||
|
// Create a renderer with HW rendering support
|
||||||
|
val renderer = DefaultRenderersFactory(this)
|
||||||
|
|
||||||
|
if (Settings.useHwOffload)
|
||||||
|
renderer.setEnableAudioOffload(true)
|
||||||
|
|
||||||
|
// Create the player
|
||||||
|
player = ExoPlayer.Builder(this)
|
||||||
|
.setAudioAttributes(getAudioAttributes(), true)
|
||||||
|
.setWakeMode(getWakeModeFlag())
|
||||||
|
.setHandleAudioBecomingNoisy(true)
|
||||||
|
.setMediaSourceFactory(DefaultMediaSourceFactory(cacheDataSourceFactory))
|
||||||
|
.setRenderersFactory(renderer)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
// Enable audio offload
|
||||||
|
if (Settings.useHwOffload)
|
||||||
|
player.experimentalSetOffloadSchedulingEnabled(true)
|
||||||
|
|
||||||
|
// Create browser interface
|
||||||
|
librarySessionCallback = AutoMediaBrowserCallback(player)
|
||||||
|
|
||||||
|
// This will need to use the AutoCalls
|
||||||
|
mediaLibrarySession = MediaLibrarySession.Builder(this, player, librarySessionCallback)
|
||||||
|
.setMediaItemFiller(CustomMediaItemFiller())
|
||||||
|
.setSessionActivity(getPendingIntentForContent())
|
||||||
|
.build()
|
||||||
|
|
||||||
|
// Set a listener to update the API client when the active server has changed
|
||||||
|
rxBusSubscription += RxBus.activeServerChangeObservable.subscribe {
|
||||||
|
val newClient: SubsonicAPIClient by inject()
|
||||||
|
apiDataSource.setAPIClient(newClient)
|
||||||
|
|
||||||
|
// Set the player wake mode
|
||||||
|
player.setWakeMode(getWakeModeFlag())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen to the shutdown command
|
||||||
|
rxBusSubscription += RxBus.shutdownCommandObservable.subscribe {
|
||||||
|
Timber.i("Received destroy command via Rx")
|
||||||
|
onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
isStarted = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getPendingIntentForContent(): PendingIntent {
|
||||||
|
val intent = Intent(this, NavigationActivity::class.java)
|
||||||
|
.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||||
|
var flags = PendingIntent.FLAG_UPDATE_CURRENT
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||||
|
// needed starting Android 12 (S = 31)
|
||||||
|
flags = flags or PendingIntent.FLAG_IMMUTABLE
|
||||||
|
}
|
||||||
|
intent.putExtra(Constants.INTENT_SHOW_PLAYER, true)
|
||||||
|
return PendingIntent.getActivity(this, 0, intent, flags)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getAudioAttributes(): AudioAttributes {
|
||||||
|
return AudioAttributes.Builder()
|
||||||
|
.setUsage(USAGE_MEDIA)
|
||||||
|
.setContentType(CONTENT_TYPE_MUSIC)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,218 @@
|
||||||
|
/*
|
||||||
|
* UltrasonicAppWidgetProvider.kt
|
||||||
|
* Copyright (C) 2009-2022 Ultrasonic developers
|
||||||
|
*
|
||||||
|
* Distributed under terms of the GNU GPLv3 license.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.moire.ultrasonic.provider
|
||||||
|
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.appwidget.AppWidgetManager
|
||||||
|
import android.appwidget.AppWidgetProvider
|
||||||
|
import android.content.ComponentName
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Environment
|
||||||
|
import android.view.KeyEvent
|
||||||
|
import android.widget.RemoteViews
|
||||||
|
import java.lang.Exception
|
||||||
|
import org.moire.ultrasonic.R
|
||||||
|
import org.moire.ultrasonic.activity.NavigationActivity
|
||||||
|
import org.moire.ultrasonic.domain.Track
|
||||||
|
import org.moire.ultrasonic.imageloader.BitmapUtils
|
||||||
|
import org.moire.ultrasonic.receiver.MediaButtonIntentReceiver
|
||||||
|
import org.moire.ultrasonic.util.Constants
|
||||||
|
import timber.log.Timber
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Widget Provider for the Ultrasonic Widgets
|
||||||
|
*/
|
||||||
|
@Suppress("MagicNumber")
|
||||||
|
open class UltrasonicAppWidgetProvider : AppWidgetProvider() {
|
||||||
|
@JvmField
|
||||||
|
protected var layoutId = 0
|
||||||
|
override fun onUpdate(
|
||||||
|
context: Context,
|
||||||
|
appWidgetManager: AppWidgetManager,
|
||||||
|
appWidgetIds: IntArray
|
||||||
|
) {
|
||||||
|
defaultAppWidget(context, appWidgetIds)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize given widgets to default state, where we launch Ultrasonic on default click
|
||||||
|
* and hide actions if service not running.
|
||||||
|
*/
|
||||||
|
private fun defaultAppWidget(context: Context, appWidgetIds: IntArray) {
|
||||||
|
val res = context.resources
|
||||||
|
val views = RemoteViews(context.packageName, layoutId)
|
||||||
|
views.setTextViewText(R.id.title, null)
|
||||||
|
views.setTextViewText(R.id.album, null)
|
||||||
|
views.setTextViewText(R.id.artist, res.getText(R.string.widget_initial_text))
|
||||||
|
linkButtons(context, views, false)
|
||||||
|
pushUpdate(context, appWidgetIds, views)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun pushUpdate(context: Context, appWidgetIds: IntArray?, views: RemoteViews) {
|
||||||
|
// Update specific list of appWidgetIds if given, otherwise default to all
|
||||||
|
val manager = AppWidgetManager.getInstance(context)
|
||||||
|
if (manager != null) {
|
||||||
|
if (appWidgetIds != null) {
|
||||||
|
manager.updateAppWidget(appWidgetIds, views)
|
||||||
|
} else {
|
||||||
|
manager.updateAppWidget(ComponentName(context, this.javaClass), views)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle a change notification coming over from [MediaPlayerController]
|
||||||
|
*/
|
||||||
|
fun notifyChange(context: Context, currentSong: Track?, playing: Boolean, setAlbum: Boolean) {
|
||||||
|
if (hasInstances(context)) {
|
||||||
|
performUpdate(context, currentSong, playing, setAlbum)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check against [AppWidgetManager] if there are any instances of this widget.
|
||||||
|
*/
|
||||||
|
private fun hasInstances(context: Context): Boolean {
|
||||||
|
val manager = AppWidgetManager.getInstance(context)
|
||||||
|
if (manager != null) {
|
||||||
|
val appWidgetIds = manager.getAppWidgetIds(ComponentName(context, javaClass))
|
||||||
|
return appWidgetIds.isNotEmpty()
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update all active widget instances by pushing changes
|
||||||
|
*/
|
||||||
|
private fun performUpdate(
|
||||||
|
context: Context,
|
||||||
|
currentSong: Track?,
|
||||||
|
playing: Boolean,
|
||||||
|
setAlbum: Boolean
|
||||||
|
) {
|
||||||
|
Timber.d("Updating Widget")
|
||||||
|
val res = context.resources
|
||||||
|
val views = RemoteViews(context.packageName, layoutId)
|
||||||
|
val title = currentSong?.title
|
||||||
|
val artist = currentSong?.artist
|
||||||
|
val album = currentSong?.album
|
||||||
|
var errorState: CharSequence? = null
|
||||||
|
|
||||||
|
// Show error message?
|
||||||
|
val status = Environment.getExternalStorageState()
|
||||||
|
if (status == Environment.MEDIA_SHARED || status == Environment.MEDIA_UNMOUNTED) {
|
||||||
|
errorState = res.getText(R.string.widget_sdcard_busy)
|
||||||
|
} else if (status == Environment.MEDIA_REMOVED) {
|
||||||
|
errorState = res.getText(R.string.widget_sdcard_missing)
|
||||||
|
} else if (currentSong == null) {
|
||||||
|
errorState = res.getText(R.string.widget_initial_text)
|
||||||
|
}
|
||||||
|
if (errorState != null) {
|
||||||
|
// Show error state to user
|
||||||
|
views.setTextViewText(R.id.title, null)
|
||||||
|
views.setTextViewText(R.id.artist, errorState)
|
||||||
|
if (setAlbum) {
|
||||||
|
views.setTextViewText(R.id.album, null)
|
||||||
|
}
|
||||||
|
views.setImageViewResource(R.id.appwidget_coverart, R.drawable.unknown_album)
|
||||||
|
} else {
|
||||||
|
// No error, so show normal titles
|
||||||
|
views.setTextViewText(R.id.title, title)
|
||||||
|
views.setTextViewText(R.id.artist, artist)
|
||||||
|
if (setAlbum) {
|
||||||
|
views.setTextViewText(R.id.album, album)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set correct drawable for pause state
|
||||||
|
if (playing) {
|
||||||
|
views.setImageViewResource(R.id.control_play, R.drawable.media_pause_normal_dark)
|
||||||
|
} else {
|
||||||
|
views.setImageViewResource(R.id.control_play, R.drawable.media_start_normal_dark)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the cover art
|
||||||
|
try {
|
||||||
|
val bitmap =
|
||||||
|
if (currentSong == null) null else BitmapUtils.getAlbumArtBitmapFromDisk(
|
||||||
|
currentSong,
|
||||||
|
240
|
||||||
|
)
|
||||||
|
if (bitmap == null) {
|
||||||
|
// Set default cover art
|
||||||
|
views.setImageViewResource(R.id.appwidget_coverart, R.drawable.unknown_album)
|
||||||
|
} else {
|
||||||
|
views.setImageViewBitmap(R.id.appwidget_coverart, bitmap)
|
||||||
|
}
|
||||||
|
} catch (all: Exception) {
|
||||||
|
Timber.e(all, "Failed to load cover art")
|
||||||
|
views.setImageViewResource(R.id.appwidget_coverart, R.drawable.unknown_album)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Link actions buttons to intents
|
||||||
|
linkButtons(context, views, currentSong != null)
|
||||||
|
pushUpdate(context, null, views)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/**
|
||||||
|
* Link up various button actions using [PendingIntent].
|
||||||
|
*/
|
||||||
|
private fun linkButtons(context: Context, views: RemoteViews, playerActive: Boolean) {
|
||||||
|
var intent = Intent(
|
||||||
|
context,
|
||||||
|
NavigationActivity::class.java
|
||||||
|
).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||||
|
if (playerActive) intent.putExtra(Constants.INTENT_SHOW_PLAYER, true)
|
||||||
|
intent.action = "android.intent.action.MAIN"
|
||||||
|
intent.addCategory("android.intent.category.LAUNCHER")
|
||||||
|
var flags = PendingIntent.FLAG_UPDATE_CURRENT
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||||
|
// needed starting Android 12 (S = 31)
|
||||||
|
flags = flags or PendingIntent.FLAG_IMMUTABLE
|
||||||
|
}
|
||||||
|
var pendingIntent =
|
||||||
|
PendingIntent.getActivity(context, 10, intent, flags)
|
||||||
|
views.setOnClickPendingIntent(R.id.appwidget_coverart, pendingIntent)
|
||||||
|
views.setOnClickPendingIntent(R.id.appwidget_top, pendingIntent)
|
||||||
|
|
||||||
|
// Emulate media button clicks.
|
||||||
|
intent = Intent(Constants.CMD_PROCESS_KEYCODE)
|
||||||
|
intent.component = ComponentName(context, MediaButtonIntentReceiver::class.java)
|
||||||
|
intent.putExtra(
|
||||||
|
Intent.EXTRA_KEY_EVENT,
|
||||||
|
KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE)
|
||||||
|
)
|
||||||
|
flags = 0
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||||
|
// needed starting Android 12 (S = 31)
|
||||||
|
flags = flags or PendingIntent.FLAG_IMMUTABLE
|
||||||
|
}
|
||||||
|
pendingIntent = PendingIntent.getBroadcast(context, 11, intent, flags)
|
||||||
|
views.setOnClickPendingIntent(R.id.control_play, pendingIntent)
|
||||||
|
intent = Intent(Constants.CMD_PROCESS_KEYCODE)
|
||||||
|
intent.component = ComponentName(context, MediaButtonIntentReceiver::class.java)
|
||||||
|
intent.putExtra(
|
||||||
|
Intent.EXTRA_KEY_EVENT,
|
||||||
|
KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_NEXT)
|
||||||
|
)
|
||||||
|
pendingIntent = PendingIntent.getBroadcast(context, 12, intent, flags)
|
||||||
|
views.setOnClickPendingIntent(R.id.control_next, pendingIntent)
|
||||||
|
intent = Intent(Constants.CMD_PROCESS_KEYCODE)
|
||||||
|
intent.component = ComponentName(context, MediaButtonIntentReceiver::class.java)
|
||||||
|
intent.putExtra(
|
||||||
|
Intent.EXTRA_KEY_EVENT,
|
||||||
|
KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_PREVIOUS)
|
||||||
|
)
|
||||||
|
pendingIntent = PendingIntent.getBroadcast(context, 13, intent, flags)
|
||||||
|
views.setOnClickPendingIntent(R.id.control_previous, pendingIntent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,28 @@
|
||||||
|
/*
|
||||||
|
* UltrasonicAppWidgetProvider4X1.kt
|
||||||
|
* Copyright (C) 2009-2022 Ultrasonic developers
|
||||||
|
*
|
||||||
|
* Distributed under terms of the GNU GPLv3 license.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.moire.ultrasonic.provider
|
||||||
|
|
||||||
|
import org.moire.ultrasonic.R
|
||||||
|
|
||||||
|
class UltrasonicAppWidgetProvider4X1 : UltrasonicAppWidgetProvider() {
|
||||||
|
companion object {
|
||||||
|
@get:Synchronized
|
||||||
|
var instance: UltrasonicAppWidgetProvider4X1? = null
|
||||||
|
get() {
|
||||||
|
if (field == null) {
|
||||||
|
field = UltrasonicAppWidgetProvider4X1()
|
||||||
|
}
|
||||||
|
return field
|
||||||
|
}
|
||||||
|
private set
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
layoutId = R.layout.appwidget4x1
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,28 @@
|
||||||
|
/*
|
||||||
|
* UltrasonicAppWidgetProvider4X2.kt
|
||||||
|
* Copyright (C) 2009-2022 Ultrasonic developers
|
||||||
|
*
|
||||||
|
* Distributed under terms of the GNU GPLv3 license.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.moire.ultrasonic.provider
|
||||||
|
|
||||||
|
import org.moire.ultrasonic.R
|
||||||
|
|
||||||
|
class UltrasonicAppWidgetProvider4X2 : UltrasonicAppWidgetProvider() {
|
||||||
|
companion object {
|
||||||
|
@get:Synchronized
|
||||||
|
var instance: UltrasonicAppWidgetProvider4X2? = null
|
||||||
|
get() {
|
||||||
|
if (field == null) {
|
||||||
|
field = UltrasonicAppWidgetProvider4X2()
|
||||||
|
}
|
||||||
|
return field
|
||||||
|
}
|
||||||
|
private set
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
layoutId = R.layout.appwidget4x2
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,28 @@
|
||||||
|
/*
|
||||||
|
* UltrasonicAppWidgetProvider4X3.kt
|
||||||
|
* Copyright (C) 2009-2022 Ultrasonic developers
|
||||||
|
*
|
||||||
|
* Distributed under terms of the GNU GPLv3 license.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.moire.ultrasonic.provider
|
||||||
|
|
||||||
|
import org.moire.ultrasonic.R
|
||||||
|
|
||||||
|
class UltrasonicAppWidgetProvider4X3 : UltrasonicAppWidgetProvider() {
|
||||||
|
companion object {
|
||||||
|
@get:Synchronized
|
||||||
|
var instance: UltrasonicAppWidgetProvider4X3? = null
|
||||||
|
get() {
|
||||||
|
if (field == null) {
|
||||||
|
field = UltrasonicAppWidgetProvider4X3()
|
||||||
|
}
|
||||||
|
return field
|
||||||
|
}
|
||||||
|
private set
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
layoutId = R.layout.appwidget4x3
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,28 @@
|
||||||
|
/*
|
||||||
|
* UltrasonicAppWidgetProvider4X4.kt
|
||||||
|
* Copyright (C) 2009-2022 Ultrasonic developers
|
||||||
|
*
|
||||||
|
* Distributed under terms of the GNU GPLv3 license.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.moire.ultrasonic.provider
|
||||||
|
|
||||||
|
import org.moire.ultrasonic.R
|
||||||
|
|
||||||
|
class UltrasonicAppWidgetProvider4X4 : UltrasonicAppWidgetProvider() {
|
||||||
|
companion object {
|
||||||
|
@get:Synchronized
|
||||||
|
var instance: UltrasonicAppWidgetProvider4X4? = null
|
||||||
|
get() {
|
||||||
|
if (field == null) {
|
||||||
|
field = UltrasonicAppWidgetProvider4X4()
|
||||||
|
}
|
||||||
|
return field
|
||||||
|
}
|
||||||
|
private set
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
layoutId = R.layout.appwidget4x4
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,52 @@
|
||||||
|
/*
|
||||||
|
* MediaButtonIntentReceiver.kt
|
||||||
|
* Copyright (C) 2009-2022 Ultrasonic developers
|
||||||
|
*
|
||||||
|
* Distributed under terms of the GNU GPLv3 license.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.moire.ultrasonic.receiver
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Parcelable
|
||||||
|
import java.lang.Exception
|
||||||
|
import org.koin.core.component.KoinComponent
|
||||||
|
import org.koin.core.component.inject
|
||||||
|
import org.moire.ultrasonic.service.MediaPlayerLifecycleSupport
|
||||||
|
import org.moire.ultrasonic.util.Constants
|
||||||
|
import org.moire.ultrasonic.util.Settings
|
||||||
|
import timber.log.Timber
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This class is used to receive commands from the widget
|
||||||
|
*/
|
||||||
|
class MediaButtonIntentReceiver : BroadcastReceiver(), KoinComponent {
|
||||||
|
private val lifecycleSupport: MediaPlayerLifecycleSupport by inject()
|
||||||
|
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
val intentAction = intent.action
|
||||||
|
|
||||||
|
// If media button are turned off and we received a media button, exit
|
||||||
|
if (!Settings.mediaButtonsEnabled && Intent.ACTION_MEDIA_BUTTON == intentAction) return
|
||||||
|
|
||||||
|
// Only process media buttons and CMD_PROCESS_KEYCODE, which is received from the widgets
|
||||||
|
if (Intent.ACTION_MEDIA_BUTTON != intentAction &&
|
||||||
|
Constants.CMD_PROCESS_KEYCODE != intentAction
|
||||||
|
) return
|
||||||
|
val extras = intent.extras ?: return
|
||||||
|
val event = extras[Intent.EXTRA_KEY_EVENT] as Parcelable?
|
||||||
|
Timber.i("Got MEDIA_BUTTON key event: %s", event)
|
||||||
|
try {
|
||||||
|
val serviceIntent = Intent(Constants.CMD_PROCESS_KEYCODE)
|
||||||
|
serviceIntent.putExtra(Intent.EXTRA_KEY_EVENT, event)
|
||||||
|
lifecycleSupport.receiveIntent(serviceIntent)
|
||||||
|
if (isOrderedBroadcast) {
|
||||||
|
abortBroadcast()
|
||||||
|
}
|
||||||
|
} catch (ignored: Exception) {
|
||||||
|
// Ignored.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,118 +0,0 @@
|
||||||
package org.moire.ultrasonic.service
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.media.AudioAttributes
|
|
||||||
import android.media.AudioManager
|
|
||||||
import android.media.AudioManager.OnAudioFocusChangeListener
|
|
||||||
import androidx.media.AudioAttributesCompat
|
|
||||||
import androidx.media.AudioFocusRequestCompat
|
|
||||||
import androidx.media.AudioManagerCompat
|
|
||||||
import org.koin.java.KoinJavaComponent.inject
|
|
||||||
import org.moire.ultrasonic.domain.PlayerState
|
|
||||||
import org.moire.ultrasonic.util.Settings
|
|
||||||
import timber.log.Timber
|
|
||||||
|
|
||||||
class AudioFocusHandler(private val context: Context) {
|
|
||||||
// TODO: This is a circular reference, try to remove it
|
|
||||||
// This should be doable by using the native MediaController framework
|
|
||||||
private val mediaPlayerControllerLazy =
|
|
||||||
inject<MediaPlayerController>(MediaPlayerController::class.java)
|
|
||||||
|
|
||||||
private val audioManager by lazy {
|
|
||||||
context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
|
||||||
}
|
|
||||||
|
|
||||||
private val lossPref: Int
|
|
||||||
get() = Settings.tempLoss
|
|
||||||
|
|
||||||
private val audioAttributesCompat by lazy {
|
|
||||||
AudioAttributesCompat.Builder()
|
|
||||||
.setUsage(AudioAttributesCompat.USAGE_MEDIA)
|
|
||||||
.setContentType(AudioAttributesCompat.CONTENT_TYPE_MUSIC)
|
|
||||||
.setLegacyStreamType(AudioManager.STREAM_MUSIC)
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun requestAudioFocus() {
|
|
||||||
if (!hasFocus) {
|
|
||||||
hasFocus = true
|
|
||||||
AudioManagerCompat.requestAudioFocus(audioManager, focusRequest)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val listener = OnAudioFocusChangeListener { focusChange ->
|
|
||||||
|
|
||||||
val mediaPlayerController = mediaPlayerControllerLazy.value
|
|
||||||
|
|
||||||
when (focusChange) {
|
|
||||||
AudioManager.AUDIOFOCUS_GAIN -> {
|
|
||||||
Timber.v("Regained Audio Focus")
|
|
||||||
if (pauseFocus) {
|
|
||||||
pauseFocus = false
|
|
||||||
mediaPlayerController.start()
|
|
||||||
} else if (lowerFocus) {
|
|
||||||
lowerFocus = false
|
|
||||||
mediaPlayerController.setVolume(1.0f)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
AudioManager.AUDIOFOCUS_LOSS -> {
|
|
||||||
if (!mediaPlayerController.isJukeboxEnabled) {
|
|
||||||
hasFocus = false
|
|
||||||
mediaPlayerController.pause()
|
|
||||||
AudioManagerCompat.abandonAudioFocusRequest(audioManager, focusRequest)
|
|
||||||
Timber.v("Abandoned Audio Focus")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
|
|
||||||
if (!mediaPlayerController.isJukeboxEnabled) {
|
|
||||||
Timber.v("Lost Audio Focus")
|
|
||||||
|
|
||||||
if (mediaPlayerController.playerState === PlayerState.STARTED) {
|
|
||||||
if (lossPref == 0 || lossPref == 1) {
|
|
||||||
pauseFocus = true
|
|
||||||
mediaPlayerController.pause()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> {
|
|
||||||
if (!mediaPlayerController.isJukeboxEnabled) {
|
|
||||||
Timber.v("Lost Audio Focus")
|
|
||||||
|
|
||||||
if (mediaPlayerController.playerState === PlayerState.STARTED) {
|
|
||||||
if (lossPref == 2 || lossPref == 1) {
|
|
||||||
lowerFocus = true
|
|
||||||
mediaPlayerController.setVolume(0.1f)
|
|
||||||
} else if (lossPref == 0 || lossPref == 1) {
|
|
||||||
pauseFocus = true
|
|
||||||
mediaPlayerController.pause()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val focusRequest: AudioFocusRequestCompat by lazy {
|
|
||||||
AudioFocusRequestCompat.Builder(AudioManagerCompat.AUDIOFOCUS_GAIN)
|
|
||||||
.setAudioAttributes(audioAttributesCompat)
|
|
||||||
.setWillPauseWhenDucked(true)
|
|
||||||
.setOnAudioFocusChangeListener(listener)
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private var hasFocus = false
|
|
||||||
private var pauseFocus = false
|
|
||||||
private var lowerFocus = false
|
|
||||||
|
|
||||||
// TODO: This can be removed if we switch to androidx.media2.player
|
|
||||||
fun getAudioAttributes(): AudioAttributes {
|
|
||||||
return AudioAttributes.Builder()
|
|
||||||
.setUsage(AudioAttributes.USAGE_MEDIA)
|
|
||||||
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
|
|
||||||
.setLegacyStreamType(AudioManager.STREAM_MUSIC)
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -7,27 +7,18 @@
|
||||||
|
|
||||||
package org.moire.ultrasonic.service
|
package org.moire.ultrasonic.service
|
||||||
|
|
||||||
import android.text.TextUtils
|
|
||||||
import androidx.lifecycle.MutableLiveData
|
import androidx.lifecycle.MutableLiveData
|
||||||
|
import androidx.media3.common.MediaItem
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.io.InputStream
|
|
||||||
import java.io.OutputStream
|
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import org.koin.core.component.KoinComponent
|
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.Identifiable
|
||||||
import org.moire.ultrasonic.domain.Track
|
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.CancellableTask
|
||||||
import org.moire.ultrasonic.util.FileUtil
|
import org.moire.ultrasonic.util.FileUtil
|
||||||
import org.moire.ultrasonic.util.Settings
|
import org.moire.ultrasonic.util.Settings
|
||||||
import org.moire.ultrasonic.util.Storage
|
import org.moire.ultrasonic.util.Storage
|
||||||
import org.moire.ultrasonic.util.Util
|
import org.moire.ultrasonic.util.Util
|
||||||
import org.moire.ultrasonic.util.Util.safeClose
|
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -44,29 +35,22 @@ class DownloadFile(
|
||||||
) : KoinComponent, Identifiable {
|
) : KoinComponent, Identifiable {
|
||||||
val partialFile: String
|
val partialFile: String
|
||||||
lateinit var completeFile: String
|
lateinit var completeFile: String
|
||||||
val saveFile: String = FileUtil.getSongFile(track)
|
val pinnedFile: String = FileUtil.getSongFile(track)
|
||||||
var shouldSave = save
|
var shouldSave = save
|
||||||
private var downloadTask: CancellableTask? = null
|
internal var downloadTask: CancellableTask? = null
|
||||||
var isFailed = false
|
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 priority = 100
|
||||||
var downloadPrepared = false
|
var downloadPrepared = false
|
||||||
|
|
||||||
@Volatile
|
@Volatile
|
||||||
private var isPlaying = false
|
internal var saveWhenDone = false
|
||||||
|
|
||||||
@Volatile
|
@Volatile
|
||||||
private var saveWhenDone = false
|
var completeWhenDone = false
|
||||||
|
|
||||||
@Volatile
|
|
||||||
private var completeWhenDone = false
|
|
||||||
|
|
||||||
private val downloader: Downloader by inject()
|
|
||||||
private val imageLoaderProvider: ImageLoaderProvider by inject()
|
|
||||||
private val activeServerProvider: ActiveServerProvider by inject()
|
|
||||||
|
|
||||||
val progress: MutableLiveData<Int> = MutableLiveData(0)
|
val progress: MutableLiveData<Int> = MutableLiveData(0)
|
||||||
|
|
||||||
|
@ -78,7 +62,7 @@ class DownloadFile(
|
||||||
|
|
||||||
private val lazyInitialStatus: Lazy<DownloadStatus> = lazy {
|
private val lazyInitialStatus: Lazy<DownloadStatus> = lazy {
|
||||||
when {
|
when {
|
||||||
Storage.isPathExists(saveFile) -> {
|
Storage.isPathExists(pinnedFile) -> {
|
||||||
DownloadStatus.PINNED
|
DownloadStatus.PINNED
|
||||||
}
|
}
|
||||||
Storage.isPathExists(completeFile) -> {
|
Storage.isPathExists(completeFile) -> {
|
||||||
|
@ -95,10 +79,10 @@ class DownloadFile(
|
||||||
}
|
}
|
||||||
|
|
||||||
init {
|
init {
|
||||||
partialFile = FileUtil.getParentPath(saveFile) + "/" +
|
partialFile = FileUtil.getParentPath(pinnedFile) + "/" +
|
||||||
FileUtil.getPartialFile(FileUtil.getNameFromPath(saveFile))
|
FileUtil.getPartialFile(FileUtil.getNameFromPath(pinnedFile))
|
||||||
completeFile = FileUtil.getParentPath(saveFile) + "/" +
|
completeFile = FileUtil.getParentPath(pinnedFile) + "/" +
|
||||||
FileUtil.getCompleteFile(FileUtil.getNameFromPath(saveFile))
|
FileUtil.getCompleteFile(FileUtil.getNameFromPath(pinnedFile))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -115,44 +99,29 @@ class DownloadFile(
|
||||||
downloadPrepared = true
|
downloadPrepared = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
fun download() {
|
|
||||||
FileUtil.createDirectoryForParent(saveFile)
|
|
||||||
isFailed = false
|
|
||||||
downloadTask = DownloadTask()
|
|
||||||
downloadTask!!.start()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun cancelDownload() {
|
fun cancelDownload() {
|
||||||
downloadTask?.cancel()
|
downloadTask?.cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
val completeOrSaveFile: String
|
val completeOrSaveFile: String
|
||||||
get() = if (Storage.isPathExists(saveFile)) {
|
get() = if (Storage.isPathExists(pinnedFile)) {
|
||||||
saveFile
|
pinnedFile
|
||||||
} else {
|
} else {
|
||||||
completeFile
|
completeFile
|
||||||
}
|
}
|
||||||
|
|
||||||
val completeOrPartialFile: String
|
|
||||||
get() = if (isCompleteFileAvailable) {
|
|
||||||
completeOrSaveFile
|
|
||||||
} else {
|
|
||||||
partialFile
|
|
||||||
}
|
|
||||||
|
|
||||||
val isSaved: Boolean
|
val isSaved: Boolean
|
||||||
get() = Storage.isPathExists(saveFile)
|
get() = Storage.isPathExists(pinnedFile)
|
||||||
|
|
||||||
@get:Synchronized
|
@get:Synchronized
|
||||||
val isCompleteFileAvailable: Boolean
|
val isCompleteFileAvailable: Boolean
|
||||||
get() = Storage.isPathExists(completeFile) || Storage.isPathExists(saveFile)
|
get() = Storage.isPathExists(completeFile) || Storage.isPathExists(pinnedFile)
|
||||||
|
|
||||||
@get:Synchronized
|
@get:Synchronized
|
||||||
val isWorkDone: Boolean
|
val isWorkDone: Boolean
|
||||||
get() = Storage.isPathExists(completeFile) && !shouldSave ||
|
get() = Storage.isPathExists(completeFile) && !shouldSave ||
|
||||||
Storage.isPathExists(saveFile) || saveWhenDone || completeWhenDone
|
Storage.isPathExists(pinnedFile) || saveWhenDone || completeWhenDone
|
||||||
|
|
||||||
@get:Synchronized
|
@get:Synchronized
|
||||||
val isDownloading: Boolean
|
val isDownloading: Boolean
|
||||||
|
@ -170,54 +139,66 @@ class DownloadFile(
|
||||||
cancelDownload()
|
cancelDownload()
|
||||||
Storage.delete(partialFile)
|
Storage.delete(partialFile)
|
||||||
Storage.delete(completeFile)
|
Storage.delete(completeFile)
|
||||||
Storage.delete(saveFile)
|
Storage.delete(pinnedFile)
|
||||||
|
|
||||||
status.postValue(DownloadStatus.IDLE)
|
status.postValue(DownloadStatus.IDLE)
|
||||||
|
|
||||||
Util.scanMedia(saveFile)
|
Util.scanMedia(pinnedFile)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun unpin() {
|
fun unpin() {
|
||||||
val file = Storage.getFromPath(saveFile) ?: return
|
Timber.e("CLEANING")
|
||||||
|
val file = Storage.getFromPath(pinnedFile) ?: return
|
||||||
Storage.rename(file, completeFile)
|
Storage.rename(file, completeFile)
|
||||||
status.postValue(DownloadStatus.DONE)
|
status.postValue(DownloadStatus.DONE)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun cleanup(): Boolean {
|
fun cleanup(): Boolean {
|
||||||
|
Timber.e("CLEANING")
|
||||||
var ok = true
|
var ok = true
|
||||||
if (Storage.isPathExists(completeFile) || Storage.isPathExists(saveFile)) {
|
if (Storage.isPathExists(completeFile) || Storage.isPathExists(pinnedFile)) {
|
||||||
ok = Storage.delete(partialFile)
|
ok = Storage.delete(partialFile)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Storage.isPathExists(saveFile)) {
|
if (Storage.isPathExists(pinnedFile)) {
|
||||||
ok = ok and Storage.delete(completeFile)
|
ok = ok and Storage.delete(completeFile)
|
||||||
}
|
}
|
||||||
|
|
||||||
return ok
|
return ok
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setPlaying(isPlaying: Boolean) {
|
/**
|
||||||
if (!isPlaying) doPendingRename()
|
* Create a MediaItem instance representing the data inside this DownloadFile
|
||||||
this.isPlaying = isPlaying
|
*/
|
||||||
|
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
|
// Do a pending rename after the song has stopped playing
|
||||||
private fun doPendingRename() {
|
private fun doPendingRename() {
|
||||||
try {
|
try {
|
||||||
|
Timber.e("CLEANING")
|
||||||
if (saveWhenDone) {
|
if (saveWhenDone) {
|
||||||
Storage.rename(completeFile, saveFile)
|
Storage.rename(completeFile, pinnedFile)
|
||||||
saveWhenDone = false
|
saveWhenDone = false
|
||||||
} else if (completeWhenDone) {
|
} else if (completeWhenDone) {
|
||||||
if (shouldSave) {
|
if (shouldSave) {
|
||||||
Storage.rename(partialFile, saveFile)
|
Storage.rename(partialFile, pinnedFile)
|
||||||
Util.scanMedia(saveFile)
|
Util.scanMedia(pinnedFile)
|
||||||
} else {
|
} else {
|
||||||
Storage.rename(partialFile, completeFile)
|
Storage.rename(partialFile, completeFile)
|
||||||
}
|
}
|
||||||
completeWhenDone = false
|
completeWhenDone = false
|
||||||
}
|
}
|
||||||
} catch (e: IOException) {
|
} 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)
|
return String.format(Locale.ROOT, "DownloadFile (%s)", track)
|
||||||
}
|
}
|
||||||
|
|
||||||
private inner class DownloadTask : CancellableTask() {
|
internal fun setProgress(totalBytesCopied: Long) {
|
||||||
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) {
|
|
||||||
if (track.size != null) {
|
if (track.size != null) {
|
||||||
progress.postValue((totalBytesCopied * 100 / track.size!!).toInt())
|
progress.postValue((totalBytesCopied * 100 / track.size!!).toInt())
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,231 @@
|
||||||
|
/*
|
||||||
|
* MediaPlayerService.kt
|
||||||
|
* Copyright (C) 2009-2021 Ultrasonic developers
|
||||||
|
*
|
||||||
|
* Distributed under terms of the GNU GPLv3 license.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.moire.ultrasonic.service
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.app.Service
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.IBinder
|
||||||
|
import android.support.v4.media.session.MediaSessionCompat
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import java.util.concurrent.Semaphore
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import org.koin.android.ext.android.inject
|
||||||
|
import org.moire.ultrasonic.R
|
||||||
|
import org.moire.ultrasonic.activity.NavigationActivity
|
||||||
|
import org.moire.ultrasonic.app.UApp
|
||||||
|
import org.moire.ultrasonic.util.Constants
|
||||||
|
import org.moire.ultrasonic.util.SimpleServiceBinder
|
||||||
|
import timber.log.Timber
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Android Foreground service which is used to download tracks even when the app is not visible
|
||||||
|
*
|
||||||
|
* "A foreground service is a service that the user is
|
||||||
|
* actively aware of and isn’t a candidate for the system to kill when low on memory."
|
||||||
|
*
|
||||||
|
* TODO: Migrate this to use the Media3 DownloadHelper
|
||||||
|
*/
|
||||||
|
class DownloadService : Service() {
|
||||||
|
private val binder: IBinder = SimpleServiceBinder(this)
|
||||||
|
|
||||||
|
private val downloader by inject<Downloader>()
|
||||||
|
|
||||||
|
private var mediaSession: MediaSessionCompat? = null
|
||||||
|
|
||||||
|
private var isInForeground = false
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent): IBinder {
|
||||||
|
return binder
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
|
||||||
|
// Create Notification Channel
|
||||||
|
createNotificationChannel()
|
||||||
|
updateNotification()
|
||||||
|
|
||||||
|
instance = this
|
||||||
|
startedSemaphore.release()
|
||||||
|
Timber.i("DownloadService initiated")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
|
||||||
|
super.onStartCommand(intent, flags, startId)
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
super.onDestroy()
|
||||||
|
instance = null
|
||||||
|
try {
|
||||||
|
downloader.stop()
|
||||||
|
|
||||||
|
mediaSession?.release()
|
||||||
|
mediaSession = null
|
||||||
|
} catch (ignored: Throwable) {
|
||||||
|
}
|
||||||
|
Timber.i("DownloadService destroyed")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun notifyDownloaderStopped() {
|
||||||
|
Timber.i("DownloadService stopped")
|
||||||
|
isInForeground = false
|
||||||
|
stopForeground(true)
|
||||||
|
stopSelf()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createNotificationChannel() {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
|
||||||
|
// The suggested importance of a startForeground service notification is IMPORTANCE_LOW
|
||||||
|
val channel = NotificationChannel(
|
||||||
|
NOTIFICATION_CHANNEL_ID,
|
||||||
|
NOTIFICATION_CHANNEL_NAME,
|
||||||
|
NotificationManager.IMPORTANCE_LOW
|
||||||
|
)
|
||||||
|
|
||||||
|
channel.lightColor = android.R.color.holo_blue_dark
|
||||||
|
channel.lockscreenVisibility = Notification.VISIBILITY_PUBLIC
|
||||||
|
channel.setShowBadge(false)
|
||||||
|
|
||||||
|
val manager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
manager.createNotificationChannel(channel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// We should use a single notification builder, otherwise the notification may not be updated
|
||||||
|
// Set some values that never change
|
||||||
|
private val notificationBuilder: NotificationCompat.Builder by lazy {
|
||||||
|
NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.drawable.ic_stat_ultrasonic)
|
||||||
|
.setAutoCancel(false)
|
||||||
|
.setOngoing(true)
|
||||||
|
.setOnlyAlertOnce(true)
|
||||||
|
.setWhen(System.currentTimeMillis())
|
||||||
|
.setShowWhen(false)
|
||||||
|
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||||
|
.setContentIntent(getPendingIntentForContent())
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateNotification() {
|
||||||
|
|
||||||
|
val notification = buildForegroundNotification()
|
||||||
|
|
||||||
|
if (isInForeground) {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
val manager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
manager.notify(NOTIFICATION_ID, notification)
|
||||||
|
} else {
|
||||||
|
val manager = NotificationManagerCompat.from(this)
|
||||||
|
manager.notify(NOTIFICATION_ID, notification)
|
||||||
|
}
|
||||||
|
Timber.v("Updated notification")
|
||||||
|
} else {
|
||||||
|
startForeground(NOTIFICATION_ID, notification)
|
||||||
|
isInForeground = true
|
||||||
|
Timber.v("Created Foreground notification")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This method builds a notification, reusing the Notification Builder if possible
|
||||||
|
*/
|
||||||
|
@Suppress("SpreadOperator")
|
||||||
|
private fun buildForegroundNotification(): Notification {
|
||||||
|
|
||||||
|
if (downloader.started) {
|
||||||
|
// No song is playing, but Ultrasonic is downloading files
|
||||||
|
notificationBuilder.setContentTitle(
|
||||||
|
getString(R.string.notification_downloading_title)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return notificationBuilder.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getPendingIntentForContent(): PendingIntent {
|
||||||
|
val intent = Intent(this, NavigationActivity::class.java)
|
||||||
|
.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||||
|
var flags = PendingIntent.FLAG_UPDATE_CURRENT
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||||
|
// needed starting Android 12 (S = 31)
|
||||||
|
flags = flags or PendingIntent.FLAG_IMMUTABLE
|
||||||
|
}
|
||||||
|
intent.putExtra(Constants.INTENT_SHOW_PLAYER, true)
|
||||||
|
return PendingIntent.getActivity(this, 0, intent, flags)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("MagicNumber")
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
private const val NOTIFICATION_CHANNEL_ID = "org.moire.ultrasonic"
|
||||||
|
private const val NOTIFICATION_CHANNEL_NAME = "Ultrasonic background service"
|
||||||
|
private const val NOTIFICATION_ID = 3033
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var instance: DownloadService? = null
|
||||||
|
private val instanceLock = Any()
|
||||||
|
private val startedSemaphore: Semaphore = Semaphore(0)
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun getInstance(): DownloadService? {
|
||||||
|
val context = UApp.applicationContext()
|
||||||
|
if (instance != null) return instance
|
||||||
|
synchronized(instanceLock) {
|
||||||
|
if (instance != null) return instance
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
context.startForegroundService(
|
||||||
|
Intent(context, DownloadService::class.java)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
context.startService(Intent(context, DownloadService::class.java))
|
||||||
|
}
|
||||||
|
Timber.i("DownloadService starting...")
|
||||||
|
if (startedSemaphore.tryAcquire(10, TimeUnit.SECONDS)) {
|
||||||
|
Timber.i("DownloadService started")
|
||||||
|
return instance
|
||||||
|
}
|
||||||
|
Timber.w("DownloadService failed to start!")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
val runningInstance: DownloadService?
|
||||||
|
get() {
|
||||||
|
synchronized(instanceLock) { return instance }
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun executeOnStartedDownloadService(
|
||||||
|
taskToExecute: (DownloadService) -> Unit
|
||||||
|
) {
|
||||||
|
|
||||||
|
val t: Thread = object : Thread() {
|
||||||
|
override fun run() {
|
||||||
|
val instance = getInstance()
|
||||||
|
if (instance == null) {
|
||||||
|
Timber.e("ExecuteOnStarted.. failed to get a DownloadService instance!")
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
taskToExecute(instance)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.start()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,89 +1,115 @@
|
||||||
package org.moire.ultrasonic.service
|
package org.moire.ultrasonic.service
|
||||||
|
|
||||||
import android.net.wifi.WifiManager
|
import android.net.wifi.WifiManager
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.text.TextUtils
|
||||||
import androidx.lifecycle.MutableLiveData
|
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.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.KoinComponent
|
||||||
import org.koin.core.component.inject
|
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.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.LRUCache
|
||||||
import org.moire.ultrasonic.util.Settings
|
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
|
||||||
|
import org.moire.ultrasonic.util.Util.safeClose
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This class is responsible for maintaining the playlist and downloading
|
* This class is responsible for maintaining the playlist and downloading
|
||||||
* its items from the network to the filesystem.
|
* its items from the network to the filesystem.
|
||||||
*
|
*
|
||||||
* TODO: Move away from managing the queue with scheduled checks, instead use callbacks when
|
* TODO: Move entirely to subclass the Media3.DownloadService
|
||||||
* Downloads are finished
|
|
||||||
*/
|
*/
|
||||||
class Downloader(
|
class Downloader(
|
||||||
private val shufflePlayBuffer: ShufflePlayBuffer,
|
private val storageMonitor: ExternalStorageMonitor,
|
||||||
private val externalStorageMonitor: ExternalStorageMonitor,
|
private val legacyPlaylistManager: LegacyPlaylistManager,
|
||||||
private val localMediaPlayer: LocalMediaPlayer
|
|
||||||
) : KoinComponent {
|
) : 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 started: Boolean = false
|
||||||
|
var shouldStop: Boolean = false
|
||||||
|
var isPolling: Boolean = false
|
||||||
|
|
||||||
private val downloadQueue = PriorityQueue<DownloadFile>()
|
private val downloadQueue = PriorityQueue<DownloadFile>()
|
||||||
private val activelyDownloading = mutableListOf<DownloadFile>()
|
private val activelyDownloading = mutableListOf<DownloadFile>()
|
||||||
|
|
||||||
// TODO: The playlist is now published with RX, while the observableDownloads is using LiveData.
|
// The generic list models expect a LiveData, so even though we are using Rx for many events
|
||||||
// Use the same for both
|
// surrounding playback the list of Downloads is published as LiveData.
|
||||||
val observableDownloads = MutableLiveData<List<DownloadFile>>()
|
val observableDownloads = MutableLiveData<List<DownloadFile>>()
|
||||||
|
|
||||||
private val jukeboxMediaPlayer: JukeboxMediaPlayer by inject()
|
|
||||||
|
|
||||||
// This cache helps us to avoid creating duplicate DownloadFile instances when showing Entries
|
// 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 wifiLock: WifiManager.WifiLock? = null
|
||||||
|
|
||||||
private var playlistUpdateRevision: Long = 0
|
private var backgroundPriorityCounter = 100
|
||||||
private set(value) {
|
|
||||||
field = value
|
private val rxBusSubscription: CompositeDisposable = CompositeDisposable()
|
||||||
RxBus.playlistPublisher.onNext(playlist)
|
|
||||||
|
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
|
private var downloadChecker = object : Runnable {
|
||||||
|
override fun run() {
|
||||||
val downloadChecker = Runnable {
|
try {
|
||||||
try {
|
Timber.w("Checking Downloads")
|
||||||
Timber.w("Checking Downloads")
|
checkDownloadsInternal()
|
||||||
checkDownloadsInternal()
|
} catch (all: Exception) {
|
||||||
} catch (all: Exception) {
|
Timber.e(all, "checkDownloads() failed.")
|
||||||
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() {
|
fun onDestroy() {
|
||||||
stop()
|
stop()
|
||||||
clearPlaylist()
|
rxBusSubscription.dispose()
|
||||||
clearBackground()
|
clearBackground()
|
||||||
observableDownloads.value = listOf()
|
observableDownloads.value = listOf()
|
||||||
Timber.i("Downloader destroyed")
|
Timber.i("Downloader destroyed")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
fun start() {
|
fun start() {
|
||||||
|
if (started) return
|
||||||
started = true
|
started = true
|
||||||
if (executorService == null) {
|
|
||||||
executorService = Executors.newSingleThreadScheduledExecutor()
|
// Start our loop
|
||||||
executorService!!.scheduleWithFixedDelay(
|
handler.postDelayed(downloadChecker, 100)
|
||||||
downloadChecker, 0L, CHECK_INTERVAL, TimeUnit.SECONDS
|
|
||||||
)
|
|
||||||
Timber.i("Downloader started")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (wifiLock == null) {
|
if (wifiLock == null) {
|
||||||
wifiLock = Util.createWifiLock(toString())
|
wifiLock = Util.createWifiLock(toString())
|
||||||
|
@ -92,61 +118,56 @@ class Downloader(
|
||||||
}
|
}
|
||||||
|
|
||||||
fun stop() {
|
fun stop() {
|
||||||
|
if (!started) return
|
||||||
started = false
|
started = false
|
||||||
executorService?.shutdown()
|
shouldStop = true
|
||||||
executorService = null
|
|
||||||
wifiLock?.release()
|
wifiLock?.release()
|
||||||
wifiLock = null
|
wifiLock = null
|
||||||
MediaPlayerService.runningInstance?.notifyDownloaderStopped()
|
DownloadService.runningInstance?.notifyDownloaderStopped()
|
||||||
Timber.i("Downloader stopped")
|
Timber.i("Downloader stopped")
|
||||||
}
|
}
|
||||||
|
|
||||||
fun checkDownloads() {
|
fun checkDownloads() {
|
||||||
if (
|
if (!started) {
|
||||||
executorService == null ||
|
|
||||||
executorService!!.isTerminated ||
|
|
||||||
executorService!!.isShutdown
|
|
||||||
) {
|
|
||||||
start()
|
start()
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
executorService?.execute(downloadChecker)
|
handler.postDelayed(downloadChecker, 100)
|
||||||
} catch (exception: RejectedExecutionException) {
|
} catch (all: Exception) {
|
||||||
Timber.w(
|
Timber.w(
|
||||||
exception,
|
all,
|
||||||
"checkDownloads() can't run, maybe the Downloader is shutting down..."
|
"checkDownloads() can't run, maybe the Downloader is shutting down..."
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
@Suppress("ComplexMethod", "ComplexCondition")
|
@Suppress("ComplexMethod", "ComplexCondition")
|
||||||
fun checkDownloadsInternal() {
|
@Synchronized
|
||||||
if (
|
private fun checkDownloadsInternal() {
|
||||||
!Util.isExternalStoragePresent() ||
|
if (!Util.isExternalStoragePresent() || !storageMonitor.isExternalStorageAvailable) {
|
||||||
!externalStorageMonitor.isExternalStorageAvailable
|
|
||||||
) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (shufflePlayBuffer.isEnabled) {
|
|
||||||
checkShufflePlay()
|
if (legacyPlaylistManager.jukeboxMediaPlayer.isEnabled || !Util.isNetworkConnected()) {
|
||||||
}
|
|
||||||
if (jukeboxMediaPlayer.isEnabled || !Util.isNetworkConnected()) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
Timber.v("Downloader checkDownloadsInternal checking downloads")
|
Timber.v("Downloader checkDownloadsInternal checking downloads")
|
||||||
|
|
||||||
// Check the active downloads for failures or completions and remove them
|
// Check the active downloads for failures or completions and remove them
|
||||||
// Store the result in a flag to know if changes have occurred
|
// Store the result in a flag to know if changes have occurred
|
||||||
var listChanged = cleanupActiveDownloads()
|
var listChanged = cleanupActiveDownloads()
|
||||||
|
|
||||||
|
val playlist = legacyPlaylistManager.playlist
|
||||||
|
|
||||||
// Check if need to preload more from playlist
|
// Check if need to preload more from playlist
|
||||||
val preloadCount = Settings.preloadCount
|
val preloadCount = Settings.preloadCount
|
||||||
|
|
||||||
// Start preloading at the current playing song
|
// Start preloading at the current playing song
|
||||||
var start = currentPlayingIndex
|
var start = mediaController.currentMediaItemIndex
|
||||||
if (start == -1) start = 0
|
|
||||||
|
if (start == -1 || start > playlist.size) start = 0
|
||||||
|
|
||||||
val end = (start + preloadCount).coerceAtMost(playlist.size)
|
val end = (start + preloadCount).coerceAtMost(playlist.size)
|
||||||
|
|
||||||
|
@ -173,10 +194,6 @@ class Downloader(
|
||||||
activelyDownloading.add(task)
|
activelyDownloading.add(task)
|
||||||
startDownloadOnService(task)
|
startDownloadOnService(task)
|
||||||
|
|
||||||
// The next file on the playlist is currently downloading
|
|
||||||
if (playlist.indexOf(task) == 1) {
|
|
||||||
localMediaPlayer.setNextPlayerState(PlayerState.DOWNLOADING)
|
|
||||||
}
|
|
||||||
listChanged = true
|
listChanged = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -194,10 +211,15 @@ class Downloader(
|
||||||
observableDownloads.postValue(downloads)
|
observableDownloads.postValue(downloads)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun startDownloadOnService(task: DownloadFile) {
|
private fun startDownloadOnService(file: DownloadFile) {
|
||||||
task.prepare()
|
if (file.isDownloading) return
|
||||||
MediaPlayerService.executeOnStartedMediaPlayerService {
|
file.prepare()
|
||||||
task.download()
|
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)
|
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
|
@get:Synchronized
|
||||||
val all: List<DownloadFile>
|
val all: List<DownloadFile>
|
||||||
get() {
|
get() {
|
||||||
val temp: MutableList<DownloadFile> = ArrayList()
|
val temp: MutableList<DownloadFile> = ArrayList()
|
||||||
temp.addAll(activelyDownloading)
|
temp.addAll(activelyDownloading)
|
||||||
temp.addAll(downloadQueue)
|
temp.addAll(downloadQueue)
|
||||||
temp.addAll(playlist)
|
temp.addAll(legacyPlaylistManager.playlist)
|
||||||
return temp.distinct().sorted()
|
return temp.distinct().sorted()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -267,7 +268,7 @@ class Downloader(
|
||||||
temp.addAll(activelyDownloading)
|
temp.addAll(activelyDownloading)
|
||||||
temp.addAll(downloadQueue)
|
temp.addAll(downloadQueue)
|
||||||
temp.addAll(
|
temp.addAll(
|
||||||
playlist.filter {
|
legacyPlaylistManager.playlist.filter {
|
||||||
if (!it.isStatusInitialized) false
|
if (!it.isStatusInitialized) false
|
||||||
else when (it.status.value) {
|
else when (it.status.value) {
|
||||||
DownloadStatus.DOWNLOADING -> true
|
DownloadStatus.DOWNLOADING -> true
|
||||||
|
@ -278,37 +279,13 @@ class Downloader(
|
||||||
return temp.distinct().sorted()
|
return temp.distinct().sorted()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Public facing playlist (immutable)
|
|
||||||
@Synchronized
|
|
||||||
fun getPlaylist(): List<DownloadFile> = playlist
|
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun clearDownloadFileCache() {
|
fun clearDownloadFileCache() {
|
||||||
downloadFileCache.clear()
|
downloadFileCache.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun clearPlaylist() {
|
fun clearBackground() {
|
||||||
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() {
|
|
||||||
// Clear the pending queue
|
// Clear the pending queue
|
||||||
downloadQueue.clear()
|
downloadQueue.clear()
|
||||||
|
|
||||||
|
@ -333,79 +310,6 @@ class Downloader(
|
||||||
updateLiveData()
|
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
|
@Synchronized
|
||||||
fun downloadBackground(songs: List<Track>, save: Boolean) {
|
fun downloadBackground(songs: List<Track>, save: Boolean) {
|
||||||
|
|
||||||
|
@ -413,30 +317,20 @@ class Downloader(
|
||||||
for (song in songs) {
|
for (song in songs) {
|
||||||
val file = song.getDownloadFile()
|
val file = song.getDownloadFile()
|
||||||
file.shouldSave = save
|
file.shouldSave = save
|
||||||
file.priority = backgroundPriorityCounter++
|
if (!file.isDownloading) {
|
||||||
downloadQueue.add(file)
|
file.priority = backgroundPriorityCounter++
|
||||||
|
downloadQueue.add(file)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Timber.v("downloadBackground Checking Downloads")
|
||||||
checkDownloads()
|
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
|
@Synchronized
|
||||||
@Suppress("ReturnCount")
|
@Suppress("ReturnCount")
|
||||||
fun getDownloadFileForSong(song: Track): DownloadFile {
|
fun getDownloadFileForSong(song: Track): DownloadFile {
|
||||||
for (downloadFile in playlist) {
|
for (downloadFile in legacyPlaylistManager.playlist) {
|
||||||
if (downloadFile.track == song) {
|
if (downloadFile.track == song) {
|
||||||
return downloadFile
|
return downloadFile
|
||||||
}
|
}
|
||||||
|
@ -459,63 +353,209 @@ class Downloader(
|
||||||
return downloadFile
|
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 {
|
companion object {
|
||||||
const val PARALLEL_DOWNLOADS = 3
|
const val PARALLEL_DOWNLOADS = 2
|
||||||
const val CHECK_INTERVAL = 5L
|
const val CHECK_INTERVAL = 5000L
|
||||||
const val SHUFFLE_BUFFER_LIMIT = 4
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extension function
|
* Extension function
|
||||||
* Gathers the download file for a given song, and modifies shouldSave if provided.
|
* 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 {
|
return getDownloadFileForSong(this).apply {
|
||||||
if (save != null) this.shouldSave = save
|
if (save != null) this.shouldSave = save
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private inner class DownloadTask(private val downloadFile: DownloadFile) : CancellableTask() {
|
||||||
|
val musicService = MusicServiceFactory.getMusicService()
|
||||||
|
|
||||||
|
@Suppress("LongMethod", "ComplexMethod", "NestedBlockDepth")
|
||||||
|
override fun execute() {
|
||||||
|
|
||||||
|
downloadFile.downloadPrepared = false
|
||||||
|
var inputStream: InputStream? = null
|
||||||
|
var outputStream: OutputStream? = null
|
||||||
|
try {
|
||||||
|
if (Storage.isPathExists(downloadFile.pinnedFile)) {
|
||||||
|
Timber.i("%s already exists. Skipping.", downloadFile.pinnedFile)
|
||||||
|
downloadFile.status.postValue(DownloadStatus.PINNED)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Storage.isPathExists(downloadFile.completeFile)) {
|
||||||
|
var newStatus: DownloadStatus = DownloadStatus.DONE
|
||||||
|
if (downloadFile.shouldSave) {
|
||||||
|
if (downloadFile.isPlaying) {
|
||||||
|
downloadFile.saveWhenDone = true
|
||||||
|
} else {
|
||||||
|
Storage.rename(
|
||||||
|
downloadFile.completeFile,
|
||||||
|
downloadFile.pinnedFile
|
||||||
|
)
|
||||||
|
newStatus = DownloadStatus.PINNED
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Timber.i(
|
||||||
|
"%s already exists. Skipping.",
|
||||||
|
downloadFile.completeFile
|
||||||
|
)
|
||||||
|
}
|
||||||
|
downloadFile.status.postValue(newStatus)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadFile.status.postValue(DownloadStatus.DOWNLOADING)
|
||||||
|
|
||||||
|
// Some devices seem to throw error on partial file which doesn't exist
|
||||||
|
val needsDownloading: Boolean
|
||||||
|
val duration = downloadFile.track.duration
|
||||||
|
val fileLength = Storage.getFromPath(downloadFile.partialFile)?.length ?: 0
|
||||||
|
|
||||||
|
needsDownloading = (
|
||||||
|
downloadFile.desiredBitRate == 0 ||
|
||||||
|
duration == null ||
|
||||||
|
duration == 0 ||
|
||||||
|
fileLength == 0L
|
||||||
|
)
|
||||||
|
|
||||||
|
if (needsDownloading) {
|
||||||
|
// Attempt partial HTTP GET, appending to the file if it exists.
|
||||||
|
val (inStream, isPartial) = musicService.getDownloadInputStream(
|
||||||
|
downloadFile.track, fileLength,
|
||||||
|
downloadFile.desiredBitRate,
|
||||||
|
downloadFile.shouldSave
|
||||||
|
)
|
||||||
|
|
||||||
|
inputStream = inStream
|
||||||
|
|
||||||
|
if (isPartial) {
|
||||||
|
Timber.i("Executed partial HTTP GET, skipping %d bytes", fileLength)
|
||||||
|
}
|
||||||
|
|
||||||
|
outputStream = Storage.getOrCreateFileFromPath(downloadFile.partialFile)
|
||||||
|
.getFileOutputStream(isPartial)
|
||||||
|
|
||||||
|
val len = inputStream.copyTo(outputStream) { totalBytesCopied ->
|
||||||
|
downloadFile.setProgress(totalBytesCopied)
|
||||||
|
}
|
||||||
|
|
||||||
|
Timber.i("Downloaded %d bytes to %s", len, downloadFile.partialFile)
|
||||||
|
|
||||||
|
inputStream.close()
|
||||||
|
outputStream.flush()
|
||||||
|
outputStream.close()
|
||||||
|
|
||||||
|
if (isCancelled) {
|
||||||
|
downloadFile.status.postValue(DownloadStatus.CANCELLED)
|
||||||
|
throw RuntimeException(
|
||||||
|
String.format(
|
||||||
|
Locale.ROOT, "Download of '%s' was cancelled",
|
||||||
|
downloadFile.track
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (downloadFile.track.artistId != null) {
|
||||||
|
cacheMetadata(downloadFile.track.artistId!!)
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadAndSaveCoverArt()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (downloadFile.isPlaying) {
|
||||||
|
downloadFile.completeWhenDone = true
|
||||||
|
} else {
|
||||||
|
if (downloadFile.shouldSave) {
|
||||||
|
Storage.rename(
|
||||||
|
downloadFile.partialFile,
|
||||||
|
downloadFile.pinnedFile
|
||||||
|
)
|
||||||
|
downloadFile.status.postValue(DownloadStatus.PINNED)
|
||||||
|
Util.scanMedia(downloadFile.pinnedFile)
|
||||||
|
} else {
|
||||||
|
Storage.rename(
|
||||||
|
downloadFile.partialFile,
|
||||||
|
downloadFile.completeFile
|
||||||
|
)
|
||||||
|
downloadFile.status.postValue(DownloadStatus.DONE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (all: Exception) {
|
||||||
|
outputStream.safeClose()
|
||||||
|
Storage.delete(downloadFile.completeFile)
|
||||||
|
Storage.delete(downloadFile.pinnedFile)
|
||||||
|
if (!isCancelled) {
|
||||||
|
downloadFile.isFailed = true
|
||||||
|
if (downloadFile.retryCount > 1) {
|
||||||
|
downloadFile.status.postValue(DownloadStatus.RETRYING)
|
||||||
|
--downloadFile.retryCount
|
||||||
|
} else if (downloadFile.retryCount == 1) {
|
||||||
|
downloadFile.status.postValue(DownloadStatus.FAILED)
|
||||||
|
--downloadFile.retryCount
|
||||||
|
}
|
||||||
|
Timber.w(all, "Failed to download '%s'.", downloadFile.track)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
inputStream.safeClose()
|
||||||
|
outputStream.safeClose()
|
||||||
|
CacheCleaner().cleanSpace()
|
||||||
|
Timber.v("DownloadTask checking downloads")
|
||||||
|
checkDownloads()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun toString(): String {
|
||||||
|
return String.format(Locale.ROOT, "DownloadTask (%s)", downloadFile.track)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cacheMetadata(artistId: String) {
|
||||||
|
// TODO: Right now it's caching the track artist.
|
||||||
|
// Once the albums are cached in db, we should retrieve the album,
|
||||||
|
// and then cache the album artist.
|
||||||
|
if (artistId.isEmpty()) return
|
||||||
|
var artist: Artist? =
|
||||||
|
activeServerProvider.getActiveMetaDatabase().artistsDao().get(artistId)
|
||||||
|
|
||||||
|
// If we are downloading a new album, and the user has not visited the Artists list
|
||||||
|
// recently, then the artist won't be in the database.
|
||||||
|
if (artist == null) {
|
||||||
|
val artists: List<Artist> = musicService.getArtists(true)
|
||||||
|
artist = artists.find {
|
||||||
|
it.id == artistId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we have found an artist, catch it.
|
||||||
|
if (artist != null) {
|
||||||
|
activeServerProvider.offlineMetaDatabase.artistsDao().insert(artist)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun downloadAndSaveCoverArt() {
|
||||||
|
try {
|
||||||
|
if (!TextUtils.isEmpty(downloadFile.track.coverArt)) {
|
||||||
|
// Download the largest size that we can display in the UI
|
||||||
|
imageLoaderProvider.getImageLoader().cacheCoverArt(downloadFile.track)
|
||||||
|
}
|
||||||
|
} catch (all: Exception) {
|
||||||
|
Timber.e(all, "Failed to get cover art.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(IOException::class)
|
||||||
|
fun InputStream.copyTo(out: OutputStream, onCopy: (totalBytesCopied: Long) -> Any): Long {
|
||||||
|
var bytesCopied: Long = 0
|
||||||
|
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||||
|
var bytes = read(buffer)
|
||||||
|
while (!isCancelled && bytes >= 0) {
|
||||||
|
out.write(buffer, 0, bytes)
|
||||||
|
bytesCopied += bytes
|
||||||
|
onCopy(bytesCopied)
|
||||||
|
bytes = read(buffer)
|
||||||
|
}
|
||||||
|
return bytesCopied
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,341 @@
|
||||||
|
/*
|
||||||
|
* JukeboxMediaPlayer.kt
|
||||||
|
* Copyright (C) 2009-2022 Ultrasonic developers
|
||||||
|
*
|
||||||
|
* Distributed under terms of the GNU GPLv3 license.
|
||||||
|
*/
|
||||||
|
package org.moire.ultrasonic.service
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.view.Gravity
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.widget.ProgressBar
|
||||||
|
import android.widget.Toast
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
import java.util.concurrent.LinkedBlockingQueue
|
||||||
|
import java.util.concurrent.ScheduledFuture
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
import java.util.concurrent.atomic.AtomicLong
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
import org.koin.java.KoinJavaComponent.inject
|
||||||
|
import org.moire.ultrasonic.R
|
||||||
|
import org.moire.ultrasonic.api.subsonic.ApiNotSupportedException
|
||||||
|
import org.moire.ultrasonic.api.subsonic.SubsonicRESTException
|
||||||
|
import org.moire.ultrasonic.app.UApp.Companion.applicationContext
|
||||||
|
import org.moire.ultrasonic.data.ActiveServerProvider.Companion.isOffline
|
||||||
|
import org.moire.ultrasonic.domain.JukeboxStatus
|
||||||
|
import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService
|
||||||
|
import org.moire.ultrasonic.util.Util.sleepQuietly
|
||||||
|
import org.moire.ultrasonic.util.Util.toast
|
||||||
|
import timber.log.Timber
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides an asynchronous interface to the remote jukebox on the Subsonic server.
|
||||||
|
*
|
||||||
|
* TODO: Report warning if queue fills up.
|
||||||
|
* TODO: Create shutdown method?
|
||||||
|
* TODO: Disable repeat.
|
||||||
|
* TODO: Persist RC state?
|
||||||
|
* TODO: Minimize status updates.
|
||||||
|
*/
|
||||||
|
class JukeboxMediaPlayer(private val downloader: Downloader) {
|
||||||
|
private val tasks = TaskQueue()
|
||||||
|
private val executorService = Executors.newSingleThreadScheduledExecutor()
|
||||||
|
private var statusUpdateFuture: ScheduledFuture<*>? = null
|
||||||
|
private val timeOfLastUpdate = AtomicLong()
|
||||||
|
private var jukeboxStatus: JukeboxStatus? = null
|
||||||
|
private var gain = 0.5f
|
||||||
|
private var volumeToast: VolumeToast? = null
|
||||||
|
private val running = AtomicBoolean()
|
||||||
|
private var serviceThread: Thread? = null
|
||||||
|
private var enabled = false
|
||||||
|
|
||||||
|
// TODO: These create circular references, try to refactor
|
||||||
|
private val mediaPlayerControllerLazy = inject<MediaPlayerController>(
|
||||||
|
MediaPlayerController::class.java
|
||||||
|
)
|
||||||
|
|
||||||
|
fun startJukeboxService() {
|
||||||
|
if (running.get()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
running.set(true)
|
||||||
|
startProcessTasks()
|
||||||
|
Timber.d("Started Jukebox Service")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stopJukeboxService() {
|
||||||
|
running.set(false)
|
||||||
|
sleepQuietly(1000)
|
||||||
|
if (serviceThread != null) {
|
||||||
|
serviceThread!!.interrupt()
|
||||||
|
}
|
||||||
|
Timber.d("Stopped Jukebox Service")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startProcessTasks() {
|
||||||
|
serviceThread = object : Thread() {
|
||||||
|
override fun run() {
|
||||||
|
processTasks()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(serviceThread as Thread).start()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
private fun startStatusUpdate() {
|
||||||
|
stopStatusUpdate()
|
||||||
|
val updateTask = Runnable {
|
||||||
|
tasks.remove(GetStatus::class.java)
|
||||||
|
tasks.add(GetStatus())
|
||||||
|
}
|
||||||
|
statusUpdateFuture = executorService.scheduleWithFixedDelay(
|
||||||
|
updateTask,
|
||||||
|
STATUS_UPDATE_INTERVAL_SECONDS,
|
||||||
|
STATUS_UPDATE_INTERVAL_SECONDS,
|
||||||
|
TimeUnit.SECONDS
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
private fun stopStatusUpdate() {
|
||||||
|
if (statusUpdateFuture != null) {
|
||||||
|
statusUpdateFuture!!.cancel(false)
|
||||||
|
statusUpdateFuture = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun processTasks() {
|
||||||
|
while (running.get()) {
|
||||||
|
var task: JukeboxTask? = null
|
||||||
|
try {
|
||||||
|
if (!isOffline()) {
|
||||||
|
task = tasks.take()
|
||||||
|
val status = task.execute()
|
||||||
|
onStatusUpdate(status)
|
||||||
|
}
|
||||||
|
} catch (ignored: InterruptedException) {
|
||||||
|
} catch (x: Throwable) {
|
||||||
|
onError(task, x)
|
||||||
|
}
|
||||||
|
sleepQuietly(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onStatusUpdate(jukeboxStatus: JukeboxStatus) {
|
||||||
|
timeOfLastUpdate.set(System.currentTimeMillis())
|
||||||
|
this.jukeboxStatus = jukeboxStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onError(task: JukeboxTask?, x: Throwable) {
|
||||||
|
if (x is ApiNotSupportedException && task !is Stop) {
|
||||||
|
disableJukeboxOnError(x, R.string.download_jukebox_server_too_old)
|
||||||
|
} else if (x is OfflineException && task !is Stop) {
|
||||||
|
disableJukeboxOnError(x, R.string.download_jukebox_offline)
|
||||||
|
} else if (x is SubsonicRESTException && x.code == 50 && task !is Stop) {
|
||||||
|
disableJukeboxOnError(x, R.string.download_jukebox_not_authorized)
|
||||||
|
} else {
|
||||||
|
Timber.e(x, "Failed to process jukebox task")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun disableJukeboxOnError(x: Throwable, resourceId: Int) {
|
||||||
|
Timber.w(x.toString())
|
||||||
|
val context = applicationContext()
|
||||||
|
Handler(Looper.getMainLooper()).post { toast(context, resourceId, false) }
|
||||||
|
mediaPlayerControllerLazy.value.isJukeboxEnabled = false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updatePlaylist() {
|
||||||
|
if (!enabled) return
|
||||||
|
tasks.remove(Skip::class.java)
|
||||||
|
tasks.remove(Stop::class.java)
|
||||||
|
tasks.remove(Start::class.java)
|
||||||
|
val ids: MutableList<String> = ArrayList()
|
||||||
|
for (file in downloader.all) {
|
||||||
|
ids.add(file.track.id)
|
||||||
|
}
|
||||||
|
tasks.add(SetPlaylist(ids))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun skip(index: Int, offsetSeconds: Int) {
|
||||||
|
tasks.remove(Skip::class.java)
|
||||||
|
tasks.remove(Stop::class.java)
|
||||||
|
tasks.remove(Start::class.java)
|
||||||
|
startStatusUpdate()
|
||||||
|
if (jukeboxStatus != null) {
|
||||||
|
jukeboxStatus!!.positionSeconds = offsetSeconds
|
||||||
|
}
|
||||||
|
tasks.add(Skip(index, offsetSeconds))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop() {
|
||||||
|
tasks.remove(Stop::class.java)
|
||||||
|
tasks.remove(Start::class.java)
|
||||||
|
stopStatusUpdate()
|
||||||
|
tasks.add(Stop())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun start() {
|
||||||
|
tasks.remove(Stop::class.java)
|
||||||
|
tasks.remove(Start::class.java)
|
||||||
|
startStatusUpdate()
|
||||||
|
tasks.add(Start())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun adjustVolume(up: Boolean) {
|
||||||
|
val delta = if (up) 0.05f else -0.05f
|
||||||
|
gain += delta
|
||||||
|
gain = gain.coerceAtLeast(0.0f)
|
||||||
|
gain = gain.coerceAtMost(1.0f)
|
||||||
|
tasks.remove(SetGain::class.java)
|
||||||
|
tasks.add(SetGain(gain))
|
||||||
|
val context = applicationContext()
|
||||||
|
if (volumeToast == null) volumeToast = VolumeToast(context)
|
||||||
|
volumeToast!!.setVolume(gain)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val musicService: MusicService
|
||||||
|
get() = getMusicService()
|
||||||
|
|
||||||
|
val positionSeconds: Int
|
||||||
|
get() {
|
||||||
|
if (jukeboxStatus == null ||
|
||||||
|
jukeboxStatus!!.positionSeconds == null ||
|
||||||
|
timeOfLastUpdate.get() == 0L
|
||||||
|
) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if (jukeboxStatus!!.isPlaying) {
|
||||||
|
val secondsSinceLastUpdate =
|
||||||
|
((System.currentTimeMillis() - timeOfLastUpdate.get()) / 1000L).toInt()
|
||||||
|
return jukeboxStatus!!.positionSeconds!! + secondsSinceLastUpdate
|
||||||
|
}
|
||||||
|
return jukeboxStatus!!.positionSeconds!!
|
||||||
|
}
|
||||||
|
|
||||||
|
var isEnabled: Boolean
|
||||||
|
set(enabled) {
|
||||||
|
Timber.d("Jukebox Service setting enabled to %b", enabled)
|
||||||
|
this.enabled = enabled
|
||||||
|
tasks.clear()
|
||||||
|
if (enabled) {
|
||||||
|
updatePlaylist()
|
||||||
|
}
|
||||||
|
stop()
|
||||||
|
}
|
||||||
|
get() {
|
||||||
|
return enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
private class TaskQueue {
|
||||||
|
private val queue = LinkedBlockingQueue<JukeboxTask>()
|
||||||
|
fun add(jukeboxTask: JukeboxTask) {
|
||||||
|
queue.add(jukeboxTask)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(InterruptedException::class)
|
||||||
|
fun take(): JukeboxTask {
|
||||||
|
return queue.take()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun remove(taskClass: Class<out JukeboxTask?>) {
|
||||||
|
try {
|
||||||
|
val iterator = queue.iterator()
|
||||||
|
while (iterator.hasNext()) {
|
||||||
|
val task = iterator.next()
|
||||||
|
if (taskClass == task.javaClass) {
|
||||||
|
iterator.remove()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (x: Throwable) {
|
||||||
|
Timber.w(x, "Failed to clean-up task queue.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clear() {
|
||||||
|
queue.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private abstract class JukeboxTask {
|
||||||
|
@Throws(Exception::class)
|
||||||
|
abstract fun execute(): JukeboxStatus
|
||||||
|
override fun toString(): String {
|
||||||
|
return javaClass.simpleName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private inner class GetStatus : JukeboxTask() {
|
||||||
|
@Throws(Exception::class)
|
||||||
|
override fun execute(): JukeboxStatus {
|
||||||
|
return musicService.getJukeboxStatus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private inner class SetPlaylist(private val ids: List<String>) :
|
||||||
|
JukeboxTask() {
|
||||||
|
@Throws(Exception::class)
|
||||||
|
override fun execute(): JukeboxStatus {
|
||||||
|
return musicService.updateJukeboxPlaylist(ids)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private inner class Skip(
|
||||||
|
private val index: Int,
|
||||||
|
private val offsetSeconds: Int
|
||||||
|
) : JukeboxTask() {
|
||||||
|
@Throws(Exception::class)
|
||||||
|
override fun execute(): JukeboxStatus {
|
||||||
|
return musicService.skipJukebox(index, offsetSeconds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private inner class Stop : JukeboxTask() {
|
||||||
|
@Throws(Exception::class)
|
||||||
|
override fun execute(): JukeboxStatus {
|
||||||
|
return musicService.stopJukebox()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private inner class Start : JukeboxTask() {
|
||||||
|
@Throws(Exception::class)
|
||||||
|
override fun execute(): JukeboxStatus {
|
||||||
|
return musicService.startJukebox()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private inner class SetGain(private val gain: Float) : JukeboxTask() {
|
||||||
|
@Throws(Exception::class)
|
||||||
|
override fun execute(): JukeboxStatus {
|
||||||
|
return musicService.setJukeboxGain(gain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class VolumeToast(context: Context) : Toast(context) {
|
||||||
|
private val progressBar: ProgressBar
|
||||||
|
fun setVolume(volume: Float) {
|
||||||
|
progressBar.progress = (100 * volume).roundToInt()
|
||||||
|
show()
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
duration = LENGTH_SHORT
|
||||||
|
val inflater =
|
||||||
|
context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater
|
||||||
|
val view = inflater.inflate(R.layout.jukebox_volume, null)
|
||||||
|
progressBar = view.findViewById<View>(R.id.jukebox_volume_progress_bar) as ProgressBar
|
||||||
|
setView(view)
|
||||||
|
setGravity(Gravity.TOP, 0, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val STATUS_UPDATE_INTERVAL_SECONDS = 5L
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,745 +0,0 @@
|
||||||
/*
|
|
||||||
* LocalMediaPlayer.kt
|
|
||||||
* Copyright (C) 2009-2021 Ultrasonic developers
|
|
||||||
*
|
|
||||||
* Distributed under terms of the GNU GPLv3 license.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.moire.ultrasonic.service
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Context.POWER_SERVICE
|
|
||||||
import android.content.Intent
|
|
||||||
import android.media.MediaPlayer
|
|
||||||
import android.media.MediaPlayer.OnCompletionListener
|
|
||||||
import android.media.audiofx.AudioEffect
|
|
||||||
import android.os.Handler
|
|
||||||
import android.os.Looper
|
|
||||||
import android.os.PowerManager
|
|
||||||
import android.os.PowerManager.PARTIAL_WAKE_LOCK
|
|
||||||
import android.os.PowerManager.WakeLock
|
|
||||||
import androidx.lifecycle.MutableLiveData
|
|
||||||
import java.net.URLEncoder
|
|
||||||
import java.util.Locale
|
|
||||||
import kotlin.math.abs
|
|
||||||
import kotlin.math.max
|
|
||||||
import org.koin.core.component.KoinComponent
|
|
||||||
import org.koin.core.component.inject
|
|
||||||
import org.moire.ultrasonic.audiofx.EqualizerController
|
|
||||||
import org.moire.ultrasonic.audiofx.VisualizerController
|
|
||||||
import org.moire.ultrasonic.data.ActiveServerProvider.Companion.isOffline
|
|
||||||
import org.moire.ultrasonic.domain.PlayerState
|
|
||||||
import org.moire.ultrasonic.util.CancellableTask
|
|
||||||
import org.moire.ultrasonic.util.Constants
|
|
||||||
import org.moire.ultrasonic.util.Settings
|
|
||||||
import org.moire.ultrasonic.util.Storage
|
|
||||||
import org.moire.ultrasonic.util.StreamProxy
|
|
||||||
import org.moire.ultrasonic.util.Util
|
|
||||||
import timber.log.Timber
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Represents a Media Player which uses the mobile's resources for playback
|
|
||||||
*/
|
|
||||||
@Suppress("TooManyFunctions")
|
|
||||||
class LocalMediaPlayer : KoinComponent {
|
|
||||||
|
|
||||||
private val audioFocusHandler by inject<AudioFocusHandler>()
|
|
||||||
private val context by inject<Context>()
|
|
||||||
|
|
||||||
@JvmField
|
|
||||||
var onSongCompleted: ((DownloadFile?) -> Unit?)? = null
|
|
||||||
|
|
||||||
@JvmField
|
|
||||||
var onPrepared: (() -> Any?)? = null
|
|
||||||
|
|
||||||
@JvmField
|
|
||||||
var onNextSongRequested: Runnable? = null
|
|
||||||
|
|
||||||
@JvmField
|
|
||||||
@Volatile
|
|
||||||
var playerState = PlayerState.IDLE
|
|
||||||
|
|
||||||
@JvmField
|
|
||||||
var currentPlaying: DownloadFile? = null
|
|
||||||
|
|
||||||
@JvmField
|
|
||||||
var nextPlaying: DownloadFile? = null
|
|
||||||
|
|
||||||
private var nextPlayerState = PlayerState.IDLE
|
|
||||||
private var nextSetup = false
|
|
||||||
private var nextPlayingTask: CancellableTask? = null
|
|
||||||
private var mediaPlayer: MediaPlayer = MediaPlayer()
|
|
||||||
private var nextMediaPlayer: MediaPlayer? = null
|
|
||||||
private var mediaPlayerLooper: Looper? = null
|
|
||||||
private var mediaPlayerHandler: Handler? = null
|
|
||||||
private var cachedPosition = 0
|
|
||||||
private var proxy: StreamProxy? = null
|
|
||||||
private var bufferTask: CancellableTask? = null
|
|
||||||
private var positionCache: PositionCache? = null
|
|
||||||
|
|
||||||
private val pm = context.getSystemService(POWER_SERVICE) as PowerManager
|
|
||||||
private val wakeLock: WakeLock = pm.newWakeLock(PARTIAL_WAKE_LOCK, this.javaClass.name)
|
|
||||||
|
|
||||||
val secondaryProgress: MutableLiveData<Int> = MutableLiveData(0)
|
|
||||||
|
|
||||||
fun init() {
|
|
||||||
Thread {
|
|
||||||
Thread.currentThread().name = "MediaPlayerThread"
|
|
||||||
Looper.prepare()
|
|
||||||
mediaPlayer.setWakeMode(context, PARTIAL_WAKE_LOCK)
|
|
||||||
mediaPlayer.setOnErrorListener { _, what, more ->
|
|
||||||
handleError(
|
|
||||||
Exception(
|
|
||||||
String.format(
|
|
||||||
Locale.getDefault(),
|
|
||||||
"MediaPlayer error: %d (%d)", what, more
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
false
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
val i = Intent(AudioEffect.ACTION_OPEN_AUDIO_EFFECT_CONTROL_SESSION)
|
|
||||||
i.putExtra(AudioEffect.EXTRA_AUDIO_SESSION, mediaPlayer.audioSessionId)
|
|
||||||
i.putExtra(AudioEffect.EXTRA_PACKAGE_NAME, context.packageName)
|
|
||||||
context.sendBroadcast(i)
|
|
||||||
} catch (ignored: Throwable) {
|
|
||||||
// Froyo or lower
|
|
||||||
}
|
|
||||||
mediaPlayerLooper = Looper.myLooper()
|
|
||||||
mediaPlayerHandler = Handler(mediaPlayerLooper!!)
|
|
||||||
Looper.loop()
|
|
||||||
}.start()
|
|
||||||
|
|
||||||
// Create Equalizer and Visualizer on a new thread as this can potentially take some time
|
|
||||||
Thread {
|
|
||||||
EqualizerController.create(context, mediaPlayer)
|
|
||||||
VisualizerController.create(mediaPlayer)
|
|
||||||
}.start()
|
|
||||||
|
|
||||||
wakeLock.setReferenceCounted(false)
|
|
||||||
Timber.i("LocalMediaPlayer created")
|
|
||||||
}
|
|
||||||
|
|
||||||
fun release() {
|
|
||||||
// Calling reset() will result in changing this player's state. If we allow
|
|
||||||
// the onPlayerStateChanged callback, then the state change will cause this
|
|
||||||
// to resurrect the media session which has just been destroyed.
|
|
||||||
reset()
|
|
||||||
try {
|
|
||||||
val i = Intent(AudioEffect.ACTION_CLOSE_AUDIO_EFFECT_CONTROL_SESSION)
|
|
||||||
i.putExtra(AudioEffect.EXTRA_AUDIO_SESSION, mediaPlayer.audioSessionId)
|
|
||||||
i.putExtra(AudioEffect.EXTRA_PACKAGE_NAME, context.packageName)
|
|
||||||
context.sendBroadcast(i)
|
|
||||||
EqualizerController.release()
|
|
||||||
VisualizerController.release()
|
|
||||||
mediaPlayer.release()
|
|
||||||
|
|
||||||
mediaPlayer = MediaPlayer()
|
|
||||||
|
|
||||||
if (nextMediaPlayer != null) {
|
|
||||||
nextMediaPlayer!!.release()
|
|
||||||
}
|
|
||||||
mediaPlayerLooper!!.quit()
|
|
||||||
if (bufferTask != null) {
|
|
||||||
bufferTask!!.cancel()
|
|
||||||
}
|
|
||||||
if (nextPlayingTask != null) {
|
|
||||||
nextPlayingTask!!.cancel()
|
|
||||||
}
|
|
||||||
|
|
||||||
wakeLock.release()
|
|
||||||
} catch (exception: Throwable) {
|
|
||||||
Timber.w(exception, "LocalMediaPlayer onDestroy exception: ")
|
|
||||||
}
|
|
||||||
Timber.i("LocalMediaPlayer destroyed")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
fun setPlayerState(playerState: PlayerState, track: DownloadFile?) {
|
|
||||||
Timber.i("%s -> %s (%s)", this.playerState.name, playerState.name, track)
|
|
||||||
synchronized(playerState) {
|
|
||||||
this.playerState = playerState
|
|
||||||
}
|
|
||||||
if (playerState === PlayerState.STARTED) {
|
|
||||||
audioFocusHandler.requestAudioFocus()
|
|
||||||
}
|
|
||||||
|
|
||||||
RxBus.playerStatePublisher.onNext(RxBus.StateWithTrack(playerState, track))
|
|
||||||
|
|
||||||
if (playerState === PlayerState.STARTED && positionCache == null) {
|
|
||||||
positionCache = PositionCache()
|
|
||||||
val thread = Thread(positionCache)
|
|
||||||
thread.start()
|
|
||||||
} else if (playerState !== PlayerState.STARTED && positionCache != null) {
|
|
||||||
positionCache!!.stop()
|
|
||||||
positionCache = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Set the current playing file. It's called with null to reset the player.
|
|
||||||
*/
|
|
||||||
@Synchronized
|
|
||||||
fun setCurrentPlaying(currentPlaying: DownloadFile?) {
|
|
||||||
// In some cases this function is called twice
|
|
||||||
if (this.currentPlaying == currentPlaying) return
|
|
||||||
this.currentPlaying = currentPlaying
|
|
||||||
RxBus.playerStatePublisher.onNext(RxBus.StateWithTrack(playerState, currentPlaying))
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Set the next playing file. nextToPlay cannot be null
|
|
||||||
*/
|
|
||||||
@Synchronized
|
|
||||||
fun setNextPlaying(nextToPlay: DownloadFile) {
|
|
||||||
nextPlaying = nextToPlay
|
|
||||||
nextPlayingTask = CheckCompletionTask(nextPlaying)
|
|
||||||
nextPlayingTask?.start()
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Clear the next playing file. setIdle controls whether the playerState is affected as well
|
|
||||||
*/
|
|
||||||
@Synchronized
|
|
||||||
fun clearNextPlaying(setIdle: Boolean) {
|
|
||||||
nextSetup = false
|
|
||||||
nextPlaying = null
|
|
||||||
if (nextPlayingTask != null) {
|
|
||||||
nextPlayingTask!!.cancel()
|
|
||||||
nextPlayingTask = null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (setIdle) {
|
|
||||||
setNextPlayerState(PlayerState.IDLE)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
fun setNextPlayerState(playerState: PlayerState) {
|
|
||||||
Timber.i("Next: %s -> %s (%s)", nextPlayerState.name, playerState.name, nextPlaying)
|
|
||||||
nextPlayerState = playerState
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Public method to play a given file.
|
|
||||||
* Optionally specify a position to start at.
|
|
||||||
*/
|
|
||||||
@Synchronized
|
|
||||||
@JvmOverloads
|
|
||||||
fun play(fileToPlay: DownloadFile?, position: Int = 0, autoStart: Boolean = true) {
|
|
||||||
if (nextPlayingTask != null) {
|
|
||||||
nextPlayingTask!!.cancel()
|
|
||||||
nextPlayingTask = null
|
|
||||||
}
|
|
||||||
setCurrentPlaying(fileToPlay)
|
|
||||||
|
|
||||||
if (fileToPlay != null) {
|
|
||||||
bufferAndPlay(fileToPlay, position, autoStart)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
fun playNext() {
|
|
||||||
if (nextMediaPlayer == null || nextPlaying == null) return
|
|
||||||
|
|
||||||
mediaPlayer = nextMediaPlayer!!
|
|
||||||
|
|
||||||
setCurrentPlaying(nextPlaying)
|
|
||||||
setPlayerState(PlayerState.STARTED, currentPlaying)
|
|
||||||
|
|
||||||
attachHandlersToPlayer(mediaPlayer, nextPlaying!!, false)
|
|
||||||
|
|
||||||
postRunnable(onNextSongRequested)
|
|
||||||
|
|
||||||
// Proxy should not be being used here since the next player was already setup to play
|
|
||||||
proxy?.stop()
|
|
||||||
proxy = null
|
|
||||||
}
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
fun pause() {
|
|
||||||
try {
|
|
||||||
mediaPlayer.pause()
|
|
||||||
} catch (x: Exception) {
|
|
||||||
handleError(x)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
fun start() {
|
|
||||||
try {
|
|
||||||
mediaPlayer.start()
|
|
||||||
} catch (x: Exception) {
|
|
||||||
handleError(x)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
fun seekTo(position: Int) {
|
|
||||||
try {
|
|
||||||
mediaPlayer.seekTo(position)
|
|
||||||
cachedPosition = position
|
|
||||||
} catch (x: Exception) {
|
|
||||||
handleError(x)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@get:Synchronized
|
|
||||||
val playerPosition: Int
|
|
||||||
get() = try {
|
|
||||||
when (playerState) {
|
|
||||||
PlayerState.IDLE -> 0
|
|
||||||
PlayerState.DOWNLOADING -> 0
|
|
||||||
PlayerState.PREPARING -> 0
|
|
||||||
else -> cachedPosition
|
|
||||||
}
|
|
||||||
} catch (x: Exception) {
|
|
||||||
handleError(x)
|
|
||||||
0
|
|
||||||
}
|
|
||||||
|
|
||||||
@get:Synchronized
|
|
||||||
val playerDuration: Int
|
|
||||||
get() {
|
|
||||||
if (currentPlaying != null) {
|
|
||||||
val duration = currentPlaying!!.track.duration
|
|
||||||
if (duration != null) {
|
|
||||||
return duration * 1000
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (playerState !== PlayerState.IDLE &&
|
|
||||||
playerState !== PlayerState.DOWNLOADING &&
|
|
||||||
playerState !== PlayerState.PREPARING
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
return mediaPlayer.duration
|
|
||||||
} catch (x: Exception) {
|
|
||||||
handleError(x)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setVolume(volume: Float) {
|
|
||||||
mediaPlayer.setVolume(volume, volume)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
private fun bufferAndPlay(fileToPlay: DownloadFile, position: Int, autoStart: Boolean) {
|
|
||||||
if (playerState !== PlayerState.PREPARED && !fileToPlay.isWorkDone) {
|
|
||||||
reset()
|
|
||||||
bufferTask = BufferTask(fileToPlay, position, autoStart)
|
|
||||||
bufferTask!!.start()
|
|
||||||
} else {
|
|
||||||
doPlay(fileToPlay, position, autoStart)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
private fun doPlay(downloadFile: DownloadFile, position: Int, start: Boolean) {
|
|
||||||
setPlayerState(PlayerState.IDLE, downloadFile)
|
|
||||||
|
|
||||||
// In many cases we will be resetting the mediaPlayer a second time here.
|
|
||||||
// figure out if we can remove this call...
|
|
||||||
resetMediaPlayer()
|
|
||||||
|
|
||||||
try {
|
|
||||||
downloadFile.setPlaying(false)
|
|
||||||
|
|
||||||
val file = Storage.getFromPath(downloadFile.completeOrPartialFile)
|
|
||||||
val partial = !downloadFile.isCompleteFileAvailable
|
|
||||||
|
|
||||||
// TODO this won't work with SAF, we should use something else, e.g. a recent list
|
|
||||||
// downloadFile.updateModificationDate()
|
|
||||||
mediaPlayer.setOnCompletionListener(null)
|
|
||||||
|
|
||||||
setAudioAttributes(mediaPlayer)
|
|
||||||
|
|
||||||
var streamUrl: String? = null
|
|
||||||
if (partial) {
|
|
||||||
if (proxy == null) {
|
|
||||||
proxy = StreamProxy(object : Supplier<DownloadFile?>() {
|
|
||||||
override fun get(): DownloadFile {
|
|
||||||
return currentPlaying!!
|
|
||||||
}
|
|
||||||
})
|
|
||||||
proxy!!.start()
|
|
||||||
}
|
|
||||||
streamUrl = String.format(
|
|
||||||
Locale.getDefault(), "http://127.0.0.1:%d/%s",
|
|
||||||
proxy!!.port, URLEncoder.encode(file!!.path, Constants.UTF_8)
|
|
||||||
)
|
|
||||||
Timber.i("Data Source: %s", streamUrl)
|
|
||||||
} else if (proxy != null) {
|
|
||||||
proxy?.stop()
|
|
||||||
proxy = null
|
|
||||||
}
|
|
||||||
|
|
||||||
Timber.i("Preparing media player")
|
|
||||||
|
|
||||||
if (streamUrl != null) {
|
|
||||||
Timber.v("LocalMediaPlayer doPlay dataSource: %s", streamUrl)
|
|
||||||
mediaPlayer.setDataSource(streamUrl)
|
|
||||||
} else {
|
|
||||||
Timber.v("LocalMediaPlayer doPlay Path: %s", file!!.path)
|
|
||||||
val descriptor = file.getDocumentFileDescriptor("r")!!
|
|
||||||
mediaPlayer.setDataSource(descriptor.fileDescriptor)
|
|
||||||
descriptor.close()
|
|
||||||
}
|
|
||||||
|
|
||||||
setPlayerState(PlayerState.PREPARING, downloadFile)
|
|
||||||
|
|
||||||
mediaPlayer.setOnBufferingUpdateListener { mp, percent ->
|
|
||||||
val song = downloadFile.track
|
|
||||||
|
|
||||||
if (percent == 100) {
|
|
||||||
mp.setOnBufferingUpdateListener(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The secondary progress is an indicator of how far the song is cached.
|
|
||||||
if (song.transcodedContentType == null && Settings.maxBitRate == 0) {
|
|
||||||
val progress = (percent.toDouble() / 100.toDouble() * playerDuration).toInt()
|
|
||||||
secondaryProgress.postValue(progress)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
mediaPlayer.setOnPreparedListener {
|
|
||||||
Timber.i("Media player prepared")
|
|
||||||
setPlayerState(PlayerState.PREPARED, downloadFile)
|
|
||||||
|
|
||||||
// Populate seek bar secondary progress if we have a complete file for consistency
|
|
||||||
if (downloadFile.isWorkDone) {
|
|
||||||
secondaryProgress.postValue(playerDuration)
|
|
||||||
}
|
|
||||||
|
|
||||||
synchronized(this@LocalMediaPlayer) {
|
|
||||||
if (position != 0) {
|
|
||||||
Timber.i("Restarting player from position %d", position)
|
|
||||||
seekTo(position)
|
|
||||||
}
|
|
||||||
cachedPosition = position
|
|
||||||
if (start) {
|
|
||||||
mediaPlayer.start()
|
|
||||||
setPlayerState(PlayerState.STARTED, downloadFile)
|
|
||||||
} else {
|
|
||||||
setPlayerState(PlayerState.PAUSED, downloadFile)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
postRunnable {
|
|
||||||
onPrepared
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
attachHandlersToPlayer(mediaPlayer, downloadFile, partial)
|
|
||||||
mediaPlayer.prepareAsync()
|
|
||||||
} catch (x: Exception) {
|
|
||||||
handleError(x)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun setAudioAttributes(player: MediaPlayer) {
|
|
||||||
player.setAudioAttributes(AudioFocusHandler.getAudioAttributes())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Suppress("ComplexCondition")
|
|
||||||
@Synchronized
|
|
||||||
private fun setupNext(downloadFile: DownloadFile) {
|
|
||||||
try {
|
|
||||||
val file = Storage.getFromPath(downloadFile.completeOrPartialFile)
|
|
||||||
|
|
||||||
// Release the media player if it is not our active player
|
|
||||||
if (nextMediaPlayer != null && nextMediaPlayer != mediaPlayer) {
|
|
||||||
nextMediaPlayer!!.setOnCompletionListener(null)
|
|
||||||
nextMediaPlayer!!.release()
|
|
||||||
nextMediaPlayer = null
|
|
||||||
}
|
|
||||||
nextMediaPlayer = MediaPlayer()
|
|
||||||
nextMediaPlayer!!.setWakeMode(context, PARTIAL_WAKE_LOCK)
|
|
||||||
|
|
||||||
setAudioAttributes(nextMediaPlayer!!)
|
|
||||||
|
|
||||||
// This has nothing to do with the MediaSession, it is used to associate
|
|
||||||
// the equalizer or visualizer with the player
|
|
||||||
try {
|
|
||||||
nextMediaPlayer!!.audioSessionId = mediaPlayer.audioSessionId
|
|
||||||
} catch (ignored: Throwable) {
|
|
||||||
}
|
|
||||||
|
|
||||||
Timber.v("LocalMediaPlayer setupNext Path: %s", file!!.path)
|
|
||||||
val descriptor = file.getDocumentFileDescriptor("r")!!
|
|
||||||
nextMediaPlayer!!.setDataSource(descriptor.fileDescriptor)
|
|
||||||
descriptor.close()
|
|
||||||
|
|
||||||
setNextPlayerState(PlayerState.PREPARING)
|
|
||||||
nextMediaPlayer!!.setOnPreparedListener {
|
|
||||||
try {
|
|
||||||
setNextPlayerState(PlayerState.PREPARED)
|
|
||||||
if (Settings.gaplessPlayback &&
|
|
||||||
(playerState === PlayerState.STARTED || playerState === PlayerState.PAUSED)
|
|
||||||
) {
|
|
||||||
mediaPlayer.setNextMediaPlayer(nextMediaPlayer)
|
|
||||||
nextSetup = true
|
|
||||||
}
|
|
||||||
} catch (x: Exception) {
|
|
||||||
handleErrorNext(x)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
nextMediaPlayer!!.setOnErrorListener { _, what, extra ->
|
|
||||||
Timber.w("Error on playing next (%d, %d): %s", what, extra, downloadFile)
|
|
||||||
true
|
|
||||||
}
|
|
||||||
nextMediaPlayer!!.prepareAsync()
|
|
||||||
} catch (x: Exception) {
|
|
||||||
handleErrorNext(x)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun attachHandlersToPlayer(
|
|
||||||
mediaPlayer: MediaPlayer,
|
|
||||||
downloadFile: DownloadFile,
|
|
||||||
isPartial: Boolean
|
|
||||||
) {
|
|
||||||
mediaPlayer.setOnErrorListener { _, what, extra ->
|
|
||||||
Timber.w("Error on playing file (%d, %d): %s", what, extra, downloadFile)
|
|
||||||
val pos = cachedPosition
|
|
||||||
reset()
|
|
||||||
downloadFile.setPlaying(false)
|
|
||||||
doPlay(downloadFile, pos, true)
|
|
||||||
downloadFile.setPlaying(true)
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
var duration = 0
|
|
||||||
if (downloadFile.track.duration != null) {
|
|
||||||
duration = downloadFile.track.duration!! * 1000
|
|
||||||
}
|
|
||||||
|
|
||||||
mediaPlayer.setOnCompletionListener(object : OnCompletionListener {
|
|
||||||
override fun onCompletion(mediaPlayer: MediaPlayer) {
|
|
||||||
// Acquire a temporary wakelock, since when we return from
|
|
||||||
// this callback the MediaPlayer will release its wakelock
|
|
||||||
// and allow the device to go to sleep.
|
|
||||||
wakeLock.acquire(60000)
|
|
||||||
val pos = cachedPosition
|
|
||||||
Timber.i("Ending position %d of %d", pos, duration)
|
|
||||||
|
|
||||||
if (!isPartial || downloadFile.isWorkDone && abs(duration - pos) < 1000) {
|
|
||||||
setPlayerState(PlayerState.COMPLETED, downloadFile)
|
|
||||||
if (Settings.gaplessPlayback &&
|
|
||||||
nextPlaying != null &&
|
|
||||||
nextPlayerState === PlayerState.PREPARED
|
|
||||||
) {
|
|
||||||
if (nextSetup) {
|
|
||||||
nextSetup = false
|
|
||||||
}
|
|
||||||
playNext()
|
|
||||||
} else {
|
|
||||||
if (onSongCompleted != null) {
|
|
||||||
val mainHandler = Handler(context.mainLooper)
|
|
||||||
val myRunnable = Runnable { onSongCompleted!!(currentPlaying) }
|
|
||||||
mainHandler.post(myRunnable)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
synchronized(this) {
|
|
||||||
if (downloadFile.isWorkDone) {
|
|
||||||
// Complete was called early even though file is fully buffered
|
|
||||||
Timber.i("Requesting restart from %d of %d", pos, duration)
|
|
||||||
reset()
|
|
||||||
downloadFile.setPlaying(false)
|
|
||||||
doPlay(downloadFile, pos, true)
|
|
||||||
downloadFile.setPlaying(true)
|
|
||||||
} else {
|
|
||||||
Timber.i("Requesting restart from %d of %d", pos, duration)
|
|
||||||
reset()
|
|
||||||
bufferTask = BufferTask(downloadFile, pos)
|
|
||||||
bufferTask!!.start()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
fun reset() {
|
|
||||||
if (bufferTask != null) {
|
|
||||||
bufferTask!!.cancel()
|
|
||||||
}
|
|
||||||
|
|
||||||
resetMediaPlayer()
|
|
||||||
|
|
||||||
try {
|
|
||||||
setPlayerState(PlayerState.IDLE, currentPlaying)
|
|
||||||
mediaPlayer.setOnErrorListener(null)
|
|
||||||
mediaPlayer.setOnCompletionListener(null)
|
|
||||||
} catch (x: Exception) {
|
|
||||||
handleError(x)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
fun resetMediaPlayer() {
|
|
||||||
try {
|
|
||||||
mediaPlayer.reset()
|
|
||||||
} catch (x: Exception) {
|
|
||||||
Timber.w(x, "MediaPlayer was released but LocalMediaPlayer was not destroyed")
|
|
||||||
|
|
||||||
// Recreate MediaPlayer
|
|
||||||
mediaPlayer = MediaPlayer()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private inner class BufferTask(
|
|
||||||
private val downloadFile: DownloadFile,
|
|
||||||
private val position: Int,
|
|
||||||
private val autoStart: Boolean = true
|
|
||||||
) : CancellableTask() {
|
|
||||||
private val expectedFileSize: Long
|
|
||||||
private val partialFile: String = downloadFile.partialFile
|
|
||||||
|
|
||||||
override fun execute() {
|
|
||||||
setPlayerState(PlayerState.DOWNLOADING, downloadFile)
|
|
||||||
while (!bufferComplete() && !isOffline()) {
|
|
||||||
Util.sleepQuietly(1000L)
|
|
||||||
if (isCancelled) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
doPlay(downloadFile, position, autoStart)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun bufferComplete(): Boolean {
|
|
||||||
val completeFileAvailable = downloadFile.isWorkDone
|
|
||||||
val size = Storage.getFromPath(partialFile)?.length ?: 0
|
|
||||||
|
|
||||||
Timber.i(
|
|
||||||
"Buffering %s (%d/%d, %s)",
|
|
||||||
partialFile, size, expectedFileSize, completeFileAvailable
|
|
||||||
)
|
|
||||||
|
|
||||||
return completeFileAvailable || size >= expectedFileSize
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun toString(): String {
|
|
||||||
return String.format("BufferTask (%s)", downloadFile)
|
|
||||||
}
|
|
||||||
|
|
||||||
init {
|
|
||||||
var bufferLength = Settings.bufferLength.toLong()
|
|
||||||
if (bufferLength == 0L) {
|
|
||||||
// Set to seconds in a day, basically infinity
|
|
||||||
bufferLength = 86400L
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate roughly how many bytes BUFFER_LENGTH_SECONDS corresponds to.
|
|
||||||
val bitRate = downloadFile.getBitRate()
|
|
||||||
val byteCount = max(100000, bitRate * 1024L / 8L * bufferLength)
|
|
||||||
|
|
||||||
// Find out how large the file should grow before resuming playback.
|
|
||||||
Timber.i("Buffering from position %d and bitrate %d", position, bitRate)
|
|
||||||
expectedFileSize = position * bitRate / 8 + byteCount
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private inner class CheckCompletionTask(downloadFile: DownloadFile?) : CancellableTask() {
|
|
||||||
private val downloadFile: DownloadFile?
|
|
||||||
private val partialFile: String?
|
|
||||||
override fun execute() {
|
|
||||||
Thread.currentThread().name = "CheckCompletionTask"
|
|
||||||
if (downloadFile == null) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Do an initial sleep so this prepare can't compete with main prepare
|
|
||||||
Util.sleepQuietly(5000L)
|
|
||||||
while (!bufferComplete()) {
|
|
||||||
Util.sleepQuietly(5000L)
|
|
||||||
if (isCancelled) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start the setup of the next media player
|
|
||||||
mediaPlayerHandler!!.post { setupNext(downloadFile) }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun bufferComplete(): Boolean {
|
|
||||||
val completeFileAvailable = downloadFile!!.isWorkDone
|
|
||||||
val state = (playerState === PlayerState.STARTED || playerState === PlayerState.PAUSED)
|
|
||||||
|
|
||||||
val length = if (partialFile == null) 0
|
|
||||||
else Storage.getFromPath(partialFile)?.length ?: 0
|
|
||||||
|
|
||||||
Timber.i("Buffering next %s (%d)", partialFile, length)
|
|
||||||
|
|
||||||
return completeFileAvailable && state
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun toString(): String {
|
|
||||||
return String.format("CheckCompletionTask (%s)", downloadFile)
|
|
||||||
}
|
|
||||||
|
|
||||||
init {
|
|
||||||
setNextPlayerState(PlayerState.IDLE)
|
|
||||||
this.downloadFile = downloadFile
|
|
||||||
partialFile = downloadFile?.partialFile
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private inner class PositionCache : Runnable {
|
|
||||||
var isRunning = true
|
|
||||||
fun stop() {
|
|
||||||
isRunning = false
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun run() {
|
|
||||||
Thread.currentThread().name = "PositionCache"
|
|
||||||
|
|
||||||
// Stop checking position before the song reaches completion
|
|
||||||
while (isRunning) {
|
|
||||||
try {
|
|
||||||
if (playerState === PlayerState.STARTED) {
|
|
||||||
synchronized(playerState) {
|
|
||||||
if (playerState === PlayerState.STARTED) {
|
|
||||||
cachedPosition = mediaPlayer.currentPosition
|
|
||||||
}
|
|
||||||
}
|
|
||||||
RxBus.playbackPositionPublisher.onNext(cachedPosition)
|
|
||||||
}
|
|
||||||
Util.sleepQuietly(100L)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Timber.w(e, "Crashed getting current position")
|
|
||||||
isRunning = false
|
|
||||||
positionCache = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun handleError(x: Exception) {
|
|
||||||
Timber.w(x, "Media player error")
|
|
||||||
try {
|
|
||||||
mediaPlayer.reset()
|
|
||||||
} catch (ex: Exception) {
|
|
||||||
Timber.w(ex, "Exception encountered when resetting media player")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun handleErrorNext(x: Exception) {
|
|
||||||
Timber.w(x, "Next Media player error")
|
|
||||||
nextMediaPlayer!!.reset()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun postRunnable(runnable: Runnable?) {
|
|
||||||
if (runnable != null) {
|
|
||||||
val mainHandler = Handler(context.mainLooper)
|
|
||||||
val myRunnable = Runnable { runnable.run() }
|
|
||||||
mainHandler.post(myRunnable)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -6,20 +6,35 @@
|
||||||
*/
|
*/
|
||||||
package org.moire.ultrasonic.service
|
package org.moire.ultrasonic.service
|
||||||
|
|
||||||
|
import android.content.ComponentName
|
||||||
|
import android.content.Context
|
||||||
import android.content.Intent
|
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.KoinComponent
|
||||||
import org.koin.core.component.inject
|
import org.koin.core.component.inject
|
||||||
import org.moire.ultrasonic.app.UApp
|
import org.moire.ultrasonic.app.UApp
|
||||||
import org.moire.ultrasonic.data.ActiveServerProvider
|
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.domain.Track
|
||||||
import org.moire.ultrasonic.service.MediaPlayerService.Companion.executeOnStartedMediaPlayerService
|
import org.moire.ultrasonic.playback.LegacyPlaylistManager
|
||||||
import org.moire.ultrasonic.service.MediaPlayerService.Companion.getInstance
|
import org.moire.ultrasonic.playback.PlaybackService
|
||||||
import org.moire.ultrasonic.service.MediaPlayerService.Companion.runningInstance
|
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.service.MusicServiceFactory.getMusicService
|
||||||
|
import org.moire.ultrasonic.util.FileUtil
|
||||||
import org.moire.ultrasonic.util.Settings
|
import org.moire.ultrasonic.util.Settings
|
||||||
import org.moire.ultrasonic.util.ShufflePlayBuffer
|
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -32,8 +47,8 @@ class MediaPlayerController(
|
||||||
private val playbackStateSerializer: PlaybackStateSerializer,
|
private val playbackStateSerializer: PlaybackStateSerializer,
|
||||||
private val externalStorageMonitor: ExternalStorageMonitor,
|
private val externalStorageMonitor: ExternalStorageMonitor,
|
||||||
private val downloader: Downloader,
|
private val downloader: Downloader,
|
||||||
private val shufflePlayBuffer: ShufflePlayBuffer,
|
private val legacyPlaylistManager: LegacyPlaylistManager,
|
||||||
private val localMediaPlayer: LocalMediaPlayer
|
val context: Context
|
||||||
) : KoinComponent {
|
) : KoinComponent {
|
||||||
|
|
||||||
private var created = false
|
private var created = false
|
||||||
|
@ -42,22 +57,192 @@ class MediaPlayerController(
|
||||||
var showVisualization = false
|
var showVisualization = false
|
||||||
private var autoPlayStart = false
|
private var autoPlayStart = false
|
||||||
|
|
||||||
|
private val scrobbler = Scrobbler()
|
||||||
|
|
||||||
private val jukeboxMediaPlayer: JukeboxMediaPlayer by inject()
|
private val jukeboxMediaPlayer: JukeboxMediaPlayer by inject()
|
||||||
private val activeServerProvider: ActiveServerProvider 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
|
if (created) return
|
||||||
externalStorageMonitor.onCreate { reset() }
|
externalStorageMonitor.onCreate { reset() }
|
||||||
isJukeboxEnabled = activeServerProvider.getActiveServer().jukeboxByDefault
|
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
|
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() {
|
fun onDestroy() {
|
||||||
if (!created) return
|
if (!created) return
|
||||||
|
|
||||||
|
// First stop listening to events
|
||||||
|
rxBusSubscription.dispose()
|
||||||
|
controller?.removeListener(listeners)
|
||||||
|
|
||||||
|
// Shutdown the rest
|
||||||
val context = UApp.applicationContext()
|
val context = UApp.applicationContext()
|
||||||
externalStorageMonitor.onDestroy()
|
externalStorageMonitor.onDestroy()
|
||||||
context.stopService(Intent(context, MediaPlayerService::class.java))
|
context.stopService(Intent(context, DownloadService::class.java))
|
||||||
|
legacyPlaylistManager.onDestroy()
|
||||||
downloader.onDestroy()
|
downloader.onDestroy()
|
||||||
created = false
|
created = false
|
||||||
Timber.i("MediaPlayerController destroyed")
|
Timber.i("MediaPlayerController destroyed")
|
||||||
|
@ -65,140 +250,144 @@ class MediaPlayerController(
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun restore(
|
fun restore(
|
||||||
songs: List<Track?>?,
|
songs: List<Track>,
|
||||||
currentPlayingIndex: Int,
|
currentPlayingIndex: Int,
|
||||||
currentPlayingPosition: Int,
|
currentPlayingPosition: Int,
|
||||||
autoPlay: Boolean,
|
autoPlay: Boolean,
|
||||||
newPlaylist: Boolean
|
newPlaylist: Boolean
|
||||||
) {
|
) {
|
||||||
|
val insertionMode = if (newPlaylist) InsertionMode.CLEAR
|
||||||
|
else InsertionMode.APPEND
|
||||||
|
|
||||||
addToPlaylist(
|
addToPlaylist(
|
||||||
songs,
|
songs,
|
||||||
save = false,
|
cachePermanently = false,
|
||||||
autoPlay = false,
|
autoPlay = false,
|
||||||
playNext = false,
|
|
||||||
shuffle = 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
|
if (currentPlayingIndex != -1) {
|
||||||
fun preload() {
|
if (jukeboxMediaPlayer.isEnabled) {
|
||||||
getInstance()
|
jukeboxMediaPlayer.skip(
|
||||||
|
currentPlayingIndex,
|
||||||
|
currentPlayingPosition / 1000
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
seekTo(currentPlayingIndex, currentPlayingPosition)
|
||||||
|
}
|
||||||
|
|
||||||
|
prepare()
|
||||||
|
|
||||||
|
if (autoPlay) {
|
||||||
|
play()
|
||||||
|
}
|
||||||
|
|
||||||
|
autoPlayStart = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun play(index: Int) {
|
fun play(index: Int) {
|
||||||
executeOnStartedMediaPlayerService { service: MediaPlayerService ->
|
controller?.seekTo(index, 0L)
|
||||||
service.play(index, true)
|
controller?.play()
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun play() {
|
fun play() {
|
||||||
executeOnStartedMediaPlayerService { service: MediaPlayerService ->
|
if (jukeboxMediaPlayer.isEnabled) {
|
||||||
service.play()
|
jukeboxMediaPlayer.start()
|
||||||
|
} else {
|
||||||
|
controller?.prepare()
|
||||||
|
controller?.play()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun prepare() {
|
||||||
|
controller?.prepare()
|
||||||
|
}
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun resumeOrPlay() {
|
fun resumeOrPlay() {
|
||||||
executeOnStartedMediaPlayerService { service: MediaPlayerService ->
|
controller?.play()
|
||||||
service.resumeOrPlay()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun togglePlayPause() {
|
fun togglePlayPause() {
|
||||||
if (localMediaPlayer.playerState === PlayerState.IDLE) autoPlayStart = true
|
if (playbackState == Player.STATE_IDLE) autoPlayStart = true
|
||||||
executeOnStartedMediaPlayerService { service: MediaPlayerService ->
|
if (controller?.isPlaying == true) {
|
||||||
service.togglePlayPause()
|
controller?.pause()
|
||||||
}
|
} else {
|
||||||
}
|
controller?.play()
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
fun start() {
|
|
||||||
executeOnStartedMediaPlayerService { service: MediaPlayerService ->
|
|
||||||
service.start()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun seekTo(position: Int) {
|
fun seekTo(position: Int) {
|
||||||
val mediaPlayerService = runningInstance
|
Timber.i("SeekTo: %s", position)
|
||||||
mediaPlayerService?.seekTo(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
|
@Synchronized
|
||||||
fun pause() {
|
fun pause() {
|
||||||
val mediaPlayerService = runningInstance
|
if (jukeboxMediaPlayer.isEnabled) {
|
||||||
mediaPlayerService?.pause()
|
jukeboxMediaPlayer.stop()
|
||||||
|
} else {
|
||||||
|
controller?.pause()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun stop() {
|
fun stop() {
|
||||||
val mediaPlayerService = runningInstance
|
if (jukeboxMediaPlayer.isEnabled) {
|
||||||
mediaPlayerService?.stop()
|
jukeboxMediaPlayer.stop()
|
||||||
|
} else {
|
||||||
|
controller?.stop()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
@Suppress("LongParameterList")
|
|
||||||
fun addToPlaylist(
|
fun addToPlaylist(
|
||||||
songs: List<Track?>?,
|
songs: List<Track>,
|
||||||
save: Boolean,
|
cachePermanently: Boolean,
|
||||||
autoPlay: Boolean,
|
autoPlay: Boolean,
|
||||||
playNext: Boolean,
|
|
||||||
shuffle: Boolean,
|
shuffle: Boolean,
|
||||||
newPlaylist: Boolean
|
insertionMode: InsertionMode
|
||||||
) {
|
) {
|
||||||
if (songs == null) return
|
var insertAt = 0
|
||||||
val filteredSongs = songs.filterNotNull()
|
|
||||||
downloader.addToPlaylist(filteredSongs, save, autoPlay, playNext, newPlaylist)
|
|
||||||
jukeboxMediaPlayer.updatePlaylist()
|
|
||||||
if (shuffle) shuffle()
|
|
||||||
val isLastTrack = (downloader.getPlaylist().size - 1 == downloader.currentPlayingIndex)
|
|
||||||
|
|
||||||
if (!playNext && !autoPlay && isLastTrack) {
|
when (insertionMode) {
|
||||||
val mediaPlayerService = runningInstance
|
InsertionMode.CLEAR -> clear()
|
||||||
mediaPlayerService?.setNextPlaying()
|
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) {
|
if (autoPlay) {
|
||||||
play(0)
|
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
|
@Synchronized
|
||||||
|
@ -206,17 +395,6 @@ class MediaPlayerController(
|
||||||
if (songs == null) return
|
if (songs == null) return
|
||||||
val filteredSongs = songs.filterNotNull()
|
val filteredSongs = songs.filterNotNull()
|
||||||
downloader.downloadBackground(filteredSongs, save)
|
downloader.downloadBackground(filteredSongs, save)
|
||||||
playbackStateSerializer.serialize(
|
|
||||||
downloader.getPlaylist(),
|
|
||||||
downloader.currentPlayingIndex,
|
|
||||||
playerPosition
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
fun setCurrentPlaying(index: Int) {
|
|
||||||
val mediaPlayerService = runningInstance
|
|
||||||
mediaPlayerService?.setCurrentPlaying(index)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun stopJukeboxService() {
|
fun stopJukeboxService() {
|
||||||
|
@ -225,58 +403,47 @@ class MediaPlayerController(
|
||||||
|
|
||||||
@set:Synchronized
|
@set:Synchronized
|
||||||
var isShufflePlayEnabled: Boolean
|
var isShufflePlayEnabled: Boolean
|
||||||
get() = shufflePlayBuffer.isEnabled
|
get() = controller?.shuffleModeEnabled == true
|
||||||
set(enabled) {
|
set(enabled) {
|
||||||
shufflePlayBuffer.isEnabled = enabled
|
controller?.shuffleModeEnabled = enabled
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
clear()
|
|
||||||
downloader.checkDownloads()
|
downloader.checkDownloads()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun shuffle() {
|
fun toggleShuffle(): Boolean {
|
||||||
downloader.shuffle()
|
isShufflePlayEnabled = !isShufflePlayEnabled
|
||||||
playbackStateSerializer.serialize(
|
return isShufflePlayEnabled
|
||||||
downloader.getPlaylist(),
|
|
||||||
downloader.currentPlayingIndex,
|
|
||||||
playerPosition
|
|
||||||
)
|
|
||||||
jukeboxMediaPlayer.updatePlaylist()
|
|
||||||
val mediaPlayerService = runningInstance
|
|
||||||
mediaPlayerService?.setNextPlaying()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val bufferedPercentage: Int
|
||||||
|
get() = controller?.bufferedPercentage ?: 0
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun moveItemInPlaylist(oldPos: Int, newPos: Int) {
|
fun moveItemInPlaylist(oldPos: Int, newPos: Int) {
|
||||||
downloader.moveItemInPlaylist(oldPos, newPos)
|
controller?.moveMediaItem(oldPos, newPos)
|
||||||
}
|
}
|
||||||
|
|
||||||
@set:Synchronized
|
@set:Synchronized
|
||||||
var repeatMode: RepeatMode
|
var repeatMode: Int
|
||||||
get() = Settings.repeatMode
|
get() = controller?.repeatMode ?: 0
|
||||||
set(repeatMode) {
|
set(newMode) {
|
||||||
Settings.repeatMode = repeatMode
|
controller?.repeatMode = newMode
|
||||||
val mediaPlayerService = runningInstance
|
|
||||||
mediaPlayerService?.setNextPlaying()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
@JvmOverloads
|
@JvmOverloads
|
||||||
fun clear(serialize: Boolean = true) {
|
fun clear(serialize: Boolean = true) {
|
||||||
val mediaPlayerService = runningInstance
|
|
||||||
if (mediaPlayerService != null) {
|
controller?.clearMediaItems()
|
||||||
mediaPlayerService.clear(serialize)
|
|
||||||
} else {
|
if (controller != null && serialize) {
|
||||||
// If no MediaPlayerService is available, just empty the playlist
|
playbackStateSerializer.serialize(
|
||||||
downloader.clearPlaylist()
|
listOf(), -1, 0
|
||||||
if (serialize) {
|
)
|
||||||
playbackStateSerializer.serialize(
|
|
||||||
downloader.getPlaylist(),
|
|
||||||
downloader.currentPlayingIndex, playerPosition
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
jukeboxMediaPlayer.updatePlaylist()
|
jukeboxMediaPlayer.updatePlaylist()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -289,38 +456,30 @@ class MediaPlayerController(
|
||||||
fun clearIncomplete() {
|
fun clearIncomplete() {
|
||||||
reset()
|
reset()
|
||||||
|
|
||||||
downloader.clearIncomplete()
|
downloader.clearActiveDownloads()
|
||||||
|
downloader.clearBackground()
|
||||||
playbackStateSerializer.serialize(
|
|
||||||
downloader.getPlaylist(),
|
|
||||||
downloader.currentPlayingIndex,
|
|
||||||
playerPosition
|
|
||||||
)
|
|
||||||
|
|
||||||
jukeboxMediaPlayer.updatePlaylist()
|
jukeboxMediaPlayer.updatePlaylist()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
// TODO: If a playlist contains an item twice, this call will wrongly remove all
|
fun removeFromPlaylist(position: Int) {
|
||||||
fun removeFromPlaylist(downloadFile: DownloadFile) {
|
|
||||||
if (downloadFile == localMediaPlayer.currentPlaying) {
|
|
||||||
reset()
|
|
||||||
currentPlaying = null
|
|
||||||
}
|
|
||||||
downloader.removeFromPlaylist(downloadFile)
|
|
||||||
|
|
||||||
playbackStateSerializer.serialize(
|
controller?.removeMediaItem(position)
|
||||||
downloader.getPlaylist(),
|
|
||||||
downloader.currentPlayingIndex,
|
|
||||||
playerPosition
|
|
||||||
)
|
|
||||||
|
|
||||||
jukeboxMediaPlayer.updatePlaylist()
|
jukeboxMediaPlayer.updatePlaylist()
|
||||||
|
}
|
||||||
|
|
||||||
if (downloadFile == localMediaPlayer.nextPlaying) {
|
@Synchronized
|
||||||
val mediaPlayerService = runningInstance
|
private fun serializeCurrentSession() {
|
||||||
mediaPlayerService?.setNextPlaying()
|
// Don't serialize invalid sessions
|
||||||
}
|
if (currentMediaItemIndex == -1) return
|
||||||
|
|
||||||
|
playbackStateSerializer.serialize(
|
||||||
|
legacyPlaylistManager.playlist,
|
||||||
|
currentMediaItemIndex,
|
||||||
|
playerPosition
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
|
@ -341,80 +500,52 @@ class MediaPlayerController(
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun previous() {
|
fun previous() {
|
||||||
val index = downloader.currentPlayingIndex
|
controller?.seekToPrevious()
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
operator fun next() {
|
operator fun next() {
|
||||||
val index = downloader.currentPlayingIndex
|
controller?.seekToNext()
|
||||||
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 -> {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun reset() {
|
fun reset() {
|
||||||
val mediaPlayerService = runningInstance
|
controller?.clearMediaItems()
|
||||||
if (mediaPlayerService != null) localMediaPlayer.reset()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@get:Synchronized
|
@get:Synchronized
|
||||||
val playerPosition: Int
|
val playerPosition: Int
|
||||||
get() {
|
get() {
|
||||||
val mediaPlayerService = runningInstance ?: return 0
|
return if (jukeboxMediaPlayer.isEnabled) {
|
||||||
return mediaPlayerService.playerPosition
|
jukeboxMediaPlayer.positionSeconds * 1000
|
||||||
|
} else {
|
||||||
|
controller?.currentPosition?.toInt() ?: 0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@get:Synchronized
|
@get:Synchronized
|
||||||
val playerDuration: Int
|
val playerDuration: Int
|
||||||
get() {
|
get() {
|
||||||
val mediaPlayerService = runningInstance ?: return 0
|
return controller?.duration?.toInt() ?: return 0
|
||||||
return mediaPlayerService.playerDuration
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@set:Synchronized
|
val playbackState: Int
|
||||||
var playerState: PlayerState
|
get() = controller?.playbackState ?: 0
|
||||||
get() = localMediaPlayer.playerState
|
|
||||||
set(state) {
|
val isPlaying: Boolean
|
||||||
val mediaPlayerService = runningInstance
|
get() = controller?.isPlaying ?: false
|
||||||
if (mediaPlayerService != null)
|
|
||||||
localMediaPlayer.setPlayerState(state, localMediaPlayer.currentPlaying)
|
|
||||||
}
|
|
||||||
|
|
||||||
@set:Synchronized
|
@set:Synchronized
|
||||||
var isJukeboxEnabled: Boolean
|
var isJukeboxEnabled: Boolean
|
||||||
get() = jukeboxMediaPlayer.isEnabled
|
get() = jukeboxMediaPlayer.isEnabled
|
||||||
set(jukeboxEnabled) {
|
set(jukeboxEnabled) {
|
||||||
jukeboxMediaPlayer.isEnabled = jukeboxEnabled
|
jukeboxMediaPlayer.isEnabled = jukeboxEnabled
|
||||||
playerState = PlayerState.IDLE
|
|
||||||
if (jukeboxEnabled) {
|
if (jukeboxEnabled) {
|
||||||
jukeboxMediaPlayer.startJukeboxService()
|
jukeboxMediaPlayer.startJukeboxService()
|
||||||
reset()
|
reset()
|
||||||
|
|
||||||
// Cancel current download, if necessary.
|
// Cancel current downloads
|
||||||
downloader.clearActiveDownloads()
|
downloader.clearActiveDownloads()
|
||||||
} else {
|
} else {
|
||||||
jukeboxMediaPlayer.stopJukeboxService()
|
jukeboxMediaPlayer.stopJukeboxService()
|
||||||
|
@ -441,19 +572,12 @@ class MediaPlayerController(
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setVolume(volume: Float) {
|
fun setVolume(volume: Float) {
|
||||||
if (runningInstance != null) localMediaPlayer.setVolume(volume)
|
controller?.volume = volume
|
||||||
}
|
|
||||||
|
|
||||||
private fun updateNotification() {
|
|
||||||
runningInstance?.updateNotification(
|
|
||||||
localMediaPlayer.playerState,
|
|
||||||
localMediaPlayer.currentPlaying
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun toggleSongStarred() {
|
fun toggleSongStarred() {
|
||||||
if (localMediaPlayer.currentPlaying == null) return
|
if (legacyPlaylistManager.currentPlaying == null) return
|
||||||
val song = localMediaPlayer.currentPlaying!!.track
|
val song = legacyPlaylistManager.currentPlaying!!.track
|
||||||
|
|
||||||
Thread {
|
Thread {
|
||||||
val musicService = getMusicService()
|
val musicService = getMusicService()
|
||||||
|
@ -469,15 +593,16 @@ class MediaPlayerController(
|
||||||
}.start()
|
}.start()
|
||||||
|
|
||||||
// Trigger an update
|
// Trigger an update
|
||||||
localMediaPlayer.setCurrentPlaying(localMediaPlayer.currentPlaying)
|
// TODO Update Metadata of MediaItem...
|
||||||
|
// localMediaPlayer.setCurrentPlaying(localMediaPlayer.currentPlaying)
|
||||||
song.starred = !song.starred
|
song.starred = !song.starred
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suppress("TooGenericExceptionCaught") // The interface throws only generic exceptions
|
@Suppress("TooGenericExceptionCaught") // The interface throws only generic exceptions
|
||||||
fun setSongRating(rating: Int) {
|
fun setSongRating(rating: Int) {
|
||||||
if (!Settings.useFiveStarRating) return
|
if (!Settings.useFiveStarRating) return
|
||||||
if (localMediaPlayer.currentPlaying == null) return
|
if (legacyPlaylistManager.currentPlaying == null) return
|
||||||
val song = localMediaPlayer.currentPlaying!!.track
|
val song = legacyPlaylistManager.currentPlaying!!.track
|
||||||
song.userRating = rating
|
song.userRating = rating
|
||||||
Thread {
|
Thread {
|
||||||
try {
|
try {
|
||||||
|
@ -487,33 +612,64 @@ class MediaPlayerController(
|
||||||
}
|
}
|
||||||
}.start()
|
}.start()
|
||||||
// TODO this would be better handled with a Rx command
|
// TODO this would be better handled with a Rx command
|
||||||
updateNotification()
|
// updateNotification()
|
||||||
}
|
}
|
||||||
|
|
||||||
@set:Synchronized
|
val currentMediaItem: MediaItem?
|
||||||
var currentPlaying: DownloadFile?
|
get() = controller?.currentMediaItem
|
||||||
get() = localMediaPlayer.currentPlaying
|
|
||||||
set(currentPlaying) {
|
|
||||||
if (runningInstance != null) localMediaPlayer.setCurrentPlaying(currentPlaying)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
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
|
val playlistSize: Int
|
||||||
get() = downloader.getPlaylist().size
|
get() = legacyPlaylistManager.playlist.size
|
||||||
|
|
||||||
val currentPlayingNumberOnPlaylist: Int
|
|
||||||
get() = downloader.currentPlayingIndex
|
|
||||||
|
|
||||||
|
@Deprecated("Use native APIs")
|
||||||
val playList: List<DownloadFile>
|
val playList: List<DownloadFile>
|
||||||
get() = downloader.getPlaylist()
|
get() = legacyPlaylistManager.playlist
|
||||||
|
|
||||||
|
@Deprecated("Use timeline")
|
||||||
val playListDuration: Long
|
val playListDuration: Long
|
||||||
get() = downloader.downloadListDuration
|
get() = legacyPlaylistManager.playlistDuration
|
||||||
|
|
||||||
fun getDownloadFileForSong(song: Track): DownloadFile {
|
fun getDownloadFileForSong(song: Track): DownloadFile {
|
||||||
return downloader.getDownloadFileForSong(song)
|
return downloader.getDownloadFileForSong(song)
|
||||||
}
|
}
|
||||||
|
|
||||||
init {
|
init {
|
||||||
Timber.i("MediaPlayerController constructed")
|
Timber.i("MediaPlayerController instance initiated")
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class InsertionMode {
|
||||||
|
CLEAR, APPEND, AFTER_CURRENT
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun Track.toMediaItem(): MediaItem {
|
||||||
|
|
||||||
|
val filePath = FileUtil.getSongFile(this)
|
||||||
|
val bitrate = Settings.maxBitRate
|
||||||
|
val uri = "$id|$bitrate|$filePath"
|
||||||
|
|
||||||
|
val metadata = MediaMetadata.Builder()
|
||||||
|
metadata.setTitle(title)
|
||||||
|
.setArtist(artist)
|
||||||
|
.setAlbumTitle(album)
|
||||||
|
.setMediaUri(uri.toUri())
|
||||||
|
.setAlbumArtist(artist)
|
||||||
|
|
||||||
|
val mediaItem = MediaItem.Builder()
|
||||||
|
.setUri(uri)
|
||||||
|
.setMediaId(id)
|
||||||
|
.setMediaMetadata(metadata.build())
|
||||||
|
|
||||||
|
return mediaItem.build()
|
||||||
|
}
|
||||||
|
|
|
@ -13,12 +13,9 @@ import android.content.Intent
|
||||||
import android.content.IntentFilter
|
import android.content.IntentFilter
|
||||||
import android.media.AudioManager
|
import android.media.AudioManager
|
||||||
import android.view.KeyEvent
|
import android.view.KeyEvent
|
||||||
import io.reactivex.rxjava3.disposables.Disposable
|
|
||||||
import org.koin.core.component.KoinComponent
|
import org.koin.core.component.KoinComponent
|
||||||
import org.koin.core.component.inject
|
import org.koin.core.component.inject
|
||||||
import org.moire.ultrasonic.R
|
|
||||||
import org.moire.ultrasonic.app.UApp.Companion.applicationContext
|
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.CacheCleaner
|
||||||
import org.moire.ultrasonic.util.Constants
|
import org.moire.ultrasonic.util.Constants
|
||||||
import org.moire.ultrasonic.util.Settings
|
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
|
* This class is responsible for handling received events for the Media Player implementation
|
||||||
*
|
|
||||||
* @author Sindre Mehus
|
|
||||||
*/
|
*/
|
||||||
class MediaPlayerLifecycleSupport : KoinComponent {
|
class MediaPlayerLifecycleSupport : KoinComponent {
|
||||||
private val playbackStateSerializer by inject<PlaybackStateSerializer>()
|
private val playbackStateSerializer by inject<PlaybackStateSerializer>()
|
||||||
private val mediaPlayerController by inject<MediaPlayerController>()
|
private val mediaPlayerController by inject<MediaPlayerController>()
|
||||||
private val downloader by inject<Downloader>()
|
|
||||||
|
|
||||||
private var created = false
|
private var created = false
|
||||||
private var headsetEventReceiver: BroadcastReceiver? = null
|
private var headsetEventReceiver: BroadcastReceiver? = null
|
||||||
private var mediaButtonEventSubscription: Disposable? = null
|
|
||||||
|
|
||||||
fun onCreate() {
|
fun onCreate() {
|
||||||
onCreate(false, null)
|
onCreate(false, null)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onCreate(autoPlay: Boolean, afterCreated: Runnable?) {
|
private fun onCreate(autoPlay: Boolean, afterRestore: Runnable?) {
|
||||||
|
|
||||||
if (created) {
|
if (created) {
|
||||||
afterCreated?.run()
|
afterRestore?.run()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
mediaButtonEventSubscription = RxBus.mediaButtonEventObservable.subscribe {
|
mediaPlayerController.onCreate {
|
||||||
handleKeyEvent(it)
|
restoreLastSession(autoPlay, afterRestore)
|
||||||
}
|
}
|
||||||
|
|
||||||
registerHeadsetReceiver()
|
registerHeadsetReceiver()
|
||||||
mediaPlayerController.onCreate()
|
|
||||||
if (autoPlay) mediaPlayerController.preload()
|
|
||||||
|
|
||||||
|
CacheCleaner().clean()
|
||||||
|
created = true
|
||||||
|
Timber.i("LifecycleSupport created")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun restoreLastSession(autoPlay: Boolean, afterRestore: Runnable?) {
|
||||||
playbackStateSerializer.deserialize {
|
playbackStateSerializer.deserialize {
|
||||||
|
|
||||||
|
Timber.i("Restoring %s songs", it!!.songs.size)
|
||||||
|
|
||||||
mediaPlayerController.restore(
|
mediaPlayerController.restore(
|
||||||
it!!.songs,
|
it.songs,
|
||||||
it.currentPlayingIndex,
|
it.currentPlayingIndex,
|
||||||
it.currentPlayingPosition,
|
it.currentPlayingPosition,
|
||||||
autoPlay,
|
autoPlay,
|
||||||
false
|
false
|
||||||
)
|
)
|
||||||
|
|
||||||
// Work-around: Serialize again, as the restore() method creates a
|
afterRestore?.run()
|
||||||
// serialization without current playing info.
|
|
||||||
playbackStateSerializer.serialize(
|
|
||||||
downloader.getPlaylist(),
|
|
||||||
downloader.currentPlayingIndex,
|
|
||||||
mediaPlayerController.playerPosition
|
|
||||||
)
|
|
||||||
afterCreated?.run()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
CacheCleaner().clean()
|
|
||||||
created = true
|
|
||||||
Timber.i("LifecycleSupport created")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onDestroy() {
|
fun onDestroy() {
|
||||||
|
@ -88,13 +76,14 @@ class MediaPlayerLifecycleSupport : KoinComponent {
|
||||||
if (!created) return
|
if (!created) return
|
||||||
|
|
||||||
playbackStateSerializer.serializeNow(
|
playbackStateSerializer.serializeNow(
|
||||||
downloader.getPlaylist(),
|
mediaPlayerController.playList,
|
||||||
downloader.currentPlayingIndex,
|
mediaPlayerController.currentMediaItemIndex,
|
||||||
mediaPlayerController.playerPosition
|
mediaPlayerController.playerPosition
|
||||||
)
|
)
|
||||||
|
|
||||||
mediaPlayerController.clear(false)
|
mediaPlayerController.clear(false)
|
||||||
mediaButtonEventSubscription?.dispose()
|
RxBus.shutdownCommandPublisher.onNext(Unit)
|
||||||
|
|
||||||
applicationContext().unregisterReceiver(headsetEventReceiver)
|
applicationContext().unregisterReceiver(headsetEventReceiver)
|
||||||
mediaPlayerController.onDestroy()
|
mediaPlayerController.onDestroy()
|
||||||
|
|
||||||
|
@ -129,11 +118,6 @@ class MediaPlayerLifecycleSupport : KoinComponent {
|
||||||
*/
|
*/
|
||||||
private fun registerHeadsetReceiver() {
|
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() {
|
headsetEventReceiver = object : BroadcastReceiver() {
|
||||||
override fun onReceive(context: Context, intent: Intent) {
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
val extras = intent.extras ?: return
|
val extras = intent.extras ?: return
|
||||||
|
@ -148,12 +132,10 @@ class MediaPlayerLifecycleSupport : KoinComponent {
|
||||||
}
|
}
|
||||||
} else if (state == 1) {
|
} else if (state == 1) {
|
||||||
if (!mediaPlayerController.isJukeboxEnabled &&
|
if (!mediaPlayerController.isJukeboxEnabled &&
|
||||||
sp.getBoolean(
|
Settings.resumePlayOnHeadphonePlug && !mediaPlayerController.isPlaying
|
||||||
spKey,
|
|
||||||
false
|
|
||||||
) && mediaPlayerController.playerState === PlayerState.PAUSED
|
|
||||||
) {
|
) {
|
||||||
mediaPlayerController.start()
|
mediaPlayerController.prepare()
|
||||||
|
mediaPlayerController.play()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -169,18 +151,7 @@ class MediaPlayerLifecycleSupport : KoinComponent {
|
||||||
|
|
||||||
if (event.action != KeyEvent.ACTION_DOWN || event.repeatCount > 0) return
|
if (event.action != KeyEvent.ACTION_DOWN || event.repeatCount > 0) return
|
||||||
|
|
||||||
val keyCode: Int
|
val keyCode: Int = event.keyCode
|
||||||
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 autoStart =
|
val autoStart =
|
||||||
keyCode == KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE ||
|
keyCode == KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE ||
|
||||||
|
@ -197,14 +168,7 @@ class MediaPlayerLifecycleSupport : KoinComponent {
|
||||||
KeyEvent.KEYCODE_MEDIA_PREVIOUS -> mediaPlayerController.previous()
|
KeyEvent.KEYCODE_MEDIA_PREVIOUS -> mediaPlayerController.previous()
|
||||||
KeyEvent.KEYCODE_MEDIA_NEXT -> mediaPlayerController.next()
|
KeyEvent.KEYCODE_MEDIA_NEXT -> mediaPlayerController.next()
|
||||||
KeyEvent.KEYCODE_MEDIA_STOP -> mediaPlayerController.stop()
|
KeyEvent.KEYCODE_MEDIA_STOP -> mediaPlayerController.stop()
|
||||||
|
KeyEvent.KEYCODE_MEDIA_PLAY -> mediaPlayerController.play()
|
||||||
KeyEvent.KEYCODE_MEDIA_PLAY ->
|
|
||||||
if (mediaPlayerController.playerState === PlayerState.IDLE) {
|
|
||||||
mediaPlayerController.play()
|
|
||||||
} else if (mediaPlayerController.playerState !== PlayerState.STARTED) {
|
|
||||||
mediaPlayerController.start()
|
|
||||||
}
|
|
||||||
|
|
||||||
KeyEvent.KEYCODE_MEDIA_PAUSE -> mediaPlayerController.pause()
|
KeyEvent.KEYCODE_MEDIA_PAUSE -> mediaPlayerController.pause()
|
||||||
KeyEvent.KEYCODE_1 -> mediaPlayerController.setSongRating(1)
|
KeyEvent.KEYCODE_1 -> mediaPlayerController.setSongRating(1)
|
||||||
KeyEvent.KEYCODE_2 -> mediaPlayerController.setSongRating(2)
|
KeyEvent.KEYCODE_2 -> mediaPlayerController.setSongRating(2)
|
||||||
|
@ -222,28 +186,23 @@ class MediaPlayerLifecycleSupport : KoinComponent {
|
||||||
* This function processes the intent that could come from other applications.
|
* This function processes the intent that could come from other applications.
|
||||||
*/
|
*/
|
||||||
@Suppress("ComplexMethod")
|
@Suppress("ComplexMethod")
|
||||||
private fun handleUltrasonicIntent(intentAction: String) {
|
private fun handleUltrasonicIntent(action: String) {
|
||||||
|
|
||||||
val isRunning = created
|
val isRunning = created
|
||||||
|
|
||||||
// If Ultrasonic is not running, do nothing to stop or pause
|
// If Ultrasonic is not running, do nothing to stop or pause
|
||||||
if (
|
if (!isRunning && (action == Constants.CMD_PAUSE || action == Constants.CMD_STOP))
|
||||||
!isRunning && (
|
return
|
||||||
intentAction == Constants.CMD_PAUSE ||
|
|
||||||
intentAction == Constants.CMD_STOP
|
|
||||||
)
|
|
||||||
) return
|
|
||||||
|
|
||||||
val autoStart =
|
val autoStart = action == Constants.CMD_PLAY ||
|
||||||
intentAction == Constants.CMD_PLAY ||
|
action == Constants.CMD_RESUME_OR_PLAY ||
|
||||||
intentAction == Constants.CMD_RESUME_OR_PLAY ||
|
action == Constants.CMD_TOGGLEPAUSE ||
|
||||||
intentAction == Constants.CMD_TOGGLEPAUSE ||
|
action == Constants.CMD_PREVIOUS ||
|
||||||
intentAction == Constants.CMD_PREVIOUS ||
|
action == Constants.CMD_NEXT
|
||||||
intentAction == Constants.CMD_NEXT
|
|
||||||
|
|
||||||
// We can receive intents when everything is stopped, so we need to start
|
// We can receive intents when everything is stopped, so we need to start
|
||||||
onCreate(autoStart) {
|
onCreate(autoStart) {
|
||||||
when (intentAction) {
|
when (action) {
|
||||||
Constants.CMD_PLAY -> mediaPlayerController.play()
|
Constants.CMD_PLAY -> mediaPlayerController.play()
|
||||||
Constants.CMD_RESUME_OR_PLAY ->
|
Constants.CMD_RESUME_OR_PLAY ->
|
||||||
// If Ultrasonic wasn't running, the autoStart is enough to resume,
|
// 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_NEXT -> mediaPlayerController.next()
|
||||||
Constants.CMD_PREVIOUS -> mediaPlayerController.previous()
|
Constants.CMD_PREVIOUS -> mediaPlayerController.previous()
|
||||||
Constants.CMD_TOGGLEPAUSE -> mediaPlayerController.togglePlayPause()
|
Constants.CMD_TOGGLEPAUSE -> mediaPlayerController.togglePlayPause()
|
||||||
|
Constants.CMD_STOP -> mediaPlayerController.stop()
|
||||||
Constants.CMD_STOP -> {
|
|
||||||
// TODO: There is a stop() function, shouldn't we use that?
|
|
||||||
mediaPlayerController.pause()
|
|
||||||
mediaPlayerController.seekTo(0)
|
|
||||||
}
|
|
||||||
Constants.CMD_PAUSE -> mediaPlayerController.pause()
|
Constants.CMD_PAUSE -> mediaPlayerController.pause()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,769 +0,0 @@
|
||||||
/*
|
|
||||||
* MediaPlayerService.kt
|
|
||||||
* Copyright (C) 2009-2021 Ultrasonic developers
|
|
||||||
*
|
|
||||||
* Distributed under terms of the GNU GPLv3 license.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.moire.ultrasonic.service
|
|
||||||
|
|
||||||
import android.app.Notification
|
|
||||||
import android.app.NotificationChannel
|
|
||||||
import android.app.NotificationManager
|
|
||||||
import android.app.PendingIntent
|
|
||||||
import android.app.Service
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.os.Build
|
|
||||||
import android.os.Handler
|
|
||||||
import android.os.IBinder
|
|
||||||
import android.os.Looper
|
|
||||||
import android.support.v4.media.session.MediaSessionCompat
|
|
||||||
import android.view.KeyEvent
|
|
||||||
import androidx.core.app.NotificationCompat
|
|
||||||
import androidx.core.app.NotificationManagerCompat
|
|
||||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
|
||||||
import kotlin.collections.ArrayList
|
|
||||||
import org.koin.android.ext.android.inject
|
|
||||||
import org.moire.ultrasonic.R
|
|
||||||
import org.moire.ultrasonic.activity.NavigationActivity
|
|
||||||
import org.moire.ultrasonic.app.UApp
|
|
||||||
import org.moire.ultrasonic.domain.PlayerState
|
|
||||||
import org.moire.ultrasonic.domain.RepeatMode
|
|
||||||
import org.moire.ultrasonic.domain.Track
|
|
||||||
import org.moire.ultrasonic.imageloader.BitmapUtils
|
|
||||||
import org.moire.ultrasonic.provider.UltrasonicAppWidgetProvider4X1
|
|
||||||
import org.moire.ultrasonic.provider.UltrasonicAppWidgetProvider4X2
|
|
||||||
import org.moire.ultrasonic.provider.UltrasonicAppWidgetProvider4X3
|
|
||||||
import org.moire.ultrasonic.provider.UltrasonicAppWidgetProvider4X4
|
|
||||||
import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService
|
|
||||||
import org.moire.ultrasonic.util.Constants
|
|
||||||
import org.moire.ultrasonic.util.MediaSessionHandler
|
|
||||||
import org.moire.ultrasonic.util.Settings
|
|
||||||
import org.moire.ultrasonic.util.ShufflePlayBuffer
|
|
||||||
import org.moire.ultrasonic.util.SimpleServiceBinder
|
|
||||||
import org.moire.ultrasonic.util.Util
|
|
||||||
import timber.log.Timber
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Android Foreground Service for playing music
|
|
||||||
* while the rest of the Ultrasonic App is in the background.
|
|
||||||
*
|
|
||||||
* "A foreground service is a service that the user is
|
|
||||||
* actively aware of and isn’t a candidate for the system to kill when low on memory."
|
|
||||||
*/
|
|
||||||
@Suppress("LargeClass")
|
|
||||||
class MediaPlayerService : Service() {
|
|
||||||
private val binder: IBinder = SimpleServiceBinder(this)
|
|
||||||
private val scrobbler = Scrobbler()
|
|
||||||
|
|
||||||
private val jukeboxMediaPlayer by inject<JukeboxMediaPlayer>()
|
|
||||||
private val playbackStateSerializer by inject<PlaybackStateSerializer>()
|
|
||||||
private val shufflePlayBuffer by inject<ShufflePlayBuffer>()
|
|
||||||
private val downloader by inject<Downloader>()
|
|
||||||
private val localMediaPlayer by inject<LocalMediaPlayer>()
|
|
||||||
private val mediaSessionHandler by inject<MediaSessionHandler>()
|
|
||||||
|
|
||||||
private var mediaSession: MediaSessionCompat? = null
|
|
||||||
private var mediaSessionToken: MediaSessionCompat.Token? = null
|
|
||||||
private var isInForeground = false
|
|
||||||
private var notificationBuilder: NotificationCompat.Builder? = null
|
|
||||||
private var rxBusSubscription: CompositeDisposable = CompositeDisposable()
|
|
||||||
|
|
||||||
private var currentPlayerState: PlayerState? = null
|
|
||||||
private var currentTrack: DownloadFile? = null
|
|
||||||
|
|
||||||
override fun onBind(intent: Intent): IBinder {
|
|
||||||
return binder
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreate() {
|
|
||||||
super.onCreate()
|
|
||||||
|
|
||||||
shufflePlayBuffer.onCreate()
|
|
||||||
localMediaPlayer.init()
|
|
||||||
|
|
||||||
setupOnSongCompletedHandler()
|
|
||||||
|
|
||||||
localMediaPlayer.onPrepared = {
|
|
||||||
playbackStateSerializer.serialize(
|
|
||||||
downloader.getPlaylist(),
|
|
||||||
downloader.currentPlayingIndex,
|
|
||||||
playerPosition
|
|
||||||
)
|
|
||||||
null
|
|
||||||
}
|
|
||||||
|
|
||||||
localMediaPlayer.onNextSongRequested = Runnable { setNextPlaying() }
|
|
||||||
|
|
||||||
// Create Notification Channel
|
|
||||||
createNotificationChannel()
|
|
||||||
|
|
||||||
// Update notification early. It is better to show an empty one temporarily
|
|
||||||
// than waiting too long and letting Android kill the app
|
|
||||||
updateNotification(PlayerState.IDLE, null)
|
|
||||||
|
|
||||||
// Subscribing should be after updateNotification to avoid concurrency
|
|
||||||
rxBusSubscription += RxBus.playerStateObservable.subscribe {
|
|
||||||
playerStateChangedHandler(it.state, it.track)
|
|
||||||
}
|
|
||||||
|
|
||||||
rxBusSubscription += RxBus.mediaSessionTokenObservable.subscribe {
|
|
||||||
mediaSessionToken = it
|
|
||||||
}
|
|
||||||
|
|
||||||
rxBusSubscription += RxBus.skipToQueueItemCommandObservable.subscribe {
|
|
||||||
play(it.toInt())
|
|
||||||
}
|
|
||||||
|
|
||||||
mediaSessionHandler.initialize()
|
|
||||||
|
|
||||||
instance = this
|
|
||||||
Timber.i("MediaPlayerService created")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
|
|
||||||
super.onStartCommand(intent, flags, startId)
|
|
||||||
return START_NOT_STICKY
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDestroy() {
|
|
||||||
super.onDestroy()
|
|
||||||
instance = null
|
|
||||||
try {
|
|
||||||
mediaSessionHandler.release()
|
|
||||||
rxBusSubscription.dispose()
|
|
||||||
|
|
||||||
localMediaPlayer.release()
|
|
||||||
downloader.stop()
|
|
||||||
shufflePlayBuffer.onDestroy()
|
|
||||||
|
|
||||||
mediaSession?.release()
|
|
||||||
mediaSession = null
|
|
||||||
} catch (ignored: Throwable) {
|
|
||||||
}
|
|
||||||
Timber.i("MediaPlayerService stopped")
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun stopIfIdle() {
|
|
||||||
synchronized(instanceLock) {
|
|
||||||
// currentPlaying could be changed from another thread in the meantime,
|
|
||||||
// so check again before stopping for good
|
|
||||||
if (localMediaPlayer.currentPlaying == null ||
|
|
||||||
localMediaPlayer.playerState === PlayerState.STOPPED
|
|
||||||
) {
|
|
||||||
stopSelf()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun notifyDownloaderStopped() {
|
|
||||||
// TODO It would be nice to know if the service really can be stopped instead of just
|
|
||||||
// checking if it is idle once...
|
|
||||||
val handler = Handler(Looper.getMainLooper())
|
|
||||||
handler.postDelayed({ stopIfIdle() }, 1000)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
fun seekTo(position: Int) {
|
|
||||||
if (jukeboxMediaPlayer.isEnabled) {
|
|
||||||
// TODO These APIs should be more aligned
|
|
||||||
val seconds = position / 1000
|
|
||||||
jukeboxMediaPlayer.skip(downloader.currentPlayingIndex, seconds)
|
|
||||||
} else {
|
|
||||||
localMediaPlayer.seekTo(position)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@get:Synchronized
|
|
||||||
val playerPosition: Int
|
|
||||||
get() {
|
|
||||||
if (localMediaPlayer.playerState === PlayerState.IDLE ||
|
|
||||||
localMediaPlayer.playerState === PlayerState.DOWNLOADING ||
|
|
||||||
localMediaPlayer.playerState === PlayerState.PREPARING
|
|
||||||
) {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return if (jukeboxMediaPlayer.isEnabled) {
|
|
||||||
jukeboxMediaPlayer.positionSeconds * 1000
|
|
||||||
} else {
|
|
||||||
localMediaPlayer.playerPosition
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@get:Synchronized
|
|
||||||
val playerDuration: Int
|
|
||||||
get() = localMediaPlayer.playerDuration
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
fun setCurrentPlaying(currentPlayingIndex: Int) {
|
|
||||||
try {
|
|
||||||
localMediaPlayer.setCurrentPlaying(downloader.getPlaylist()[currentPlayingIndex])
|
|
||||||
} catch (ignored: IndexOutOfBoundsException) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
fun setNextPlaying() {
|
|
||||||
// Download the next few songs if necessary
|
|
||||||
downloader.checkDownloads()
|
|
||||||
|
|
||||||
if (!Settings.gaplessPlayback) {
|
|
||||||
localMediaPlayer.clearNextPlaying(true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var index = downloader.currentPlayingIndex
|
|
||||||
|
|
||||||
if (index != -1) {
|
|
||||||
when (Settings.repeatMode) {
|
|
||||||
RepeatMode.OFF -> index += 1
|
|
||||||
RepeatMode.ALL -> index = (index + 1) % downloader.getPlaylist().size
|
|
||||||
RepeatMode.SINGLE -> {
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
localMediaPlayer.clearNextPlaying(false)
|
|
||||||
if (index < downloader.getPlaylist().size && index != -1) {
|
|
||||||
localMediaPlayer.setNextPlaying(downloader.getPlaylist()[index])
|
|
||||||
} else {
|
|
||||||
localMediaPlayer.clearNextPlaying(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
fun togglePlayPause() {
|
|
||||||
if (localMediaPlayer.playerState === PlayerState.PAUSED ||
|
|
||||||
localMediaPlayer.playerState === PlayerState.COMPLETED ||
|
|
||||||
localMediaPlayer.playerState === PlayerState.STOPPED
|
|
||||||
) {
|
|
||||||
start()
|
|
||||||
} else if (localMediaPlayer.playerState === PlayerState.IDLE) {
|
|
||||||
play()
|
|
||||||
} else if (localMediaPlayer.playerState === PlayerState.STARTED) {
|
|
||||||
pause()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
fun resumeOrPlay() {
|
|
||||||
if (localMediaPlayer.playerState === PlayerState.PAUSED ||
|
|
||||||
localMediaPlayer.playerState === PlayerState.COMPLETED ||
|
|
||||||
localMediaPlayer.playerState === PlayerState.STOPPED
|
|
||||||
) {
|
|
||||||
start()
|
|
||||||
} else if (localMediaPlayer.playerState === PlayerState.IDLE) {
|
|
||||||
play()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Plays either the current song (resume) or the first/next one in queue.
|
|
||||||
*/
|
|
||||||
@Synchronized
|
|
||||||
fun play() {
|
|
||||||
val current = downloader.currentPlayingIndex
|
|
||||||
if (current == -1) {
|
|
||||||
play(0)
|
|
||||||
} else {
|
|
||||||
play(current)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
fun play(index: Int) {
|
|
||||||
play(index, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
fun play(index: Int, start: Boolean) {
|
|
||||||
Timber.v("play requested for %d", index)
|
|
||||||
if (index < 0 || index >= downloader.getPlaylist().size) {
|
|
||||||
resetPlayback()
|
|
||||||
} else {
|
|
||||||
setCurrentPlaying(index)
|
|
||||||
if (start) {
|
|
||||||
if (jukeboxMediaPlayer.isEnabled) {
|
|
||||||
jukeboxMediaPlayer.skip(index, 0)
|
|
||||||
} else {
|
|
||||||
localMediaPlayer.play(downloader.getPlaylist()[index])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setNextPlaying()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
private fun resetPlayback() {
|
|
||||||
localMediaPlayer.reset()
|
|
||||||
localMediaPlayer.setCurrentPlaying(null)
|
|
||||||
playbackStateSerializer.serialize(
|
|
||||||
downloader.getPlaylist(),
|
|
||||||
downloader.currentPlayingIndex, playerPosition
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
fun pause() {
|
|
||||||
if (localMediaPlayer.playerState === PlayerState.STARTED) {
|
|
||||||
if (jukeboxMediaPlayer.isEnabled) {
|
|
||||||
jukeboxMediaPlayer.stop()
|
|
||||||
} else {
|
|
||||||
localMediaPlayer.pause()
|
|
||||||
}
|
|
||||||
localMediaPlayer.setPlayerState(PlayerState.PAUSED, localMediaPlayer.currentPlaying)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
fun stop() {
|
|
||||||
if (localMediaPlayer.playerState === PlayerState.STARTED) {
|
|
||||||
if (jukeboxMediaPlayer.isEnabled) {
|
|
||||||
jukeboxMediaPlayer.stop()
|
|
||||||
} else {
|
|
||||||
localMediaPlayer.pause()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
localMediaPlayer.setPlayerState(PlayerState.STOPPED, null)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
fun start() {
|
|
||||||
if (jukeboxMediaPlayer.isEnabled) {
|
|
||||||
jukeboxMediaPlayer.start()
|
|
||||||
} else {
|
|
||||||
localMediaPlayer.start()
|
|
||||||
}
|
|
||||||
localMediaPlayer.setPlayerState(PlayerState.STARTED, localMediaPlayer.currentPlaying)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun updateWidget(playerState: PlayerState, song: Track?) {
|
|
||||||
val started = playerState === PlayerState.STARTED
|
|
||||||
val context = this@MediaPlayerService
|
|
||||||
|
|
||||||
UltrasonicAppWidgetProvider4X1.getInstance().notifyChange(context, song, started, false)
|
|
||||||
UltrasonicAppWidgetProvider4X2.getInstance().notifyChange(context, song, started, true)
|
|
||||||
UltrasonicAppWidgetProvider4X3.getInstance().notifyChange(context, song, started, false)
|
|
||||||
UltrasonicAppWidgetProvider4X4.getInstance().notifyChange(context, song, started, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun playerStateChangedHandler(
|
|
||||||
playerState: PlayerState,
|
|
||||||
currentPlaying: DownloadFile?
|
|
||||||
) {
|
|
||||||
val context = this@MediaPlayerService
|
|
||||||
// AVRCP handles these separately so we must differentiate between the cases
|
|
||||||
val isStateChanged = playerState != currentPlayerState
|
|
||||||
val isTrackChanged = currentPlaying != currentTrack
|
|
||||||
if (!isStateChanged && !isTrackChanged) return
|
|
||||||
|
|
||||||
val showWhenPaused = playerState !== PlayerState.STOPPED &&
|
|
||||||
Settings.isNotificationAlwaysEnabled
|
|
||||||
|
|
||||||
val show = playerState === PlayerState.STARTED || showWhenPaused
|
|
||||||
val song = currentPlaying?.track
|
|
||||||
|
|
||||||
if (isStateChanged) {
|
|
||||||
when {
|
|
||||||
playerState === PlayerState.PAUSED -> {
|
|
||||||
playbackStateSerializer.serialize(
|
|
||||||
downloader.getPlaylist(), downloader.currentPlayingIndex, playerPosition
|
|
||||||
)
|
|
||||||
}
|
|
||||||
playerState === PlayerState.STARTED -> {
|
|
||||||
scrobbler.scrobble(currentPlaying, false)
|
|
||||||
}
|
|
||||||
playerState === PlayerState.COMPLETED -> {
|
|
||||||
scrobbler.scrobble(currentPlaying, true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Util.broadcastPlaybackStatusChange(context, playerState)
|
|
||||||
Util.broadcastA2dpPlayStatusChange(
|
|
||||||
context, playerState, song,
|
|
||||||
downloader.getPlaylist().size,
|
|
||||||
downloader.getPlaylist().indexOf(currentPlaying) + 1, playerPosition
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
// State didn't change, only the track
|
|
||||||
Util.broadcastA2dpMetaDataChange(
|
|
||||||
this@MediaPlayerService, playerPosition, currentPlaying,
|
|
||||||
downloader.all.size, downloader.currentPlayingIndex + 1
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isTrackChanged) {
|
|
||||||
Util.broadcastNewTrackInfo(this@MediaPlayerService, currentPlaying?.track)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update widget
|
|
||||||
updateWidget(playerState, song)
|
|
||||||
|
|
||||||
if (show) {
|
|
||||||
// Only update notification if player state is one that will change the icon
|
|
||||||
if (playerState === PlayerState.STARTED || playerState === PlayerState.PAUSED) {
|
|
||||||
updateNotification(playerState, currentPlaying)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
stopForeground(true)
|
|
||||||
isInForeground = false
|
|
||||||
stopIfIdle()
|
|
||||||
}
|
|
||||||
|
|
||||||
currentPlayerState = playerState
|
|
||||||
currentTrack = currentPlaying
|
|
||||||
|
|
||||||
Timber.d("Processed player state change")
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun setupOnSongCompletedHandler() {
|
|
||||||
localMediaPlayer.onSongCompleted = { currentPlaying: DownloadFile? ->
|
|
||||||
val index = downloader.currentPlayingIndex
|
|
||||||
|
|
||||||
if (currentPlaying != null) {
|
|
||||||
val song = currentPlaying.track
|
|
||||||
if (song.bookmarkPosition > 0 && Settings.shouldClearBookmark) {
|
|
||||||
val musicService = getMusicService()
|
|
||||||
try {
|
|
||||||
musicService.deleteBookmark(song.id)
|
|
||||||
} catch (ignored: Exception) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (index != -1) {
|
|
||||||
when (Settings.repeatMode) {
|
|
||||||
RepeatMode.OFF -> {
|
|
||||||
if (index + 1 < 0 || index + 1 >= downloader.getPlaylist().size) {
|
|
||||||
if (Settings.shouldClearPlaylist) {
|
|
||||||
clear(true)
|
|
||||||
jukeboxMediaPlayer.updatePlaylist()
|
|
||||||
}
|
|
||||||
resetPlayback()
|
|
||||||
} else {
|
|
||||||
play(index + 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
RepeatMode.ALL -> {
|
|
||||||
play((index + 1) % downloader.getPlaylist().size)
|
|
||||||
}
|
|
||||||
RepeatMode.SINGLE -> play(index)
|
|
||||||
else -> {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
fun clear(serialize: Boolean) {
|
|
||||||
localMediaPlayer.reset()
|
|
||||||
downloader.clearPlaylist()
|
|
||||||
localMediaPlayer.setCurrentPlaying(null)
|
|
||||||
setNextPlaying()
|
|
||||||
if (serialize) {
|
|
||||||
playbackStateSerializer.serialize(
|
|
||||||
downloader.getPlaylist(),
|
|
||||||
downloader.currentPlayingIndex, playerPosition
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun createNotificationChannel() {
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
||||||
|
|
||||||
// The suggested importance of a startForeground service notification is IMPORTANCE_LOW
|
|
||||||
val channel = NotificationChannel(
|
|
||||||
NOTIFICATION_CHANNEL_ID,
|
|
||||||
NOTIFICATION_CHANNEL_NAME,
|
|
||||||
NotificationManager.IMPORTANCE_LOW
|
|
||||||
)
|
|
||||||
|
|
||||||
channel.lightColor = android.R.color.holo_blue_dark
|
|
||||||
channel.lockscreenVisibility = Notification.VISIBILITY_PUBLIC
|
|
||||||
channel.setShowBadge(false)
|
|
||||||
|
|
||||||
val manager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
|
|
||||||
manager.createNotificationChannel(channel)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun updateNotification(playerState: PlayerState, currentPlaying: DownloadFile?) {
|
|
||||||
val notification = buildForegroundNotification(playerState, currentPlaying)
|
|
||||||
|
|
||||||
if (Settings.isNotificationEnabled) {
|
|
||||||
if (isInForeground) {
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
||||||
val manager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
|
|
||||||
manager.notify(NOTIFICATION_ID, notification)
|
|
||||||
} else {
|
|
||||||
val manager = NotificationManagerCompat.from(this)
|
|
||||||
manager.notify(NOTIFICATION_ID, notification)
|
|
||||||
}
|
|
||||||
Timber.v("Updated notification")
|
|
||||||
} else {
|
|
||||||
startForeground(NOTIFICATION_ID, notification)
|
|
||||||
isInForeground = true
|
|
||||||
Timber.v("Created Foreground notification")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This method builds a notification, reusing the Notification Builder if possible
|
|
||||||
*/
|
|
||||||
@Suppress("SpreadOperator")
|
|
||||||
private fun buildForegroundNotification(
|
|
||||||
playerState: PlayerState,
|
|
||||||
currentPlaying: DownloadFile?
|
|
||||||
): Notification {
|
|
||||||
|
|
||||||
// Init
|
|
||||||
val context = applicationContext
|
|
||||||
val song = currentPlaying?.track
|
|
||||||
val stopIntent = Util.getPendingIntentForMediaAction(
|
|
||||||
context,
|
|
||||||
KeyEvent.KEYCODE_MEDIA_STOP,
|
|
||||||
100
|
|
||||||
)
|
|
||||||
|
|
||||||
// We should use a single notification builder, otherwise the notification may not be updated
|
|
||||||
if (notificationBuilder == null) {
|
|
||||||
notificationBuilder = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
|
|
||||||
|
|
||||||
// Set some values that never change
|
|
||||||
notificationBuilder!!.setSmallIcon(R.drawable.ic_stat_ultrasonic)
|
|
||||||
notificationBuilder!!.setAutoCancel(false)
|
|
||||||
notificationBuilder!!.setOngoing(true)
|
|
||||||
notificationBuilder!!.setOnlyAlertOnce(true)
|
|
||||||
notificationBuilder!!.setWhen(System.currentTimeMillis())
|
|
||||||
notificationBuilder!!.setShowWhen(false)
|
|
||||||
notificationBuilder!!.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
|
||||||
notificationBuilder!!.priority = NotificationCompat.PRIORITY_LOW
|
|
||||||
|
|
||||||
// Add content intent (when user taps on notification)
|
|
||||||
notificationBuilder!!.setContentIntent(getPendingIntentForContent())
|
|
||||||
|
|
||||||
// This intent is executed when the user closes the notification
|
|
||||||
notificationBuilder!!.setDeleteIntent(stopIntent)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use the Media Style, to enable native Android support for playback notification
|
|
||||||
val style = androidx.media.app.NotificationCompat.MediaStyle()
|
|
||||||
|
|
||||||
if (mediaSessionToken != null) {
|
|
||||||
style.setMediaSession(mediaSessionToken)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear old actions
|
|
||||||
notificationBuilder!!.clearActions()
|
|
||||||
|
|
||||||
if (song != null) {
|
|
||||||
// Add actions
|
|
||||||
val compactActions = addActions(context, notificationBuilder!!, playerState, song)
|
|
||||||
// Configure shortcut actions
|
|
||||||
style.setShowActionsInCompactView(*compactActions)
|
|
||||||
notificationBuilder!!.setStyle(style)
|
|
||||||
|
|
||||||
// Set song title, artist and cover
|
|
||||||
val iconSize = (256 * context.resources.displayMetrics.density).toInt()
|
|
||||||
val bitmap = BitmapUtils.getAlbumArtBitmapFromDisk(song, iconSize)
|
|
||||||
notificationBuilder!!.setContentTitle(song.title)
|
|
||||||
notificationBuilder!!.setContentText(song.artist)
|
|
||||||
notificationBuilder!!.setLargeIcon(bitmap)
|
|
||||||
notificationBuilder!!.setSubText(song.album)
|
|
||||||
} else if (downloader.started) {
|
|
||||||
// No song is playing, but Ultrasonic is downloading files
|
|
||||||
notificationBuilder!!.setContentTitle(
|
|
||||||
getString(R.string.notification_downloading_title)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return notificationBuilder!!.build()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun addActions(
|
|
||||||
context: Context,
|
|
||||||
notificationBuilder: NotificationCompat.Builder,
|
|
||||||
playerState: PlayerState,
|
|
||||||
song: Track?
|
|
||||||
): IntArray {
|
|
||||||
// Init
|
|
||||||
val compactActionList = ArrayList<Int>()
|
|
||||||
var numActions = 0 // we start and 0 and then increment by 1 for each call to generateAction
|
|
||||||
|
|
||||||
// Star
|
|
||||||
if (song != null) {
|
|
||||||
notificationBuilder.addAction(generateStarAction(context, numActions, song.starred))
|
|
||||||
}
|
|
||||||
numActions++
|
|
||||||
|
|
||||||
// Next
|
|
||||||
notificationBuilder.addAction(generateAction(context, numActions))
|
|
||||||
compactActionList.add(numActions)
|
|
||||||
numActions++
|
|
||||||
|
|
||||||
// Play/Pause button
|
|
||||||
notificationBuilder.addAction(generatePlayPauseAction(context, numActions, playerState))
|
|
||||||
compactActionList.add(numActions)
|
|
||||||
numActions++
|
|
||||||
|
|
||||||
// Previous
|
|
||||||
notificationBuilder.addAction(generateAction(context, numActions))
|
|
||||||
compactActionList.add(numActions)
|
|
||||||
numActions++
|
|
||||||
|
|
||||||
// Close
|
|
||||||
notificationBuilder.addAction(generateAction(context, numActions))
|
|
||||||
val actionArray = IntArray(compactActionList.size)
|
|
||||||
for (i in actionArray.indices) {
|
|
||||||
actionArray[i] = compactActionList[i]
|
|
||||||
}
|
|
||||||
return actionArray
|
|
||||||
// notificationBuilder.setShowActionsInCompactView())
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun generateAction(context: Context, requestCode: Int): NotificationCompat.Action? {
|
|
||||||
val keycode: Int
|
|
||||||
val icon: Int
|
|
||||||
val label: String
|
|
||||||
|
|
||||||
when (requestCode) {
|
|
||||||
1 -> {
|
|
||||||
keycode = KeyEvent.KEYCODE_MEDIA_PREVIOUS
|
|
||||||
label = getString(R.string.common_play_previous)
|
|
||||||
icon = R.drawable.media_backward_medium_dark
|
|
||||||
}
|
|
||||||
2 -> // Is handled in generatePlayPauseAction()
|
|
||||||
return null
|
|
||||||
3 -> {
|
|
||||||
keycode = KeyEvent.KEYCODE_MEDIA_NEXT
|
|
||||||
label = getString(R.string.common_play_next)
|
|
||||||
icon = R.drawable.media_forward_medium_dark
|
|
||||||
}
|
|
||||||
4 -> {
|
|
||||||
keycode = KeyEvent.KEYCODE_MEDIA_STOP
|
|
||||||
label = getString(R.string.buttons_stop)
|
|
||||||
icon = R.drawable.ic_baseline_close
|
|
||||||
}
|
|
||||||
else -> return null
|
|
||||||
}
|
|
||||||
|
|
||||||
val pendingIntent = Util.getPendingIntentForMediaAction(context, keycode, requestCode)
|
|
||||||
return NotificationCompat.Action.Builder(icon, label, pendingIntent).build()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun generatePlayPauseAction(
|
|
||||||
context: Context,
|
|
||||||
requestCode: Int,
|
|
||||||
playerState: PlayerState
|
|
||||||
): NotificationCompat.Action {
|
|
||||||
val isPlaying = playerState === PlayerState.STARTED
|
|
||||||
val keycode = KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE
|
|
||||||
val pendingIntent = Util.getPendingIntentForMediaAction(context, keycode, requestCode)
|
|
||||||
val label: String
|
|
||||||
val icon: Int
|
|
||||||
|
|
||||||
if (isPlaying) {
|
|
||||||
label = getString(R.string.common_pause)
|
|
||||||
icon = R.drawable.media_pause_large_dark
|
|
||||||
} else {
|
|
||||||
label = getString(R.string.common_play)
|
|
||||||
icon = R.drawable.media_start_large_dark
|
|
||||||
}
|
|
||||||
|
|
||||||
return NotificationCompat.Action.Builder(icon, label, pendingIntent).build()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun generateStarAction(
|
|
||||||
context: Context,
|
|
||||||
requestCode: Int,
|
|
||||||
isStarred: Boolean
|
|
||||||
): NotificationCompat.Action {
|
|
||||||
|
|
||||||
val label: String
|
|
||||||
val icon: Int
|
|
||||||
val keyCode: Int = KeyEvent.KEYCODE_STAR
|
|
||||||
|
|
||||||
if (isStarred) {
|
|
||||||
label = getString(R.string.download_menu_star)
|
|
||||||
icon = R.drawable.ic_star_full_dark
|
|
||||||
} else {
|
|
||||||
label = getString(R.string.download_menu_star)
|
|
||||||
icon = R.drawable.ic_star_hollow_dark
|
|
||||||
}
|
|
||||||
|
|
||||||
val pendingIntent = Util.getPendingIntentForMediaAction(context, keyCode, requestCode)
|
|
||||||
return NotificationCompat.Action.Builder(icon, label, pendingIntent).build()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getPendingIntentForContent(): PendingIntent {
|
|
||||||
val intent = Intent(this, NavigationActivity::class.java)
|
|
||||||
.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
|
||||||
val flags = PendingIntent.FLAG_UPDATE_CURRENT
|
|
||||||
intent.putExtra(Constants.INTENT_SHOW_PLAYER, true)
|
|
||||||
return PendingIntent.getActivity(this, 0, intent, flags)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Suppress("MagicNumber")
|
|
||||||
companion object {
|
|
||||||
private const val NOTIFICATION_CHANNEL_ID = "org.moire.ultrasonic"
|
|
||||||
private const val NOTIFICATION_CHANNEL_NAME = "Ultrasonic background service"
|
|
||||||
private const val NOTIFICATION_ID = 3033
|
|
||||||
|
|
||||||
@Volatile
|
|
||||||
private var instance: MediaPlayerService? = null
|
|
||||||
private val instanceLock = Any()
|
|
||||||
|
|
||||||
@JvmStatic
|
|
||||||
fun getInstance(): MediaPlayerService? {
|
|
||||||
val context = UApp.applicationContext()
|
|
||||||
// Try for twenty times to retrieve a running service,
|
|
||||||
// sleep 100 millis between each try,
|
|
||||||
// and run the block that creates a service only synchronized.
|
|
||||||
for (i in 0..19) {
|
|
||||||
if (instance != null) return instance
|
|
||||||
synchronized(instanceLock) {
|
|
||||||
if (instance != null) return instance
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
||||||
context.startForegroundService(
|
|
||||||
Intent(context, MediaPlayerService::class.java)
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
context.startService(Intent(context, MediaPlayerService::class.java))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Util.sleepQuietly(100L)
|
|
||||||
}
|
|
||||||
return instance
|
|
||||||
}
|
|
||||||
|
|
||||||
@JvmStatic
|
|
||||||
val runningInstance: MediaPlayerService?
|
|
||||||
get() {
|
|
||||||
synchronized(instanceLock) { return instance }
|
|
||||||
}
|
|
||||||
|
|
||||||
@JvmStatic
|
|
||||||
fun executeOnStartedMediaPlayerService(
|
|
||||||
taskToExecute: (MediaPlayerService) -> Unit
|
|
||||||
) {
|
|
||||||
|
|
||||||
val t: Thread = object : Thread() {
|
|
||||||
override fun run() {
|
|
||||||
val instance = getInstance()
|
|
||||||
if (instance == null) {
|
|
||||||
Timber.e("ExecuteOnStarted.. failed to get a MediaPlayerService instance!")
|
|
||||||
return
|
|
||||||
} else {
|
|
||||||
taskToExecute(instance)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
t.start()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -27,8 +27,14 @@ import org.moire.ultrasonic.data.ActiveServerProvider
|
||||||
import org.moire.ultrasonic.di.OFFLINE_MUSIC_SERVICE
|
import org.moire.ultrasonic.di.OFFLINE_MUSIC_SERVICE
|
||||||
import org.moire.ultrasonic.di.ONLINE_MUSIC_SERVICE
|
import org.moire.ultrasonic.di.ONLINE_MUSIC_SERVICE
|
||||||
import org.moire.ultrasonic.di.musicServiceModule
|
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 {
|
object MusicServiceFactory : KoinComponent {
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun getMusicService(): MusicService {
|
fun getMusicService(): MusicService {
|
||||||
|
@ -45,6 +51,7 @@ object MusicServiceFactory : KoinComponent {
|
||||||
*/
|
*/
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun resetMusicService() {
|
fun resetMusicService() {
|
||||||
|
Timber.i("Regenerating Koin Music Service Module")
|
||||||
unloadKoinModules(musicServiceModule)
|
unloadKoinModules(musicServiceModule)
|
||||||
loadKoinModules(musicServiceModule)
|
loadKoinModules(musicServiceModule)
|
||||||
}
|
}
|
||||||
|
|
|
@ -563,7 +563,7 @@ class OfflineMusicService : MusicService, KoinComponent {
|
||||||
} catch (ignored: Exception) {
|
} catch (ignored: Exception) {
|
||||||
}
|
}
|
||||||
|
|
||||||
artist = meta.artist ?: file.parent!!.parent!!.name
|
artist = meta.artist ?: file.parent!!.parent?.name ?: ""
|
||||||
album = meta.album ?: file.parent!!.name
|
album = meta.album ?: file.parent!!.name
|
||||||
title = meta.title ?: title
|
title = meta.title ?: title
|
||||||
isVideo = meta.hasVideo != null
|
isVideo = meta.hasVideo != null
|
||||||
|
|
|
@ -9,8 +9,6 @@ package org.moire.ultrasonic.service
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import java.util.concurrent.atomic.AtomicBoolean
|
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.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
@ -30,35 +28,32 @@ class PlaybackStateSerializer : KoinComponent {
|
||||||
|
|
||||||
private val context by inject<Context>()
|
private val context by inject<Context>()
|
||||||
|
|
||||||
private val lock: Lock = ReentrantLock()
|
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
private val setup = AtomicBoolean(false)
|
private val mainScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||||
|
|
||||||
private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
|
||||||
|
|
||||||
fun serialize(
|
fun serialize(
|
||||||
songs: Iterable<DownloadFile>,
|
songs: Iterable<DownloadFile>,
|
||||||
currentPlayingIndex: Int,
|
currentPlayingIndex: Int,
|
||||||
currentPlayingPosition: Int
|
currentPlayingPosition: Int
|
||||||
) {
|
) {
|
||||||
if (!setup.get()) return
|
if (isSerializing.get() || !isSetup.get()) return
|
||||||
|
|
||||||
appScope.launch {
|
isSerializing.set(true)
|
||||||
if (lock.tryLock()) {
|
|
||||||
try {
|
ioScope.launch {
|
||||||
serializeNow(songs, currentPlayingIndex, currentPlayingPosition)
|
serializeNow(songs, currentPlayingIndex, currentPlayingPosition)
|
||||||
} finally {
|
}.invokeOnCompletion {
|
||||||
lock.unlock()
|
isSerializing.set(false)
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun serializeNow(
|
fun serializeNow(
|
||||||
songs: Iterable<DownloadFile>,
|
referencedList: Iterable<DownloadFile>,
|
||||||
currentPlayingIndex: Int,
|
currentPlayingIndex: Int,
|
||||||
currentPlayingPosition: Int
|
currentPlayingPosition: Int
|
||||||
) {
|
) {
|
||||||
val state = State()
|
val state = State()
|
||||||
|
val songs = referencedList.toList()
|
||||||
|
|
||||||
for (downloadFile in songs) {
|
for (downloadFile in songs) {
|
||||||
state.songs.add(downloadFile.track)
|
state.songs.add(downloadFile.track)
|
||||||
|
@ -77,16 +72,15 @@ class PlaybackStateSerializer : KoinComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
fun deserialize(afterDeserialized: (State?) -> Unit?) {
|
fun deserialize(afterDeserialized: (State?) -> Unit?) {
|
||||||
|
if (isDeserializing.get()) return
|
||||||
appScope.launch {
|
ioScope.launch {
|
||||||
try {
|
try {
|
||||||
lock.lock()
|
|
||||||
deserializeNow(afterDeserialized)
|
deserializeNow(afterDeserialized)
|
||||||
setup.set(true)
|
isSetup.set(true)
|
||||||
} catch (all: Exception) {
|
} catch (all: Exception) {
|
||||||
Timber.e(all, "Had a problem deserializing:")
|
Timber.e(all, "Had a problem deserializing:")
|
||||||
} finally {
|
} finally {
|
||||||
lock.unlock()
|
isDeserializing.set(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -103,6 +97,14 @@ class PlaybackStateSerializer : KoinComponent {
|
||||||
state.currentPlayingPosition
|
state.currentPlayingPosition
|
||||||
)
|
)
|
||||||
|
|
||||||
afterDeserialized(state)
|
mainScope.launch {
|
||||||
|
afterDeserialized(state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val isSetup = AtomicBoolean(false)
|
||||||
|
private val isSerializing = AtomicBoolean(false)
|
||||||
|
private val isDeserializing = AtomicBoolean(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,89 +1,81 @@
|
||||||
package org.moire.ultrasonic.service
|
package org.moire.ultrasonic.service
|
||||||
|
|
||||||
import android.os.Bundle
|
import android.os.Looper
|
||||||
import android.support.v4.media.session.MediaSessionCompat
|
|
||||||
import android.view.KeyEvent
|
|
||||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||||
import io.reactivex.rxjava3.core.Observable
|
import io.reactivex.rxjava3.core.Observable
|
||||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||||
import io.reactivex.rxjava3.disposables.Disposable
|
import io.reactivex.rxjava3.disposables.Disposable
|
||||||
import io.reactivex.rxjava3.subjects.PublishSubject
|
import io.reactivex.rxjava3.subjects.PublishSubject
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
import org.moire.ultrasonic.domain.PlayerState
|
|
||||||
|
|
||||||
class RxBus {
|
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()
|
PublishSubject.create()
|
||||||
val mediaButtonEventObservable: Observable<KeyEvent> =
|
var activeServerChangeObservable: Observable<Int> =
|
||||||
mediaButtonEventPublisher.observeOn(AndroidSchedulers.mainThread())
|
activeServerChangePublisher.observeOn(mainThread())
|
||||||
|
|
||||||
val themeChangedEventPublisher: PublishSubject<Unit> =
|
val themeChangedEventPublisher: PublishSubject<Unit> =
|
||||||
PublishSubject.create()
|
PublishSubject.create()
|
||||||
val themeChangedEventObservable: Observable<Unit> =
|
val themeChangedEventObservable: Observable<Unit> =
|
||||||
themeChangedEventPublisher.observeOn(AndroidSchedulers.mainThread())
|
themeChangedEventPublisher.observeOn(mainThread())
|
||||||
|
|
||||||
val musicFolderChangedEventPublisher: PublishSubject<String> =
|
val musicFolderChangedEventPublisher: PublishSubject<String> =
|
||||||
PublishSubject.create()
|
PublishSubject.create()
|
||||||
val musicFolderChangedEventObservable: Observable<String> =
|
val musicFolderChangedEventObservable: Observable<String> =
|
||||||
musicFolderChangedEventPublisher.observeOn(AndroidSchedulers.mainThread())
|
musicFolderChangedEventPublisher.observeOn(mainThread())
|
||||||
|
|
||||||
val playerStatePublisher: PublishSubject<StateWithTrack> =
|
val playerStatePublisher: PublishSubject<StateWithTrack> =
|
||||||
PublishSubject.create()
|
PublishSubject.create()
|
||||||
val playerStateObservable: Observable<StateWithTrack> =
|
val playerStateObservable: Observable<StateWithTrack> =
|
||||||
playerStatePublisher.observeOn(AndroidSchedulers.mainThread())
|
playerStatePublisher.observeOn(mainThread())
|
||||||
.replay(1)
|
.replay(1)
|
||||||
.autoConnect(0)
|
.autoConnect(0)
|
||||||
|
val throttledPlayerStateObservable: Observable<StateWithTrack> =
|
||||||
|
playerStatePublisher.observeOn(mainThread())
|
||||||
|
.replay(1)
|
||||||
|
.autoConnect(0)
|
||||||
|
.throttleLatest(300, TimeUnit.MILLISECONDS)
|
||||||
|
|
||||||
val playlistPublisher: PublishSubject<List<DownloadFile>> =
|
val playlistPublisher: PublishSubject<List<DownloadFile>> =
|
||||||
PublishSubject.create()
|
PublishSubject.create()
|
||||||
val playlistObservable: Observable<List<DownloadFile>> =
|
val playlistObservable: Observable<List<DownloadFile>> =
|
||||||
playlistPublisher.observeOn(AndroidSchedulers.mainThread())
|
playlistPublisher.observeOn(mainThread())
|
||||||
.replay(1)
|
.replay(1)
|
||||||
.autoConnect(0)
|
.autoConnect(0)
|
||||||
|
val throttledPlaylistObservable: Observable<List<DownloadFile>> =
|
||||||
val playbackPositionPublisher: PublishSubject<Int> =
|
playlistPublisher.observeOn(mainThread())
|
||||||
PublishSubject.create()
|
|
||||||
val playbackPositionObservable: Observable<Int> =
|
|
||||||
playbackPositionPublisher.observeOn(AndroidSchedulers.mainThread())
|
|
||||||
.throttleFirst(1, TimeUnit.SECONDS)
|
|
||||||
.replay(1)
|
.replay(1)
|
||||||
.autoConnect(0)
|
.autoConnect(0)
|
||||||
|
.throttleLatest(300, TimeUnit.MILLISECONDS)
|
||||||
|
|
||||||
// Commands
|
// Commands
|
||||||
val dismissNowPlayingCommandPublisher: PublishSubject<Unit> =
|
val dismissNowPlayingCommandPublisher: PublishSubject<Unit> =
|
||||||
PublishSubject.create()
|
PublishSubject.create()
|
||||||
val dismissNowPlayingCommandObservable: Observable<Unit> =
|
val dismissNowPlayingCommandObservable: Observable<Unit> =
|
||||||
dismissNowPlayingCommandPublisher.observeOn(AndroidSchedulers.mainThread())
|
dismissNowPlayingCommandPublisher.observeOn(mainThread())
|
||||||
|
|
||||||
val playFromMediaIdCommandPublisher: PublishSubject<Pair<String?, Bundle?>> =
|
val shutdownCommandPublisher: PublishSubject<Unit> =
|
||||||
PublishSubject.create()
|
PublishSubject.create()
|
||||||
val playFromMediaIdCommandObservable: Observable<Pair<String?, Bundle?>> =
|
val shutdownCommandObservable: Observable<Unit> =
|
||||||
playFromMediaIdCommandPublisher.observeOn(AndroidSchedulers.mainThread())
|
shutdownCommandPublisher.observeOn(mainThread())
|
||||||
|
|
||||||
val playFromSearchCommandPublisher: PublishSubject<Pair<String?, Bundle?>> =
|
val stopCommandPublisher: PublishSubject<Unit> =
|
||||||
PublishSubject.create()
|
PublishSubject.create()
|
||||||
val playFromSearchCommandObservable: Observable<Pair<String?, Bundle?>> =
|
val stopCommandObservable: Observable<Unit> =
|
||||||
playFromSearchCommandPublisher.observeOn(AndroidSchedulers.mainThread())
|
stopCommandPublisher.observeOn(mainThread())
|
||||||
|
|
||||||
val skipToQueueItemCommandPublisher: PublishSubject<Long> =
|
|
||||||
PublishSubject.create()
|
|
||||||
val skipToQueueItemCommandObservable: Observable<Long> =
|
|
||||||
skipToQueueItemCommandPublisher.observeOn(AndroidSchedulers.mainThread())
|
|
||||||
|
|
||||||
fun releaseMediaSessionToken() {
|
|
||||||
mediaSessionTokenPublisher = PublishSubject.create()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
operator fun CompositeDisposable.plusAssign(disposable: Disposable) {
|
||||||
|
|
|
@ -34,20 +34,23 @@ class DownloadHandler(
|
||||||
autoPlay: Boolean,
|
autoPlay: Boolean,
|
||||||
playNext: Boolean,
|
playNext: Boolean,
|
||||||
shuffle: Boolean,
|
shuffle: Boolean,
|
||||||
songs: List<Track?>
|
songs: List<Track>,
|
||||||
) {
|
) {
|
||||||
val onValid = Runnable {
|
val onValid = Runnable {
|
||||||
if (!append && !playNext) {
|
// TODO: The logic here is different than in the controller...
|
||||||
mediaPlayerController.clear()
|
val insertionMode = when {
|
||||||
|
playNext -> MediaPlayerController.InsertionMode.AFTER_CURRENT
|
||||||
|
append -> MediaPlayerController.InsertionMode.APPEND
|
||||||
|
else -> MediaPlayerController.InsertionMode.CLEAR
|
||||||
}
|
}
|
||||||
|
|
||||||
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
|
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
|
||||||
mediaPlayerController.addToPlaylist(
|
mediaPlayerController.addToPlaylist(
|
||||||
songs,
|
songs,
|
||||||
save,
|
save,
|
||||||
autoPlay,
|
autoPlay,
|
||||||
playNext,
|
|
||||||
shuffle,
|
shuffle,
|
||||||
false
|
insertionMode
|
||||||
)
|
)
|
||||||
val playlistName: String? = fragment.arguments?.getString(
|
val playlistName: String? = fragment.arguments?.getString(
|
||||||
Constants.INTENT_PLAYLIST_NAME
|
Constants.INTENT_PLAYLIST_NAME
|
||||||
|
@ -281,26 +284,28 @@ class DownloadHandler(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Called when we have collected the tracks
|
||||||
override fun done(songs: List<Track>) {
|
override fun done(songs: List<Track>) {
|
||||||
if (Settings.shouldSortByDisc) {
|
if (Settings.shouldSortByDisc) {
|
||||||
Collections.sort(songs, EntryByDiscAndTrackComparator())
|
Collections.sort(songs, EntryByDiscAndTrackComparator())
|
||||||
}
|
}
|
||||||
if (songs.isNotEmpty()) {
|
if (songs.isNotEmpty()) {
|
||||||
if (!append && !playNext && !unpin && !background) {
|
|
||||||
mediaPlayerController.clear()
|
|
||||||
}
|
|
||||||
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
|
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
|
||||||
if (!background) {
|
if (!background) {
|
||||||
if (unpin) {
|
if (unpin) {
|
||||||
mediaPlayerController.unpin(songs)
|
mediaPlayerController.unpin(songs)
|
||||||
} else {
|
} else {
|
||||||
|
val insertionMode = when {
|
||||||
|
append -> MediaPlayerController.InsertionMode.APPEND
|
||||||
|
playNext -> MediaPlayerController.InsertionMode.AFTER_CURRENT
|
||||||
|
else -> MediaPlayerController.InsertionMode.CLEAR
|
||||||
|
}
|
||||||
mediaPlayerController.addToPlaylist(
|
mediaPlayerController.addToPlaylist(
|
||||||
songs,
|
songs,
|
||||||
save,
|
save,
|
||||||
autoPlay,
|
autoPlay,
|
||||||
playNext,
|
|
||||||
shuffle,
|
shuffle,
|
||||||
false
|
insertionMode
|
||||||
)
|
)
|
||||||
if (
|
if (
|
||||||
!append &&
|
!append &&
|
||||||
|
|
|
@ -233,7 +233,7 @@ class CacheCleaner : CoroutineScope by CoroutineScope(Dispatchers.IO) {
|
||||||
) {
|
) {
|
||||||
if (file.isFile && (isPartial(file) || isComplete(file))) {
|
if (file.isFile && (isPartial(file) || isComplete(file))) {
|
||||||
files.add(file)
|
files.add(file)
|
||||||
} else {
|
} else if (file.isDirectory) {
|
||||||
// Depth-first
|
// Depth-first
|
||||||
for (child in listFiles(file)) {
|
for (child in listFiles(file)) {
|
||||||
findCandidatesForDeletion(child, files, dirs)
|
findCandidatesForDeletion(child, files, dirs)
|
||||||
|
@ -257,7 +257,7 @@ class CacheCleaner : CoroutineScope by CoroutineScope(Dispatchers.IO) {
|
||||||
for (downloadFile in downloader.value.all) {
|
for (downloadFile in downloader.value.all) {
|
||||||
filesToNotDelete.add(downloadFile.partialFile)
|
filesToNotDelete.add(downloadFile.partialFile)
|
||||||
filesToNotDelete.add(downloadFile.completeFile)
|
filesToNotDelete.add(downloadFile.completeFile)
|
||||||
filesToNotDelete.add(downloadFile.saveFile)
|
filesToNotDelete.add(downloadFile.pinnedFile)
|
||||||
}
|
}
|
||||||
|
|
||||||
filesToNotDelete.add(musicDirectory.path)
|
filesToNotDelete.add(musicDirectory.path)
|
||||||
|
|
|
@ -71,13 +71,9 @@ object Constants {
|
||||||
const val PREFERENCES_KEY_MEDIA_BUTTONS = "mediaButtons"
|
const val PREFERENCES_KEY_MEDIA_BUTTONS = "mediaButtons"
|
||||||
const val PREFERENCES_KEY_SCROBBLE = "scrobble"
|
const val PREFERENCES_KEY_SCROBBLE = "scrobble"
|
||||||
const val PREFERENCES_KEY_SERVER_SCALING = "serverScaling"
|
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_WIFI_REQUIRED_FOR_DOWNLOAD = "wifiRequiredForDownload"
|
||||||
const val PREFERENCES_KEY_BUFFER_LENGTH = "bufferLength"
|
const val PREFERENCES_KEY_BUFFER_LENGTH = "bufferLength"
|
||||||
const val PREFERENCES_KEY_NETWORK_TIMEOUT = "networkTimeout"
|
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_ALBUMS = "maxAlbums"
|
||||||
const val PREFERENCES_KEY_MAX_SONGS = "maxSongs"
|
const val PREFERENCES_KEY_MAX_SONGS = "maxSongs"
|
||||||
const val PREFERENCES_KEY_MAX_ARTISTS = "maxArtists"
|
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_SONGS = "defaultSongs"
|
||||||
const val PREFERENCES_KEY_DEFAULT_ARTISTS = "defaultArtists"
|
const val PREFERENCES_KEY_DEFAULT_ARTISTS = "defaultArtists"
|
||||||
const val PREFERENCES_KEY_SHOW_NOW_PLAYING = "showNowPlaying"
|
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_CLEAR_SEARCH_HISTORY = "clearSearchHistory"
|
||||||
const val PREFERENCES_KEY_DOWNLOAD_TRANSITION = "transitionToDownloadOnPlay"
|
const val PREFERENCES_KEY_DOWNLOAD_TRANSITION = "transitionToDownloadOnPlay"
|
||||||
const val PREFERENCES_KEY_INCREMENT_TIME = "incrementTime"
|
const val PREFERENCES_KEY_INCREMENT_TIME = "incrementTime"
|
||||||
const val PREFERENCES_KEY_SHOW_NOW_PLAYING_DETAILS = "showNowPlayingDetails"
|
const val PREFERENCES_KEY_SHOW_NOW_PLAYING_DETAILS = "showNowPlayingDetails"
|
||||||
const val PREFERENCES_KEY_ID3_TAGS = "useId3Tags"
|
const val PREFERENCES_KEY_ID3_TAGS = "useId3Tags"
|
||||||
const val PREFERENCES_KEY_SHOW_ARTIST_PICTURE = "showArtistPicture"
|
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_CHAT_REFRESH_INTERVAL = "chatRefreshInterval"
|
||||||
const val PREFERENCES_KEY_DIRECTORY_CACHE_TIME = "directoryCacheTime"
|
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_CLEAR_BOOKMARK = "clearBookmark"
|
||||||
const val PREFERENCES_KEY_DISC_SORT = "discAndTrackSort"
|
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_ASK_FOR_SHARE_DETAILS = "sharingAlwaysAskForDetails"
|
||||||
const val PREFERENCES_KEY_DEFAULT_SHARE_DESCRIPTION = "sharingDefaultDescription"
|
const val PREFERENCES_KEY_DEFAULT_SHARE_DESCRIPTION = "sharingDefaultDescription"
|
||||||
const val PREFERENCES_KEY_DEFAULT_SHARE_GREETING = "sharingDefaultGreeting"
|
const val PREFERENCES_KEY_DEFAULT_SHARE_GREETING = "sharingDefaultGreeting"
|
||||||
const val PREFERENCES_KEY_SHARE_ON_SERVER = "sharingCreateOnServer"
|
const val PREFERENCES_KEY_SHARE_ON_SERVER = "sharingCreateOnServer"
|
||||||
const val PREFERENCES_KEY_DEFAULT_SHARE_EXPIRATION = "sharingDefaultExpiration"
|
const val PREFERENCES_KEY_DEFAULT_SHARE_EXPIRATION = "sharingDefaultExpiration"
|
||||||
const val PREFERENCES_KEY_USE_FIVE_STAR_RATING = "use_five_star_rating"
|
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_CATEGORY_NOTIFICATIONS = "notificationsCategory"
|
||||||
const val PREFERENCES_KEY_FIRST_RUN_EXECUTED = "firstRunExecuted"
|
const val PREFERENCES_KEY_FIRST_RUN_EXECUTED = "firstRunExecuted"
|
||||||
const val PREFERENCES_KEY_RESUME_ON_BLUETOOTH_DEVICE = "resumeOnBluetoothDevice"
|
const val PREFERENCES_KEY_RESUME_ON_BLUETOOTH_DEVICE = "resumeOnBluetoothDevice"
|
||||||
const val PREFERENCES_KEY_PAUSE_ON_BLUETOOTH_DEVICE = "pauseOnBluetoothDevice"
|
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_DEBUG_LOG_TO_FILE = "debugLogToFile"
|
||||||
const val PREFERENCES_KEY_OVERRIDE_LANGUAGE = "overrideLanguage"
|
const val PREFERENCES_KEY_OVERRIDE_LANGUAGE = "overrideLanguage"
|
||||||
const val PREFERENCE_VALUE_ALL = 0
|
const val PREFERENCE_VALUE_ALL = 0
|
||||||
|
|
|
@ -406,7 +406,7 @@ object FileUtil {
|
||||||
return path.substringBeforeLast('/')
|
return path.substringBeforeLast('/')
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getSaveFile(name: String): String {
|
fun getPinnedFile(name: String): String {
|
||||||
val baseName = getBaseName(name)
|
val baseName = getBaseName(name)
|
||||||
if (baseName.endsWith(".partial") || baseName.endsWith(".complete")) {
|
if (baseName.endsWith(".partial") || baseName.endsWith(".complete")) {
|
||||||
return "${getBaseName(baseName)}.${getExtension(name)}"
|
return "${getBaseName(baseName)}.${getExtension(name)}"
|
||||||
|
|
|
@ -1,332 +0,0 @@
|
||||||
/*
|
|
||||||
* MediaSessionHandler.kt
|
|
||||||
* Copyright (C) 2009-2021 Ultrasonic developers
|
|
||||||
*
|
|
||||||
* Distributed under terms of the GNU GPLv3 license.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.moire.ultrasonic.util
|
|
||||||
|
|
||||||
import android.app.PendingIntent
|
|
||||||
import android.content.ComponentName
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.support.v4.media.MediaMetadataCompat
|
|
||||||
import android.support.v4.media.session.MediaSessionCompat
|
|
||||||
import android.support.v4.media.session.PlaybackStateCompat
|
|
||||||
import android.support.v4.media.session.PlaybackStateCompat.PLAYBACK_POSITION_UNKNOWN
|
|
||||||
import android.view.KeyEvent
|
|
||||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
|
||||||
import kotlin.Pair
|
|
||||||
import org.koin.core.component.KoinComponent
|
|
||||||
import org.koin.core.component.inject
|
|
||||||
import org.moire.ultrasonic.R
|
|
||||||
import org.moire.ultrasonic.domain.PlayerState
|
|
||||||
import org.moire.ultrasonic.imageloader.BitmapUtils
|
|
||||||
import org.moire.ultrasonic.receiver.MediaButtonIntentReceiver
|
|
||||||
import org.moire.ultrasonic.service.DownloadFile
|
|
||||||
import org.moire.ultrasonic.service.RxBus
|
|
||||||
import org.moire.ultrasonic.service.plusAssign
|
|
||||||
import org.moire.ultrasonic.util.Util.ifNotNull
|
|
||||||
import timber.log.Timber
|
|
||||||
|
|
||||||
private const val INTENT_CODE_MEDIA_BUTTON = 161
|
|
||||||
/**
|
|
||||||
* Central place to handle the state of the MediaSession
|
|
||||||
*/
|
|
||||||
class MediaSessionHandler : KoinComponent {
|
|
||||||
|
|
||||||
private var mediaSession: MediaSessionCompat? = null
|
|
||||||
private var playbackState: Int? = null
|
|
||||||
private var playbackActions: Long? = null
|
|
||||||
private var cachedPlayingIndex: Long? = null
|
|
||||||
|
|
||||||
private val applicationContext by inject<Context>()
|
|
||||||
|
|
||||||
private var referenceCount: Int = 0
|
|
||||||
private var cachedPlaylist: List<DownloadFile>? = null
|
|
||||||
private var cachedPosition: Long = 0
|
|
||||||
|
|
||||||
private val rxBusSubscription: CompositeDisposable = CompositeDisposable()
|
|
||||||
|
|
||||||
fun release() {
|
|
||||||
|
|
||||||
if (referenceCount > 0) referenceCount--
|
|
||||||
if (referenceCount > 0) return
|
|
||||||
|
|
||||||
mediaSession?.isActive = false
|
|
||||||
RxBus.releaseMediaSessionToken()
|
|
||||||
rxBusSubscription.dispose()
|
|
||||||
mediaSession?.release()
|
|
||||||
mediaSession = null
|
|
||||||
|
|
||||||
Timber.i("MediaSessionHandler.release Media Session released")
|
|
||||||
}
|
|
||||||
|
|
||||||
fun initialize() {
|
|
||||||
|
|
||||||
referenceCount++
|
|
||||||
if (referenceCount > 1) return
|
|
||||||
|
|
||||||
@Suppress("MagicNumber")
|
|
||||||
val keycode = 110
|
|
||||||
|
|
||||||
Timber.d("MediaSessionHandler.initialize Creating Media Session")
|
|
||||||
|
|
||||||
mediaSession = MediaSessionCompat(applicationContext, "UltrasonicService")
|
|
||||||
val mediaSessionToken = mediaSession?.sessionToken ?: return
|
|
||||||
RxBus.mediaSessionTokenPublisher.onNext(mediaSessionToken)
|
|
||||||
|
|
||||||
updateMediaButtonReceiver()
|
|
||||||
|
|
||||||
mediaSession?.setCallback(object : MediaSessionCompat.Callback() {
|
|
||||||
override fun onPlay() {
|
|
||||||
super.onPlay()
|
|
||||||
|
|
||||||
Util.getPendingIntentForMediaAction(
|
|
||||||
applicationContext,
|
|
||||||
KeyEvent.KEYCODE_MEDIA_PLAY,
|
|
||||||
keycode
|
|
||||||
).send()
|
|
||||||
|
|
||||||
Timber.v("Media Session Callback: onPlay")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onPlayFromMediaId(mediaId: String?, extras: Bundle?) {
|
|
||||||
super.onPlayFromMediaId(mediaId, extras)
|
|
||||||
|
|
||||||
Timber.d("Media Session Callback: onPlayFromMediaId %s", mediaId)
|
|
||||||
RxBus.playFromMediaIdCommandPublisher.onNext(Pair(mediaId, extras))
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onPlayFromSearch(query: String?, extras: Bundle?) {
|
|
||||||
super.onPlayFromSearch(query, extras)
|
|
||||||
|
|
||||||
Timber.d("Media Session Callback: onPlayFromSearch %s", query)
|
|
||||||
RxBus.playFromSearchCommandPublisher.onNext(Pair(query, extras))
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onPause() {
|
|
||||||
super.onPause()
|
|
||||||
Util.getPendingIntentForMediaAction(
|
|
||||||
applicationContext,
|
|
||||||
KeyEvent.KEYCODE_MEDIA_PAUSE,
|
|
||||||
keycode
|
|
||||||
).send()
|
|
||||||
Timber.v("Media Session Callback: onPause")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onStop() {
|
|
||||||
super.onStop()
|
|
||||||
Util.getPendingIntentForMediaAction(
|
|
||||||
applicationContext,
|
|
||||||
KeyEvent.KEYCODE_MEDIA_STOP,
|
|
||||||
keycode
|
|
||||||
).send()
|
|
||||||
Timber.v("Media Session Callback: onStop")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onSkipToNext() {
|
|
||||||
super.onSkipToNext()
|
|
||||||
Util.getPendingIntentForMediaAction(
|
|
||||||
applicationContext,
|
|
||||||
KeyEvent.KEYCODE_MEDIA_NEXT,
|
|
||||||
keycode
|
|
||||||
).send()
|
|
||||||
Timber.v("Media Session Callback: onSkipToNext")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onSkipToPrevious() {
|
|
||||||
super.onSkipToPrevious()
|
|
||||||
Util.getPendingIntentForMediaAction(
|
|
||||||
applicationContext,
|
|
||||||
KeyEvent.KEYCODE_MEDIA_PREVIOUS,
|
|
||||||
keycode
|
|
||||||
).send()
|
|
||||||
Timber.v("Media Session Callback: onSkipToPrevious")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onMediaButtonEvent(mediaButtonEvent: Intent): Boolean {
|
|
||||||
// This probably won't be necessary once we implement more
|
|
||||||
// of the modern media APIs, like the MediaController etc.
|
|
||||||
val event = mediaButtonEvent.extras!!["android.intent.extra.KEY_EVENT"] as KeyEvent?
|
|
||||||
event.ifNotNull { RxBus.mediaButtonEventPublisher.onNext(it) }
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onSkipToQueueItem(id: Long) {
|
|
||||||
super.onSkipToQueueItem(id)
|
|
||||||
RxBus.skipToQueueItemCommandPublisher.onNext(id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
// It seems to be the best practice to set this to true for the lifetime of the session
|
|
||||||
mediaSession?.isActive = true
|
|
||||||
rxBusSubscription += RxBus.playbackPositionObservable.subscribe {
|
|
||||||
updateMediaSessionPlaybackPosition(it)
|
|
||||||
}
|
|
||||||
rxBusSubscription += RxBus.playlistObservable.subscribe {
|
|
||||||
updateMediaSessionQueue(it)
|
|
||||||
}
|
|
||||||
rxBusSubscription += RxBus.playerStateObservable.subscribe {
|
|
||||||
updateMediaSession(it.state, it.track)
|
|
||||||
}
|
|
||||||
|
|
||||||
Timber.i("MediaSessionHandler.initialize Media Session created")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Suppress("LongMethod", "ComplexMethod")
|
|
||||||
private fun updateMediaSession(
|
|
||||||
playerState: PlayerState,
|
|
||||||
currentPlaying: DownloadFile?
|
|
||||||
) {
|
|
||||||
Timber.d("Updating the MediaSession")
|
|
||||||
|
|
||||||
// Set Metadata
|
|
||||||
val metadata = MediaMetadataCompat.Builder()
|
|
||||||
if (currentPlaying != null) {
|
|
||||||
try {
|
|
||||||
val song = currentPlaying.track
|
|
||||||
val cover = BitmapUtils.getAlbumArtBitmapFromDisk(
|
|
||||||
song, Util.getMinDisplayMetric()
|
|
||||||
)
|
|
||||||
val duration = song.duration?.times(1000) ?: -1
|
|
||||||
metadata.putLong(MediaMetadataCompat.METADATA_KEY_DURATION, duration.toLong())
|
|
||||||
metadata.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, song.artist)
|
|
||||||
metadata.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ARTIST, song.artist)
|
|
||||||
metadata.putString(MediaMetadataCompat.METADATA_KEY_ALBUM, song.album)
|
|
||||||
metadata.putString(MediaMetadataCompat.METADATA_KEY_TITLE, song.title)
|
|
||||||
metadata.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, cover)
|
|
||||||
} catch (all: Exception) {
|
|
||||||
Timber.e(all, "Error setting the metadata")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save the metadata
|
|
||||||
mediaSession?.setMetadata(metadata.build())
|
|
||||||
|
|
||||||
playbackActions = PlaybackStateCompat.ACTION_PLAY_PAUSE or
|
|
||||||
PlaybackStateCompat.ACTION_SKIP_TO_NEXT or
|
|
||||||
PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS or
|
|
||||||
PlaybackStateCompat.ACTION_PLAY_FROM_MEDIA_ID or
|
|
||||||
PlaybackStateCompat.ACTION_PLAY_FROM_SEARCH or
|
|
||||||
PlaybackStateCompat.ACTION_SKIP_TO_QUEUE_ITEM
|
|
||||||
|
|
||||||
// Map our playerState to native PlaybackState
|
|
||||||
// TODO: Synchronize these APIs
|
|
||||||
when (playerState) {
|
|
||||||
PlayerState.STARTED -> {
|
|
||||||
playbackState = PlaybackStateCompat.STATE_PLAYING
|
|
||||||
playbackActions = playbackActions!! or
|
|
||||||
PlaybackStateCompat.ACTION_PAUSE or
|
|
||||||
PlaybackStateCompat.ACTION_STOP
|
|
||||||
}
|
|
||||||
PlayerState.COMPLETED,
|
|
||||||
PlayerState.STOPPED -> {
|
|
||||||
playbackState = PlaybackStateCompat.STATE_STOPPED
|
|
||||||
cachedPosition = PLAYBACK_POSITION_UNKNOWN
|
|
||||||
}
|
|
||||||
PlayerState.IDLE -> {
|
|
||||||
// IDLE state usually just means the playback is stopped
|
|
||||||
// STATE_NONE means that there is no track to play (playlist is empty)
|
|
||||||
playbackState = if (currentPlaying == null)
|
|
||||||
PlaybackStateCompat.STATE_NONE
|
|
||||||
else
|
|
||||||
PlaybackStateCompat.STATE_STOPPED
|
|
||||||
playbackActions = 0L
|
|
||||||
cachedPosition = PLAYBACK_POSITION_UNKNOWN
|
|
||||||
}
|
|
||||||
PlayerState.PAUSED -> {
|
|
||||||
playbackState = PlaybackStateCompat.STATE_PAUSED
|
|
||||||
playbackActions = playbackActions!! or
|
|
||||||
PlaybackStateCompat.ACTION_PLAY or
|
|
||||||
PlaybackStateCompat.ACTION_STOP
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
// These are the states PREPARING, PREPARED & DOWNLOADING
|
|
||||||
playbackState = PlaybackStateCompat.STATE_PAUSED
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val playbackStateBuilder = PlaybackStateCompat.Builder()
|
|
||||||
playbackStateBuilder.setState(playbackState!!, cachedPosition, 1.0f)
|
|
||||||
|
|
||||||
// Set actions
|
|
||||||
playbackStateBuilder.setActions(playbackActions!!)
|
|
||||||
|
|
||||||
val index = cachedPlaylist?.indexOf(currentPlaying)
|
|
||||||
cachedPlayingIndex = if (index == null || index < 0) null else index.toLong()
|
|
||||||
cachedPlaylist.ifNotNull { setMediaSessionQueue(it) }
|
|
||||||
|
|
||||||
if (cachedPlaylist != null && !Settings.shouldDisableNowPlayingListSending)
|
|
||||||
cachedPlayingIndex.ifNotNull { playbackStateBuilder.setActiveQueueItemId(it) }
|
|
||||||
|
|
||||||
// Save the playback state
|
|
||||||
mediaSession?.setPlaybackState(playbackStateBuilder.build())
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun updateMediaSessionQueue(playlist: List<DownloadFile>) {
|
|
||||||
cachedPlaylist = playlist
|
|
||||||
setMediaSessionQueue(playlist)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun setMediaSessionQueue(playlist: List<DownloadFile>) {
|
|
||||||
if (mediaSession == null) return
|
|
||||||
if (Settings.shouldDisableNowPlayingListSending) return
|
|
||||||
|
|
||||||
val queue = playlist.mapIndexed { id, file ->
|
|
||||||
MediaSessionCompat.QueueItem(
|
|
||||||
Util.getMediaDescriptionForEntry(file.track),
|
|
||||||
id.toLong()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
mediaSession?.setQueueTitle(applicationContext.getString(R.string.button_bar_now_playing))
|
|
||||||
mediaSession?.setQueue(queue)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun updateMediaSessionPlaybackPosition(playbackPosition: Int) {
|
|
||||||
cachedPosition = playbackPosition.toLong()
|
|
||||||
if (playbackState == null || playbackActions == null) return
|
|
||||||
|
|
||||||
val playbackStateBuilder = PlaybackStateCompat.Builder()
|
|
||||||
playbackStateBuilder.setState(playbackState!!, cachedPosition, 1.0f)
|
|
||||||
playbackStateBuilder.setActions(playbackActions!!)
|
|
||||||
|
|
||||||
if (cachedPlaylist != null && !Settings.shouldDisableNowPlayingListSending)
|
|
||||||
cachedPlayingIndex.ifNotNull { playbackStateBuilder.setActiveQueueItemId(it) }
|
|
||||||
|
|
||||||
mediaSession?.setPlaybackState(playbackStateBuilder.build())
|
|
||||||
}
|
|
||||||
|
|
||||||
fun updateMediaButtonReceiver() {
|
|
||||||
if (Settings.mediaButtonsEnabled) {
|
|
||||||
registerMediaButtonEventReceiver()
|
|
||||||
} else {
|
|
||||||
unregisterMediaButtonEventReceiver()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun registerMediaButtonEventReceiver() {
|
|
||||||
val component = ComponentName(
|
|
||||||
applicationContext.packageName,
|
|
||||||
MediaButtonIntentReceiver::class.java.name
|
|
||||||
)
|
|
||||||
val mediaButtonIntent = Intent(Intent.ACTION_MEDIA_BUTTON)
|
|
||||||
mediaButtonIntent.component = component
|
|
||||||
|
|
||||||
val pendingIntent = PendingIntent.getBroadcast(
|
|
||||||
applicationContext,
|
|
||||||
INTENT_CODE_MEDIA_BUTTON,
|
|
||||||
mediaButtonIntent,
|
|
||||||
PendingIntent.FLAG_CANCEL_CURRENT
|
|
||||||
)
|
|
||||||
|
|
||||||
mediaSession?.setMediaButtonReceiver(pendingIntent)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun unregisterMediaButtonEventReceiver() {
|
|
||||||
mediaSession?.setMediaButtonReceiver(null)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -9,13 +9,11 @@ package org.moire.ultrasonic.util
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.SharedPreferences
|
import android.content.SharedPreferences
|
||||||
import android.os.Build
|
|
||||||
import androidx.preference.PreferenceManager
|
import androidx.preference.PreferenceManager
|
||||||
import java.util.regex.Pattern
|
import java.util.regex.Pattern
|
||||||
import org.moire.ultrasonic.R
|
import org.moire.ultrasonic.R
|
||||||
import org.moire.ultrasonic.app.UApp
|
import org.moire.ultrasonic.app.UApp
|
||||||
import org.moire.ultrasonic.data.ActiveServerProvider
|
import org.moire.ultrasonic.data.ActiveServerProvider
|
||||||
import org.moire.ultrasonic.domain.RepeatMode
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Contains convenience functions for reading and writing preferences
|
* Contains convenience functions for reading and writing preferences
|
||||||
|
@ -23,43 +21,6 @@ import org.moire.ultrasonic.domain.RepeatMode
|
||||||
object Settings {
|
object Settings {
|
||||||
private val PATTERN = Pattern.compile(":")
|
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
|
@JvmStatic
|
||||||
var theme by StringSetting(
|
var theme by StringSetting(
|
||||||
Constants.PREFERENCES_KEY_THEME,
|
Constants.PREFERENCES_KEY_THEME,
|
||||||
|
@ -163,10 +124,6 @@ object Settings {
|
||||||
var defaultArtists
|
var defaultArtists
|
||||||
by StringIntSetting(Constants.PREFERENCES_KEY_DEFAULT_ARTISTS, "3")
|
by StringIntSetting(Constants.PREFERENCES_KEY_DEFAULT_ARTISTS, "3")
|
||||||
|
|
||||||
@JvmStatic
|
|
||||||
var bufferLength
|
|
||||||
by StringIntSetting(Constants.PREFERENCES_KEY_BUFFER_LENGTH, "5")
|
|
||||||
|
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
var incrementTime
|
var incrementTime
|
||||||
by StringIntSetting(Constants.PREFERENCES_KEY_INCREMENT_TIME, "5")
|
by StringIntSetting(Constants.PREFERENCES_KEY_INCREMENT_TIME, "5")
|
||||||
|
@ -174,15 +131,25 @@ object Settings {
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
var mediaButtonsEnabled
|
var mediaButtonsEnabled
|
||||||
by BooleanSetting(Constants.PREFERENCES_KEY_MEDIA_BUTTONS, true)
|
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
|
@JvmStatic
|
||||||
var showNowPlaying
|
var showNowPlaying
|
||||||
by BooleanSetting(Constants.PREFERENCES_KEY_SHOW_NOW_PLAYING, true)
|
by BooleanSetting(Constants.PREFERENCES_KEY_SHOW_NOW_PLAYING, true)
|
||||||
|
|
||||||
@JvmStatic
|
|
||||||
var gaplessPlayback
|
|
||||||
by BooleanSetting(Constants.PREFERENCES_KEY_GAPLESS_PLAYBACK, false)
|
|
||||||
|
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
var shouldTransitionOnPlayback by BooleanSetting(
|
var shouldTransitionOnPlayback by BooleanSetting(
|
||||||
Constants.PREFERENCES_KEY_DOWNLOAD_TRANSITION,
|
Constants.PREFERENCES_KEY_DOWNLOAD_TRANSITION,
|
||||||
|
@ -197,9 +164,6 @@ object Settings {
|
||||||
var shouldUseId3Tags
|
var shouldUseId3Tags
|
||||||
by BooleanSetting(Constants.PREFERENCES_KEY_ID3_TAGS, false)
|
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 activeServer by IntSetting(Constants.PREFERENCES_KEY_SERVER_INSTANCE, -1)
|
||||||
|
|
||||||
var serverScaling by BooleanSetting(Constants.PREFERENCES_KEY_SERVER_SCALING, false)
|
var serverScaling by BooleanSetting(Constants.PREFERENCES_KEY_SERVER_SCALING, false)
|
||||||
|
@ -227,37 +191,12 @@ object Settings {
|
||||||
"300"
|
"300"
|
||||||
)
|
)
|
||||||
|
|
||||||
var shouldClearPlaylist
|
|
||||||
by BooleanSetting(Constants.PREFERENCES_KEY_CLEAR_PLAYLIST, false)
|
|
||||||
|
|
||||||
var shouldSortByDisc
|
var shouldSortByDisc
|
||||||
by BooleanSetting(Constants.PREFERENCES_KEY_DISC_SORT, false)
|
by BooleanSetting(Constants.PREFERENCES_KEY_DISC_SORT, false)
|
||||||
|
|
||||||
var shouldClearBookmark
|
var shouldClearBookmark
|
||||||
by BooleanSetting(Constants.PREFERENCES_KEY_CLEAR_BOOKMARK, false)
|
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
|
var shouldAskForShareDetails
|
||||||
by BooleanSetting(Constants.PREFERENCES_KEY_ASK_FOR_SHARE_DETAILS, true)
|
by BooleanSetting(Constants.PREFERENCES_KEY_ASK_FOR_SHARE_DETAILS, true)
|
||||||
|
|
||||||
|
@ -300,18 +239,6 @@ object Settings {
|
||||||
return 0
|
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
|
@JvmStatic
|
||||||
var debugLogToFile by BooleanSetting(Constants.PREFERENCES_KEY_DEBUG_LOG_TO_FILE, false)
|
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 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
|
// TODO: Remove in December 2022
|
||||||
fun migrateFeatureStorage() {
|
fun migrateFeatureStorage() {
|
||||||
val sp = appContext.getSharedPreferences("feature_flags", Context.MODE_PRIVATE)
|
val sp = appContext.getSharedPreferences("feature_flags", Context.MODE_PRIVATE)
|
||||||
|
|
|
@ -76,4 +76,8 @@ class BooleanSetting(private val key: String, private val defaultValue: Boolean
|
||||||
|
|
||||||
override fun setValue(thisRef: Any, property: KProperty<*>, value: Boolean) =
|
override fun setValue(thisRef: Any, property: KProperty<*>, value: Boolean) =
|
||||||
sharedPreferences.edit { putBoolean(key, value) }
|
sharedPreferences.edit { putBoolean(key, value) }
|
||||||
|
|
||||||
|
constructor(stringId: Int, defaultValue: Boolean = false) : this(
|
||||||
|
Util.appContext().getString(stringId), defaultValue
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,10 +9,8 @@ package org.moire.ultrasonic.util
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import android.app.PendingIntent
|
|
||||||
import android.content.ContentResolver
|
import android.content.ContentResolver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
||||||
|
@ -28,18 +26,13 @@ import android.net.Uri
|
||||||
import android.net.wifi.WifiManager
|
import android.net.wifi.WifiManager
|
||||||
import android.net.wifi.WifiManager.WifiLock
|
import android.net.wifi.WifiManager.WifiLock
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
|
||||||
import android.os.Environment
|
import android.os.Environment
|
||||||
import android.os.Parcelable
|
|
||||||
import android.support.v4.media.MediaDescriptionCompat
|
|
||||||
import android.text.TextUtils
|
import android.text.TextUtils
|
||||||
import android.util.TypedValue
|
import android.util.TypedValue
|
||||||
import android.view.Gravity
|
import android.view.Gravity
|
||||||
import android.view.KeyEvent
|
|
||||||
import android.view.inputmethod.InputMethodManager
|
import android.view.inputmethod.InputMethodManager
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.annotation.AnyRes
|
import androidx.annotation.AnyRes
|
||||||
import androidx.media.utils.MediaConstants
|
|
||||||
import java.io.Closeable
|
import java.io.Closeable
|
||||||
import java.io.UnsupportedEncodingException
|
import java.io.UnsupportedEncodingException
|
||||||
import java.security.MessageDigest
|
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.app.UApp.Companion.applicationContext
|
||||||
import org.moire.ultrasonic.domain.Bookmark
|
import org.moire.ultrasonic.domain.Bookmark
|
||||||
import org.moire.ultrasonic.domain.MusicDirectory
|
import org.moire.ultrasonic.domain.MusicDirectory
|
||||||
import org.moire.ultrasonic.domain.PlayerState
|
|
||||||
import org.moire.ultrasonic.domain.SearchResult
|
import org.moire.ultrasonic.domain.SearchResult
|
||||||
import org.moire.ultrasonic.domain.Track
|
import org.moire.ultrasonic.domain.Track
|
||||||
import org.moire.ultrasonic.service.DownloadFile
|
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
||||||
private const val LINE_LENGTH = 60
|
private const val LINE_LENGTH = 60
|
||||||
|
@ -77,11 +68,6 @@ object Util {
|
||||||
private var MEGA_BYTE_LOCALIZED_FORMAT: DecimalFormat? = null
|
private var MEGA_BYTE_LOCALIZED_FORMAT: DecimalFormat? = null
|
||||||
private var KILO_BYTE_LOCALIZED_FORMAT: DecimalFormat? = null
|
private var KILO_BYTE_LOCALIZED_FORMAT: DecimalFormat? = null
|
||||||
private var 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()
|
// Used by hexEncode()
|
||||||
private val HEX_DIGITS =
|
private val HEX_DIGITS =
|
||||||
|
@ -448,150 +434,6 @@ object Util {
|
||||||
return musicDirectory
|
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
|
@JvmStatic
|
||||||
@Suppress("MagicNumber")
|
@Suppress("MagicNumber")
|
||||||
fun getNotificationImageSize(context: Context): Int {
|
fun getNotificationImageSize(context: Context): Int {
|
||||||
|
@ -776,39 +618,6 @@ object Util {
|
||||||
var fileFormat: String?,
|
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")
|
@Suppress("ComplexMethod", "LongMethod")
|
||||||
fun readableEntryDescription(song: Track): ReadableEntryDescription {
|
fun readableEntryDescription(song: Track): ReadableEntryDescription {
|
||||||
val artist = StringBuilder(LINE_LENGTH)
|
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 {
|
fun getConnectivityManager(): ConnectivityManager {
|
||||||
val context = appContext()
|
val context = appContext()
|
||||||
return context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
return context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||||
|
|
|
@ -1,9 +0,0 @@
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="24dp"
|
|
||||||
android:height="24dp"
|
|
||||||
android:viewportWidth="24"
|
|
||||||
android:viewportHeight="24">
|
|
||||||
<path
|
|
||||||
android:fillColor="#FFF"
|
|
||||||
android:pathData="M9,13.75c-2.34,0 -7,1.17 -7,3.5L2,19h14v-1.75c0,-2.33 -4.66,-3.5 -7,-3.5zM4.34,17c0.84,-0.58 2.87,-1.25 4.66,-1.25s3.82,0.67 4.66,1.25L4.34,17zM9,12c1.93,0 3.5,-1.57 3.5,-3.5S10.93,5 9,5 5.5,6.57 5.5,8.5 7.07,12 9,12zM9,7c0.83,0 1.5,0.67 1.5,1.5S9.83,10 9,10s-1.5,-0.67 -1.5,-1.5S8.17,7 9,7zM16.04,13.81c1.16,0.84 1.96,1.96 1.96,3.44L18,19h4v-1.75c0,-2.02 -3.5,-3.17 -5.96,-3.44zM15,12c1.93,0 3.5,-1.57 3.5,-3.5S16.93,5 15,5c-0.54,0 -1.04,0.13 -1.5,0.35 0.63,0.89 1,1.98 1,3.15s-0.37,2.26 -1,3.15c0.46,0.22 0.96,0.35 1.5,0.35z"/>
|
|
||||||
</vector>
|
|
|
@ -1,9 +0,0 @@
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="24dp"
|
|
||||||
android:height="24dp"
|
|
||||||
android:viewportWidth="24"
|
|
||||||
android:viewportHeight="24">
|
|
||||||
<path
|
|
||||||
android:fillColor="#FFF"
|
|
||||||
android:pathData="M22,6h-5v8.18C16.69,14.07 16.35,14 16,14c-1.66,0 -3,1.34 -3,3s1.34,3 3,3s3,-1.34 3,-3V8h3V6zM15,6H3v2h12V6zM15,10H3v2h12V10zM11,14H3v2h8V14z"/>
|
|
||||||
</vector>
|
|
|
@ -1,9 +0,0 @@
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="24dp"
|
|
||||||
android:height="24dp"
|
|
||||||
android:viewportWidth="24"
|
|
||||||
android:viewportHeight="24">
|
|
||||||
<path
|
|
||||||
android:fillColor="#FFF"
|
|
||||||
android:pathData="M8,12 L20,4v16z"/>
|
|
||||||
</vector>
|
|
|
@ -61,10 +61,7 @@
|
||||||
<string name="download.menu_screen_on">Obrazovka zapnuta</string>
|
<string name="download.menu_screen_on">Obrazovka zapnuta</string>
|
||||||
<string name="download.menu_show_album">Zobrazit album</string>
|
<string name="download.menu_show_album">Zobrazit album</string>
|
||||||
<string name="download.menu_shuffle">Náhodně</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.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.playerstate_playing_shuffle">Přehrávám mix</string>
|
||||||
<string name="download.playlist_done">Playlist úspěšně uložen.</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>
|
<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.genres_title">Žánry</string>
|
||||||
<string name="main.music">Hudba</string>
|
<string name="main.music">Hudba</string>
|
||||||
<string name="main.offline">Bez připojení</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_random">Náhodné</string>
|
||||||
<string name="main.songs_starred">Označené hvězdičkou</string>
|
<string name="main.songs_starred">Označené hvězdičkou</string>
|
||||||
<string name="main.songs_title">Skladby</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">Smazaný playlist %s</string>
|
||||||
<string name="menu.deleted_playlist_error">Chyba smazání playlistu %s</string>
|
<string name="menu.deleted_playlist_error">Chyba smazání playlistu %s</string>
|
||||||
<string name="menu.exit">Ukončit</string>
|
<string name="menu.exit">Ukončit</string>
|
||||||
<string name="menu.navigation">Navigace</string>
|
|
||||||
<string name="menu.settings">Nastavení</string>
|
<string name="menu.settings">Nastavení</string>
|
||||||
<string name="menu.refresh">Obnovit</string>
|
<string name="menu.refresh">Obnovit</string>
|
||||||
<string name="music_library.label">Knihovna médií</string>
|
<string name="music_library.label">Knihovna médií</string>
|
||||||
<string name="music_library.label_offline">Offline média</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.label">Playlisty</string>
|
||||||
<string name="playlist.update_info">Aktualizovat informace</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">Aktualizované informace playlistu pro %s</string>
|
||||||
<string name="playlist.updated_info_error">Chyba aktualizace informací playlistu %s</string>
|
<string name="playlist.updated_info_error">Chyba aktualizace informací playlistu %s</string>
|
||||||
<string name="progress.wait">Chvilku strpení…</string>
|
|
||||||
<string name="search.albums">Alba</string>
|
<string name="search.albums">Alba</string>
|
||||||
<string name="search.artists">Umělci</string>
|
<string name="search.artists">Umělci</string>
|
||||||
<string name="search.label">Vyhledávání</string>
|
<string name="search.label">Vyhledávání</string>
|
||||||
<string name="search.more">Zobrazit více</string>
|
<string name="search.more">Zobrazit více</string>
|
||||||
<string name="search.no_match">Nenalezeno, zkuste znovu</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.songs">Skladby</string>
|
||||||
<string name="search.title">Hledat</string>
|
<string name="search.title">Hledat</string>
|
||||||
<string name="select_album.empty">Média nenalezena</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_artist.folder">Vybrat adresář</string>
|
||||||
<string name="select_genre.empty">Žánry nenalezeny</string>
|
<string name="select_genre.empty">Žánry nenalezeny</string>
|
||||||
<string name="select_playlist.empty">Žádné uložené playlisty na serveru</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.appearance_title">Vzhled</string>
|
||||||
<string name="settings.buffer_length">Délka bufferu</string>
|
<string name="settings.buffer_length">Délka bufferu</string>
|
||||||
<string name="settings.buffer_length_0">Vypnuto</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.chat_refresh">Interval obnovení chatu</string>
|
||||||
<string name="settings.clear_bookmark">Zahodit záložku</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_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.clear_search_history">Vyčistit historii vyhledávání</string>
|
||||||
<string name="settings.connection_failure">Chyba připojení.</string>
|
<string name="settings.connection_failure">Chyba připojení.</string>
|
||||||
<string name="settings.default_albums">Výchozí alba</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">Ř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.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.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_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_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.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.increment_time">Interval přeskočení</string>
|
||||||
<string name="settings.invalid_url">Zadejte funkční adresu URL.</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_albums">Maximum alb</string>
|
||||||
<string name="settings.max_artists">Maximum umělců</string>
|
<string name="settings.max_artists">Maximum umělců</string>
|
||||||
<string name="settings.max_bitrate_112">112 Kbps</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_75">75</string>
|
||||||
<string name="settings.search_history_cleared">Historie hledání vyčištěna</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.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_manage_servers">Spravovat servery</string>
|
||||||
<string name="settings.server_address">Adresa serveru</string>
|
<string name="settings.server_address">Adresa serveru</string>
|
||||||
<string name="settings.server_name">Název</string>
|
<string name="settings.server_name">Název</string>
|
||||||
<string name="settings.server_password">Heslo</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_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_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.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">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_now_playing_summary">Zobrazovat přehrávanou skladbu v aktivitách</string>
|
||||||
<string name="settings.show_track_number">Zobrazovat číslo skladby</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_all">Všechny bluetooth přístroje</string>
|
||||||
<string name="settings.playback.bluetooth_a2dp">Pouze audio (A2DP) 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.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.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_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>
|
<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="many">%d skladeb</item>
|
||||||
<item quantity="other">%d skladeb</item>
|
<item quantity="other">%d skladeb</item>
|
||||||
</plurals>
|
</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 -->
|
<!-- Subsonic api errors -->
|
||||||
<string name="api.subsonic.generic">Obecná api chyba: %1$s</string>
|
<string name="api.subsonic.generic">Obecná api chyba: %1$s</string>
|
||||||
|
|
|
@ -41,11 +41,8 @@
|
||||||
<string name="common.name">Name</string>
|
<string name="common.name">Name</string>
|
||||||
<string name="common.ok">OK</string>
|
<string name="common.ok">OK</string>
|
||||||
<string name="common.pin">Anheften</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_last">Zuletzt spielen</string>
|
||||||
<string name="common.play_next">Als nächstes 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_now">Jetzt spielen</string>
|
||||||
<string name="common.play_shuffled">Zufällig spielen</string>
|
<string name="common.play_shuffled">Zufällig spielen</string>
|
||||||
<string name="common.public">Öffentlich</string>
|
<string name="common.public">Öffentlich</string>
|
||||||
|
@ -75,10 +72,7 @@
|
||||||
<string name="download.menu_screen_on">Bildschirm an</string>
|
<string name="download.menu_screen_on">Bildschirm an</string>
|
||||||
<string name="download.menu_show_album">Album anzeigen</string>
|
<string name="download.menu_show_album">Album anzeigen</string>
|
||||||
<string name="download.menu_shuffle">Mischen</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.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.playerstate_playing_shuffle">Wiedergabeliste mischen</string>
|
||||||
<string name="download.playlist_done">Die Wiedergabeliste wurde gespeichert</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>
|
<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.music">Musik</string>
|
||||||
<string name="main.offline">Offline</string>
|
<string name="main.offline">Offline</string>
|
||||||
<string name="main.setup_server">%s - Server einrichten</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_random">Zufällig</string>
|
||||||
<string name="main.songs_starred">Mit Stern</string>
|
<string name="main.songs_starred">Mit Stern</string>
|
||||||
<string name="main.songs_title">Titel</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.deleted_playlist_error">Löschen der Wiedergabeliste %s ist fehlgeschlagen</string>
|
||||||
<string name="menu.downloads">Downloads</string>
|
<string name="menu.downloads">Downloads</string>
|
||||||
<string name="menu.exit">Beenden</string>
|
<string name="menu.exit">Beenden</string>
|
||||||
<string name="menu.navigation">Navigation</string>
|
|
||||||
<string name="menu.settings">Einstellungen</string>
|
<string name="menu.settings">Einstellungen</string>
|
||||||
<string name="menu.refresh">Aktualisierung</string>
|
<string name="menu.refresh">Aktualisierung</string>
|
||||||
<string name="music_library.label">Medienbibliothek</string>
|
<string name="music_library.label">Medienbibliothek</string>
|
||||||
<string name="music_library.label_offline">Offline Medien</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.label">Wiedergabelisten</string>
|
||||||
<string name="playlist.update_info">Aktualisierungs-Informationen</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">Wiedergabeliste für %s aktualisiert</string>
|
||||||
<string name="playlist.updated_info_error">Aktualisierung der Wiedergabeliste %s ist fehlgeschlagen</string>
|
<string name="playlist.updated_info_error">Aktualisierung der Wiedergabeliste %s ist fehlgeschlagen</string>
|
||||||
<string name="progress.wait">Bitte warten…</string>
|
|
||||||
<string name="search.albums">Alben</string>
|
<string name="search.albums">Alben</string>
|
||||||
<string name="search.artists">Künstler*innen</string>
|
<string name="search.artists">Künstler*innen</string>
|
||||||
<string name="search.label">Suche</string>
|
<string name="search.label">Suche</string>
|
||||||
<string name="search.more">Zeige mehr</string>
|
<string name="search.more">Zeige mehr</string>
|
||||||
<string name="search.no_match">Keine Treffer, bitte erneut versuchen</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.songs">Titel</string>
|
||||||
<string name="search.title">Suche</string>
|
<string name="search.title">Suche</string>
|
||||||
<string name="select_album.empty">Keine Medien gefunden</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_artist.folder">Ordner wählen</string>
|
||||||
<string name="select_genre.empty">Keine Genres gefunden</string>
|
<string name="select_genre.empty">Keine Genres gefunden</string>
|
||||||
<string name="select_playlist.empty">Keine Wiedergabelisten auf dem Server</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.appearance_title">Aussehen</string>
|
||||||
<string name="settings.buffer_length">Puffer-Länge</string>
|
<string name="settings.buffer_length">Puffer-Länge</string>
|
||||||
<string name="settings.buffer_length_0">Deaktiviert</string>
|
<string name="settings.buffer_length_0">Deaktiviert</string>
|
||||||
|
@ -211,8 +196,6 @@
|
||||||
<string name="settings.chat_refresh">Chat Aktualisierungsintervall</string>
|
<string name="settings.chat_refresh">Chat Aktualisierungsintervall</string>
|
||||||
<string name="settings.clear_bookmark">Lesezeichen löschen</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_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.clear_search_history">Suchverlauf löschen</string>
|
||||||
<string name="settings.connection_failure">Verbindungsfehler</string>
|
<string name="settings.connection_failure">Verbindungsfehler</string>
|
||||||
<string name="settings.default_albums">Anzahl der Alben</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.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">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.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_summary">Musikdateien vor anderen Apps verbergen</string>
|
||||||
<string name="settings.hide_media_title">Vor anderen 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.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.increment_time">Sprunglänge</string>
|
||||||
<string name="settings.invalid_url">Bitte eine gültige URL angeben.</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_albums">Max. Anzahl der Alben</string>
|
||||||
<string name="settings.max_artists">Max. Anzahl der Künstler*innen</string>
|
<string name="settings.max_artists">Max. Anzahl der Künstler*innen</string>
|
||||||
<string name="settings.max_bitrate_112">112 Kbps</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_75">75</string>
|
||||||
<string name="settings.search_history_cleared">Suchhistorie gelöscht</string>
|
<string name="settings.search_history_cleared">Suchhistorie gelöscht</string>
|
||||||
<string name="settings.search_title">Sucheinstellungen</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_manage_servers">Server verwalten</string>
|
||||||
<string name="settings.server_address">Server Adresse</string>
|
<string name="settings.server_address">Server Adresse</string>
|
||||||
<string name="settings.server_name">Name</string>
|
<string name="settings.server_name">Name</string>
|
||||||
<string name="settings.server_password">Kennwort</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_summary">Skalierte Cover vom Server laden (spart Bandbreite)</string>
|
||||||
<string name="settings.server_scaling_title">Serverseitige Skalierung der Cover</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_username">Benutzername</string>
|
||||||
<string name="settings.server_color">Server Farbe</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">Aktuellen Titel anzeigen</string>
|
||||||
<string name="settings.show_now_playing_summary">Aktuellen Titel in allen Aktivitäten 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>
|
<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_all">Alle Bluetooth Geräte</string>
|
||||||
<string name="settings.playback.bluetooth_a2dp">Nur Audio (A2DP) 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.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.title">Debug Optionen</string>
|
||||||
<string name="settings.debug.log_to_file">Schreibe Debug Log in Datei</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>
|
<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="one">%d Titel nach aktuellen Titel hinzugefügt</item>
|
||||||
<item quantity="other">%d Titel nach aktuellen Titel hinzugefügt</item>
|
<item quantity="other">%d Titel nach aktuellen Titel hinzugefügt</item>
|
||||||
</plurals>
|
</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 -->
|
<!-- Subsonic api errors -->
|
||||||
<string name="api.subsonic.generic">Allgemeiner API Fehler: %1$s</string>
|
<string name="api.subsonic.generic">Allgemeiner API Fehler: %1$s</string>
|
||||||
|
|
|
@ -41,11 +41,8 @@
|
||||||
<string name="common.name">Nombre</string>
|
<string name="common.name">Nombre</string>
|
||||||
<string name="common.ok">OK</string>
|
<string name="common.ok">OK</string>
|
||||||
<string name="common.pin">Anclar</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_last">Reproducir última</string>
|
||||||
<string name="common.play_next">Reproducir a continuación</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_now">Reproducir ahora</string>
|
||||||
<string name="common.play_shuffled">Reproducción aleatoria</string>
|
<string name="common.play_shuffled">Reproducción aleatoria</string>
|
||||||
<string name="common.public">Public</string>
|
<string name="common.public">Public</string>
|
||||||
|
@ -75,10 +72,7 @@
|
||||||
<string name="download.menu_screen_on">Pantalla encendida</string>
|
<string name="download.menu_screen_on">Pantalla encendida</string>
|
||||||
<string name="download.menu_show_album">Mostrar Álbum</string>
|
<string name="download.menu_show_album">Mostrar Álbum</string>
|
||||||
<string name="download.menu_shuffle">Aleatorio</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.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.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_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>
|
<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.music">Música</string>
|
||||||
<string name="main.offline">Sin conexión</string>
|
<string name="main.offline">Sin conexión</string>
|
||||||
<string name="main.setup_server">%s - Configurar servidor</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_random">Aleatorio</string>
|
||||||
<string name="main.songs_starred">Me gusta</string>
|
<string name="main.songs_starred">Me gusta</string>
|
||||||
<string name="main.songs_title">Canciones</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.deleted_playlist_error">Fallo al eliminar la lista de reproducción %s</string>
|
||||||
<string name="menu.downloads">Descargas</string>
|
<string name="menu.downloads">Descargas</string>
|
||||||
<string name="menu.exit">Salir</string>
|
<string name="menu.exit">Salir</string>
|
||||||
<string name="menu.navigation">Navegación</string>
|
|
||||||
<string name="menu.settings">Configuración</string>
|
<string name="menu.settings">Configuración</string>
|
||||||
<string name="menu.refresh">Actualizar</string>
|
<string name="menu.refresh">Actualizar</string>
|
||||||
<string name="music_library.label">Biblioteca de medios</string>
|
<string name="music_library.label">Biblioteca de medios</string>
|
||||||
<string name="music_library.label_offline">Medios sin conexión</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.label">Listas de reproducción</string>
|
||||||
<string name="playlist.update_info">Actualizar Informació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">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="playlist.updated_info_error">Fallo al actualizar la información de la lista de reproducción para %s</string>
|
||||||
<string name="progress.wait">Por favor espere…</string>
|
|
||||||
<string name="search.albums">Álbumes</string>
|
<string name="search.albums">Álbumes</string>
|
||||||
<string name="search.artists">Artistas</string>
|
<string name="search.artists">Artistas</string>
|
||||||
<string name="search.label">Buscar</string>
|
<string name="search.label">Buscar</string>
|
||||||
<string name="search.more">Mostrar mas</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.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.songs">Canciones</string>
|
||||||
<string name="search.title">Buscar</string>
|
<string name="search.title">Buscar</string>
|
||||||
<string name="select_album.empty">No se han encontrado medios</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_artist.folder">Seleccionar la carpeta</string>
|
||||||
<string name="select_genre.empty">No se han encontrado géneros</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="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.appearance_title">Apariencia</string>
|
||||||
<string name="settings.buffer_length">Duración del Buffer</string>
|
<string name="settings.buffer_length">Duración del Buffer</string>
|
||||||
<string name="settings.buffer_length_0">Deshabilitado</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.chat_refresh">Intervalo de refresco del Chat</string>
|
||||||
<string name="settings.clear_bookmark">Limpiar marcador</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_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.clear_search_history">Limpiar el historial de búsqueda</string>
|
||||||
<string name="settings.connection_failure">Fallo de conexión.</string>
|
<string name="settings.connection_failure">Fallo de conexión.</string>
|
||||||
<string name="settings.default_albums">Álbumes predeterminados</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.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">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.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_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_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.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.increment_time">Intervalo de salto</string>
|
||||||
<string name="settings.invalid_url">Por favor especifica una URL válida.</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_albums">Máximo de Álbumes</string>
|
||||||
<string name="settings.max_artists">Máximo de Artistas</string>
|
<string name="settings.max_artists">Máximo de Artistas</string>
|
||||||
<string name="settings.max_bitrate_112">112 Kbps</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_75">75</string>
|
||||||
<string name="settings.search_history_cleared">Se ha limpiado el historial de búsqueda</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.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_manage_servers">Administrar servidores</string>
|
||||||
<string name="settings.server_address">Dirección del servidor</string>
|
<string name="settings.server_address">Dirección del servidor</string>
|
||||||
<string name="settings.server_name">Nombre</string>
|
<string name="settings.server_name">Nombre</string>
|
||||||
<string name="settings.server_password">Contraseña</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_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_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_username">Nombre de usuario</string>
|
||||||
<string name="settings.server_color">Color del servidor</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">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_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>
|
<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_all">Todos los dispositivos Bluetooth</string>
|
||||||
<string name="settings.playback.bluetooth_a2dp">Solo dispositivos de audio (A2DP)</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.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.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_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>
|
<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="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>
|
<item quantity="other">%d canciones insertadas después de la canción actual.</item>
|
||||||
</plurals>
|
</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 -->
|
<!-- Subsonic api errors -->
|
||||||
<string name="api.subsonic.generic">Error genérico de api: %1$s</string>
|
<string name="api.subsonic.generic">Error genérico de api: %1$s</string>
|
||||||
|
|
|
@ -41,11 +41,8 @@
|
||||||
<string name="common.name">Nom</string>
|
<string name="common.name">Nom</string>
|
||||||
<string name="common.ok">OK</string>
|
<string name="common.ok">OK</string>
|
||||||
<string name="common.pin">Épingler</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_last">Jouer en dernier</string>
|
||||||
<string name="common.play_next">Jouer à la suite</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_now">Jouer maintenant</string>
|
||||||
<string name="common.play_shuffled">Jouer aléatoirement</string>
|
<string name="common.play_shuffled">Jouer aléatoirement</string>
|
||||||
<string name="common.public">Public</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_screen_on">Sur l\'écran</string>
|
||||||
<string name="download.menu_show_album">Afficher l\'album</string>
|
<string name="download.menu_show_album">Afficher l\'album</string>
|
||||||
<string name="download.menu_shuffle">Aléatoire</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.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.playerstate_playing_shuffle">En lecture aléatoire</string>
|
||||||
<string name="download.playlist_done">Playlist enregistrée avec succès !</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>
|
<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.music">Musique</string>
|
||||||
<string name="main.offline">Hors-ligne</string>
|
<string name="main.offline">Hors-ligne</string>
|
||||||
<string name="main.setup_server">%s - Configurer le serveur</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_random">Aléatoire</string>
|
||||||
<string name="main.songs_starred">Favoris</string>
|
<string name="main.songs_starred">Favoris</string>
|
||||||
<string name="main.songs_title">Titres</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.deleted_playlist_error">Échec de suppression de la playlist %s</string>
|
||||||
<string name="menu.downloads">Téléchargements</string>
|
<string name="menu.downloads">Téléchargements</string>
|
||||||
<string name="menu.exit">Quitter</string>
|
<string name="menu.exit">Quitter</string>
|
||||||
<string name="menu.navigation">Navigation</string>
|
|
||||||
<string name="menu.settings">Paramètres</string>
|
<string name="menu.settings">Paramètres</string>
|
||||||
<string name="menu.refresh">Rafraichir</string>
|
<string name="menu.refresh">Rafraichir</string>
|
||||||
<string name="music_library.label">Bibliothèque musicale</string>
|
<string name="music_library.label">Bibliothèque musicale</string>
|
||||||
<string name="music_library.label_offline">Musique hors-ligne</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.label">Playlists</string>
|
||||||
<string name="playlist.update_info">Mise à jour des informations</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">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="playlist.updated_info_error">Échec de mise à jour des informations de la playlist %s</string>
|
||||||
<string name="progress.wait">Veuillez patienter…</string>
|
|
||||||
<string name="search.albums">Albums</string>
|
<string name="search.albums">Albums</string>
|
||||||
<string name="search.artists">Artistes</string>
|
<string name="search.artists">Artistes</string>
|
||||||
<string name="search.label">Recherche</string>
|
<string name="search.label">Recherche</string>
|
||||||
<string name="search.more">Afficher plus</string>
|
<string name="search.more">Afficher plus</string>
|
||||||
<string name="search.no_match">Aucun résultat, veuillez essayer à nouveau</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.songs">Titres</string>
|
||||||
<string name="search.title">Recherche</string>
|
<string name="search.title">Recherche</string>
|
||||||
<string name="select_album.empty">Aucun titre trouvé</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_artist.folder">Sélectionner le dossier</string>
|
||||||
<string name="select_genre.empty">Aucun genre trouvé</string>
|
<string name="select_genre.empty">Aucun genre trouvé</string>
|
||||||
<string name="select_playlist.empty">Aucune playlist sur le serveur</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.appearance_title">Apparence</string>
|
||||||
<string name="settings.buffer_length">Taille de la mémoire tampon</string>
|
<string name="settings.buffer_length">Taille de la mémoire tampon</string>
|
||||||
<string name="settings.buffer_length_0">Désactivé</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.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">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_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.clear_search_history">Effacer l\'historique des recherches</string>
|
||||||
<string name="settings.connection_failure">Échec de la connexion</string>
|
<string name="settings.connection_failure">Échec de la connexion</string>
|
||||||
<string name="settings.default_albums">Albums par défaut</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.disc_sort_summary">Trier la liste des titres par numéro de disques/pistes</string>
|
||||||
<string name="settings.display_bitrate">Afficher le débit et l’extension de fichier</string>
|
<string name="settings.display_bitrate">Afficher le débit et l’extension de fichier</string>
|
||||||
<string name="settings.display_bitrate_summary">Ajouter le nom d\'artiste, débit et suffixe du fichier</string>
|
<string name="settings.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_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_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.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.increment_time">Intervalle de saut</string>
|
||||||
<string name="settings.invalid_url">Veuillez spécifier une URL valide.</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_albums">Albums maximum</string>
|
||||||
<string name="settings.max_artists">Artistes maximum</string>
|
<string name="settings.max_artists">Artistes maximum</string>
|
||||||
<string name="settings.max_bitrate_112">112 kbit/s</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_75">75</string>
|
||||||
<string name="settings.search_history_cleared">Historique des recherches effacé</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.search_title">Paramètres de recherche</string>
|
||||||
<string name="settings.send_bluetooth_notification_summary">Envoyer des notifications de lecture via Bluetooth</string>
|
|
||||||
<string name="settings.send_bluetooth_notification">Envoyer une notification Bluetooth</string>
|
|
||||||
<string name="settings.send_bluetooth_album_art_summary">Envoyer la pochette de l\'album via Bluetooth (peut causer l\'échec des notifications Bluetooth)</string>
|
|
||||||
<string name="settings.send_bluetooth_album_art">Pochette de l\'album via Bluetooth</string>
|
|
||||||
<string name="settings.disable_send_now_playing_list_summary">La liste de lecture ne sera pas envoyée aux appareils connectés. Cela peut restaurer la compatibilité avec les appareils AVRCP 1.3 lorsque l’affichage de la piste actuelle n’est pas mise à jour.</string>
|
|
||||||
<string name="settings.disable_send_now_playing_list">Désactiver l’envoi de la liste de lecture</string>
|
|
||||||
<string name="settings.server_manage_servers">Gérer les serveurs</string>
|
<string name="settings.server_manage_servers">Gérer les serveurs</string>
|
||||||
<string name="settings.server_address">Adresse du serveur</string>
|
<string name="settings.server_address">Adresse du serveur</string>
|
||||||
<string name="settings.server_name">Nom</string>
|
<string name="settings.server_name">Nom</string>
|
||||||
<string name="settings.server_password">Mot de passe</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_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_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_username">Nom d\'utilisateur</string>
|
||||||
<string name="settings.server_color">Couleur du serveur</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">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_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>
|
<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_all">Tous les appareils Bluetooth</string>
|
||||||
<string name="settings.playback.bluetooth_a2dp">Seulement les appareils audio (A2DP)</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.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.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_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>
|
<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="one">%d titre</item>
|
||||||
<item quantity="other">%d titres</item>
|
<item quantity="other">%d titres</item>
|
||||||
</plurals>
|
</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 -->
|
<!-- Subsonic api errors -->
|
||||||
<string name="api.subsonic.generic">Erreur de l\'API générique : %1$s</string>
|
<string name="api.subsonic.generic">Erreur de l\'API générique : %1$s</string>
|
||||||
|
|
|
@ -39,11 +39,8 @@
|
||||||
<string name="common.name">Név</string>
|
<string name="common.name">Név</string>
|
||||||
<string name="common.ok">OK</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.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_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_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_now">Lejátszás</string>
|
||||||
<string name="common.play_shuffled">Véletlen sorrendű lejátszás</string>
|
<string name="common.play_shuffled">Véletlen sorrendű lejátszás</string>
|
||||||
<string name="common.public">Nyilvános</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_screen_on">Kijelző be</string>
|
||||||
<string name="download.menu_show_album">Ugrás az albumhoz</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">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.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.playerstate_playing_shuffle">Véletlen sorrendű</string>
|
||||||
<string name="download.playlist_done">Lejátszási lista mentése sikeres.</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>
|
<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.genres_title">Műfajok</string>
|
||||||
<string name="main.music">Zenék</string>
|
<string name="main.music">Zenék</string>
|
||||||
<string name="main.offline">Kapcsolat nélküli</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_random">Véletlenszerű</string>
|
||||||
<string name="main.songs_starred">Csillaggal megjelölt</string>
|
<string name="main.songs_starred">Csillaggal megjelölt</string>
|
||||||
<string name="main.songs_title">Dalok</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">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.deleted_playlist_error">Lejátszási lista törlése sikertelen %s</string>
|
||||||
<string name="menu.exit">Kilépé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.settings">Beállítások</string>
|
||||||
<string name="menu.refresh">Frissítés</string>
|
<string name="menu.refresh">Frissítés</string>
|
||||||
<string name="music_library.label">Mediakönyvtár</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_library.label_offline">Kapcsolat nélküli médiák</string>
|
||||||
<string name="music_service.retry">Hálózati hiba történt! Újrapróbálkozás %1$d - %2$d.</string>
|
|
||||||
<string name="parser.artist_count">%d előadó található a médiakönyvtárban.</string>
|
|
||||||
<string name="parser.reading">Olvasás a kiszolgálóról…</string>
|
|
||||||
<string name="parser.reading_done">Olvasás a kiszolgálóról… Kész!</string>
|
|
||||||
<string name="playlist.label">Lejátszási listák</string>
|
<string name="playlist.label">Lejátszási listák</string>
|
||||||
<string name="playlist.update_info">Módosítás</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">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="playlist.updated_info_error">Lejátszási lista módosítása sikertelen %s</string>
|
||||||
<string name="progress.wait">Kérem várjon!…</string>
|
|
||||||
<string name="search.albums">Albumok</string>
|
<string name="search.albums">Albumok</string>
|
||||||
<string name="search.artists">Előadók</string>
|
<string name="search.artists">Előadók</string>
|
||||||
<string name="search.label">Keresés</string>
|
<string name="search.label">Keresés</string>
|
||||||
<string name="search.more">Továbbiak</string>
|
<string name="search.more">Továbbiak</string>
|
||||||
<string name="search.no_match">Nincs találat, próbálja újra!</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.songs">Dalok</string>
|
||||||
<string name="search.title">Keresés</string>
|
<string name="search.title">Keresés</string>
|
||||||
<string name="select_album.empty">Nem található média!</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_artist.folder">Mappa kiválasztása</string>
|
||||||
<string name="select_genre.empty">Műfajok nem találhatók!</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="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.appearance_title">Megjelenés</string>
|
||||||
<string name="settings.buffer_length">Pufferméret</string>
|
<string name="settings.buffer_length">Pufferméret</string>
|
||||||
<string name="settings.buffer_length_0">Letiltva</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.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">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_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.clear_search_history">Keresési előzmények törlése</string>
|
||||||
<string name="settings.connection_failure">Csatlakozási hiba!</string>
|
<string name="settings.connection_failure">Csatlakozási hiba!</string>
|
||||||
<string name="settings.default_albums">Albumok találati száma</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">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.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.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_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_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.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.increment_time">Ugrás időintervalluma</string>
|
||||||
<string name="settings.invalid_url">Adjon meg egy érvényes URL-t!</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_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_artists">Előadók max. találati száma</string>
|
||||||
<string name="settings.max_bitrate_112">112 Kbps</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_75">75</string>
|
||||||
<string name="settings.search_history_cleared">Keresési előzmények törölve.</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.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_manage_servers">Kiszolgálók kezelése</string>
|
||||||
<string name="settings.server_address">Kiszolgáló címe</string>
|
<string name="settings.server_address">Kiszolgáló címe</string>
|
||||||
<string name="settings.server_name">Név</string>
|
<string name="settings.server_name">Név</string>
|
||||||
<string name="settings.server_password">Jelszó</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_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_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.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">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_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>
|
<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_all">Minden Bluetooth eszköz</string>
|
||||||
<string name="settings.playback.bluetooth_a2dp">Csak audio (A2DP) eszközök</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.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.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_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>
|
<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="one">%d dal</item>
|
||||||
<item quantity="other">%d dal</item>
|
<item quantity="other">%d dal</item>
|
||||||
</plurals>
|
</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 -->
|
<!-- Subsonic api errors -->
|
||||||
<string name="api.subsonic.generic">Általános api hiba: %1$s</string>
|
<string name="api.subsonic.generic">Általános api hiba: %1$s</string>
|
||||||
|
|
|
@ -58,10 +58,7 @@
|
||||||
<string name="download.menu_screen_on">Schermo acceso</string>
|
<string name="download.menu_screen_on">Schermo acceso</string>
|
||||||
<string name="download.menu_show_album">Visualizza Album</string>
|
<string name="download.menu_show_album">Visualizza Album</string>
|
||||||
<string name="download.menu_shuffle">Casuale</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.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.playerstate_playing_shuffle">Riproduzione casuale</string>
|
||||||
<string name="download.playlist_done">Playlist salvata con successo </string>
|
<string name="download.playlist_done">Playlist salvata con successo </string>
|
||||||
<string name="download.playlist_error">Impossibile salvare la playlist, riprovare più tardi.</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.genres_title">Generi</string>
|
||||||
<string name="main.music">Musica</string>
|
<string name="main.music">Musica</string>
|
||||||
<string name="main.offline">Disconnesso</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_random">Casuale</string>
|
||||||
<string name="main.songs_starred">Preferiti</string>
|
<string name="main.songs_starred">Preferiti</string>
|
||||||
<string name="main.songs_title">Canzoni</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">Playlist %s eliminata</string>
|
||||||
<string name="menu.deleted_playlist_error">Impossibile eliminare la playlist %s</string>
|
<string name="menu.deleted_playlist_error">Impossibile eliminare la playlist %s</string>
|
||||||
<string name="menu.exit">Esci</string>
|
<string name="menu.exit">Esci</string>
|
||||||
<string name="menu.navigation">Navigazione</string>
|
|
||||||
<string name="menu.settings">Impostazioni</string>
|
<string name="menu.settings">Impostazioni</string>
|
||||||
<string name="music_library.label">Libreria</string>
|
<string name="music_library.label">Libreria</string>
|
||||||
<string name="music_library.label_offline">Media Offline</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.label">Playlist</string>
|
||||||
<string name="playlist.update_info">Aggiorna Informazioni</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">Aggiorna informazioni playlist per %s</string>
|
||||||
<string name="playlist.updated_info_error">Impossibile aggiornare informazioni playlist per %s</string>
|
<string name="playlist.updated_info_error">Impossibile aggiornare informazioni playlist per %s</string>
|
||||||
<string name="progress.wait">Attendere, per favore…</string>
|
|
||||||
<string name="search.albums">Album</string>
|
<string name="search.albums">Album</string>
|
||||||
<string name="search.artists">Artisti</string>
|
<string name="search.artists">Artisti</string>
|
||||||
<string name="search.label">Cerca</string>
|
<string name="search.label">Cerca</string>
|
||||||
<string name="search.more">Mostra di più</string>
|
<string name="search.more">Mostra di più</string>
|
||||||
<string name="search.no_match">Nessun risultato, riprova per favore</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.songs">Canzoni</string>
|
||||||
<string name="search.title">Cerca</string>
|
<string name="search.title">Cerca</string>
|
||||||
<string name="select_album.empty">Nessun media trovato</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_artist.folder">Seleziona cartella</string>
|
||||||
<string name="select_genre.empty">Nessun genere trovato</string>
|
<string name="select_genre.empty">Nessun genere trovato</string>
|
||||||
<string name="select_playlist.empty">Nessuna playlist salvata sul server</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.appearance_title">Aspetto</string>
|
||||||
<string name="settings.buffer_length">Lunghezza buffer</string>
|
<string name="settings.buffer_length">Lunghezza buffer</string>
|
||||||
<string name="settings.buffer_length_0">Disabilitato</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.chat_refresh">Intervallo Aggiornamento Chat</string>
|
||||||
<string name="settings.clear_bookmark">Pulisci Segnalibro</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_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.clear_search_history">Pulisci Storico Ricerca</string>
|
||||||
<string name="settings.connection_failure">Errore connessione.</string>
|
<string name="settings.connection_failure">Errore connessione.</string>
|
||||||
<string name="settings.default_albums">Album predefiniti</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">Ordina Canzoni secondo Disco</string>
|
||||||
<string name="settings.disc_sort_summary">Ordina lista canzoni secondo il numero disco e traccia</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.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_summary">Nascondi file musicali di altre app</string>
|
||||||
<string name="settings.hide_media_title">Nascondi Da Altro</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.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_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_albums">N° Max Album</string>
|
||||||
<string name="settings.max_artists">N° Max Artisti</string>
|
<string name="settings.max_artists">N° Max Artisti</string>
|
||||||
<string name="settings.max_bitrate_112">112 Kbps</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_75">75</string>
|
||||||
<string name="settings.search_history_cleared">Storico ricerche eliminato</string>
|
<string name="settings.search_history_cleared">Storico ricerche eliminato</string>
|
||||||
<string name="settings.search_title">Impostazioni ricerca</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_address">Indirizzo Server</string>
|
||||||
<string name="settings.server_name">Nome</string>
|
<string name="settings.server_name">Nome</string>
|
||||||
<string name="settings.server_password">Password</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_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_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.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">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_now_playing_summary">Mostra la traccia attualmente in riproduzione in tutte le attività</string>
|
||||||
<string name="settings.show_track_number">Visualizza numero traccia</string>
|
<string name="settings.show_track_number">Visualizza numero traccia</string>
|
||||||
|
|
|
@ -41,11 +41,8 @@
|
||||||
<string name="common.name">Naam</string>
|
<string name="common.name">Naam</string>
|
||||||
<string name="common.ok">Oké</string>
|
<string name="common.ok">Oké</string>
|
||||||
<string name="common.pin">Vastmaken</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_last">Laatste afspelen</string>
|
||||||
<string name="common.play_next">Volgende 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_now">Nu afspelen</string>
|
||||||
<string name="common.play_shuffled">Willekeurig afspelen</string>
|
<string name="common.play_shuffled">Willekeurig afspelen</string>
|
||||||
<string name="common.public">Openbaar</string>
|
<string name="common.public">Openbaar</string>
|
||||||
|
@ -75,10 +72,7 @@
|
||||||
<string name="download.menu_screen_on">Scherm aan</string>
|
<string name="download.menu_screen_on">Scherm aan</string>
|
||||||
<string name="download.menu_show_album">Album tonen</string>
|
<string name="download.menu_show_album">Album tonen</string>
|
||||||
<string name="download.menu_shuffle">Willekeurig</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.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.playerstate_playing_shuffle">Bezig met willekeurig afspelen</string>
|
||||||
<string name="download.playlist_done">Afspeellijst is opgeslagen.</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>
|
<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.music">Muziek</string>
|
||||||
<string name="main.offline">Offline</string>
|
<string name="main.offline">Offline</string>
|
||||||
<string name="main.setup_server">%s - Server instellen</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_random">Willekeurig</string>
|
||||||
<string name="main.songs_starred">Favorieten</string>
|
<string name="main.songs_starred">Favorieten</string>
|
||||||
<string name="main.songs_title">Nummers</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.deleted_playlist_error">Afspeellijst %s kan niet worden verwijderd</string>
|
||||||
<string name="menu.downloads">Downloads</string>
|
<string name="menu.downloads">Downloads</string>
|
||||||
<string name="menu.exit">Afsluiten</string>
|
<string name="menu.exit">Afsluiten</string>
|
||||||
<string name="menu.navigation">Navigatie</string>
|
|
||||||
<string name="menu.settings">Instellingen</string>
|
<string name="menu.settings">Instellingen</string>
|
||||||
<string name="menu.refresh">Verversen</string>
|
<string name="menu.refresh">Verversen</string>
|
||||||
<string name="music_library.label">Mediabibliotheek</string>
|
<string name="music_library.label">Mediabibliotheek</string>
|
||||||
<string name="music_library.label_offline">Offline media</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.label">Afspeellijsten</string>
|
||||||
<string name="playlist.update_info">Informatie bijwerken</string>
|
<string name="playlist.update_info">Informatie bijwerken</string>
|
||||||
<string name="playlist.updated_info">Afspeellijstinformatie bijgewerkt voor %s</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="playlist.updated_info_error">Kan afspeellijstinformatie voor %s niet bijwerken</string>
|
||||||
<string name="progress.wait">Even geduld…</string>
|
|
||||||
<string name="search.albums">Albums</string>
|
<string name="search.albums">Albums</string>
|
||||||
<string name="search.artists">Artiesten</string>
|
<string name="search.artists">Artiesten</string>
|
||||||
<string name="search.label">Zoeken</string>
|
<string name="search.label">Zoeken</string>
|
||||||
<string name="search.more">Meer tonen</string>
|
<string name="search.more">Meer tonen</string>
|
||||||
<string name="search.no_match">Geen overeenkomsten; probeer het opnieuw</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.songs">Nummers</string>
|
||||||
<string name="search.title">Zoeken</string>
|
<string name="search.title">Zoeken</string>
|
||||||
<string name="select_album.empty">Geen media gevonden</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_artist.folder">Map kiezen</string>
|
||||||
<string name="select_genre.empty">Geen genres gevonden</string>
|
<string name="select_genre.empty">Geen genres gevonden</string>
|
||||||
<string name="select_playlist.empty">Geen opgeslagen afspeellijsten op server</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.appearance_title">Uiterlijk</string>
|
||||||
<string name="settings.buffer_length">Bufferduur</string>
|
<string name="settings.buffer_length">Bufferduur</string>
|
||||||
<string name="settings.buffer_length_0">Uitgeschakeld</string>
|
<string name="settings.buffer_length_0">Uitgeschakeld</string>
|
||||||
|
@ -211,8 +196,6 @@
|
||||||
<string name="settings.chat_refresh">Chat-ververstussenpoos</string>
|
<string name="settings.chat_refresh">Chat-ververstussenpoos</string>
|
||||||
<string name="settings.clear_bookmark">Bladwijzer verwijderen</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_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.clear_search_history">Zoekgeschiedenis wissen</string>
|
||||||
<string name="settings.connection_failure">Verbindingsfout.</string>
|
<string name="settings.connection_failure">Verbindingsfout.</string>
|
||||||
<string name="settings.default_albums">Standaardalbums</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.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">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.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_summary">Muziekbestanden verbergen voor andere apps.</string>
|
||||||
<string name="settings.hide_media_title">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.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.increment_time">Overslaantussenpoos</string>
|
||||||
<string name="settings.invalid_url">Geef een geldige URL op.</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_albums">Max. aantal albums</string>
|
||||||
<string name="settings.max_artists">Max. aantal artiesten</string>
|
<string name="settings.max_artists">Max. aantal artiesten</string>
|
||||||
<string name="settings.max_bitrate_112">112 Kbps</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_75">75</string>
|
||||||
<string name="settings.search_history_cleared">Zoekgeschiedenis gewist</string>
|
<string name="settings.search_history_cleared">Zoekgeschiedenis gewist</string>
|
||||||
<string name="settings.search_title">Zoekinstellingen</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_manage_servers">Manage Servers</string>
|
||||||
<string name="settings.server_address">Serveradres</string>
|
<string name="settings.server_address">Serveradres</string>
|
||||||
<string name="settings.server_name">Naam</string>
|
<string name="settings.server_name">Naam</string>
|
||||||
<string name="settings.server_password">Wachtwoord</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_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_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_username">Gebruikersnaam</string>
|
||||||
<string name="settings.server_color">Serverkleur</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">\"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_now_playing_summary">Toont het momenteel afspelende nummer in alle activiteiten</string>
|
||||||
<string name="settings.show_track_number">Itemnummer tonen</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_all">Alle bluetoothapparaten</string>
|
||||||
<string name="settings.playback.bluetooth_a2dp">Alleen audio-apparaten (AD2P)</string>
|
<string name="settings.playback.bluetooth_a2dp">Alleen audio-apparaten (AD2P)</string>
|
||||||
<string name="settings.playback.bluetooth_disabled">Uitgeschakeld</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.title">Foutopsporingsopties</string>
|
||||||
<string name="settings.debug.log_to_file">Foutopsporingslogboek bijhouden</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>
|
<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="one">%d nummer ingevoegd na het huidige nummer</item>
|
||||||
<item quantity="other">%d nummers ingevoegd na het huidige nummer</item>
|
<item quantity="other">%d nummers ingevoegd na het huidige nummer</item>
|
||||||
</plurals>
|
</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 -->
|
<!-- Subsonic api errors -->
|
||||||
<string name="api.subsonic.generic">Algemene API-fout: %1$s</string>
|
<string name="api.subsonic.generic">Algemene API-fout: %1$s</string>
|
||||||
|
|
|
@ -61,10 +61,7 @@
|
||||||
<string name="download.menu_screen_on">Ekran włączony</string>
|
<string name="download.menu_screen_on">Ekran włączony</string>
|
||||||
<string name="download.menu_show_album">Wyświetl album</string>
|
<string name="download.menu_show_album">Wyświetl album</string>
|
||||||
<string name="download.menu_shuffle">Wymieszaj</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.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.playerstate_playing_shuffle">Odtwarzanie losowe</string>
|
||||||
<string name="download.playlist_done">Playlista została zapisana.</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>
|
<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.genres_title">Gatunki</string>
|
||||||
<string name="main.music">Muzyka</string>
|
<string name="main.music">Muzyka</string>
|
||||||
<string name="main.offline">Offline</string>
|
<string name="main.offline">Offline</string>
|
||||||
<string name="main.shuffle">Losowo</string>
|
|
||||||
<string name="main.songs_random">Losowe</string>
|
<string name="main.songs_random">Losowe</string>
|
||||||
<string name="main.songs_starred">Ulubione</string>
|
<string name="main.songs_starred">Ulubione</string>
|
||||||
<string name="main.songs_title">Utwory</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">Usunięto playlistę %s</string>
|
||||||
<string name="menu.deleted_playlist_error">Usunięcie playlisty %s nie powiodło się</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.exit">Zakończ</string>
|
||||||
<string name="menu.navigation">Nawigacja</string>
|
|
||||||
<string name="menu.settings">Ustawienia</string>
|
<string name="menu.settings">Ustawienia</string>
|
||||||
<string name="menu.refresh">Refresh</string>
|
<string name="menu.refresh">Refresh</string>
|
||||||
<string name="music_library.label">Biblioteka mediów</string>
|
<string name="music_library.label">Biblioteka mediów</string>
|
||||||
<string name="music_library.label_offline">Media offline</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.label">Playlisty</string>
|
||||||
<string name="playlist.update_info">Aktualizacja informacji</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">Zaktualizowano informacje dla playlisty %s</string>
|
||||||
<string name="playlist.updated_info_error">Błąc podczas aktualizacji playlisty %s</string>
|
<string name="playlist.updated_info_error">Błąc podczas aktualizacji playlisty %s</string>
|
||||||
<string name="progress.wait">Proszę czekać…</string>
|
|
||||||
<string name="search.albums">Albumy</string>
|
<string name="search.albums">Albumy</string>
|
||||||
<string name="search.artists">Artyści</string>
|
<string name="search.artists">Artyści</string>
|
||||||
<string name="search.label">Wyszukaj</string>
|
<string name="search.label">Wyszukaj</string>
|
||||||
<string name="search.more">Wyświetl więcej</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.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.songs">Utwory</string>
|
||||||
<string name="search.title">Wyszukiwanie</string>
|
<string name="search.title">Wyszukiwanie</string>
|
||||||
<string name="select_album.empty">Brak mediów</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_artist.folder">Wybierz folder</string>
|
||||||
<string name="select_genre.empty">Brak gatunków</string>
|
<string name="select_genre.empty">Brak gatunków</string>
|
||||||
<string name="select_playlist.empty">Brak zapisanych playlist na serwerze</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.appearance_title">Wygląd</string>
|
||||||
<string name="settings.buffer_length">Wielkość bufora</string>
|
<string name="settings.buffer_length">Wielkość bufora</string>
|
||||||
<string name="settings.buffer_length_0">Wyłączone</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.chat_refresh">Okres odświeżania czatu</string>
|
||||||
<string name="settings.clear_bookmark">Czyszczenie zakładek</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_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.clear_search_history">Wyczyść historię wyszukiwania</string>
|
||||||
<string name="settings.connection_failure">Błąd połączenia.</string>
|
<string name="settings.connection_failure">Błąd połączenia.</string>
|
||||||
<string name="settings.default_albums">Domyślna ilość wyników - albumy</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">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.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.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_summary">Ukrywa pliki muzyczne przed innymi aplikacjami.</string>
|
||||||
<string name="settings.hide_media_title">Ukryj pliki</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.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.increment_time">Skok przewijania</string>
|
||||||
<string name="settings.invalid_url">Proszę wprowadzić prawidłowy URL</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_albums">Maksymalna ilość wyników - albumy</string>
|
||||||
<string name="settings.max_artists">Maksymalna ilość wyników - artyści</string>
|
<string name="settings.max_artists">Maksymalna ilość wyników - artyści</string>
|
||||||
<string name="settings.max_bitrate_112">112 Kbps</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_75">75</string>
|
||||||
<string name="settings.search_history_cleared">Wyczyść historię wyszukiwania</string>
|
<string name="settings.search_history_cleared">Wyczyść historię wyszukiwania</string>
|
||||||
<string name="settings.search_title">Ustawienia 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_manage_servers">Manage Servers</string>
|
||||||
<string name="settings.server_address">Adres serwera</string>
|
<string name="settings.server_address">Adres serwera</string>
|
||||||
<string name="settings.server_name">Nazwa</string>
|
<string name="settings.server_name">Nazwa</string>
|
||||||
<string name="settings.server_password">Hasło</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_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_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.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">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_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>
|
<string name="settings.show_track_number">Wyświetlaj numer utworu</string>
|
||||||
|
@ -361,12 +332,6 @@
|
||||||
<item quantity="many">%d utworów</item>
|
<item quantity="many">%d utworów</item>
|
||||||
<item quantity="other">%d utworów</item>
|
<item quantity="other">%d utworów</item>
|
||||||
</plurals>
|
</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 -->
|
<!-- Subsonic api errors -->
|
||||||
<string name="api.subsonic.generic">Ogólny błąd interfejsu API: %1$s</string>
|
<string name="api.subsonic.generic">Ogólny błąd interfejsu API: %1$s</string>
|
||||||
|
|
|
@ -41,11 +41,8 @@
|
||||||
<string name="common.name">Nome</string>
|
<string name="common.name">Nome</string>
|
||||||
<string name="common.ok">OK</string>
|
<string name="common.ok">OK</string>
|
||||||
<string name="common.pin">Fixar</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_last">Tocar por Último</string>
|
||||||
<string name="common.play_next">Tocar na Próxima</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_now">Tocar Agora</string>
|
||||||
<string name="common.play_shuffled">Tocar Aleatoriamente</string>
|
<string name="common.play_shuffled">Tocar Aleatoriamente</string>
|
||||||
<string name="common.public">Público</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_screen_on">Tela Ligada</string>
|
||||||
<string name="download.menu_show_album">Mostrar Álbum</string>
|
<string name="download.menu_show_album">Mostrar Álbum</string>
|
||||||
<string name="download.menu_shuffle">Misturar</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.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.playerstate_playing_shuffle">Tocando misturado</string>
|
||||||
<string name="download.playlist_done">Playlist salva com sucesso.</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>
|
<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.genres_title">Gêneros</string>
|
||||||
<string name="main.music">Música</string>
|
<string name="main.music">Música</string>
|
||||||
<string name="main.offline">Offline</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_random">Aleatórias</string>
|
||||||
<string name="main.songs_starred">Favoritas</string>
|
<string name="main.songs_starred">Favoritas</string>
|
||||||
<string name="main.songs_title">Músicas</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.deleted_playlist_error">Falha ao excluir a playlist %s</string>
|
||||||
<string name="menu.downloads">Downloads</string>
|
<string name="menu.downloads">Downloads</string>
|
||||||
<string name="menu.exit">Sair</string>
|
<string name="menu.exit">Sair</string>
|
||||||
<string name="menu.navigation">Navegação</string>
|
|
||||||
<string name="menu.settings">Configurações</string>
|
<string name="menu.settings">Configurações</string>
|
||||||
<string name="menu.refresh">Atualizar</string>
|
<string name="menu.refresh">Atualizar</string>
|
||||||
<string name="music_library.label">Biblioteca de Mídia</string>
|
<string name="music_library.label">Biblioteca de Mídia</string>
|
||||||
<string name="music_library.label_offline">Mídia Offline</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.label">Playlists</string>
|
||||||
<string name="playlist.update_info">Atualizar Informação</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">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="playlist.updated_info_error">Falha ao atualizar a informação da playlist para %s</string>
|
||||||
<string name="progress.wait">Por favor aguarde…</string>
|
|
||||||
<string name="search.albums">Álbuns</string>
|
<string name="search.albums">Álbuns</string>
|
||||||
<string name="search.artists">Artistas</string>
|
<string name="search.artists">Artistas</string>
|
||||||
<string name="search.label">Pesquisar</string>
|
<string name="search.label">Pesquisar</string>
|
||||||
<string name="search.more">Mostrar Mais</string>
|
<string name="search.more">Mostrar Mais</string>
|
||||||
<string name="search.no_match">Nada coincide, tente novamente</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.songs">Músicas</string>
|
||||||
<string name="search.title">Pesquisar</string>
|
<string name="search.title">Pesquisar</string>
|
||||||
<string name="select_album.empty">Nenhuma mídia encontrada</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_artist.folder">Selecionar Pasta</string>
|
||||||
<string name="select_genre.empty">Nenhum gênero encontrado</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="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.appearance_title">Aparência</string>
|
||||||
<string name="settings.buffer_length">Tamanho do Buffer</string>
|
<string name="settings.buffer_length">Tamanho do Buffer</string>
|
||||||
<string name="settings.buffer_length_0">Desativado</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.chat_refresh">Intervalo de Atualização do Chat</string>
|
||||||
<string name="settings.clear_bookmark">Limpar Favoritos</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_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.clear_search_history">Limpar Histórico de Pesquisas</string>
|
||||||
<string name="settings.connection_failure">Falha na conexão.</string>
|
<string name="settings.connection_failure">Falha na conexão.</string>
|
||||||
<string name="settings.default_albums">Álbuns Padrões</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.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">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.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_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_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.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.increment_time">Intervalo de Salto</string>
|
||||||
<string name="settings.invalid_url">Especifique uma URL válida.</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_albums">Máximo de Álbuns</string>
|
||||||
<string name="settings.max_artists">Máximo de Artistas</string>
|
<string name="settings.max_artists">Máximo de Artistas</string>
|
||||||
<string name="settings.max_bitrate_112">112 Kbps</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_75">75</string>
|
||||||
<string name="settings.search_history_cleared">Histórico de pesquisas apagado</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.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_manage_servers">Gerenciar Servidores</string>
|
||||||
<string name="settings.server_address">Endereço do Servidor</string>
|
<string name="settings.server_address">Endereço do Servidor</string>
|
||||||
<string name="settings.server_name">Nome</string>
|
<string name="settings.server_name">Nome</string>
|
||||||
<string name="settings.server_password">Senha</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_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_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.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">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_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>
|
<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_all">Todos os dispositivos Bluetooth</string>
|
||||||
<string name="settings.playback.bluetooth_a2dp">Somente dispositivos de áudio (A2DP)</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.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.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_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>
|
<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="one">%d música</item>
|
||||||
<item quantity="other">%d músicas</item>
|
<item quantity="other">%d músicas</item>
|
||||||
</plurals>
|
</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 -->
|
<!-- Subsonic api errors -->
|
||||||
<string name="api.subsonic.generic">Erro de api genérico: %1$s</string>
|
<string name="api.subsonic.generic">Erro de api genérico: %1$s</string>
|
||||||
|
|
|
@ -61,10 +61,7 @@
|
||||||
<string name="download.menu_screen_on">Ecrã Ligado</string>
|
<string name="download.menu_screen_on">Ecrã Ligado</string>
|
||||||
<string name="download.menu_show_album">Mostrar Álbum</string>
|
<string name="download.menu_show_album">Mostrar Álbum</string>
|
||||||
<string name="download.menu_shuffle">Misturar</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.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.playerstate_playing_shuffle">Tocando misturado</string>
|
||||||
<string name="download.playlist_done">Playlist salva com sucesso.</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>
|
<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.genres_title">Gêneros</string>
|
||||||
<string name="main.music">Música</string>
|
<string name="main.music">Música</string>
|
||||||
<string name="main.offline">Offline</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_random">Aleatórias</string>
|
||||||
<string name="main.songs_starred">Favoritas</string>
|
<string name="main.songs_starred">Favoritas</string>
|
||||||
<string name="main.songs_title">Músicas</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">Playlist apagada %s</string>
|
||||||
<string name="menu.deleted_playlist_error">Falha ao apagar a playlist %s</string>
|
<string name="menu.deleted_playlist_error">Falha ao apagar a playlist %s</string>
|
||||||
<string name="menu.exit">Sair</string>
|
<string name="menu.exit">Sair</string>
|
||||||
<string name="menu.navigation">Navegação</string>
|
|
||||||
<string name="menu.settings">Configurações</string>
|
<string name="menu.settings">Configurações</string>
|
||||||
<string name="menu.refresh">Refresh</string>
|
<string name="menu.refresh">Refresh</string>
|
||||||
<string name="music_library.label">Biblioteca de Mídia</string>
|
<string name="music_library.label">Biblioteca de Mídia</string>
|
||||||
<string name="music_library.label_offline">Mídia Offline</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.label">Playlists</string>
|
||||||
<string name="playlist.update_info">Atualizar Informação</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">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="playlist.updated_info_error">Falha ao atualizar a informação da playlist para %s</string>
|
||||||
<string name="progress.wait">Por favor aguarde…</string>
|
|
||||||
<string name="search.albums">Álbuns</string>
|
<string name="search.albums">Álbuns</string>
|
||||||
<string name="search.artists">Artistas</string>
|
<string name="search.artists">Artistas</string>
|
||||||
<string name="search.label">Pesquisar</string>
|
<string name="search.label">Pesquisar</string>
|
||||||
<string name="search.more">Mostrar Mais</string>
|
<string name="search.more">Mostrar Mais</string>
|
||||||
<string name="search.no_match">Nada coincide, tente novamente</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.songs">Músicas</string>
|
||||||
<string name="search.title">Pesquisar</string>
|
<string name="search.title">Pesquisar</string>
|
||||||
<string name="select_album.empty">Nenhuma mídia encontrada</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_artist.folder">Selecionar Pasta</string>
|
||||||
<string name="select_genre.empty">Nenhum gênero encontrado</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="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.appearance_title">Aparência</string>
|
||||||
<string name="settings.buffer_length">Tamanho do Buffer</string>
|
<string name="settings.buffer_length">Tamanho do Buffer</string>
|
||||||
<string name="settings.buffer_length_0">Disabilitando</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.chat_refresh">Intervalo de Atualização do Chat</string>
|
||||||
<string name="settings.clear_bookmark">Limpar Favoritos</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_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.clear_search_history">Limpar Histórico de Pesquisas</string>
|
||||||
<string name="settings.connection_failure">Falha na conexão.</string>
|
<string name="settings.connection_failure">Falha na conexão.</string>
|
||||||
<string name="settings.default_albums">Álbuns Padrões</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">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.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.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_summary">Esconder músicas de outros aplicativos.</string>
|
||||||
<string name="settings.hide_media_title">Esconder de Outros</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.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.increment_time">Intervalo de Salto</string>
|
||||||
<string name="settings.invalid_url">Especifique uma URL válida.</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_albums">Máximo de Álbuns</string>
|
||||||
<string name="settings.max_artists">Máximo de Artistas</string>
|
<string name="settings.max_artists">Máximo de Artistas</string>
|
||||||
<string name="settings.max_bitrate_112">112 Kbps</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_75">75</string>
|
||||||
<string name="settings.search_history_cleared">Histórico de pesquisas apagado</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.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_manage_servers">Manage Servers</string>
|
||||||
<string name="settings.server_address">Endereço do Servidor</string>
|
<string name="settings.server_address">Endereço do Servidor</string>
|
||||||
<string name="settings.server_name">Nome</string>
|
<string name="settings.server_name">Nome</string>
|
||||||
<string name="settings.server_password">Senha</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_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_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.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">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_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>
|
<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="one">%d música</item>
|
||||||
<item quantity="other">%d músicas</item>
|
<item quantity="other">%d músicas</item>
|
||||||
</plurals>
|
</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 -->
|
<!-- Subsonic api errors -->
|
||||||
<string name="api.subsonic.generic">Erro de api genérico: %1$s</string>
|
<string name="api.subsonic.generic">Erro de api genérico: %1$s</string>
|
||||||
|
|
|
@ -41,11 +41,8 @@
|
||||||
<string name="common.name">Имя</string>
|
<string name="common.name">Имя</string>
|
||||||
<string name="common.ok">Ок</string>
|
<string name="common.ok">Ок</string>
|
||||||
<string name="common.pin">Пин</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_last">Воспроизвести последний</string>
|
||||||
<string name="common.play_next">Воспроизвести следующий</string>
|
<string name="common.play_next">Воспроизвести следующий</string>
|
||||||
<string name="common.play_previous">Воспроизвести предыдущий</string>
|
|
||||||
<string name="common.play_now">Воспроизвести сейчас</string>
|
<string name="common.play_now">Воспроизвести сейчас</string>
|
||||||
<string name="common.play_shuffled">Играть в случайном порядке</string>
|
<string name="common.play_shuffled">Играть в случайном порядке</string>
|
||||||
<string name="common.public">Публичный</string>
|
<string name="common.public">Публичный</string>
|
||||||
|
@ -75,10 +72,7 @@
|
||||||
<string name="download.menu_screen_on">Включение дисплея</string>
|
<string name="download.menu_screen_on">Включение дисплея</string>
|
||||||
<string name="download.menu_show_album">Показать альбом</string>
|
<string name="download.menu_show_album">Показать альбом</string>
|
||||||
<string name="download.menu_shuffle">Случайное воспроизведение</string>
|
<string name="download.menu_shuffle">Случайное воспроизведение</string>
|
||||||
<string name="download.menu_shuffle_notification">Плейлист в случайном порядке</string>
|
|
||||||
<string name="download.menu_visualizer">Визуализатор</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.playerstate_playing_shuffle">Игра в случайном порядке</string>
|
||||||
<string name="download.playlist_done">Плейлист был успешно сохранен.</string>
|
<string name="download.playlist_done">Плейлист был успешно сохранен.</string>
|
||||||
<string name="download.playlist_error">Не удалось сохранить плейлист, попробуйте позже.</string>
|
<string name="download.playlist_error">Не удалось сохранить плейлист, попробуйте позже.</string>
|
||||||
|
@ -119,7 +113,6 @@
|
||||||
<string name="main.genres_title">Жанры</string>
|
<string name="main.genres_title">Жанры</string>
|
||||||
<string name="main.music">Музыка</string>
|
<string name="main.music">Музыка</string>
|
||||||
<string name="main.offline">Не в сети</string>
|
<string name="main.offline">Не в сети</string>
|
||||||
<string name="main.shuffle">Играть в случайном порядке</string>
|
|
||||||
<string name="main.songs_random">Случайный</string>
|
<string name="main.songs_random">Случайный</string>
|
||||||
<string name="main.songs_starred">Отмеченные</string>
|
<string name="main.songs_starred">Отмеченные</string>
|
||||||
<string name="main.songs_title">Песни</string>
|
<string name="main.songs_title">Песни</string>
|
||||||
|
@ -133,26 +126,19 @@
|
||||||
<string name="menu.deleted_playlist_error">Не удалось удалить плейлист %s</string>
|
<string name="menu.deleted_playlist_error">Не удалось удалить плейлист %s</string>
|
||||||
<string name="menu.downloads">Загрузки</string>
|
<string name="menu.downloads">Загрузки</string>
|
||||||
<string name="menu.exit">Выход</string>
|
<string name="menu.exit">Выход</string>
|
||||||
<string name="menu.navigation">Навигация</string>
|
|
||||||
<string name="menu.settings">Настройки</string>
|
<string name="menu.settings">Настройки</string>
|
||||||
<string name="menu.refresh">Обновить</string>
|
<string name="menu.refresh">Обновить</string>
|
||||||
<string name="music_library.label">Медиа библиотека</string>
|
<string name="music_library.label">Медиа библиотека</string>
|
||||||
<string name="music_library.label_offline">Медиа Оффлайн</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.label">Плейлисты</string>
|
||||||
<string name="playlist.update_info">Обновление информации</string>
|
<string name="playlist.update_info">Обновление информации</string>
|
||||||
<string name="playlist.updated_info">Обновлена информация о плейлисте для %s</string>
|
<string name="playlist.updated_info">Обновлена информация о плейлисте для %s</string>
|
||||||
<string name="playlist.updated_info_error">Не удалось обновить информацию о плейлисте для %s</string>
|
<string name="playlist.updated_info_error">Не удалось обновить информацию о плейлисте для %s</string>
|
||||||
<string name="progress.wait">Пожалуйста, подождите#8230;</string>
|
|
||||||
<string name="search.albums">Альбомы</string>
|
<string name="search.albums">Альбомы</string>
|
||||||
<string name="search.artists">Исполнители</string>
|
<string name="search.artists">Исполнители</string>
|
||||||
<string name="search.label">Поиск</string>
|
<string name="search.label">Поиск</string>
|
||||||
<string name="search.more">Показать еще</string>
|
<string name="search.more">Показать еще</string>
|
||||||
<string name="search.no_match">Нет совпадений, пожалуйста попробуйте еще раз</string>
|
<string name="search.no_match">Нет совпадений, пожалуйста попробуйте еще раз</string>
|
||||||
<string name="search.search">Нажми для поиска</string>
|
|
||||||
<string name="search.songs">Песни</string>
|
<string name="search.songs">Песни</string>
|
||||||
<string name="search.title">Поиск</string>
|
<string name="search.title">Поиск</string>
|
||||||
<string name="select_album.empty">Медиа не найдена</string>
|
<string name="select_album.empty">Медиа не найдена</string>
|
||||||
|
@ -162,7 +148,6 @@
|
||||||
<string name="select_artist.folder">Выбрать папку</string>
|
<string name="select_artist.folder">Выбрать папку</string>
|
||||||
<string name="select_genre.empty">Жанры не найдены</string>
|
<string name="select_genre.empty">Жанры не найдены</string>
|
||||||
<string name="select_playlist.empty">Нет сохраненных плейлистов на сервере</string>
|
<string name="select_playlist.empty">Нет сохраненных плейлистов на сервере</string>
|
||||||
<string name="service.connecting">Свяжитесь с сервером, пожалуйста, подождите.</string>
|
|
||||||
<string name="settings.appearance_title">Появление</string>
|
<string name="settings.appearance_title">Появление</string>
|
||||||
<string name="settings.buffer_length">Размер буфера</string>
|
<string name="settings.buffer_length">Размер буфера</string>
|
||||||
<string name="settings.buffer_length_0">Отключить</string>
|
<string name="settings.buffer_length_0">Отключить</string>
|
||||||
|
@ -202,8 +187,6 @@
|
||||||
<string name="settings.chat_refresh">Интервал обновления чата</string>
|
<string name="settings.chat_refresh">Интервал обновления чата</string>
|
||||||
<string name="settings.clear_bookmark">Очистить закладку</string>
|
<string name="settings.clear_bookmark">Очистить закладку</string>
|
||||||
<string name="settings.clear_bookmark_summary">Очистить закладку после завершения воспроизведения песни</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.clear_search_history">Очистить историю поиска</string>
|
||||||
<string name="settings.connection_failure">Ошибка подключения.</string>
|
<string name="settings.connection_failure">Ошибка подключения.</string>
|
||||||
<string name="settings.default_albums">Альбомы по умолчанию</string>
|
<string name="settings.default_albums">Альбомы по умолчанию</string>
|
||||||
|
@ -220,14 +203,11 @@
|
||||||
<string name="settings.disc_sort">Время кэша каталогов</string>
|
<string name="settings.disc_sort">Время кэша каталогов</string>
|
||||||
<string name="settings.disc_sort_summary">Сортировать список песен по номеру диска и треку</string>
|
<string name="settings.disc_sort_summary">Сортировать список песен по номеру диска и треку</string>
|
||||||
<string name="settings.display_bitrate_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_summary">Включить воспроизведение без паузы</string>
|
||||||
<string name="settings.hide_media_title">Скрыть от других</string>
|
<string name="settings.hide_media_title">Скрыть от других</string>
|
||||||
<string name="settings.hide_media_toast">Вступает в силу в следующий раз, Android сканирует ваш телефон на предмет музыки</string>
|
<string name="settings.hide_media_toast">Вступает в силу в следующий раз, Android сканирует ваш телефон на предмет музыки</string>
|
||||||
<string name="settings.increment_time">Пропустить интервал</string>
|
<string name="settings.increment_time">Пропустить интервал</string>
|
||||||
<string name="settings.invalid_url">Пожалуйста, укажите действительный URL.</string>
|
<string name="settings.invalid_url">Пожалуйста, укажите действительный URL.</string>
|
||||||
<string name="settings.invalid_username">Пожалуйста, укажите правильное имя пользователя (без пробелов).</string>
|
|
||||||
<string name="settings.max_albums">Максимум альбомов</string>
|
<string name="settings.max_albums">Максимум альбомов</string>
|
||||||
<string name="settings.max_artists">Максимум исполнителей</string>
|
<string name="settings.max_artists">Максимум исполнителей</string>
|
||||||
<string name="settings.max_bitrate_112">112 Kbps</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_75">75</string>
|
||||||
<string name="settings.search_history_cleared">История поиска очищена</string>
|
<string name="settings.search_history_cleared">История поиска очищена</string>
|
||||||
<string name="settings.search_title">Настройки поиска</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_manage_servers">Управление серверами</string>
|
||||||
<string name="settings.server_address">Адрес сервера</string>
|
<string name="settings.server_address">Адрес сервера</string>
|
||||||
<string name="settings.server_name">Имя</string>
|
<string name="settings.server_name">Имя</string>
|
||||||
<string name="settings.server_password">Пароль</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_summary">Загрузка масштабированных изображений с сервера вместо полноразмерного (экономит трафик)</string>
|
||||||
<string name="settings.server_scaling_title">Серверное масштабирование обложек альбомов</string>
|
<string name="settings.server_scaling_title">Серверное масштабирование обложек альбомов</string>
|
||||||
<string name="settings.server_unused">Неиспользуемый</string>
|
|
||||||
<string name="settings.server_username">Имя пользователя</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">Показать что сейчас играет</string>
|
||||||
<string name="settings.show_now_playing_summary">Показать текущий воспроизводимый трек во всех активностях</string>
|
<string name="settings.show_now_playing_summary">Показать текущий воспроизводимый трек во всех активностях</string>
|
||||||
<string name="settings.show_track_number">Показать номер трека</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_all">Все устройства Bluetooth</string>
|
||||||
<string name="settings.playback.bluetooth_a2dp">Только аудио (A2DP) устройства</string>
|
<string name="settings.playback.bluetooth_a2dp">Только аудио (A2DP) устройства</string>
|
||||||
<string name="settings.playback.bluetooth_disabled">Отключено</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.title">Настройки отладки</string>
|
||||||
<string name="settings.debug.log_to_file">Записать журнал отладки в файл</string>
|
<string name="settings.debug.log_to_file">Записать журнал отладки в файл</string>
|
||||||
<string name="settings.debug.log_path">Файлы журнала доступны по адресу %1$s/%2$s</string>
|
<string name="settings.debug.log_path">Файлы журнала доступны по адресу %1$s/%2$s</string>
|
||||||
|
@ -404,12 +371,6 @@
|
||||||
<item quantity="many">%d песен</item>
|
<item quantity="many">%d песен</item>
|
||||||
<item quantity="other">%d песен</item>
|
<item quantity="other">%d песен</item>
|
||||||
</plurals>
|
</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 -->
|
<!-- Subsonic api errors -->
|
||||||
<string name="api.subsonic.generic">Общая ошибка API: %1$s</string>
|
<string name="api.subsonic.generic">Общая ошибка API: %1$s</string>
|
||||||
|
|
|
@ -41,11 +41,8 @@
|
||||||
<string name="common.name">名称</string>
|
<string name="common.name">名称</string>
|
||||||
<string name="common.ok">确定</string>
|
<string name="common.ok">确定</string>
|
||||||
<string name="common.pin">固定</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_last">最后一首</string>
|
||||||
<string name="common.play_next">下一首</string>
|
<string name="common.play_next">下一首</string>
|
||||||
<string name="common.play_previous">上一首</string>
|
|
||||||
<string name="common.play_now">现在播放</string>
|
<string name="common.play_now">现在播放</string>
|
||||||
<string name="common.play_shuffled">随机播放</string>
|
<string name="common.play_shuffled">随机播放</string>
|
||||||
<string name="common.public">公开</string>
|
<string name="common.public">公开</string>
|
||||||
|
@ -75,10 +72,7 @@
|
||||||
<string name="download.menu_screen_on">开启屏幕常亮</string>
|
<string name="download.menu_screen_on">开启屏幕常亮</string>
|
||||||
<string name="download.menu_show_album">显示专辑</string>
|
<string name="download.menu_show_album">显示专辑</string>
|
||||||
<string name="download.menu_shuffle">随机</string>
|
<string name="download.menu_shuffle">随机</string>
|
||||||
<string name="download.menu_shuffle_notification">已随机排列播放列表</string>
|
|
||||||
<string name="download.menu_visualizer">可视化</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.playerstate_playing_shuffle">随机播放</string>
|
||||||
<string name="download.playlist_done">已成功保存播放列表。</string>
|
<string name="download.playlist_done">已成功保存播放列表。</string>
|
||||||
<string name="download.playlist_error">保存播放列表失败,请重试。</string>
|
<string name="download.playlist_error">保存播放列表失败,请重试。</string>
|
||||||
|
@ -110,7 +104,6 @@
|
||||||
<string name="main.music">音乐</string>
|
<string name="main.music">音乐</string>
|
||||||
<string name="main.offline">离线</string>
|
<string name="main.offline">离线</string>
|
||||||
<string name="main.setup_server">%s - 已设置服务器</string>
|
<string name="main.setup_server">%s - 已设置服务器</string>
|
||||||
<string name="main.shuffle">随机播放</string>
|
|
||||||
<string name="main.songs_random">随机</string>
|
<string name="main.songs_random">随机</string>
|
||||||
<string name="main.songs_starred">收藏夹</string>
|
<string name="main.songs_starred">收藏夹</string>
|
||||||
<string name="main.songs_title">歌曲</string>
|
<string name="main.songs_title">歌曲</string>
|
||||||
|
@ -124,26 +117,19 @@
|
||||||
<string name="menu.deleted_playlist_error">播放列表删除失败%s</string>
|
<string name="menu.deleted_playlist_error">播放列表删除失败%s</string>
|
||||||
<string name="menu.downloads">下载</string>
|
<string name="menu.downloads">下载</string>
|
||||||
<string name="menu.exit">退出</string>
|
<string name="menu.exit">退出</string>
|
||||||
<string name="menu.navigation">导航</string>
|
|
||||||
<string name="menu.settings">设置</string>
|
<string name="menu.settings">设置</string>
|
||||||
<string name="menu.refresh">刷新</string>
|
<string name="menu.refresh">刷新</string>
|
||||||
<string name="music_library.label">媒体库</string>
|
<string name="music_library.label">媒体库</string>
|
||||||
<string name="music_library.label_offline">离线媒体</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.label">播放列表</string>
|
||||||
<string name="playlist.update_info">更新信息</string>
|
<string name="playlist.update_info">更新信息</string>
|
||||||
<string name="playlist.updated_info">已更新此播放列表信息 - %s</string>
|
<string name="playlist.updated_info">已更新此播放列表信息 - %s</string>
|
||||||
<string name="playlist.updated_info_error">更新播放列表信息失败 - %s</string>
|
<string name="playlist.updated_info_error">更新播放列表信息失败 - %s</string>
|
||||||
<string name="progress.wait">请稍等…</string>
|
|
||||||
<string name="search.albums">专辑</string>
|
<string name="search.albums">专辑</string>
|
||||||
<string name="search.artists">艺人</string>
|
<string name="search.artists">艺人</string>
|
||||||
<string name="search.label">搜索</string>
|
<string name="search.label">搜索</string>
|
||||||
<string name="search.more">显示更多</string>
|
<string name="search.more">显示更多</string>
|
||||||
<string name="search.no_match">没有匹配的结果,请重试</string>
|
<string name="search.no_match">没有匹配的结果,请重试</string>
|
||||||
<string name="search.search">点击搜索</string>
|
|
||||||
<string name="search.songs">歌曲</string>
|
<string name="search.songs">歌曲</string>
|
||||||
<string name="search.title">搜索</string>
|
<string name="search.title">搜索</string>
|
||||||
<string name="select_album.empty">找不到歌曲</string>
|
<string name="select_album.empty">找不到歌曲</string>
|
||||||
|
@ -154,7 +140,6 @@
|
||||||
<string name="select_artist.folder">选择文件夹</string>
|
<string name="select_artist.folder">选择文件夹</string>
|
||||||
<string name="select_genre.empty">找不到流派</string>
|
<string name="select_genre.empty">找不到流派</string>
|
||||||
<string name="select_playlist.empty">服务器上没有保存的播放列表</string>
|
<string name="select_playlist.empty">服务器上没有保存的播放列表</string>
|
||||||
<string name="service.connecting">服务器连接中,请稍等。</string>
|
|
||||||
<string name="settings.appearance_title">外观</string>
|
<string name="settings.appearance_title">外观</string>
|
||||||
<string name="settings.buffer_length">缓冲长度</string>
|
<string name="settings.buffer_length">缓冲长度</string>
|
||||||
<string name="settings.buffer_length_0">已禁用</string>
|
<string name="settings.buffer_length_0">已禁用</string>
|
||||||
|
@ -195,8 +180,6 @@
|
||||||
<string name="settings.chat_refresh">聊天消息刷新时间间隔</string>
|
<string name="settings.chat_refresh">聊天消息刷新时间间隔</string>
|
||||||
<string name="settings.clear_bookmark">清空书签</string>
|
<string name="settings.clear_bookmark">清空书签</string>
|
||||||
<string name="settings.clear_bookmark_summary">歌曲播放完毕后清除书签</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.clear_search_history">清空搜索历史</string>
|
||||||
<string name="settings.connection_failure">连接失败</string>
|
<string name="settings.connection_failure">连接失败</string>
|
||||||
<string name="settings.default_albums">默认专辑</string>
|
<string name="settings.default_albums">默认专辑</string>
|
||||||
|
@ -214,14 +197,11 @@
|
||||||
<string name="settings.disc_sort_summary">按光盘编号和曲目编号对歌曲列表进行排序</string>
|
<string name="settings.disc_sort_summary">按光盘编号和曲目编号对歌曲列表进行排序</string>
|
||||||
<string name="settings.display_bitrate">展示比特率和文件后缀</string>
|
<string name="settings.display_bitrate">展示比特率和文件后缀</string>
|
||||||
<string name="settings.display_bitrate_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_summary">隐藏来自其他应用的音乐</string>
|
||||||
<string name="settings.hide_media_title">隐藏其他来源</string>
|
<string name="settings.hide_media_title">隐藏其他来源</string>
|
||||||
<string name="settings.hide_media_toast">在安卓系统下次扫描音乐时生效。</string>
|
<string name="settings.hide_media_toast">在安卓系统下次扫描音乐时生效。</string>
|
||||||
<string name="settings.increment_time">快进间隔</string>
|
<string name="settings.increment_time">快进间隔</string>
|
||||||
<string name="settings.invalid_url">请填写有效的URL。</string>
|
<string name="settings.invalid_url">请填写有效的URL。</string>
|
||||||
<string name="settings.invalid_username">请填写有效用户名 (请去除尾部空格)。</string>
|
|
||||||
<string name="settings.max_albums">最大专辑</string>
|
<string name="settings.max_albums">最大专辑</string>
|
||||||
<string name="settings.max_artists">最大艺术家</string>
|
<string name="settings.max_artists">最大艺术家</string>
|
||||||
<string name="settings.max_bitrate_112">112 Kbps</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_75">75</string>
|
||||||
<string name="settings.search_history_cleared">搜索记录已清除</string>
|
<string name="settings.search_history_cleared">搜索记录已清除</string>
|
||||||
<string name="settings.search_title">搜索设置</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_manage_servers">管理服务器</string>
|
||||||
<string name="settings.server_address">服务器地址</string>
|
<string name="settings.server_address">服务器地址</string>
|
||||||
<string name="settings.server_name">名称</string>
|
<string name="settings.server_name">名称</string>
|
||||||
<string name="settings.server_password">密码</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_summary">从服务器下载缩放图像而不是全尺寸(节省数据流量)</string>
|
||||||
<string name="settings.server_scaling_title">服务器端专辑图片缩放</string>
|
<string name="settings.server_scaling_title">服务器端专辑图片缩放</string>
|
||||||
<string name="settings.server_unused">未启用</string>
|
|
||||||
<string name="settings.server_username">用户名</string>
|
<string name="settings.server_username">用户名</string>
|
||||||
<string name="settings.server_color">服务器颜色</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">显示正在播放</string>
|
||||||
<string name="settings.show_now_playing_summary">在所有活动页面显示正在播放信息</string>
|
<string name="settings.show_now_playing_summary">在所有活动页面显示正在播放信息</string>
|
||||||
<string name="settings.show_track_number">显示曲目编号</string>
|
<string name="settings.show_track_number">显示曲目编号</string>
|
||||||
|
@ -378,7 +344,6 @@
|
||||||
<string name="settings.playback.bluetooth_all">所有蓝牙设备</string>
|
<string name="settings.playback.bluetooth_all">所有蓝牙设备</string>
|
||||||
<string name="settings.playback.bluetooth_a2dp">仅音频 (A2DP) 设备</string>
|
<string name="settings.playback.bluetooth_a2dp">仅音频 (A2DP) 设备</string>
|
||||||
<string name="settings.playback.bluetooth_disabled">已禁用</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.title">调试选项</string>
|
||||||
<string name="settings.debug.log_to_file">将调试日志写入文件</string>
|
<string name="settings.debug.log_to_file">将调试日志写入文件</string>
|
||||||
<string name="settings.debug.log_path">日志文件可在 %1$s/%2$s 获取</string>
|
<string name="settings.debug.log_path">日志文件可在 %1$s/%2$s 获取</string>
|
||||||
|
@ -427,9 +392,6 @@
|
||||||
<plurals name="select_album_n_songs_play_next">
|
<plurals name="select_album_n_songs_play_next">
|
||||||
<item quantity="other">在当前歌曲之后插入了 %d 首歌曲。</item>
|
<item quantity="other">在当前歌曲之后插入了 %d 首歌曲。</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
<plurals name="select_album_donate_dialog_n_trial_days_left">
|
|
||||||
<item quantity="other">试用期还剩 %d 天</item>
|
|
||||||
</plurals>
|
|
||||||
|
|
||||||
<!-- Subsonic api errors -->
|
<!-- Subsonic api errors -->
|
||||||
<string name="api.subsonic.generic">一般api错误: %1$s</string>
|
<string name="api.subsonic.generic">一般api错误: %1$s</string>
|
||||||
|
|
|
@ -1,4 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<resources>
|
|
||||||
<string name="settings.playback.resume_play_on_headphones_plug" translatable="false">playback.resume_play_on_headphones_plug</string>
|
|
||||||
</resources>
|
|
|
@ -0,0 +1,4 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="setting_keys.resume_play_on_headphones_plug" translatable="false">playback.resume_play_on_headphones_plug</string>
|
||||||
|
</resources>
|
|
@ -41,11 +41,8 @@
|
||||||
<string name="common.name">Name</string>
|
<string name="common.name">Name</string>
|
||||||
<string name="common.ok">OK</string>
|
<string name="common.ok">OK</string>
|
||||||
<string name="common.pin">Pin</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_last">Play Last</string>
|
||||||
<string name="common.play_next">Play Next</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_now">Play Now</string>
|
||||||
<string name="common.play_shuffled">Play Shuffled</string>
|
<string name="common.play_shuffled">Play Shuffled</string>
|
||||||
<string name="common.public">Public</string>
|
<string name="common.public">Public</string>
|
||||||
|
@ -75,10 +72,10 @@
|
||||||
<string name="download.menu_screen_on">Screen On</string>
|
<string name="download.menu_screen_on">Screen On</string>
|
||||||
<string name="download.menu_show_album">Show Album</string>
|
<string name="download.menu_show_album">Show Album</string>
|
||||||
<string name="download.menu_shuffle">Shuffle</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.menu_visualizer">Visualizer</string>
|
||||||
<string name="download.playerstate_buffering">Buffering</string>
|
<string name="download.playerstate_loading">Buffering …</string>
|
||||||
<string name="download.playerstate_downloading">Downloading - %s</string>
|
|
||||||
<string name="download.playerstate_playing_shuffle">Playing shuffle</string>
|
<string name="download.playerstate_playing_shuffle">Playing shuffle</string>
|
||||||
<string name="download.playlist_done">Playlist was successfully saved.</string>
|
<string name="download.playlist_done">Playlist was successfully saved.</string>
|
||||||
<string name="download.playlist_error">Failed to save playlist, please try later.</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.music">Music</string>
|
||||||
<string name="main.offline">Offline</string>
|
<string name="main.offline">Offline</string>
|
||||||
<string name="main.setup_server">%s - Set up Server</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_random">Random</string>
|
||||||
<string name="main.songs_starred">Starred</string>
|
<string name="main.songs_starred">Starred</string>
|
||||||
<string name="main.songs_title">Songs</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.deleted_playlist_error">Failed to delete playlist %s</string>
|
||||||
<string name="menu.downloads">Downloads</string>
|
<string name="menu.downloads">Downloads</string>
|
||||||
<string name="menu.exit">Exit</string>
|
<string name="menu.exit">Exit</string>
|
||||||
<string name="menu.navigation">Navigation</string>
|
|
||||||
<string name="menu.settings">Settings</string>
|
<string name="menu.settings">Settings</string>
|
||||||
<string name="menu.refresh">Refresh</string>
|
<string name="menu.refresh">Refresh</string>
|
||||||
<string name="music_library.label">Media Library</string>
|
<string name="music_library.label">Media Library</string>
|
||||||
<string name="music_library.label_offline">Offline Media</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.label">Playlists</string>
|
||||||
<string name="playlist.update_info">Update Information</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">Updated playlist information for %s</string>
|
||||||
<string name="playlist.updated_info_error">Failed to update playlist information for %s</string>
|
<string name="playlist.updated_info_error">Failed to update playlist information for %s</string>
|
||||||
<string name="progress.wait">Please wait…</string>
|
|
||||||
<string name="search.albums">Albums</string>
|
<string name="search.albums">Albums</string>
|
||||||
<string name="search.artists">Artists</string>
|
<string name="search.artists">Artists</string>
|
||||||
<string name="search.label">Search</string>
|
<string name="search.label">Search</string>
|
||||||
<string name="search.more">Show More</string>
|
<string name="search.more">Show More</string>
|
||||||
<string name="search.no_match">No matches, please try again</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.songs">Songs</string>
|
||||||
<string name="search.title">Search</string>
|
<string name="search.title">Search</string>
|
||||||
<string name="select_album.empty">No media found</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_artist.folder">Select Folder</string>
|
||||||
<string name="select_genre.empty">No genres found</string>
|
<string name="select_genre.empty">No genres found</string>
|
||||||
<string name="select_playlist.empty">No saved playlists on server</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.appearance_title">Appearance</string>
|
||||||
<string name="settings.buffer_length">Buffer Length</string>
|
<string name="settings.buffer_length">Buffer Length</string>
|
||||||
<string name="settings.buffer_length_0">Disabled</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.chat_refresh">Chat Refresh Interval</string>
|
||||||
<string name="settings.clear_bookmark">Clear Bookmark</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_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.clear_search_history">Clear Search History</string>
|
||||||
<string name="settings.connection_failure">Connection failure.</string>
|
<string name="settings.connection_failure">Connection failure.</string>
|
||||||
<string name="settings.default_albums">Default Albums</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.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">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.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_summary">Hide music files from other apps.</string>
|
||||||
<string name="settings.hide_media_title">Hide From Other</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.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.increment_time">Skip Interval</string>
|
||||||
<string name="settings.invalid_url">Please specify a valid URL.</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_albums">Max Albums</string>
|
||||||
<string name="settings.max_artists">Max Artists</string>
|
<string name="settings.max_artists">Max Artists</string>
|
||||||
<string name="settings.max_bitrate_112">112 Kbps</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">Override the language</string>
|
||||||
<string name="settings.override_language_summary">You need to restart the app after changing 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_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">Songs To Preload</string>
|
||||||
<string name="settings.preload_1">1 song</string>
|
<string name="settings.preload_1">1 song</string>
|
||||||
<string name="settings.preload_10">10 songs</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_3">3 songs</string>
|
||||||
<string name="settings.preload_5">5 songs</string>
|
<string name="settings.preload_5">5 songs</string>
|
||||||
<string name="settings.preload_unlimited">Unlimited</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_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.scrobble_title">Scrobble my plays</string>
|
||||||
<string name="settings.search_1">1</string>
|
<string name="settings.search_1">1</string>
|
||||||
|
@ -302,28 +288,14 @@
|
||||||
<string name="settings.search_75">75</string>
|
<string name="settings.search_75">75</string>
|
||||||
<string name="settings.search_history_cleared">Search history cleared</string>
|
<string name="settings.search_history_cleared">Search history cleared</string>
|
||||||
<string name="settings.search_title">Search Settings</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_manage_servers">Manage Servers</string>
|
||||||
<string name="settings.server_address">Server Address</string>
|
<string name="settings.server_address">Server Address</string>
|
||||||
<string name="settings.server_name">Name</string>
|
<string name="settings.server_name">Name</string>
|
||||||
<string name="settings.server_password">Password</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_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_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_username">Username</string>
|
||||||
<string name="settings.server_color">Server color</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">Show Now Playing</string>
|
||||||
<string name="settings.show_now_playing_summary">Show currently playing track in all activities</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>
|
<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="download.menu_show_artist">Show Artist</string>
|
||||||
<string name="albumArt">albumArt</string>
|
<string name="albumArt">albumArt</string>
|
||||||
<string name="common_multiple_years">Multiple Years</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.title">Debug options</string>
|
||||||
<string name="settings.debug.log_to_file">Write debug log to file</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>
|
<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="one">%d song inserted after current song</item>
|
||||||
<item quantity="other">%d songs inserted after current song</item>
|
<item quantity="other">%d songs inserted after current song</item>
|
||||||
</plurals>
|
</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 -->
|
<!-- Subsonic api errors -->
|
||||||
<string name="api.subsonic.generic">Generic api error: %1$s</string>
|
<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_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.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>
|
</resources>
|
||||||
|
|
|
@ -77,18 +77,6 @@
|
||||||
a:summary="@string/settings.download_transition_summary"
|
a:summary="@string/settings.download_transition_summary"
|
||||||
a:title="@string/settings.download_transition"
|
a:title="@string/settings.download_transition"
|
||||||
app:iconSpaceReserved="false"/>
|
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
|
<CheckBoxPreference
|
||||||
a:defaultValue="false"
|
a:defaultValue="false"
|
||||||
a:key="clearBookmark"
|
a:key="clearBookmark"
|
||||||
|
@ -104,7 +92,7 @@
|
||||||
app:iconSpaceReserved="false"/>
|
app:iconSpaceReserved="false"/>
|
||||||
<CheckBoxPreference
|
<CheckBoxPreference
|
||||||
a:defaultValue="false"
|
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:title="@string/settings.playback.resume_play_on_headphones_plug.title"
|
||||||
a:summary="@string/settings.playback.resume_play_on_headphones_plug.summary"
|
a:summary="@string/settings.playback.resume_play_on_headphones_plug.summary"
|
||||||
app:iconSpaceReserved="false"/>
|
app:iconSpaceReserved="false"/>
|
||||||
|
@ -116,18 +104,18 @@
|
||||||
a:key="pauseOnBluetoothDevice"
|
a:key="pauseOnBluetoothDevice"
|
||||||
a:title="@string/settings.playback.pause_on_bluetooth_device"
|
a:title="@string/settings.playback.pause_on_bluetooth_device"
|
||||||
app:iconSpaceReserved="false"/>
|
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
|
<CheckBoxPreference
|
||||||
a:defaultValue="false"
|
a:defaultValue="false"
|
||||||
a:key="use_five_star_rating"
|
a:key="use_five_star_rating"
|
||||||
a:summary="@string/settings.five_star_rating_description"
|
a:summary="@string/settings.five_star_rating_description"
|
||||||
a:title="@string/settings.five_star_rating_title"
|
a:title="@string/settings.five_star_rating_title"
|
||||||
app:iconSpaceReserved="false" />
|
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>
|
||||||
<PreferenceCategory
|
<PreferenceCategory
|
||||||
a:title="@string/settings.notifications_title"
|
a:title="@string/settings.notifications_title"
|
||||||
|
@ -139,42 +127,6 @@
|
||||||
a:summary="@string/settings.show_now_playing_summary"
|
a:summary="@string/settings.show_now_playing_summary"
|
||||||
a:title="@string/settings.show_now_playing"
|
a:title="@string/settings.show_now_playing"
|
||||||
app:iconSpaceReserved="false"/>
|
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>
|
||||||
<PreferenceCategory
|
<PreferenceCategory
|
||||||
a:title="@string/settings.sharing_title"
|
a:title="@string/settings.sharing_title"
|
||||||
|
|
Loading…
Reference in New Issue