mirror of
https://gitlab.com/ultrasonic/ultrasonic.git
synced 2025-04-15 08:50:35 +03:00
Merge branch '440' into 'master'
Release 4.4.0 See merge request ultrasonic/ultrasonic!1005
This commit is contained in:
commit
9786cf2abf
@ -11,7 +11,6 @@ buildscript {
|
||||
google()
|
||||
mavenCentral()
|
||||
maven { url "https://plugins.gradle.org/m2/" }
|
||||
maven { url 'https://jitpack.io' }
|
||||
}
|
||||
dependencies {
|
||||
classpath libs.gradle
|
||||
@ -34,7 +33,6 @@ allprojects {
|
||||
repositories {
|
||||
mavenCentral()
|
||||
google()
|
||||
maven { url 'https://jitpack.io' }
|
||||
}
|
||||
|
||||
// Set Kotlin JVM target to the same for all subprojects
|
||||
|
@ -13,7 +13,6 @@ dependencies {
|
||||
|
||||
testImplementation libs.kotlinJunit
|
||||
testImplementation libs.mockito
|
||||
testImplementation libs.mockitoInline
|
||||
testImplementation libs.mockitoKotlin
|
||||
testImplementation libs.kluent
|
||||
testImplementation libs.mockWebServer
|
||||
|
10
fastlane/metadata/android/en-US/changelogs/119.txt
Normal file
10
fastlane/metadata/android/en-US/changelogs/119.txt
Normal file
@ -0,0 +1,10 @@
|
||||
Features:
|
||||
- This releases focuses on shuffled playback. The view of the playlist will now present itself in the order it will actually play. You can toggle the shuffle mode to create a new order, while the past playback history will be preserved.
|
||||
- Use Coroutines for triggering the download or playback of music through the context menus
|
||||
- Enable Artists pictures by Default
|
||||
|
||||
Bug fixes:
|
||||
- Remove an unhelpful popup that "ID must be set"
|
||||
- Shuffle mode doesn't always play all tracks
|
||||
- Shuffle mode starts with the first track most of the time
|
||||
|
@ -3,12 +3,12 @@ Ultrasonic is a Subsonic (and compatible servers) client to Android. You can use
|
||||
Main features:
|
||||
* Thin
|
||||
* Fast
|
||||
* Dark and light theme
|
||||
* Material theme with dark and light variants
|
||||
* Multiple server support
|
||||
* Offline Mode
|
||||
* Bookmarks
|
||||
* Playlists on server
|
||||
* Ramdom play
|
||||
* Random play
|
||||
* Jukebox mode
|
||||
* Server chat
|
||||
* And much more!!!
|
||||
|
@ -4,10 +4,19 @@ org.gradle.configureondemand=true
|
||||
org.gradle.caching=true
|
||||
org.gradle.jvmargs=-Xmx2g -XX:+UseParallelGC
|
||||
|
||||
|
||||
kotlin.incremental=true
|
||||
kotlin.caching.enabled=true
|
||||
kotlin.incremental.usePreciseJavaTracking=true
|
||||
|
||||
android.useAndroidX=true
|
||||
android.enableJetifier=false
|
||||
|
||||
# This properties enables transitive Resource classes, which decreases build time,
|
||||
# but could lead to problems referencing Resources. Set them to false if needed.
|
||||
android.nonTransitiveRClass=true
|
||||
android.nonFinalResIds=true
|
||||
|
||||
# This config was suggested by Android Studio to reduce build time
|
||||
# It can be removed if it makes problems
|
||||
org.gradle.unsafe.configuration-cache=true
|
||||
|
||||
|
@ -1,12 +1,12 @@
|
||||
[versions]
|
||||
# You need to run ./gradlew wrapper after updating the version
|
||||
gradle = "7.6"
|
||||
gradle = "8.1.1"
|
||||
|
||||
navigation = "2.5.3"
|
||||
gradlePlugin = "7.4.2"
|
||||
gradlePlugin = "8.0.1"
|
||||
androidxcore = "1.10.0"
|
||||
ktlint = "0.43.2"
|
||||
ktlintGradle = "11.3.1"
|
||||
ktlintGradle = "11.3.2"
|
||||
detekt = "1.22.0"
|
||||
preferences = "1.2.0"
|
||||
media3 = "1.0.1"
|
||||
@ -16,9 +16,9 @@ materialDesign = "1.8.0"
|
||||
constraintLayout = "2.1.4"
|
||||
multidex = "2.0.1"
|
||||
room = "2.5.1"
|
||||
kotlin = "1.8.20"
|
||||
kotlinxCoroutines = "1.6.4"
|
||||
kotlinxGuava = "1.6.4"
|
||||
kotlin = "1.8.21"
|
||||
kotlinxCoroutines = "1.7.0"
|
||||
kotlinxGuava = "1.7.0"
|
||||
viewModelKtx = "2.6.1"
|
||||
swipeRefresh = "1.1.0"
|
||||
|
||||
@ -30,12 +30,12 @@ koin = "3.3.2"
|
||||
picasso = "2.8"
|
||||
|
||||
junit4 = "4.13.2"
|
||||
junit5 = "5.9.2"
|
||||
mockito = "5.2.0"
|
||||
junit5 = "5.9.3"
|
||||
mockito = "5.3.1"
|
||||
mockitoKotlin = "4.1.0"
|
||||
kluent = "1.72"
|
||||
kluent = "1.73"
|
||||
apacheCodecs = "1.15"
|
||||
robolectric = "4.9.2"
|
||||
robolectric = "4.10.2"
|
||||
timber = "5.0.1"
|
||||
fastScroll = "2.0.1"
|
||||
colorPicker = "2.2.4"
|
||||
@ -96,7 +96,6 @@ junitVintage = { module = "org.junit.vintage:junit-vintage-engine", v
|
||||
kotlinJunit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" }
|
||||
mockitoKotlin = { module = "org.mockito.kotlin:mockito-kotlin", version.ref = "mockitoKotlin" }
|
||||
mockito = { module = "org.mockito:mockito-core", version.ref = "mockito" }
|
||||
mockitoInline = { module = "org.mockito:mockito-inline", version.ref = "mockito" }
|
||||
kluent = { module = "org.amshove.kluent:kluent", version.ref = "kluent" }
|
||||
kluentAndroid = { module = "org.amshove.kluent:kluent-android", version.ref = "kluent" }
|
||||
mockWebServer = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" }
|
||||
|
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Binary file not shown.
2
gradle/wrapper/gradle-wrapper.properties
vendored
2
gradle/wrapper/gradle-wrapper.properties
vendored
@ -1,6 +1,6 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.0.2-all.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.1.1-all.zip
|
||||
networkTimeout=10000
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
|
7
gradlew
vendored
7
gradlew
vendored
@ -85,9 +85,6 @@ done
|
||||
APP_BASE_NAME=${0##*/}
|
||||
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
@ -197,6 +194,10 @@ if "$cygwin" || "$msys" ; then
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command;
|
||||
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
|
||||
# shell script including quotes and variable substitutions, so put them in
|
||||
|
@ -9,8 +9,8 @@ android {
|
||||
|
||||
defaultConfig {
|
||||
applicationId "org.moire.ultrasonic"
|
||||
versionCode 117
|
||||
versionName "4.3.4"
|
||||
versionCode 119
|
||||
versionName "4.4.0"
|
||||
|
||||
minSdkVersion versions.minSdk
|
||||
targetSdkVersion versions.targetSdk
|
||||
@ -34,7 +34,7 @@ android {
|
||||
minifyEnabled false
|
||||
multiDexEnabled true
|
||||
testCoverageEnabled true
|
||||
applicationIdSuffix ".debug"
|
||||
applicationIdSuffix '.debug'
|
||||
}
|
||||
}
|
||||
|
||||
@ -56,6 +56,7 @@ android {
|
||||
buildFeatures {
|
||||
viewBinding true
|
||||
dataBinding true
|
||||
buildConfig true
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
@ -136,7 +137,6 @@ dependencies {
|
||||
testImplementation libs.kotlinJunit
|
||||
testImplementation libs.kluent
|
||||
testImplementation libs.mockito
|
||||
testImplementation libs.mockitoInline
|
||||
testImplementation libs.mockitoKotlin
|
||||
testImplementation libs.robolectric
|
||||
|
||||
|
@ -7,7 +7,6 @@
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK"/>
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS"/>
|
||||
<uses-permission android:name="android.permission.BLUETOOTH"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
|
@ -15,17 +15,17 @@ import android.view.ViewGroup
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import androidx.media3.common.HeartRating
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.drakeet.multitype.ItemViewDelegate
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
import org.moire.ultrasonic.R
|
||||
import org.moire.ultrasonic.data.RatingUpdate
|
||||
import org.moire.ultrasonic.domain.Album
|
||||
import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService
|
||||
import org.moire.ultrasonic.service.RxBus
|
||||
import org.moire.ultrasonic.subsonic.ImageLoaderProvider
|
||||
import org.moire.ultrasonic.util.LayoutType
|
||||
import org.moire.ultrasonic.util.Settings.shouldUseId3Tags
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
* Creates a Row in a RecyclerView which contains the details of an Album
|
||||
@ -112,27 +112,13 @@ open class AlbumRowDelegate(
|
||||
private fun onStarClick(entry: Album, star: ImageView) {
|
||||
entry.starred = !entry.starred
|
||||
star.setImageResource(if (entry.starred) starDrawable else starHollowDrawable)
|
||||
val musicService = getMusicService()
|
||||
Thread {
|
||||
val useId3 = shouldUseId3Tags
|
||||
try {
|
||||
if (entry.starred) {
|
||||
musicService.star(
|
||||
if (!useId3) entry.id else null,
|
||||
if (useId3) entry.id else null,
|
||||
null
|
||||
)
|
||||
} else {
|
||||
musicService.unstar(
|
||||
if (!useId3) entry.id else null,
|
||||
if (useId3) entry.id else null,
|
||||
null
|
||||
)
|
||||
}
|
||||
} catch (all: Exception) {
|
||||
Timber.e(all)
|
||||
}
|
||||
}.start()
|
||||
|
||||
RxBus.ratingSubmitter.onNext(
|
||||
RatingUpdate(
|
||||
entry.id,
|
||||
HeartRating(entry.starred)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(context: Context, parent: ViewGroup): ListViewHolder {
|
||||
|
@ -62,8 +62,6 @@ class TrackViewBinder(
|
||||
diffAdapter.isSelected(item.longId)
|
||||
)
|
||||
|
||||
// Timber.v("Setting listeners")
|
||||
|
||||
holder.itemView.setOnLongClickListener {
|
||||
if (onContextMenuClick != null) {
|
||||
val popup = createContextMenu(holder.itemView, track)
|
||||
@ -116,8 +114,6 @@ class TrackViewBinder(
|
||||
|
||||
if (newStatus != holder.check.isChecked) holder.check.isChecked = newStatus
|
||||
}
|
||||
|
||||
// Timber.v("Setting listeners done")
|
||||
}
|
||||
|
||||
override fun onViewRecycled(holder: TrackViewHolder) {
|
||||
|
@ -10,6 +10,7 @@ import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.isGone
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.media3.common.HeartRating
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.progressindicator.CircularProgressIndicator
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
@ -19,10 +20,10 @@ import kotlinx.coroutines.launch
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.moire.ultrasonic.R
|
||||
import org.moire.ultrasonic.data.ActiveServerProvider
|
||||
import org.moire.ultrasonic.data.RatingUpdate
|
||||
import org.moire.ultrasonic.domain.Track
|
||||
import org.moire.ultrasonic.service.DownloadService
|
||||
import org.moire.ultrasonic.service.DownloadState
|
||||
import org.moire.ultrasonic.service.MusicServiceFactory
|
||||
import org.moire.ultrasonic.service.RxBus
|
||||
import org.moire.ultrasonic.service.plusAssign
|
||||
import org.moire.ultrasonic.util.Settings
|
||||
@ -81,7 +82,6 @@ class TrackViewHolder(val view: View) :
|
||||
draggable: Boolean,
|
||||
isSelected: Boolean = false
|
||||
) {
|
||||
// Timber.v("Setting song")
|
||||
val useFiveStarRating = Settings.useFiveStarRating
|
||||
entry = song
|
||||
|
||||
@ -118,9 +118,9 @@ class TrackViewHolder(val view: View) :
|
||||
}
|
||||
|
||||
if (useFiveStarRating) {
|
||||
setFiveStars(entry?.userRating ?: 0)
|
||||
updateFiveStars(entry?.userRating ?: 0)
|
||||
} else {
|
||||
setSingleStar(entry!!.starred)
|
||||
updateSingleStar(entry!!.starred)
|
||||
}
|
||||
|
||||
if (song.isVideo) {
|
||||
@ -131,7 +131,7 @@ class TrackViewHolder(val view: View) :
|
||||
// Create new Disposable for the new Subscriptions
|
||||
rxBusSubscription = CompositeDisposable()
|
||||
rxBusSubscription!! += RxBus.playerStateObservable.subscribe {
|
||||
setPlayIcon(it.index == bindingAdapterPosition && it.track?.id == song.id)
|
||||
setPlayIcon(it.track?.id == song.id && it.index == bindingAdapterPosition)
|
||||
}
|
||||
|
||||
rxBusSubscription!! += RxBus.trackDownloadStateObservable.subscribe {
|
||||
@ -165,48 +165,32 @@ class TrackViewHolder(val view: View) :
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupStarButtons(song: Track, useFiveStarRating: Boolean) {
|
||||
private fun setupStarButtons(track: Track, useFiveStarRating: Boolean) {
|
||||
if (useFiveStarRating) {
|
||||
// Hide single star
|
||||
star.isGone = true
|
||||
rating.isVisible = true
|
||||
val rating = if (song.userRating == null) 0 else song.userRating!!
|
||||
setFiveStars(rating)
|
||||
val rating = if (track.userRating == null) 0 else track.userRating!!
|
||||
updateFiveStars(rating)
|
||||
|
||||
// Five star rating has no click handler because in the
|
||||
// track view theres not enough space
|
||||
} else {
|
||||
star.isVisible = true
|
||||
rating.isGone = true
|
||||
setSingleStar(song.starred)
|
||||
updateSingleStar(track.starred)
|
||||
star.setOnClickListener {
|
||||
val isStarred = song.starred
|
||||
val id = song.id
|
||||
|
||||
if (!isStarred) {
|
||||
star.setImageResource(R.drawable.ic_star_full)
|
||||
song.starred = true
|
||||
} else {
|
||||
star.setImageResource(R.drawable.ic_star_hollow)
|
||||
song.starred = false
|
||||
}
|
||||
|
||||
// Should this be done here ?
|
||||
Thread {
|
||||
val musicService = MusicServiceFactory.getMusicService()
|
||||
try {
|
||||
if (!isStarred) {
|
||||
musicService.star(id, null, null)
|
||||
} else {
|
||||
musicService.unstar(id, null, null)
|
||||
}
|
||||
} catch (all: Exception) {
|
||||
Timber.e(all)
|
||||
}
|
||||
}.start()
|
||||
track.starred = !track.starred
|
||||
updateSingleStar(track.starred)
|
||||
RxBus.ratingSubmitter.onNext(
|
||||
RatingUpdate(track.id, HeartRating(track.starred))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
private fun setFiveStars(rating: Int) {
|
||||
private fun updateFiveStars(rating: Int) {
|
||||
fiveStar1.setImageResource(
|
||||
if (rating > 0) R.drawable.ic_star_full else R.drawable.ic_star_hollow
|
||||
)
|
||||
@ -224,7 +208,7 @@ class TrackViewHolder(val view: View) :
|
||||
)
|
||||
}
|
||||
|
||||
private fun setSingleStar(starred: Boolean) {
|
||||
private fun updateSingleStar(starred: Boolean) {
|
||||
if (starred) {
|
||||
star.setImageResource(R.drawable.ic_star_full)
|
||||
} else {
|
||||
|
@ -0,0 +1,16 @@
|
||||
/*
|
||||
* RatingUpdate.kt
|
||||
* Copyright (C) 2009-2023 Ultrasonic developers
|
||||
*
|
||||
* Distributed under terms of the GNU GPLv3 license.
|
||||
*/
|
||||
|
||||
package org.moire.ultrasonic.data
|
||||
|
||||
import androidx.media3.common.Rating
|
||||
|
||||
data class RatingUpdate(
|
||||
val id: String,
|
||||
val rating: Rating,
|
||||
val success: Boolean? = null
|
||||
)
|
@ -21,6 +21,7 @@ import org.moire.ultrasonic.domain.GenericEntry
|
||||
import org.moire.ultrasonic.domain.Identifiable
|
||||
import org.moire.ultrasonic.service.RxBus
|
||||
import org.moire.ultrasonic.service.plusAssign
|
||||
import org.moire.ultrasonic.subsonic.DownloadAction
|
||||
import org.moire.ultrasonic.subsonic.DownloadHandler
|
||||
import org.moire.ultrasonic.util.Settings
|
||||
|
||||
@ -129,81 +130,54 @@ abstract class EntryListFragment<T : GenericEntry> : MultiListFragment<T>() {
|
||||
): Boolean {
|
||||
when (menuItem.itemId) {
|
||||
R.id.menu_play_now ->
|
||||
downloadHandler.downloadRecursively(
|
||||
downloadHandler.fetchTracksAndAddToController(
|
||||
fragment,
|
||||
item.id,
|
||||
save = false,
|
||||
append = false,
|
||||
autoPlay = true,
|
||||
shuffle = false,
|
||||
background = false,
|
||||
playNext = false,
|
||||
unpin = false,
|
||||
isArtist = isArtist
|
||||
)
|
||||
R.id.menu_play_next ->
|
||||
downloadHandler.downloadRecursively(
|
||||
downloadHandler.fetchTracksAndAddToController(
|
||||
fragment,
|
||||
item.id,
|
||||
save = false,
|
||||
append = false,
|
||||
autoPlay = true,
|
||||
shuffle = true,
|
||||
background = false,
|
||||
playNext = true,
|
||||
unpin = false,
|
||||
isArtist = isArtist
|
||||
)
|
||||
R.id.menu_play_last ->
|
||||
downloadHandler.downloadRecursively(
|
||||
downloadHandler.fetchTracksAndAddToController(
|
||||
fragment,
|
||||
item.id,
|
||||
save = false,
|
||||
append = true,
|
||||
autoPlay = false,
|
||||
shuffle = false,
|
||||
background = false,
|
||||
playNext = false,
|
||||
unpin = false,
|
||||
isArtist = isArtist
|
||||
)
|
||||
R.id.menu_pin ->
|
||||
downloadHandler.downloadRecursively(
|
||||
downloadHandler.justDownload(
|
||||
action = DownloadAction.PIN,
|
||||
fragment,
|
||||
item.id,
|
||||
save = true,
|
||||
append = true,
|
||||
autoPlay = false,
|
||||
shuffle = false,
|
||||
background = false,
|
||||
playNext = false,
|
||||
unpin = false,
|
||||
isArtist = isArtist
|
||||
)
|
||||
R.id.menu_unpin ->
|
||||
downloadHandler.downloadRecursively(
|
||||
downloadHandler.justDownload(
|
||||
action = DownloadAction.UNPIN,
|
||||
fragment,
|
||||
item.id,
|
||||
save = false,
|
||||
append = false,
|
||||
autoPlay = false,
|
||||
shuffle = false,
|
||||
background = false,
|
||||
playNext = false,
|
||||
unpin = true,
|
||||
isArtist = isArtist
|
||||
)
|
||||
R.id.menu_download ->
|
||||
downloadHandler.downloadRecursively(
|
||||
downloadHandler.justDownload(
|
||||
action = DownloadAction.DOWNLOAD,
|
||||
fragment,
|
||||
item.id,
|
||||
save = false,
|
||||
append = false,
|
||||
autoPlay = false,
|
||||
shuffle = false,
|
||||
background = true,
|
||||
playNext = false,
|
||||
unpin = false,
|
||||
isArtist = isArtist
|
||||
)
|
||||
else -> return false
|
||||
|
@ -149,10 +149,10 @@ class NowPlayingFragment : Fragment() {
|
||||
if (abs(deltaX) > MIN_DISTANCE) {
|
||||
// left or right
|
||||
if (deltaX < 0) {
|
||||
mediaPlayerController.previous()
|
||||
mediaPlayerController.seekToPrevious()
|
||||
}
|
||||
if (deltaX > 0) {
|
||||
mediaPlayerController.next()
|
||||
mediaPlayerController.seekToNext()
|
||||
}
|
||||
} else if (abs(deltaY) > MIN_DISTANCE) {
|
||||
if (deltaY < 0) {
|
||||
|
@ -35,11 +35,15 @@ import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import android.widget.ViewFlipper
|
||||
import androidx.core.content.res.ResourcesCompat
|
||||
import androidx.core.view.MenuHost
|
||||
import androidx.core.view.MenuProvider
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.media3.common.HeartRating
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.session.SessionResult
|
||||
import androidx.media3.common.StarRating
|
||||
import androidx.navigation.Navigation
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.recyclerview.widget.ItemTouchHelper
|
||||
@ -49,8 +53,6 @@ import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.LinearSmoothScroller
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.button.MaterialButton
|
||||
import com.google.common.util.concurrent.FutureCallback
|
||||
import com.google.common.util.concurrent.Futures
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import java.text.DateFormat
|
||||
import java.text.SimpleDateFormat
|
||||
@ -76,6 +78,7 @@ import org.moire.ultrasonic.adapters.TrackViewBinder
|
||||
import org.moire.ultrasonic.api.subsonic.models.AlbumListType
|
||||
import org.moire.ultrasonic.audiofx.EqualizerController
|
||||
import org.moire.ultrasonic.data.ActiveServerProvider.Companion.isOffline
|
||||
import org.moire.ultrasonic.data.RatingUpdate
|
||||
import org.moire.ultrasonic.domain.Identifiable
|
||||
import org.moire.ultrasonic.domain.MusicDirectory
|
||||
import org.moire.ultrasonic.domain.Track
|
||||
@ -98,7 +101,7 @@ import timber.log.Timber
|
||||
|
||||
/**
|
||||
* Contains the Music Player screen of Ultrasonic with playback controls and the playlist
|
||||
* TODO: Add timeline lister -> updateProgressBar().
|
||||
*
|
||||
*/
|
||||
@Suppress("LargeClass", "TooManyFunctions", "MagicNumber")
|
||||
class PlayerFragment :
|
||||
@ -132,7 +135,6 @@ class PlayerFragment :
|
||||
|
||||
// Views and UI Elements
|
||||
private lateinit var playlistNameView: EditText
|
||||
private lateinit var starMenuItem: MenuItem
|
||||
private lateinit var fiveStar1ImageView: ImageView
|
||||
private lateinit var fiveStar2ImageView: ImageView
|
||||
private lateinit var fiveStar3ImageView: ImageView
|
||||
@ -154,6 +156,8 @@ class PlayerFragment :
|
||||
private lateinit var pauseButton: View
|
||||
private lateinit var stopButton: View
|
||||
private lateinit var playButton: View
|
||||
private lateinit var previousButton: MaterialButton
|
||||
private lateinit var nextButton: MaterialButton
|
||||
private lateinit var shuffleButton: View
|
||||
private lateinit var repeatButton: MaterialButton
|
||||
private lateinit var progressBar: SeekBar
|
||||
@ -196,6 +200,8 @@ class PlayerFragment :
|
||||
pauseButton = view.findViewById(R.id.button_pause)
|
||||
stopButton = view.findViewById(R.id.button_stop)
|
||||
playButton = view.findViewById(R.id.button_start)
|
||||
nextButton = view.findViewById(R.id.button_next)
|
||||
previousButton = view.findViewById(R.id.button_previous)
|
||||
repeatButton = view.findViewById(R.id.button_repeat)
|
||||
fiveStar1ImageView = view.findViewById(R.id.song_five_star_1)
|
||||
fiveStar2ImageView = view.findViewById(R.id.song_five_star_2)
|
||||
@ -226,7 +232,13 @@ class PlayerFragment :
|
||||
height = size.y
|
||||
}
|
||||
|
||||
setHasOptionsMenu(true)
|
||||
// Register our options menu
|
||||
(requireActivity() as MenuHost).addMenuProvider(
|
||||
menuProvider,
|
||||
viewLifecycleOwner,
|
||||
Lifecycle.State.RESUMED
|
||||
)
|
||||
|
||||
useFiveStarRating = Settings.useFiveStarRating
|
||||
swipeDistance = (width + height) * PERCENTAGE_OF_SCREEN_FOR_SWIPE / 100
|
||||
swipeVelocity = swipeDistance
|
||||
@ -259,9 +271,7 @@ class PlayerFragment :
|
||||
previousButton.setOnClickListener {
|
||||
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
|
||||
launch(CommunicationError.getHandler(context)) {
|
||||
mediaPlayerController.previous()
|
||||
onCurrentChanged()
|
||||
onSliderProgressChanged()
|
||||
mediaPlayerController.seekToPrevious()
|
||||
}
|
||||
}
|
||||
|
||||
@ -272,9 +282,7 @@ class PlayerFragment :
|
||||
nextButton.setOnClickListener {
|
||||
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
|
||||
launch(CommunicationError.getHandler(context)) {
|
||||
mediaPlayerController.next()
|
||||
onCurrentChanged()
|
||||
onSliderProgressChanged()
|
||||
mediaPlayerController.seekToNext()
|
||||
}
|
||||
}
|
||||
|
||||
@ -285,16 +293,12 @@ class PlayerFragment :
|
||||
pauseButton.setOnClickListener {
|
||||
launch(CommunicationError.getHandler(context)) {
|
||||
mediaPlayerController.pause()
|
||||
onCurrentChanged()
|
||||
onSliderProgressChanged()
|
||||
}
|
||||
}
|
||||
|
||||
stopButton.setOnClickListener {
|
||||
launch(CommunicationError.getHandler(context)) {
|
||||
mediaPlayerController.reset()
|
||||
onCurrentChanged()
|
||||
onSliderProgressChanged()
|
||||
}
|
||||
}
|
||||
|
||||
@ -304,8 +308,6 @@ class PlayerFragment :
|
||||
|
||||
launch(CommunicationError.getHandler(context)) {
|
||||
mediaPlayerController.play()
|
||||
onCurrentChanged()
|
||||
onSliderProgressChanged()
|
||||
}
|
||||
}
|
||||
|
||||
@ -342,7 +344,6 @@ class PlayerFragment :
|
||||
override fun onStopTrackingTouch(seekBar: SeekBar) {
|
||||
launch(CommunicationError.getHandler(context)) {
|
||||
mediaPlayerController.seekTo(progressBar.progress)
|
||||
onSliderProgressChanged()
|
||||
}
|
||||
}
|
||||
|
||||
@ -367,11 +368,13 @@ class PlayerFragment :
|
||||
// Observe playlist changes and update the UI
|
||||
rxBusSubscription += RxBus.playlistObservable.subscribe {
|
||||
onPlaylistChanged()
|
||||
onSliderProgressChanged()
|
||||
updateSeekBar()
|
||||
}
|
||||
|
||||
rxBusSubscription += RxBus.playerStateObservable.subscribe {
|
||||
update()
|
||||
updateTitle(it.state)
|
||||
updateButtonStates(it.state)
|
||||
}
|
||||
|
||||
// Query the Jukebox state in an IO Context
|
||||
@ -432,7 +435,7 @@ class PlayerFragment :
|
||||
} else {
|
||||
// Download list and Album art must be updated when resumed
|
||||
onPlaylistChanged()
|
||||
onCurrentChanged()
|
||||
onTrackChanged()
|
||||
}
|
||||
|
||||
val handler = Handler(Looper.getMainLooper())
|
||||
@ -472,23 +475,55 @@ class PlayerFragment :
|
||||
super.onDestroyView()
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
|
||||
inflater.inflate(R.menu.nowplaying, menu)
|
||||
super.onCreateOptionsMenu(menu, inflater)
|
||||
private val menuProvider: MenuProvider = object : MenuProvider {
|
||||
override fun onPrepareMenu(menu: Menu) {
|
||||
setupOptionsMenu(menu)
|
||||
}
|
||||
|
||||
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
|
||||
menuInflater.inflate(R.menu.nowplaying, menu)
|
||||
}
|
||||
|
||||
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
|
||||
return menuItemSelected(menuItem.itemId, currentSong)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("ComplexMethod", "LongMethod", "NestedBlockDepth")
|
||||
override fun onPrepareOptionsMenu(menu: Menu) {
|
||||
super.onPrepareOptionsMenu(menu)
|
||||
fun setupOptionsMenu(menu: Menu) {
|
||||
// Seems there is nothing like ViewBinding for Menus
|
||||
val screenOption = menu.findItem(R.id.menu_item_screen_on_off)
|
||||
val goToAlbum = menu.findItem(R.id.menu_show_album)
|
||||
val goToArtist = menu.findItem(R.id.menu_show_artist)
|
||||
val jukeboxOption = menu.findItem(R.id.menu_item_jukebox)
|
||||
val equalizerMenuItem = menu.findItem(R.id.menu_item_equalizer)
|
||||
val shareMenuItem = menu.findItem(R.id.menu_item_share)
|
||||
val shareSongMenuItem = menu.findItem(R.id.menu_item_share_song)
|
||||
starMenuItem = menu.findItem(R.id.menu_item_star)
|
||||
val starMenuItem = menu.findItem(R.id.menu_item_star)
|
||||
val bookmarkMenuItem = menu.findItem(R.id.menu_item_bookmark_set)
|
||||
val bookmarkRemoveMenuItem = menu.findItem(R.id.menu_item_bookmark_delete)
|
||||
|
||||
// Listen to rating changes and update the UI
|
||||
rxBusSubscription += RxBus.ratingPublishedObservable.subscribe { update ->
|
||||
|
||||
// Ignore updates which are not for the current song
|
||||
if (update.id != currentSong?.id) return@subscribe
|
||||
|
||||
// Ensure UI thread
|
||||
launch {
|
||||
if (update.success == true && update.rating is HeartRating) {
|
||||
if (update.rating.isHeart) {
|
||||
starMenuItem.setIcon(fullStar)
|
||||
} else {
|
||||
starMenuItem.setIcon(hollowStar)
|
||||
}
|
||||
} else if (update.success == false) {
|
||||
Toast.makeText(context, "Setting rating failed", Toast.LENGTH_SHORT)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isOffline()) {
|
||||
if (shareMenuItem != null) {
|
||||
shareMenuItem.isVisible = false
|
||||
@ -505,6 +540,7 @@ class PlayerFragment :
|
||||
equalizerMenuItem.isEnabled = isEqualizerAvailable
|
||||
equalizerMenuItem.isVisible = isEqualizerAvailable
|
||||
}
|
||||
|
||||
val mediaPlayerController = mediaPlayerController
|
||||
val track = mediaPlayerController.currentMediaItem?.toTrack()
|
||||
|
||||
@ -517,9 +553,13 @@ class PlayerFragment :
|
||||
if (currentSong != null) {
|
||||
starMenuItem.setIcon(if (currentSong!!.starred) fullStar else hollowStar)
|
||||
shareSongMenuItem.isVisible = true
|
||||
goToAlbum.isVisible = true
|
||||
goToArtist.isVisible = true
|
||||
} else {
|
||||
starMenuItem.setIcon(hollowStar)
|
||||
shareSongMenuItem.isVisible = false
|
||||
goToAlbum.isVisible = false
|
||||
goToArtist.isVisible = false
|
||||
}
|
||||
|
||||
if (mediaPlayerController.keepScreenOn) {
|
||||
@ -560,10 +600,6 @@ class PlayerFragment :
|
||||
return popup
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
return menuItemSelected(item.itemId, currentSong) || super.onOptionsItemSelected(item)
|
||||
}
|
||||
|
||||
private fun onContextMenuItemSelected(
|
||||
menuItem: MenuItem,
|
||||
item: MusicDirectory.Child
|
||||
@ -660,31 +696,11 @@ class PlayerFragment :
|
||||
}
|
||||
R.id.menu_item_star -> {
|
||||
if (track == null) return true
|
||||
track.starred = !track.starred
|
||||
|
||||
val isStarred = track.starred
|
||||
|
||||
mediaPlayerController.toggleSongStarred()?.let {
|
||||
Futures.addCallback(
|
||||
it,
|
||||
object : FutureCallback<SessionResult> {
|
||||
override fun onSuccess(result: SessionResult?) {
|
||||
if (isStarred) {
|
||||
starMenuItem.setIcon(hollowStar)
|
||||
track.starred = false
|
||||
} else {
|
||||
starMenuItem.setIcon(fullStar)
|
||||
track.starred = true
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(t: Throwable) {
|
||||
Toast.makeText(context, "SetRating failed", Toast.LENGTH_SHORT)
|
||||
.show()
|
||||
}
|
||||
},
|
||||
this.executorService
|
||||
)
|
||||
}
|
||||
RxBus.ratingSubmitter.onNext(
|
||||
RatingUpdate(track.id, HeartRating(track.starred))
|
||||
)
|
||||
|
||||
return true
|
||||
}
|
||||
@ -764,10 +780,9 @@ class PlayerFragment :
|
||||
if (cancel?.isCancellationRequested == true) return
|
||||
val mediaPlayerController = mediaPlayerController
|
||||
if (currentSong?.id != mediaPlayerController.currentMediaItem?.mediaId) {
|
||||
onCurrentChanged()
|
||||
onTrackChanged()
|
||||
}
|
||||
onSliderProgressChanged()
|
||||
requireActivity().invalidateOptionsMenu()
|
||||
updateSeekBar()
|
||||
}
|
||||
|
||||
private fun savePlaylistInBackground(playlistName: String) {
|
||||
@ -827,12 +842,9 @@ class PlayerFragment :
|
||||
}
|
||||
|
||||
// Create listener
|
||||
val clickHandler: ((Track, Int) -> Unit) = { _, pos ->
|
||||
mediaPlayerController.seekTo(pos, 0)
|
||||
mediaPlayerController.prepare()
|
||||
mediaPlayerController.play()
|
||||
onCurrentChanged()
|
||||
onSliderProgressChanged()
|
||||
val clickHandler: ((Track, Int) -> Unit) = { _, listPos ->
|
||||
val mediaIndex = mediaPlayerController.getUnshuffledIndexOf(listPos)
|
||||
mediaPlayerController.play(mediaIndex)
|
||||
}
|
||||
|
||||
viewAdapter.register(
|
||||
@ -931,6 +943,7 @@ class PlayerFragment :
|
||||
if (actionState == ACTION_STATE_IDLE && dragging) {
|
||||
dragging = false
|
||||
// Move the item in the playlist separately
|
||||
Timber.i("Moving item %s to %s", startPosition, endPosition)
|
||||
mediaPlayerController.moveItemInPlaylist(startPosition, endPosition)
|
||||
}
|
||||
}
|
||||
@ -1010,7 +1023,8 @@ class PlayerFragment :
|
||||
|
||||
private fun onPlaylistChanged() {
|
||||
val mediaPlayerController = mediaPlayerController
|
||||
val list = mediaPlayerController.playlist
|
||||
// Try to display playlist in play order
|
||||
val list = mediaPlayerController.playlistInPlayOrder
|
||||
emptyTextView.setText(R.string.playlist_empty)
|
||||
|
||||
viewAdapter.submitList(list.map(MediaItem::toTrack))
|
||||
@ -1020,7 +1034,7 @@ class PlayerFragment :
|
||||
updateRepeatButtonState(mediaPlayerController.repeatMode)
|
||||
}
|
||||
|
||||
private fun onCurrentChanged() {
|
||||
private fun onTrackChanged() {
|
||||
currentSong = mediaPlayerController.currentMediaItem?.toTrack()
|
||||
|
||||
scrollToCurrent()
|
||||
@ -1064,7 +1078,7 @@ class PlayerFragment :
|
||||
it.loadImage(albumArtImageView, currentSong, true, 0)
|
||||
}
|
||||
|
||||
displaySongRating()
|
||||
updateSongRating()
|
||||
} else {
|
||||
currentSong = null
|
||||
songTitleTextView.text = null
|
||||
@ -1078,24 +1092,24 @@ class PlayerFragment :
|
||||
it.loadImage(albumArtImageView, null, true, 0)
|
||||
}
|
||||
}
|
||||
|
||||
updateSongRating()
|
||||
|
||||
nextButton.isEnabled = mediaPlayerController.canSeekToNext()
|
||||
previousButton.isEnabled = mediaPlayerController.canSeekToPrevious()
|
||||
}
|
||||
|
||||
@Suppress("LongMethod")
|
||||
@Synchronized
|
||||
private fun onSliderProgressChanged() {
|
||||
|
||||
private fun updateSeekBar() {
|
||||
val isJukeboxEnabled: Boolean = mediaPlayerController.isJukeboxEnabled
|
||||
val millisPlayed: Int = max(0, mediaPlayerController.playerPosition)
|
||||
val duration: Int = mediaPlayerController.playerDuration
|
||||
val playbackState: Int = mediaPlayerController.playbackState
|
||||
val isPlaying = mediaPlayerController.isPlaying
|
||||
|
||||
if (cancellationToken.isCancellationRequested) return
|
||||
if (currentSong != null) {
|
||||
positionTextView.text = Util.formatTotalDuration(millisPlayed.toLong(), true)
|
||||
durationTextView.text = Util.formatTotalDuration(duration.toLong(), true)
|
||||
progressBar.max =
|
||||
if (duration == 0) 100 else duration // Work-around for apparent bug.
|
||||
progressBar.max = if (duration == 0) 100 else duration // Work-around for apparent bug.
|
||||
progressBar.progress = millisPlayed
|
||||
progressBar.isEnabled = mediaPlayerController.isPlaying || isJukeboxEnabled
|
||||
} else {
|
||||
@ -1107,18 +1121,18 @@ class PlayerFragment :
|
||||
}
|
||||
|
||||
val progress = mediaPlayerController.bufferedPercentage
|
||||
updateBufferProgress(playbackState, progress)
|
||||
}
|
||||
|
||||
private fun updateTitle(playbackState: Int) {
|
||||
when (playbackState) {
|
||||
Player.STATE_BUFFERING -> {
|
||||
|
||||
val downloadStatus = resources.getString(
|
||||
R.string.download_playerstate_loading
|
||||
)
|
||||
progressBar.secondaryProgress = progress
|
||||
setTitle(this@PlayerFragment, downloadStatus)
|
||||
}
|
||||
Player.STATE_READY -> {
|
||||
progressBar.secondaryProgress = progress
|
||||
if (mediaPlayerController.isShufflePlayEnabled) {
|
||||
setTitle(
|
||||
this@PlayerFragment,
|
||||
@ -1128,13 +1142,22 @@ class PlayerFragment :
|
||||
setTitle(this@PlayerFragment, R.string.common_appname)
|
||||
}
|
||||
}
|
||||
Player.STATE_IDLE,
|
||||
Player.STATE_ENDED,
|
||||
-> {
|
||||
}
|
||||
Player.STATE_IDLE, Player.STATE_ENDED -> {}
|
||||
else -> setTitle(this@PlayerFragment, R.string.common_appname)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateBufferProgress(playbackState: Int, progress: Int) {
|
||||
when (playbackState) {
|
||||
Player.STATE_BUFFERING, Player.STATE_READY -> {
|
||||
progressBar.secondaryProgress = progress
|
||||
}
|
||||
else -> { }
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateButtonStates(playbackState: Int) {
|
||||
val isPlaying = mediaPlayerController.isPlaying
|
||||
when (playbackState) {
|
||||
Player.STATE_READY -> {
|
||||
pauseButton.isVisible = isPlaying
|
||||
@ -1152,10 +1175,6 @@ class PlayerFragment :
|
||||
playButton.isVisible = true
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: It would be a lot nicer if MediaPlayerController would send an event
|
||||
// when this is necessary instead of updating every time
|
||||
displaySongRating()
|
||||
}
|
||||
|
||||
private fun seek(forward: Boolean) {
|
||||
@ -1189,18 +1208,14 @@ class PlayerFragment :
|
||||
// Right to Left swipe
|
||||
if (e1X - e2X > swipeDistance && absX > swipeVelocity) {
|
||||
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
|
||||
mediaPlayerController.next()
|
||||
onCurrentChanged()
|
||||
onSliderProgressChanged()
|
||||
mediaPlayerController.seekToNext()
|
||||
return true
|
||||
}
|
||||
|
||||
// Left to Right swipe
|
||||
if (e2X - e1X > swipeDistance && absX > swipeVelocity) {
|
||||
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
|
||||
mediaPlayerController.previous()
|
||||
onCurrentChanged()
|
||||
onSliderProgressChanged()
|
||||
mediaPlayerController.seekToPrevious()
|
||||
return true
|
||||
}
|
||||
|
||||
@ -1208,7 +1223,6 @@ class PlayerFragment :
|
||||
if (e2Y - e1Y > swipeDistance && absY > swipeVelocity) {
|
||||
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
|
||||
mediaPlayerController.seekTo(mediaPlayerController.playerPosition + 30000)
|
||||
onSliderProgressChanged()
|
||||
return true
|
||||
}
|
||||
|
||||
@ -1216,7 +1230,6 @@ class PlayerFragment :
|
||||
if (e1Y - e2Y > swipeDistance && absY > swipeVelocity) {
|
||||
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
|
||||
mediaPlayerController.seekTo(mediaPlayerController.playerPosition - 8000)
|
||||
onSliderProgressChanged()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
@ -1237,12 +1250,8 @@ class PlayerFragment :
|
||||
return false
|
||||
}
|
||||
|
||||
private fun displaySongRating() {
|
||||
var rating = 0
|
||||
|
||||
if (currentSong?.userRating != null) {
|
||||
rating = currentSong!!.userRating!!
|
||||
}
|
||||
private fun updateSongRating() {
|
||||
val rating = currentSong?.userRating ?: 0
|
||||
|
||||
fiveStar1ImageView.setImageResource(if (rating > 0) fullStar else hollowStar)
|
||||
fiveStar2ImageView.setImageResource(if (rating > 1) fullStar else hollowStar)
|
||||
@ -1253,8 +1262,15 @@ class PlayerFragment :
|
||||
|
||||
private fun setSongRating(rating: Int) {
|
||||
if (currentSong == null) return
|
||||
displaySongRating()
|
||||
mediaPlayerController.setSongRating(rating)
|
||||
currentSong?.userRating = rating
|
||||
updateSongRating()
|
||||
|
||||
RxBus.ratingSubmitter.onNext(
|
||||
RatingUpdate(
|
||||
currentSong!!.id,
|
||||
StarRating(5, rating.toFloat())
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@SuppressLint("InflateParams")
|
||||
|
@ -309,7 +309,6 @@ class SearchFragment : MultiListFragment<Identifiable>(), KoinComponent {
|
||||
}
|
||||
mediaPlayerController.addToPlaylist(
|
||||
listOf(song),
|
||||
cachePermanently = false,
|
||||
autoPlay = false,
|
||||
shuffle = false,
|
||||
insertionMode = MediaPlayerController.InsertionMode.APPEND
|
||||
@ -367,40 +366,37 @@ class SearchFragment : MultiListFragment<Identifiable>(), KoinComponent {
|
||||
when (menuItem.itemId) {
|
||||
R.id.song_menu_play_now -> {
|
||||
songs.add(item)
|
||||
downloadHandler.download(
|
||||
fragment = this,
|
||||
append = false,
|
||||
save = false,
|
||||
autoPlay = true,
|
||||
playNext = false,
|
||||
shuffle = false,
|
||||
downloadHandler.addTracksToMediaController(
|
||||
songs = songs,
|
||||
append = false,
|
||||
playNext = false,
|
||||
autoPlay = true,
|
||||
shuffle = false,
|
||||
fragment = this,
|
||||
playlistName = null
|
||||
)
|
||||
}
|
||||
R.id.song_menu_play_next -> {
|
||||
songs.add(item)
|
||||
downloadHandler.download(
|
||||
fragment = this,
|
||||
append = true,
|
||||
save = false,
|
||||
autoPlay = false,
|
||||
playNext = true,
|
||||
shuffle = false,
|
||||
downloadHandler.addTracksToMediaController(
|
||||
songs = songs,
|
||||
append = true,
|
||||
playNext = true,
|
||||
autoPlay = false,
|
||||
shuffle = false,
|
||||
fragment = this,
|
||||
playlistName = null
|
||||
)
|
||||
}
|
||||
R.id.song_menu_play_last -> {
|
||||
songs.add(item)
|
||||
downloadHandler.download(
|
||||
fragment = this,
|
||||
append = true,
|
||||
save = false,
|
||||
autoPlay = false,
|
||||
playNext = false,
|
||||
shuffle = false,
|
||||
downloadHandler.addTracksToMediaController(
|
||||
songs = songs,
|
||||
append = true,
|
||||
playNext = false,
|
||||
autoPlay = false,
|
||||
shuffle = false,
|
||||
fragment = this,
|
||||
playlistName = null
|
||||
)
|
||||
}
|
||||
|
@ -40,11 +40,10 @@ import org.moire.ultrasonic.domain.MusicDirectory
|
||||
import org.moire.ultrasonic.domain.Track
|
||||
import org.moire.ultrasonic.fragment.FragmentTitle.Companion.setTitle
|
||||
import org.moire.ultrasonic.model.TrackCollectionModel
|
||||
import org.moire.ultrasonic.service.DownloadService
|
||||
import org.moire.ultrasonic.service.MediaPlayerController
|
||||
import org.moire.ultrasonic.service.RxBus
|
||||
import org.moire.ultrasonic.service.plusAssign
|
||||
import org.moire.ultrasonic.subsonic.NetworkAndStorageChecker
|
||||
import org.moire.ultrasonic.subsonic.DownloadAction
|
||||
import org.moire.ultrasonic.subsonic.ShareHandler
|
||||
import org.moire.ultrasonic.subsonic.VideoPlayer
|
||||
import org.moire.ultrasonic.util.CancellationToken
|
||||
@ -84,7 +83,6 @@ open class TrackCollectionFragment(
|
||||
private var shareButton: MenuItem? = null
|
||||
|
||||
internal val mediaPlayerController: MediaPlayerController by inject()
|
||||
private val networkAndStorageChecker: NetworkAndStorageChecker by inject()
|
||||
private val shareHandler: ShareHandler by inject()
|
||||
internal var cancellationToken: CancellationToken? = null
|
||||
|
||||
@ -211,11 +209,14 @@ open class TrackCollectionFragment(
|
||||
}
|
||||
|
||||
playNextButton?.setOnClickListener {
|
||||
downloadHandler.download(
|
||||
this@TrackCollectionFragment, append = true,
|
||||
save = false, autoPlay = false, playNext = true, shuffle = false,
|
||||
downloadHandler.addTracksToMediaController(
|
||||
songs = getSelectedSongs(),
|
||||
playlistName = navArgs.playlistName
|
||||
append = true,
|
||||
playNext = true,
|
||||
autoPlay = false,
|
||||
shuffle = false,
|
||||
playlistName = navArgs.playlistName,
|
||||
this@TrackCollectionFragment
|
||||
)
|
||||
}
|
||||
|
||||
@ -304,9 +305,14 @@ open class TrackCollectionFragment(
|
||||
selectedSongs: List<Track> = getSelectedSongs()
|
||||
) {
|
||||
if (selectedSongs.isNotEmpty()) {
|
||||
downloadHandler.download(
|
||||
this, append, false, !append, playNext = false,
|
||||
shuffle = false, songs = selectedSongs, null
|
||||
downloadHandler.addTracksToMediaController(
|
||||
songs = selectedSongs,
|
||||
append = append,
|
||||
playNext = false,
|
||||
autoPlay = !append,
|
||||
shuffle = false,
|
||||
playlistName = null,
|
||||
fragment = this
|
||||
)
|
||||
} else {
|
||||
playAll(false, append)
|
||||
@ -337,31 +343,29 @@ open class TrackCollectionFragment(
|
||||
}
|
||||
|
||||
val isArtist = navArgs.isArtist
|
||||
val id = navArgs.id
|
||||
|
||||
// Need a valid id to download stuff
|
||||
val id = navArgs.id ?: return
|
||||
|
||||
if (hasSubFolders) {
|
||||
downloadHandler.downloadRecursively(
|
||||
downloadHandler.fetchTracksAndAddToController(
|
||||
fragment = this,
|
||||
id = id,
|
||||
save = false,
|
||||
append = append,
|
||||
autoPlay = !append,
|
||||
shuffle = shuffle,
|
||||
background = false,
|
||||
playNext = false,
|
||||
unpin = false,
|
||||
isArtist = isArtist
|
||||
)
|
||||
} else {
|
||||
downloadHandler.download(
|
||||
fragment = this,
|
||||
append = append,
|
||||
save = false,
|
||||
autoPlay = !append,
|
||||
playNext = false,
|
||||
shuffle = shuffle,
|
||||
downloadHandler.addTracksToMediaController(
|
||||
songs = getAllSongs(),
|
||||
playlistName = navArgs.playlistName
|
||||
append = append,
|
||||
playNext = false,
|
||||
autoPlay = !append,
|
||||
shuffle = shuffle,
|
||||
playlistName = navArgs.playlistName,
|
||||
fragment = this
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -416,62 +420,35 @@ open class TrackCollectionFragment(
|
||||
}
|
||||
}
|
||||
|
||||
private fun downloadBackground(save: Boolean) {
|
||||
var songs = getSelectedSongs()
|
||||
private fun downloadBackground(save: Boolean, tracks: List<Track> = getSelectedSongs()) {
|
||||
var songs = tracks
|
||||
|
||||
if (songs.isEmpty()) {
|
||||
songs = getAllSongs()
|
||||
}
|
||||
|
||||
downloadBackground(save, songs)
|
||||
}
|
||||
|
||||
private fun downloadBackground(
|
||||
save: Boolean,
|
||||
songs: List<Track?>
|
||||
) {
|
||||
val onValid = Runnable {
|
||||
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
|
||||
DownloadService.download(songs.filterNotNull(), save)
|
||||
|
||||
if (save) {
|
||||
Util.toast(
|
||||
context,
|
||||
resources.getQuantityString(
|
||||
R.plurals.select_album_n_songs_pinned, songs.size, songs.size
|
||||
)
|
||||
)
|
||||
} else {
|
||||
Util.toast(
|
||||
context,
|
||||
resources.getQuantityString(
|
||||
R.plurals.select_album_n_songs_downloaded, songs.size, songs.size
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
onValid.run()
|
||||
val action = if (save) DownloadAction.PIN else DownloadAction.DOWNLOAD
|
||||
downloadHandler.justDownload(
|
||||
action = action,
|
||||
fragment = this,
|
||||
tracks = songs
|
||||
)
|
||||
}
|
||||
|
||||
internal fun delete(songs: List<Track> = getSelectedSongs()) {
|
||||
Util.toast(
|
||||
context,
|
||||
resources.getQuantityString(
|
||||
R.plurals.select_album_n_songs_deleted, songs.size, songs.size
|
||||
)
|
||||
downloadHandler.justDownload(
|
||||
action = DownloadAction.DELETE,
|
||||
fragment = this,
|
||||
tracks = songs
|
||||
)
|
||||
|
||||
DownloadService.delete(songs)
|
||||
}
|
||||
|
||||
internal fun unpin(songs: List<Track> = getSelectedSongs()) {
|
||||
Util.toast(
|
||||
context,
|
||||
resources.getQuantityString(
|
||||
R.plurals.select_album_n_songs_unpinned, songs.size, songs.size
|
||||
)
|
||||
downloadHandler.justDownload(
|
||||
action = DownloadAction.UNPIN,
|
||||
fragment = this,
|
||||
tracks = songs
|
||||
)
|
||||
DownloadService.unpin(songs)
|
||||
}
|
||||
|
||||
override val defaultObserver: (List<MusicDirectory.Child>) -> Unit = {
|
||||
@ -597,14 +574,14 @@ open class TrackCollectionFragment(
|
||||
} else if (getVideos) {
|
||||
setTitle(R.string.main_videos)
|
||||
listModel.getVideos(refresh2)
|
||||
} else if (getRandomTracks) {
|
||||
} else if (id == null || getRandomTracks) {
|
||||
// There seems to be a bug in ViewPager when resuming the Actitivy that subfragments
|
||||
// arguments are empty. If we have no id, just show some random tracks
|
||||
setTitle(R.string.main_songs_random)
|
||||
listModel.getRandom(size, append)
|
||||
} else {
|
||||
setTitle(name)
|
||||
requireNotNull(id) {
|
||||
"ID must be set. NavArgs: ${navArgs.toBundle()}"
|
||||
}
|
||||
|
||||
if (ActiveServerProvider.isID3Enabled()) {
|
||||
if (isAlbum) {
|
||||
listModel.getAlbum(refresh2, id, name)
|
||||
@ -637,15 +614,14 @@ open class TrackCollectionFragment(
|
||||
playNow(false, songs)
|
||||
}
|
||||
R.id.song_menu_play_next -> {
|
||||
downloadHandler.download(
|
||||
fragment = this@TrackCollectionFragment,
|
||||
append = true,
|
||||
save = false,
|
||||
autoPlay = false,
|
||||
playNext = true,
|
||||
shuffle = false,
|
||||
downloadHandler.addTracksToMediaController(
|
||||
songs = songs,
|
||||
playlistName = navArgs.playlistName
|
||||
append = true,
|
||||
playNext = true,
|
||||
autoPlay = false,
|
||||
shuffle = false,
|
||||
playlistName = navArgs.playlistName,
|
||||
fragment = this@TrackCollectionFragment
|
||||
)
|
||||
}
|
||||
R.id.song_menu_play_last -> {
|
||||
|
@ -38,6 +38,7 @@ import org.moire.ultrasonic.domain.Playlist
|
||||
import org.moire.ultrasonic.fragment.FragmentTitle.Companion.setTitle
|
||||
import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService
|
||||
import org.moire.ultrasonic.service.OfflineException
|
||||
import org.moire.ultrasonic.subsonic.DownloadAction
|
||||
import org.moire.ultrasonic.subsonic.DownloadHandler
|
||||
import org.moire.ultrasonic.util.BackgroundTask
|
||||
import org.moire.ultrasonic.util.CacheCleaner
|
||||
@ -147,45 +148,33 @@ class PlaylistsFragment : Fragment() {
|
||||
val playlist = playlistsListView!!.getItemAtPosition(info.position) as Playlist
|
||||
when (menuItem.itemId) {
|
||||
R.id.playlist_menu_pin -> {
|
||||
downloadHandler.value.downloadPlaylist(
|
||||
this,
|
||||
downloadHandler.value.justDownload(
|
||||
DownloadAction.PIN,
|
||||
fragment = this,
|
||||
id = playlist.id,
|
||||
name = playlist.name,
|
||||
save = true,
|
||||
append = true,
|
||||
autoplay = false,
|
||||
shuffle = false,
|
||||
background = true,
|
||||
playNext = false,
|
||||
unpin = false
|
||||
isShare = false,
|
||||
isDirectory = false
|
||||
)
|
||||
}
|
||||
R.id.playlist_menu_unpin -> {
|
||||
downloadHandler.value.downloadPlaylist(
|
||||
this,
|
||||
downloadHandler.value.justDownload(
|
||||
DownloadAction.UNPIN,
|
||||
fragment = this,
|
||||
id = playlist.id,
|
||||
name = playlist.name,
|
||||
save = false,
|
||||
append = false,
|
||||
autoplay = false,
|
||||
shuffle = false,
|
||||
background = true,
|
||||
playNext = false,
|
||||
unpin = true
|
||||
isShare = false,
|
||||
isDirectory = false
|
||||
)
|
||||
}
|
||||
R.id.playlist_menu_download -> {
|
||||
downloadHandler.value.downloadPlaylist(
|
||||
this,
|
||||
downloadHandler.value.justDownload(
|
||||
DownloadAction.DOWNLOAD,
|
||||
fragment = this,
|
||||
id = playlist.id,
|
||||
name = playlist.name,
|
||||
save = false,
|
||||
append = false,
|
||||
autoplay = false,
|
||||
shuffle = false,
|
||||
background = true,
|
||||
playNext = false,
|
||||
unpin = false
|
||||
isShare = false,
|
||||
isDirectory = false
|
||||
)
|
||||
}
|
||||
R.id.playlist_menu_play_now -> {
|
||||
|
@ -28,7 +28,8 @@ import androidx.fragment.app.Fragment
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
import java.util.Locale
|
||||
import org.koin.java.KoinJavaComponent
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
import org.moire.ultrasonic.NavigationGraphDirections
|
||||
import org.moire.ultrasonic.R
|
||||
import org.moire.ultrasonic.api.subsonic.ApiNotSupportedException
|
||||
@ -36,6 +37,7 @@ import org.moire.ultrasonic.domain.Share
|
||||
import org.moire.ultrasonic.fragment.FragmentTitle
|
||||
import org.moire.ultrasonic.service.MusicServiceFactory
|
||||
import org.moire.ultrasonic.service.OfflineException
|
||||
import org.moire.ultrasonic.subsonic.DownloadAction
|
||||
import org.moire.ultrasonic.subsonic.DownloadHandler
|
||||
import org.moire.ultrasonic.util.BackgroundTask
|
||||
import org.moire.ultrasonic.util.CancellationToken
|
||||
@ -50,14 +52,12 @@ import org.moire.ultrasonic.view.ShareAdapter
|
||||
*
|
||||
* TODO: This file has been converted from Java, but not modernized yet.
|
||||
*/
|
||||
class SharesFragment : Fragment() {
|
||||
class SharesFragment : Fragment(), KoinComponent {
|
||||
private var refreshSharesListView: SwipeRefreshLayout? = null
|
||||
private var sharesListView: ListView? = null
|
||||
private var emptyTextView: View? = null
|
||||
private var shareAdapter: ShareAdapter? = null
|
||||
private val downloadHandler = KoinJavaComponent.inject<DownloadHandler>(
|
||||
DownloadHandler::class.java
|
||||
)
|
||||
private val downloadHandler = inject<DownloadHandler>()
|
||||
private var cancellationToken: CancellationToken? = null
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
Util.applyTheme(this.context)
|
||||
@ -72,7 +72,6 @@ class SharesFragment : Fragment() {
|
||||
return inflater.inflate(R.layout.select_share, container, false)
|
||||
}
|
||||
|
||||
@Suppress("NAME_SHADOWING")
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
cancellationToken = CancellationToken()
|
||||
refreshSharesListView = view.findViewById(R.id.select_share_refresh)
|
||||
@ -132,73 +131,55 @@ class SharesFragment : Fragment() {
|
||||
val share = sharesListView!!.getItemAtPosition(info.position) as Share
|
||||
when (menuItem.itemId) {
|
||||
R.id.share_menu_pin -> {
|
||||
downloadHandler.value.downloadShare(
|
||||
this,
|
||||
share.id,
|
||||
share.name,
|
||||
save = true,
|
||||
append = true,
|
||||
autoplay = false,
|
||||
shuffle = false,
|
||||
background = true,
|
||||
playNext = false,
|
||||
unpin = false
|
||||
downloadHandler.value.justDownload(
|
||||
DownloadAction.PIN,
|
||||
fragment = this,
|
||||
id = share.id,
|
||||
name = share.name,
|
||||
isShare = true,
|
||||
isDirectory = false
|
||||
)
|
||||
}
|
||||
R.id.share_menu_unpin -> {
|
||||
downloadHandler.value.downloadShare(
|
||||
this,
|
||||
share.id,
|
||||
share.name,
|
||||
save = false,
|
||||
append = false,
|
||||
autoplay = false,
|
||||
shuffle = false,
|
||||
background = true,
|
||||
playNext = false,
|
||||
unpin = true
|
||||
downloadHandler.value.justDownload(
|
||||
DownloadAction.UNPIN,
|
||||
fragment = this,
|
||||
id = share.id,
|
||||
name = share.name,
|
||||
isShare = true,
|
||||
isDirectory = false
|
||||
)
|
||||
}
|
||||
R.id.share_menu_download -> {
|
||||
downloadHandler.value.downloadShare(
|
||||
this,
|
||||
share.id,
|
||||
share.name,
|
||||
save = false,
|
||||
append = false,
|
||||
autoplay = false,
|
||||
shuffle = false,
|
||||
background = true,
|
||||
playNext = false,
|
||||
unpin = false
|
||||
downloadHandler.value.justDownload(
|
||||
DownloadAction.DOWNLOAD,
|
||||
fragment = this,
|
||||
id = share.id,
|
||||
name = share.name,
|
||||
isShare = true,
|
||||
isDirectory = false
|
||||
)
|
||||
}
|
||||
R.id.share_menu_play_now -> {
|
||||
downloadHandler.value.downloadShare(
|
||||
downloadHandler.value.fetchTracksAndAddToController(
|
||||
this,
|
||||
share.id,
|
||||
share.name,
|
||||
save = false,
|
||||
append = false,
|
||||
autoplay = true,
|
||||
autoPlay = true,
|
||||
shuffle = false,
|
||||
background = false,
|
||||
playNext = false,
|
||||
unpin = false
|
||||
)
|
||||
}
|
||||
R.id.share_menu_play_shuffled -> {
|
||||
downloadHandler.value.downloadShare(
|
||||
downloadHandler.value.fetchTracksAndAddToController(
|
||||
this,
|
||||
share.id,
|
||||
share.name,
|
||||
save = false,
|
||||
append = false,
|
||||
autoplay = true,
|
||||
autoPlay = true,
|
||||
shuffle = true,
|
||||
background = false,
|
||||
playNext = false,
|
||||
unpin = false
|
||||
)
|
||||
}
|
||||
R.id.share_menu_delete -> {
|
||||
|
@ -26,8 +26,6 @@ import androidx.media3.session.MediaLibraryService
|
||||
import androidx.media3.session.MediaSession
|
||||
import androidx.media3.session.SessionCommand
|
||||
import androidx.media3.session.SessionResult
|
||||
import androidx.media3.session.SessionResult.RESULT_ERROR_BAD_VALUE
|
||||
import androidx.media3.session.SessionResult.RESULT_ERROR_UNKNOWN
|
||||
import androidx.media3.session.SessionResult.RESULT_SUCCESS
|
||||
import com.google.common.collect.ImmutableList
|
||||
import com.google.common.util.concurrent.FutureCallback
|
||||
@ -44,12 +42,14 @@ import org.moire.ultrasonic.R
|
||||
import org.moire.ultrasonic.api.subsonic.models.AlbumListType
|
||||
import org.moire.ultrasonic.app.UApp
|
||||
import org.moire.ultrasonic.data.ActiveServerProvider
|
||||
import org.moire.ultrasonic.data.RatingUpdate
|
||||
import org.moire.ultrasonic.domain.MusicDirectory
|
||||
import org.moire.ultrasonic.domain.SearchCriteria
|
||||
import org.moire.ultrasonic.domain.SearchResult
|
||||
import org.moire.ultrasonic.domain.Track
|
||||
import org.moire.ultrasonic.service.MediaPlayerController
|
||||
import org.moire.ultrasonic.service.MusicServiceFactory
|
||||
import org.moire.ultrasonic.service.RatingManager
|
||||
import org.moire.ultrasonic.util.MainThreadExecutor
|
||||
import org.moire.ultrasonic.util.Settings
|
||||
import org.moire.ultrasonic.util.Util
|
||||
@ -306,21 +306,18 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
|
||||
rating: Rating
|
||||
): ListenableFuture<SessionResult> {
|
||||
return serviceScope.future {
|
||||
if (rating is HeartRating) {
|
||||
try {
|
||||
if (rating.isHeart) {
|
||||
musicService.star(mediaId, null, null)
|
||||
} else {
|
||||
musicService.unstar(mediaId, null, null)
|
||||
}
|
||||
} catch (all: Exception) {
|
||||
Timber.e(all)
|
||||
// TODO: Better handle exception
|
||||
return@future SessionResult(RESULT_ERROR_UNKNOWN)
|
||||
}
|
||||
return@future SessionResult(RESULT_SUCCESS)
|
||||
}
|
||||
return@future SessionResult(RESULT_ERROR_BAD_VALUE)
|
||||
Timber.i(controller.packageName)
|
||||
// This function even though its declared in AutoMediaBrowserCallback.kt is
|
||||
// actually called every time we set the rating on an MediaItem.
|
||||
// To avoid an event loop it does not emit a RatingUpdate event,
|
||||
// but calls the Manager directly
|
||||
RatingManager.instance.submitRating(
|
||||
RatingUpdate(
|
||||
id = mediaId,
|
||||
rating = rating
|
||||
)
|
||||
)
|
||||
return@future SessionResult(RESULT_SUCCESS)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -26,9 +26,12 @@ import androidx.media3.datasource.okhttp.OkHttpDataSource
|
||||
import androidx.media3.exoplayer.DefaultRenderersFactory
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||
import androidx.media3.exoplayer.source.ShuffleOrder.DefaultShuffleOrder
|
||||
import androidx.media3.exoplayer.source.ShuffleOrder.UnshuffledShuffleOrder
|
||||
import androidx.media3.session.MediaLibraryService
|
||||
import androidx.media3.session.MediaSession
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import java.util.Random
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
@ -175,6 +178,27 @@ class PlaybackService :
|
||||
player.setWakeMode(getWakeModeFlag())
|
||||
}
|
||||
|
||||
// Set a listener to reset the ShuffleOrder
|
||||
rxBusSubscription += RxBus.shufflePlayObservable.subscribe { shuffle ->
|
||||
val len = player.currentTimeline.windowCount
|
||||
|
||||
Timber.i("Resetting shuffle order, isShuffled: %s", shuffle)
|
||||
|
||||
// If disabling Shuffle return early
|
||||
if (!shuffle) {
|
||||
return@subscribe player.setShuffleOrder(UnshuffledShuffleOrder(len))
|
||||
}
|
||||
|
||||
// Get the position of the current track in the unshuffled order
|
||||
val cur = player.currentMediaItemIndex
|
||||
val seed = System.currentTimeMillis()
|
||||
val random = Random(seed)
|
||||
|
||||
val list = createShuffleListFromCurrentIndex(cur, len, random)
|
||||
Timber.i("New Shuffle order: %s", list.joinToString { it.toString() })
|
||||
player.setShuffleOrder(DefaultShuffleOrder(list, seed))
|
||||
}
|
||||
|
||||
// Listen to the shutdown command
|
||||
rxBusSubscription += RxBus.shutdownCommandObservable.subscribe {
|
||||
Timber.i("Received destroy command via Rx")
|
||||
@ -185,6 +209,24 @@ class PlaybackService :
|
||||
isStarted = true
|
||||
}
|
||||
|
||||
fun createShuffleListFromCurrentIndex(
|
||||
currentIndex: Int,
|
||||
length: Int,
|
||||
random: Random
|
||||
): IntArray {
|
||||
val list = IntArray(length) { it }
|
||||
|
||||
// Shuffle the remaining items using a swapping algorithm
|
||||
for (i in currentIndex + 1 until length) {
|
||||
val swapIndex = (currentIndex + 1) + random.nextInt(i - currentIndex)
|
||||
val swapItem = list[i]
|
||||
list[i] = list[swapIndex]
|
||||
list[swapIndex] = swapItem
|
||||
}
|
||||
|
||||
return list
|
||||
}
|
||||
|
||||
private val listener: Player.Listener = object : Player.Listener {
|
||||
override fun onShuffleModeEnabledChanged(shuffleModeEnabled: Boolean) {
|
||||
cacheNextSongs()
|
||||
|
@ -10,20 +10,21 @@ import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.widget.Toast
|
||||
import androidx.annotation.IntRange
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.HeartRating
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.PlaybackException
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.Player.COMMAND_SEEK_TO_NEXT
|
||||
import androidx.media3.common.Player.COMMAND_SEEK_TO_PREVIOUS
|
||||
import androidx.media3.common.Player.MEDIA_ITEM_TRANSITION_REASON_AUTO
|
||||
import androidx.media3.common.Player.REPEAT_MODE_OFF
|
||||
import androidx.media3.common.Rating
|
||||
import androidx.media3.common.StarRating
|
||||
import androidx.media3.common.Timeline
|
||||
import androidx.media3.session.MediaController
|
||||
import androidx.media3.session.SessionResult
|
||||
import androidx.media3.session.SessionToken
|
||||
import com.google.common.util.concurrent.FutureCallback
|
||||
import com.google.common.util.concurrent.Futures
|
||||
import com.google.common.util.concurrent.ListenableFuture
|
||||
import com.google.common.util.concurrent.MoreExecutors
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
@ -35,13 +36,12 @@ import org.koin.core.component.inject
|
||||
import org.moire.ultrasonic.app.UApp
|
||||
import org.moire.ultrasonic.data.ActiveServerProvider
|
||||
import org.moire.ultrasonic.data.ActiveServerProvider.Companion.OFFLINE_DB_ID
|
||||
import org.moire.ultrasonic.data.RatingUpdate
|
||||
import org.moire.ultrasonic.domain.Track
|
||||
import org.moire.ultrasonic.playback.PlaybackService
|
||||
import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService
|
||||
import org.moire.ultrasonic.util.MainThreadExecutor
|
||||
import org.moire.ultrasonic.util.Settings
|
||||
import org.moire.ultrasonic.util.Util
|
||||
import org.moire.ultrasonic.util.setPin
|
||||
import org.moire.ultrasonic.util.toMediaItem
|
||||
import org.moire.ultrasonic.util.toTrack
|
||||
import timber.log.Timber
|
||||
@ -60,6 +60,7 @@ class MediaPlayerController(
|
||||
private val externalStorageMonitor: ExternalStorageMonitor,
|
||||
val context: Context
|
||||
) : KoinComponent {
|
||||
|
||||
private val activeServerProvider: ActiveServerProvider by inject()
|
||||
|
||||
private var created = false
|
||||
@ -96,6 +97,14 @@ class MediaPlayerController(
|
||||
* We run the event through RxBus in order to throttle them
|
||||
*/
|
||||
override fun onTimelineChanged(timeline: Timeline, reason: Int) {
|
||||
val start = controller?.currentTimeline?.getFirstWindowIndex(isShufflePlayEnabled)
|
||||
Timber.w("On timeline changed. First shuffle play at index: %s", start)
|
||||
deferredPlay?.let {
|
||||
Timber.w("Executing deferred shuffle play")
|
||||
it()
|
||||
deferredPlay = null
|
||||
}
|
||||
|
||||
RxBus.playlistPublisher.onNext(playlist.map(MediaItem::toTrack))
|
||||
}
|
||||
|
||||
@ -150,19 +159,21 @@ class MediaPlayerController(
|
||||
|
||||
override fun onShuffleModeEnabledChanged(shuffleModeEnabled: Boolean) {
|
||||
val timeline: Timeline = controller!!.currentTimeline
|
||||
var windowIndex = timeline.getFirstWindowIndex( /* shuffleModeEnabled= */true)
|
||||
var windowIndex = timeline.getFirstWindowIndex(true)
|
||||
var count = 0
|
||||
Timber.d("Shuffle: windowIndex: $windowIndex, at: $count")
|
||||
while (windowIndex != C.INDEX_UNSET) {
|
||||
count++
|
||||
windowIndex = timeline.getNextWindowIndex(
|
||||
windowIndex, REPEAT_MODE_OFF, /* shuffleModeEnabled= */true
|
||||
windowIndex, REPEAT_MODE_OFF, true
|
||||
)
|
||||
Timber.d("Shuffle: windowIndex: $windowIndex, at: $count")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var deferredPlay: (() -> Unit)? = null
|
||||
|
||||
private var cachedMediaItem: MediaItem? = null
|
||||
|
||||
fun onCreate(onCreated: () -> Unit) {
|
||||
@ -216,11 +227,21 @@ class MediaPlayerController(
|
||||
clear(false)
|
||||
onDestroy()
|
||||
}
|
||||
|
||||
rxBusSubscription += RxBus.stopServiceCommandObservable.subscribe {
|
||||
clear(false)
|
||||
onDestroy()
|
||||
}
|
||||
|
||||
rxBusSubscription += RxBus.ratingSubmitterObservable.subscribe {
|
||||
// Ensure correct thread
|
||||
mainScope.launch {
|
||||
// This deals only with the current track!
|
||||
if (it.id != currentMediaItem?.toTrack()?.id) return@launch
|
||||
setRating(it.rating)
|
||||
}
|
||||
}
|
||||
|
||||
created = true
|
||||
Timber.i("MediaPlayerController started")
|
||||
}
|
||||
@ -259,7 +280,7 @@ class MediaPlayerController(
|
||||
private fun publishPlaybackState() {
|
||||
val newState = RxBus.StateWithTrack(
|
||||
track = currentMediaItem?.toTrack(),
|
||||
index = currentMediaItemIndex,
|
||||
index = if (isShufflePlayEnabled) getCurrentShuffleIndex() else currentMediaItemIndex,
|
||||
isPlaying = isPlaying,
|
||||
state = playbackState
|
||||
)
|
||||
@ -292,7 +313,6 @@ class MediaPlayerController(
|
||||
|
||||
addToPlaylist(
|
||||
state.songs,
|
||||
cachePermanently = false,
|
||||
autoPlay = false,
|
||||
shuffle = false,
|
||||
insertionMode = insertionMode
|
||||
@ -316,6 +336,8 @@ class MediaPlayerController(
|
||||
@Synchronized
|
||||
fun play(index: Int) {
|
||||
controller?.seekTo(index, 0L)
|
||||
// FIXME CHECK ITS NOT MAKING PROBLEMS
|
||||
controller?.prepare()
|
||||
controller?.play()
|
||||
}
|
||||
|
||||
@ -384,7 +406,6 @@ class MediaPlayerController(
|
||||
@Synchronized
|
||||
fun addToPlaylist(
|
||||
songs: List<Track>,
|
||||
cachePermanently: Boolean,
|
||||
autoPlay: Boolean,
|
||||
shuffle: Boolean,
|
||||
insertionMode: InsertionMode
|
||||
@ -399,11 +420,11 @@ class MediaPlayerController(
|
||||
|
||||
val mediaItems: List<MediaItem> = songs.map {
|
||||
val result = it.toMediaItem()
|
||||
if (cachePermanently) result.setPin(true)
|
||||
result
|
||||
}
|
||||
|
||||
if (shuffle) isShufflePlayEnabled = true
|
||||
Timber.w("Adding ${mediaItems.size} media items")
|
||||
controller?.addMediaItems(insertAt, mediaItems)
|
||||
|
||||
prepare()
|
||||
@ -411,10 +432,19 @@ class MediaPlayerController(
|
||||
// Playback doesn't start correctly when the player is in STATE_ENDED.
|
||||
// So we need to call seek before (this is what play(0,0)) does.
|
||||
// We can't just use play(0,0) then all random playlists will start with the first track.
|
||||
// This means that we need to generate the random first track ourselves.
|
||||
// Additionally the shuffle order becomes clear on after some time, so we need to wait for
|
||||
// the right event, and can start playback only then.
|
||||
if (autoPlay) {
|
||||
val start = controller?.currentTimeline?.getFirstWindowIndex(isShufflePlayEnabled) ?: 0
|
||||
play(start)
|
||||
if (isShufflePlayEnabled) {
|
||||
deferredPlay = {
|
||||
val start = controller?.currentTimeline
|
||||
?.getFirstWindowIndex(isShufflePlayEnabled) ?: 0
|
||||
Timber.i("Deferred shuffle play starting now at index: %s", start)
|
||||
play(start)
|
||||
}
|
||||
} else {
|
||||
play(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -422,6 +452,8 @@ class MediaPlayerController(
|
||||
var isShufflePlayEnabled: Boolean
|
||||
get() = controller?.shuffleModeEnabled == true
|
||||
set(enabled) {
|
||||
Timber.i("Shuffle is now enabled: %s", enabled)
|
||||
RxBus.shufflePlayPublisher.onNext(enabled)
|
||||
controller?.shuffleModeEnabled = enabled
|
||||
}
|
||||
|
||||
@ -431,11 +463,17 @@ class MediaPlayerController(
|
||||
return isShufflePlayEnabled
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an estimate of the percentage in the current content up to which data is
|
||||
* buffered, or 0 if no estimate is available.
|
||||
*/
|
||||
@get:IntRange(from = 0, to = 100)
|
||||
val bufferedPercentage: Int
|
||||
get() = controller?.bufferedPercentage ?: 0
|
||||
|
||||
@Synchronized
|
||||
fun moveItemInPlaylist(oldPos: Int, newPos: Int) {
|
||||
// TODO: This currently does not care about shuffle position.
|
||||
controller?.moveMediaItem(oldPos, newPos)
|
||||
}
|
||||
|
||||
@ -494,15 +532,25 @@ class MediaPlayerController(
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun previous() {
|
||||
fun seekToPrevious() {
|
||||
controller?.seekToPrevious()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
operator fun next() {
|
||||
fun canSeekToPrevious(): Boolean {
|
||||
return controller?.isCommandAvailable(COMMAND_SEEK_TO_PREVIOUS) == true
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun seekToNext() {
|
||||
controller?.seekToNext()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun canSeekToNext(): Boolean {
|
||||
return controller?.isCommandAvailable(COMMAND_SEEK_TO_NEXT) == true
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun reset() {
|
||||
controller?.clearMediaItems()
|
||||
@ -656,52 +704,49 @@ class MediaPlayerController(
|
||||
controller?.volume = volume
|
||||
}
|
||||
|
||||
fun toggleSongStarred(): ListenableFuture<SessionResult>? {
|
||||
if (currentMediaItem == null) return null
|
||||
val song = currentMediaItem!!.toTrack()
|
||||
|
||||
return (controller as? MediaController)?.setRating(
|
||||
HeartRating(!song.starred)
|
||||
)?.let {
|
||||
Futures.addCallback(
|
||||
it,
|
||||
object : FutureCallback<SessionResult> {
|
||||
override fun onSuccess(result: SessionResult?) {
|
||||
// Trigger an update
|
||||
// TODO Update Metadata of MediaItem...
|
||||
// localMediaPlayer.setCurrentPlaying(localMediaPlayer.currentPlaying)
|
||||
song.starred = !song.starred
|
||||
}
|
||||
|
||||
override fun onFailure(t: Throwable) {
|
||||
Toast.makeText(
|
||||
context,
|
||||
"There was an error updating the rating",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
},
|
||||
MainThreadExecutor()
|
||||
)
|
||||
it
|
||||
/*
|
||||
* Sets the rating of the current track
|
||||
*/
|
||||
fun setRating(rating: Rating) {
|
||||
if (controller is MediaController) {
|
||||
(controller as MediaController).setRating(rating)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("TooGenericExceptionCaught") // The interface throws only generic exceptions
|
||||
fun setSongRating(rating: Int) {
|
||||
if (!Settings.useFiveStarRating) return
|
||||
/*
|
||||
* This legacy function simply emits a rating update,
|
||||
* which will then be processed by both the RatingManager as well as the controller
|
||||
*/
|
||||
fun legacyToggleStar() {
|
||||
if (currentMediaItem == null) return
|
||||
val song = currentMediaItem!!.toTrack()
|
||||
song.userRating = rating
|
||||
Thread {
|
||||
try {
|
||||
getMusicService().setRating(song.id, rating)
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e)
|
||||
}
|
||||
}.start()
|
||||
// TODO this would be better handled with a Rx command
|
||||
// updateNotification()
|
||||
val track = currentMediaItem!!.toTrack()
|
||||
track.starred = !track.starred
|
||||
val rating = HeartRating(track.starred)
|
||||
|
||||
RxBus.ratingSubmitter.onNext(
|
||||
RatingUpdate(
|
||||
track.id,
|
||||
rating
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/*
|
||||
* This legacy function simply emits a rating update,
|
||||
* which will then be processed by both the RatingManager as well as the controller
|
||||
*/
|
||||
fun legacySetRating(num: Int) {
|
||||
if (currentMediaItem == null) return
|
||||
val track = currentMediaItem!!.toTrack()
|
||||
track.userRating = num
|
||||
val rating = StarRating(5, num.toFloat())
|
||||
|
||||
RxBus.ratingSubmitter.onNext(
|
||||
RatingUpdate(
|
||||
track.id,
|
||||
rating
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val currentMediaItem: MediaItem?
|
||||
@ -710,9 +755,64 @@ class MediaPlayerController(
|
||||
val currentMediaItemIndex: Int
|
||||
get() = controller?.currentMediaItemIndex ?: -1
|
||||
|
||||
fun getCurrentShuffleIndex(): Int {
|
||||
val currentMediaItemIndex = controller?.currentMediaItemIndex ?: return -1
|
||||
return getShuffledIndexOf(currentMediaItemIndex)
|
||||
}
|
||||
|
||||
/**
|
||||
* Loops over the timeline windows to find the entry which matches the given closure.
|
||||
*
|
||||
* @param searchClosure Determines the condition which the searched for window needs to match.
|
||||
* @return the index of the window that satisfies the search condition,
|
||||
* or [C.INDEX_UNSET] if not found.
|
||||
*/
|
||||
private fun getWindowIndexWhere(searchClosure: (Int, Int) -> Boolean): Int {
|
||||
val timeline = controller?.currentTimeline!!
|
||||
var windowIndex = timeline.getFirstWindowIndex(true)
|
||||
var count = 0
|
||||
while (windowIndex != C.INDEX_UNSET) {
|
||||
if (searchClosure(count, windowIndex)) return count
|
||||
count++
|
||||
windowIndex = timeline.getNextWindowIndex(
|
||||
windowIndex, REPEAT_MODE_OFF, true
|
||||
)
|
||||
}
|
||||
|
||||
return C.INDEX_UNSET
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the index of the shuffled position of the current playback item given its original
|
||||
* position in the unshuffled timeline.
|
||||
*
|
||||
* @param searchPosition The index of the item in the unshuffled timeline to search for
|
||||
* in the shuffled timeline.
|
||||
* @return The index of the item in the shuffled timeline, or [C.INDEX_UNSET] if not found.
|
||||
*/
|
||||
fun getShuffledIndexOf(searchPosition: Int): Int {
|
||||
return getWindowIndexWhere { _, windowIndex -> windowIndex == searchPosition }
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the index of the unshuffled position of the current playback item given its shuffled
|
||||
* position in the shuffled timeline.
|
||||
*
|
||||
* @param shufflePosition the index of the item in the shuffled timeline to search for in the
|
||||
* unshuffled timeline.
|
||||
* @return the index of the item in the unshuffled timeline, or [C.INDEX_UNSET] if not found.
|
||||
*/
|
||||
fun getUnshuffledIndexOf(shufflePosition: Int): Int {
|
||||
return getWindowIndexWhere { count, _ -> count == shufflePosition }
|
||||
}
|
||||
|
||||
val mediaItemCount: Int
|
||||
get() = controller?.mediaItemCount ?: 0
|
||||
|
||||
fun getMediaItemAt(index: Int): MediaItem? {
|
||||
return controller?.getMediaItemAt(index)
|
||||
}
|
||||
|
||||
val playlistSize: Int
|
||||
get() = controller?.currentTimeline?.windowCount ?: 0
|
||||
|
||||
@ -721,10 +821,6 @@ class MediaPlayerController(
|
||||
return Util.getPlayListFromTimeline(controller?.currentTimeline, false)
|
||||
}
|
||||
|
||||
fun getMediaItemAt(index: Int): MediaItem? {
|
||||
return controller?.getMediaItemAt(index)
|
||||
}
|
||||
|
||||
val playlistInPlayOrder: List<MediaItem>
|
||||
get() {
|
||||
return Util.getPlayListFromTimeline(
|
||||
|
@ -30,6 +30,7 @@ import timber.log.Timber
|
||||
* This class is responsible for handling received events for the Media Player implementation
|
||||
*/
|
||||
class MediaPlayerLifecycleSupport : KoinComponent {
|
||||
private lateinit var ratingManager: RatingManager
|
||||
private val playbackStateSerializer by inject<PlaybackStateSerializer>()
|
||||
private val mediaPlayerController by inject<MediaPlayerController>()
|
||||
private val imageLoaderProvider: ImageLoaderProvider by inject()
|
||||
@ -71,6 +72,7 @@ class MediaPlayerLifecycleSupport : KoinComponent {
|
||||
|
||||
CacheCleaner().clean()
|
||||
created = true
|
||||
ratingManager = RatingManager.instance
|
||||
Timber.i("LifecycleSupport created")
|
||||
}
|
||||
|
||||
@ -182,17 +184,17 @@ class MediaPlayerLifecycleSupport : KoinComponent {
|
||||
when (keyCode) {
|
||||
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE,
|
||||
KeyEvent.KEYCODE_HEADSETHOOK -> mediaPlayerController.togglePlayPause()
|
||||
KeyEvent.KEYCODE_MEDIA_PREVIOUS -> mediaPlayerController.previous()
|
||||
KeyEvent.KEYCODE_MEDIA_NEXT -> mediaPlayerController.next()
|
||||
KeyEvent.KEYCODE_MEDIA_PREVIOUS -> mediaPlayerController.seekToPrevious()
|
||||
KeyEvent.KEYCODE_MEDIA_NEXT -> mediaPlayerController.seekToNext()
|
||||
KeyEvent.KEYCODE_MEDIA_STOP -> mediaPlayerController.stop()
|
||||
KeyEvent.KEYCODE_MEDIA_PLAY -> mediaPlayerController.play()
|
||||
KeyEvent.KEYCODE_MEDIA_PAUSE -> mediaPlayerController.pause()
|
||||
KeyEvent.KEYCODE_1 -> mediaPlayerController.setSongRating(1)
|
||||
KeyEvent.KEYCODE_2 -> mediaPlayerController.setSongRating(2)
|
||||
KeyEvent.KEYCODE_3 -> mediaPlayerController.setSongRating(3)
|
||||
KeyEvent.KEYCODE_4 -> mediaPlayerController.setSongRating(4)
|
||||
KeyEvent.KEYCODE_5 -> mediaPlayerController.setSongRating(5)
|
||||
KeyEvent.KEYCODE_STAR -> mediaPlayerController.toggleSongStarred()
|
||||
KeyEvent.KEYCODE_1 -> mediaPlayerController.legacySetRating(1)
|
||||
KeyEvent.KEYCODE_2 -> mediaPlayerController.legacySetRating(2)
|
||||
KeyEvent.KEYCODE_3 -> mediaPlayerController.legacySetRating(3)
|
||||
KeyEvent.KEYCODE_4 -> mediaPlayerController.legacySetRating(4)
|
||||
KeyEvent.KEYCODE_5 -> mediaPlayerController.legacySetRating(5)
|
||||
KeyEvent.KEYCODE_STAR -> mediaPlayerController.legacyToggleStar()
|
||||
else -> {
|
||||
}
|
||||
}
|
||||
@ -226,8 +228,8 @@ class MediaPlayerLifecycleSupport : KoinComponent {
|
||||
// no need to call anything
|
||||
if (isRunning) mediaPlayerController.resumeOrPlay()
|
||||
|
||||
Constants.CMD_NEXT -> mediaPlayerController.next()
|
||||
Constants.CMD_PREVIOUS -> mediaPlayerController.previous()
|
||||
Constants.CMD_NEXT -> mediaPlayerController.seekToNext()
|
||||
Constants.CMD_PREVIOUS -> mediaPlayerController.seekToPrevious()
|
||||
Constants.CMD_TOGGLEPAUSE -> mediaPlayerController.togglePlayPause()
|
||||
Constants.CMD_STOP -> mediaPlayerController.stop()
|
||||
Constants.CMD_PAUSE -> mediaPlayerController.pause()
|
||||
|
@ -39,10 +39,10 @@ interface MusicService {
|
||||
fun getGenres(refresh: Boolean): List<Genre>
|
||||
|
||||
@Throws(Exception::class)
|
||||
fun star(id: String?, albumId: String?, artistId: String?)
|
||||
fun star(id: String?, albumId: String? = null, artistId: String? = null)
|
||||
|
||||
@Throws(Exception::class)
|
||||
fun unstar(id: String?, albumId: String?, artistId: String?)
|
||||
fun unstar(id: String?, albumId: String? = null, artistId: String? = null)
|
||||
|
||||
@Throws(Exception::class)
|
||||
fun setRating(id: String, rating: Int)
|
||||
|
@ -0,0 +1,87 @@
|
||||
/*
|
||||
* RatingManager.kt
|
||||
* Copyright (C) 2009-2023 Ultrasonic developers
|
||||
*
|
||||
* Distributed under terms of the GNU GPLv3 license.
|
||||
*/
|
||||
|
||||
package org.moire.ultrasonic.service
|
||||
|
||||
import androidx.media3.common.HeartRating
|
||||
import androidx.media3.common.StarRating
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.moire.ultrasonic.data.RatingUpdate
|
||||
import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService
|
||||
import timber.log.Timber
|
||||
|
||||
/*
|
||||
* This class subscribes to RatingEvents and submits them to the server.
|
||||
* In the future it could be extended to store the ratings when offline
|
||||
* and submit them when back online.
|
||||
* Only the manager should publish RatingSubmitted events
|
||||
*/
|
||||
class RatingManager : CoroutineScope by CoroutineScope(Dispatchers.Default) {
|
||||
private val rxBusSubscription: CompositeDisposable = CompositeDisposable()
|
||||
|
||||
var lastUpdate: RatingUpdate? = null
|
||||
|
||||
init {
|
||||
rxBusSubscription += RxBus.ratingSubmitterObservable.subscribe {
|
||||
submitRating(it)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun submitRating(update: RatingUpdate) {
|
||||
// Don't submit the same rating twice
|
||||
if (update.id == lastUpdate?.id && update.rating == lastUpdate?.rating) return
|
||||
|
||||
val service = getMusicService()
|
||||
val id = update.id
|
||||
|
||||
Timber.i("Submitting rating to server: ${update.rating} for $id")
|
||||
|
||||
if (update.rating is HeartRating) {
|
||||
launch {
|
||||
var success = false
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
if (update.rating.isHeart) service.star(id)
|
||||
else service.unstar(id)
|
||||
success = true
|
||||
} catch (all: Exception) {
|
||||
Timber.e(all)
|
||||
}
|
||||
}
|
||||
RxBus.ratingPublished.onNext(
|
||||
update.copy(success = success)
|
||||
)
|
||||
}
|
||||
} else if (update.rating is StarRating) {
|
||||
launch {
|
||||
var success = false
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
getMusicService().setRating(id, update.rating.starRating.toInt())
|
||||
success = true
|
||||
} catch (all: Exception) {
|
||||
Timber.e(all)
|
||||
}
|
||||
}
|
||||
RxBus.ratingPublished.onNext(
|
||||
update.copy(success = success)
|
||||
)
|
||||
}
|
||||
}
|
||||
lastUpdate = update
|
||||
}
|
||||
|
||||
companion object {
|
||||
val instance: RatingManager by lazy {
|
||||
RatingManager()
|
||||
}
|
||||
}
|
||||
}
|
@ -7,6 +7,7 @@ import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import io.reactivex.rxjava3.disposables.Disposable
|
||||
import io.reactivex.rxjava3.subjects.PublishSubject
|
||||
import java.util.concurrent.TimeUnit
|
||||
import org.moire.ultrasonic.data.RatingUpdate
|
||||
import org.moire.ultrasonic.domain.Track
|
||||
|
||||
class RxBus {
|
||||
@ -20,9 +21,13 @@ class RxBus {
|
||||
|
||||
private fun mainThread() = AndroidSchedulers.from(Looper.getMainLooper())
|
||||
|
||||
val shufflePlayPublisher: PublishSubject<Boolean> =
|
||||
PublishSubject.create()
|
||||
val shufflePlayObservable: Observable<Boolean> =
|
||||
shufflePlayPublisher
|
||||
|
||||
var activeServerChangingPublisher: PublishSubject<Int> =
|
||||
PublishSubject.create()
|
||||
|
||||
// Subscribers should be called synchronously, not on another thread
|
||||
var activeServerChangingObservable: Observable<Int> =
|
||||
activeServerChangingPublisher
|
||||
@ -71,6 +76,18 @@ class RxBus {
|
||||
val trackDownloadStateObservable: Observable<TrackDownloadState> =
|
||||
trackDownloadStatePublisher.observeOn(mainThread())
|
||||
|
||||
// Sends a RatingUpdate which was just triggered by the user
|
||||
val ratingSubmitter: PublishSubject<RatingUpdate> =
|
||||
PublishSubject.create()
|
||||
val ratingSubmitterObservable: Observable<RatingUpdate> =
|
||||
ratingSubmitter
|
||||
|
||||
// Sends a RatingUpdate which was successfully submitted to the server or database
|
||||
val ratingPublished: PublishSubject<RatingUpdate> =
|
||||
PublishSubject.create()
|
||||
val ratingPublishedObservable: Observable<RatingUpdate> =
|
||||
ratingPublished
|
||||
|
||||
// Commands
|
||||
val dismissNowPlayingCommandPublisher: PublishSubject<Unit> =
|
||||
PublishSubject.create()
|
||||
|
@ -7,17 +7,11 @@
|
||||
|
||||
package org.moire.ultrasonic.subsonic
|
||||
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import java.util.Collections
|
||||
import java.util.LinkedList
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineExceptionHandler
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.moire.ultrasonic.R
|
||||
import org.moire.ultrasonic.data.ActiveServerProvider.Companion.isOffline
|
||||
@ -26,12 +20,8 @@ import org.moire.ultrasonic.domain.Track
|
||||
import org.moire.ultrasonic.service.DownloadService
|
||||
import org.moire.ultrasonic.service.MediaPlayerController
|
||||
import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService
|
||||
import org.moire.ultrasonic.util.CommunicationError
|
||||
import org.moire.ultrasonic.util.EntryByDiscAndTrackComparator
|
||||
import org.moire.ultrasonic.util.InfoDialog
|
||||
import org.moire.ultrasonic.util.Settings
|
||||
import org.moire.ultrasonic.util.Util
|
||||
import timber.log.Timber
|
||||
import org.moire.ultrasonic.util.executeTaskWithToast
|
||||
|
||||
/**
|
||||
* Retrieves a list of songs and adds them to the now playing list
|
||||
@ -39,279 +29,145 @@ import timber.log.Timber
|
||||
@Suppress("LongParameterList")
|
||||
class DownloadHandler(
|
||||
val mediaPlayerController: MediaPlayerController,
|
||||
val networkAndStorageChecker: NetworkAndStorageChecker
|
||||
private val networkAndStorageChecker: NetworkAndStorageChecker
|
||||
) : CoroutineScope by CoroutineScope(Dispatchers.IO) {
|
||||
private val maxSongs = 500
|
||||
|
||||
/**
|
||||
* Exception Handler for Coroutines
|
||||
*/
|
||||
val exceptionHandler = CoroutineExceptionHandler { _, exception ->
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
Timber.w(exception)
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Use coroutine here (with proper exception handler)
|
||||
fun download(
|
||||
fun justDownload(
|
||||
action: DownloadAction,
|
||||
fragment: Fragment,
|
||||
append: Boolean,
|
||||
save: Boolean,
|
||||
autoPlay: Boolean,
|
||||
playNext: Boolean,
|
||||
shuffle: Boolean,
|
||||
songs: List<Track>,
|
||||
playlistName: String?,
|
||||
id: String? = null,
|
||||
name: String? = "",
|
||||
isShare: Boolean = false,
|
||||
isDirectory: Boolean = true,
|
||||
isArtist: Boolean = false,
|
||||
tracks: List<Track>? = null
|
||||
) {
|
||||
val onValid = Runnable {
|
||||
// TODO: The logic here is different than in the controller...
|
||||
val insertionMode = when {
|
||||
playNext -> MediaPlayerController.InsertionMode.AFTER_CURRENT
|
||||
append -> MediaPlayerController.InsertionMode.APPEND
|
||||
else -> MediaPlayerController.InsertionMode.CLEAR
|
||||
}
|
||||
var successString: String? = null
|
||||
|
||||
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
|
||||
mediaPlayerController.addToPlaylist(
|
||||
songs,
|
||||
save,
|
||||
autoPlay,
|
||||
shuffle,
|
||||
insertionMode
|
||||
)
|
||||
|
||||
if (playlistName != null) {
|
||||
mediaPlayerController.suggestedPlaylistName = playlistName
|
||||
}
|
||||
if (autoPlay) {
|
||||
if (Settings.shouldTransitionOnPlayback) {
|
||||
fragment.findNavController().popBackStack(R.id.playerFragment, true)
|
||||
fragment.findNavController().navigate(R.id.playerFragment)
|
||||
}
|
||||
} else if (save) {
|
||||
Util.toast(
|
||||
fragment.context,
|
||||
fragment.resources.getQuantityString(
|
||||
R.plurals.select_album_n_songs_pinned,
|
||||
songs.size,
|
||||
songs.size
|
||||
)
|
||||
)
|
||||
} else if (playNext) {
|
||||
Util.toast(
|
||||
fragment.context,
|
||||
fragment.resources.getQuantityString(
|
||||
R.plurals.select_album_n_songs_play_next,
|
||||
songs.size,
|
||||
songs.size
|
||||
)
|
||||
)
|
||||
} else if (append) {
|
||||
Util.toast(
|
||||
fragment.context,
|
||||
fragment.resources.getQuantityString(
|
||||
R.plurals.select_album_n_songs_added,
|
||||
songs.size,
|
||||
songs.size
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
onValid.run()
|
||||
}
|
||||
|
||||
fun downloadPlaylist(
|
||||
fragment: Fragment,
|
||||
id: String,
|
||||
name: String?,
|
||||
save: Boolean,
|
||||
append: Boolean,
|
||||
autoplay: Boolean,
|
||||
shuffle: Boolean,
|
||||
background: Boolean,
|
||||
playNext: Boolean,
|
||||
unpin: Boolean
|
||||
) {
|
||||
downloadRecursively(
|
||||
fragment,
|
||||
id,
|
||||
name,
|
||||
isShare = false,
|
||||
isDirectory = false,
|
||||
save = save,
|
||||
append = append,
|
||||
autoPlay = autoplay,
|
||||
shuffle = shuffle,
|
||||
background = background,
|
||||
playNext = playNext,
|
||||
unpin = unpin,
|
||||
isArtist = false
|
||||
)
|
||||
}
|
||||
|
||||
fun downloadShare(
|
||||
fragment: Fragment,
|
||||
id: String,
|
||||
name: String?,
|
||||
save: Boolean,
|
||||
append: Boolean,
|
||||
autoplay: Boolean,
|
||||
shuffle: Boolean,
|
||||
background: Boolean,
|
||||
playNext: Boolean,
|
||||
unpin: Boolean
|
||||
) {
|
||||
downloadRecursively(
|
||||
fragment,
|
||||
id,
|
||||
name,
|
||||
isShare = true,
|
||||
isDirectory = false,
|
||||
save = save,
|
||||
append = append,
|
||||
autoPlay = autoplay,
|
||||
shuffle = shuffle,
|
||||
background = background,
|
||||
playNext = playNext,
|
||||
unpin = unpin,
|
||||
isArtist = false
|
||||
)
|
||||
}
|
||||
|
||||
fun downloadRecursively(
|
||||
fragment: Fragment,
|
||||
id: String?,
|
||||
save: Boolean,
|
||||
append: Boolean,
|
||||
autoPlay: Boolean,
|
||||
shuffle: Boolean,
|
||||
background: Boolean,
|
||||
playNext: Boolean,
|
||||
unpin: Boolean,
|
||||
isArtist: Boolean
|
||||
) {
|
||||
if (id.isNullOrEmpty()) return
|
||||
downloadRecursively(
|
||||
fragment,
|
||||
id,
|
||||
"",
|
||||
isShare = false,
|
||||
isDirectory = true,
|
||||
save = save,
|
||||
append = append,
|
||||
autoPlay = autoPlay,
|
||||
shuffle = shuffle,
|
||||
background = background,
|
||||
playNext = playNext,
|
||||
unpin = unpin,
|
||||
isArtist = isArtist
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
) {
|
||||
// Launch the Job
|
||||
val job = launch(exceptionHandler) {
|
||||
executeTaskWithToast(fragment, {
|
||||
val tracksToDownload: List<Track> = tracks
|
||||
?: getTracksFromServer(isArtist, id!!, isDirectory, name, isShare)
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
// If we are just downloading tracks we don't need to add them to the controller
|
||||
when (action) {
|
||||
DownloadAction.DOWNLOAD -> DownloadService.download(tracksToDownload, false)
|
||||
DownloadAction.PIN -> DownloadService.download(tracksToDownload, true)
|
||||
DownloadAction.UNPIN -> DownloadService.unpin(tracksToDownload)
|
||||
DownloadAction.DELETE -> DownloadService.delete(tracksToDownload)
|
||||
}
|
||||
successString = when (action) {
|
||||
DownloadAction.DOWNLOAD -> fragment.resources.getQuantityString(
|
||||
R.plurals.select_album_n_songs_downloaded,
|
||||
tracksToDownload.size,
|
||||
tracksToDownload.size
|
||||
)
|
||||
DownloadAction.UNPIN -> {
|
||||
fragment.resources.getQuantityString(
|
||||
R.plurals.select_album_n_songs_unpinned,
|
||||
tracksToDownload.size,
|
||||
tracksToDownload.size
|
||||
)
|
||||
}
|
||||
DownloadAction.PIN -> {
|
||||
fragment.resources.getQuantityString(
|
||||
R.plurals.select_album_n_songs_pinned,
|
||||
tracksToDownload.size,
|
||||
tracksToDownload.size
|
||||
)
|
||||
}
|
||||
DownloadAction.DELETE -> {
|
||||
fragment.resources.getQuantityString(
|
||||
R.plurals.select_album_n_songs_deleted,
|
||||
tracksToDownload.size,
|
||||
tracksToDownload.size
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}) { successString }
|
||||
}
|
||||
|
||||
fun fetchTracksAndAddToController(
|
||||
fragment: Fragment,
|
||||
id: String,
|
||||
name: String? = "",
|
||||
isShare: Boolean = false,
|
||||
isDirectory: Boolean = true,
|
||||
append: Boolean,
|
||||
autoPlay: Boolean,
|
||||
shuffle: Boolean,
|
||||
playNext: Boolean,
|
||||
isArtist: Boolean = false
|
||||
) {
|
||||
var successString: String? = null
|
||||
// Launch the Job
|
||||
executeTaskWithToast(fragment, {
|
||||
val songs: MutableList<Track> =
|
||||
getTracksFromServer(isArtist, id, isDirectory, name, isShare)
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
addTracksToMediaController(
|
||||
songs,
|
||||
background,
|
||||
unpin,
|
||||
append,
|
||||
playNext,
|
||||
save,
|
||||
autoPlay,
|
||||
shuffle,
|
||||
fragment
|
||||
songs = songs,
|
||||
append = append,
|
||||
playNext = playNext,
|
||||
autoPlay = autoPlay,
|
||||
shuffle = shuffle,
|
||||
playlistName = null,
|
||||
fragment = fragment
|
||||
)
|
||||
// Play Now doesn't get a Toast :)
|
||||
if (playNext) {
|
||||
successString = fragment.resources.getQuantityString(
|
||||
R.plurals.select_album_n_songs_play_next,
|
||||
songs.size,
|
||||
songs.size
|
||||
)
|
||||
} else if (append) {
|
||||
successString = fragment.resources.getQuantityString(
|
||||
R.plurals.select_album_n_songs_added,
|
||||
songs.size,
|
||||
songs.size
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create the dialog
|
||||
val builder = InfoDialog.Builder(fragment.requireContext())
|
||||
builder.setTitle(R.string.background_task_wait)
|
||||
builder.setMessage(R.string.background_task_loading)
|
||||
builder.setOnCancelListener { job.cancel() }
|
||||
builder.setPositiveButton(R.string.common_cancel) { _, i -> job.cancel() }
|
||||
val dialog = builder.create()
|
||||
dialog.show()
|
||||
|
||||
job.invokeOnCompletion {
|
||||
dialog.dismiss()
|
||||
if (it != null && it !is CancellationException) {
|
||||
Util.toast(
|
||||
fragment.requireContext(),
|
||||
CommunicationError.getErrorMessage(it, fragment.requireContext())
|
||||
)
|
||||
}
|
||||
}
|
||||
}) { successString }
|
||||
}
|
||||
|
||||
private fun addTracksToMediaController(
|
||||
songs: MutableList<Track>,
|
||||
background: Boolean,
|
||||
unpin: Boolean,
|
||||
fun addTracksToMediaController(
|
||||
songs: List<Track>,
|
||||
append: Boolean,
|
||||
playNext: Boolean,
|
||||
save: Boolean,
|
||||
autoPlay: Boolean,
|
||||
shuffle: Boolean,
|
||||
playlistName: String? = null,
|
||||
fragment: Fragment
|
||||
) {
|
||||
if (songs.isEmpty()) return
|
||||
if (Settings.shouldSortByDisc) {
|
||||
Collections.sort(songs, EntryByDiscAndTrackComparator())
|
||||
}
|
||||
|
||||
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
|
||||
if (!background) {
|
||||
if (unpin) {
|
||||
DownloadService.unpin(songs)
|
||||
} else {
|
||||
val insertionMode = when {
|
||||
append -> MediaPlayerController.InsertionMode.APPEND
|
||||
playNext -> MediaPlayerController.InsertionMode.AFTER_CURRENT
|
||||
else -> MediaPlayerController.InsertionMode.CLEAR
|
||||
}
|
||||
mediaPlayerController.addToPlaylist(
|
||||
songs,
|
||||
save,
|
||||
autoPlay,
|
||||
shuffle,
|
||||
insertionMode
|
||||
)
|
||||
if (
|
||||
!append &&
|
||||
Settings.shouldTransitionOnPlayback
|
||||
) {
|
||||
fragment.findNavController().popBackStack(
|
||||
R.id.playerFragment,
|
||||
true
|
||||
)
|
||||
fragment.findNavController().navigate(R.id.playerFragment)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (unpin) {
|
||||
DownloadService.unpin(songs)
|
||||
} else {
|
||||
DownloadService.download(songs, save)
|
||||
}
|
||||
|
||||
val insertionMode = when {
|
||||
append -> MediaPlayerController.InsertionMode.APPEND
|
||||
playNext -> MediaPlayerController.InsertionMode.AFTER_CURRENT
|
||||
else -> MediaPlayerController.InsertionMode.CLEAR
|
||||
}
|
||||
|
||||
if (playlistName != null) {
|
||||
mediaPlayerController.suggestedPlaylistName = playlistName
|
||||
}
|
||||
|
||||
mediaPlayerController.addToPlaylist(
|
||||
songs,
|
||||
autoPlay,
|
||||
shuffle,
|
||||
insertionMode
|
||||
)
|
||||
if (Settings.shouldTransitionOnPlayback && (!append || autoPlay)) {
|
||||
fragment.findNavController().popBackStack(R.id.playerFragment, true)
|
||||
fragment.findNavController().navigate(R.id.playerFragment)
|
||||
}
|
||||
}
|
||||
|
||||
@ -396,3 +252,7 @@ class DownloadHandler(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class DownloadAction {
|
||||
DOWNLOAD, PIN, UNPIN, DELETE
|
||||
}
|
||||
|
@ -0,0 +1,82 @@
|
||||
/*
|
||||
* CoroutinePatterns.kt
|
||||
* Copyright (C) 2009-2023 Ultrasonic developers
|
||||
*
|
||||
* Distributed under terms of the GNU GPLv3 license.
|
||||
*/
|
||||
|
||||
package org.moire.ultrasonic.util
|
||||
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import androidx.fragment.app.Fragment
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineExceptionHandler
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import org.moire.ultrasonic.R
|
||||
import timber.log.Timber
|
||||
|
||||
object CoroutinePatterns {
|
||||
val loggingExceptionHandler by lazy {
|
||||
CoroutineExceptionHandler { _, exception ->
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
Timber.w(exception)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun CoroutineScope.executeTaskWithToast(
|
||||
fragment: Fragment,
|
||||
task: suspend CoroutineScope.() -> Unit,
|
||||
successString: () -> String?
|
||||
): Job {
|
||||
// Launch the Job
|
||||
val job = launch(CoroutinePatterns.loggingExceptionHandler, block = task)
|
||||
|
||||
// Setup a handler when the job is done
|
||||
job.invokeOnCompletion {
|
||||
val toastString = if (it != null && it !is CancellationException) {
|
||||
CommunicationError.getErrorMessage(it, fragment.context)
|
||||
} else {
|
||||
successString()
|
||||
}
|
||||
|
||||
// Return early if nothing to post
|
||||
if (toastString == null) return@invokeOnCompletion
|
||||
|
||||
launch(Dispatchers.Main) {
|
||||
Util.toast(fragment.context, toastString)
|
||||
}
|
||||
}
|
||||
|
||||
return job
|
||||
}
|
||||
|
||||
fun CoroutineScope.executeTaskWithModalDialog(
|
||||
fragment: Fragment,
|
||||
task: suspend CoroutineScope.() -> Unit,
|
||||
successString: () -> String
|
||||
) {
|
||||
// Create the job
|
||||
val job = executeTaskWithToast(fragment, task, successString)
|
||||
|
||||
// Create the dialog
|
||||
val builder = InfoDialog.Builder(fragment.requireContext())
|
||||
builder.setTitle(R.string.background_task_wait)
|
||||
builder.setMessage(R.string.background_task_loading)
|
||||
builder.setOnCancelListener { job.cancel() }
|
||||
builder.setPositiveButton(R.string.common_cancel) { _, _ -> job.cancel() }
|
||||
val dialog = builder.create()
|
||||
dialog.show()
|
||||
|
||||
// Add additional handler to close the dialog
|
||||
job.invokeOnCompletion {
|
||||
launch(Dispatchers.Main) {
|
||||
dialog.dismiss()
|
||||
}
|
||||
}
|
||||
}
|
@ -181,7 +181,7 @@ object Settings {
|
||||
var firstRunExecuted by BooleanSetting(getKey(R.string.setting_key_first_run_executed), false)
|
||||
|
||||
val shouldShowArtistPicture
|
||||
by BooleanSetting(getKey(R.string.setting_key_show_artist_picture), false)
|
||||
by BooleanSetting(getKey(R.string.setting_key_show_artist_picture), true)
|
||||
|
||||
@JvmStatic
|
||||
var chatRefreshInterval by StringIntSetting(
|
||||
|
@ -16,7 +16,8 @@ import timber.log.Timber
|
||||
|
||||
/**
|
||||
* Provides filesystem access abstraction which works
|
||||
* both on File based paths and Storage Access Framework Uris
|
||||
* both on File based paths (when using the internal directory for storing media files)
|
||||
* and Storage Access Framework Uris (when using a custom directory)
|
||||
*/
|
||||
object Storage {
|
||||
|
||||
|
@ -148,8 +148,8 @@ object Util {
|
||||
if (shortDuration) Toast.LENGTH_SHORT else Toast.LENGTH_LONG
|
||||
}
|
||||
toast!!.show()
|
||||
} catch (_: Exception) {
|
||||
// Ignore
|
||||
} catch (all: Exception) {
|
||||
Timber.w(all)
|
||||
}
|
||||
}
|
||||
|
||||
@ -762,7 +762,7 @@ object Util {
|
||||
|
||||
fun getPlayListFromTimeline(
|
||||
timeline: Timeline?,
|
||||
shuffle: Boolean,
|
||||
isShuffled: Boolean,
|
||||
firstIndex: Int? = null,
|
||||
count: Int? = null
|
||||
): List<MediaItem> {
|
||||
@ -770,13 +770,13 @@ object Util {
|
||||
if (timeline.windowCount < 1) return emptyList()
|
||||
|
||||
val playlist: MutableList<MediaItem> = mutableListOf()
|
||||
var i = firstIndex ?: timeline.getFirstWindowIndex(false)
|
||||
var i = firstIndex ?: timeline.getFirstWindowIndex(isShuffled)
|
||||
if (i == C.INDEX_UNSET) return emptyList()
|
||||
|
||||
while (i != C.INDEX_UNSET && (count != playlist.count())) {
|
||||
val window = timeline.getWindow(i, Timeline.Window())
|
||||
playlist.add(window.mediaItem)
|
||||
i = timeline.getNextWindowIndex(i, Player.REPEAT_MODE_OFF, shuffle)
|
||||
i = timeline.getNextWindowIndex(i, Player.REPEAT_MODE_OFF, isShuffled)
|
||||
}
|
||||
return playlist
|
||||
}
|
||||
|
@ -15,6 +15,13 @@
|
||||
app:showAsAction="ifRoom|withText"
|
||||
a:title="@string/download.menu_star"/>
|
||||
|
||||
<item
|
||||
a:id="@+id/menu_show_artist"
|
||||
a:title="@string/download.menu_show_artist"/>
|
||||
<item
|
||||
a:id="@+id/menu_show_album"
|
||||
a:title="@string/download.menu_show_album"/>
|
||||
|
||||
<item
|
||||
a:id="@+id/menu_item_share_song"
|
||||
a:icon="@drawable/ic_menu_share"
|
||||
|
@ -70,7 +70,7 @@
|
||||
<string name="download.menu_save">Guardar lista de reproducción</string>
|
||||
<string name="download.menu_screen_off">Pantalla apagada</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">Ir al álbum</string>
|
||||
<string name="download.menu_shuffle">Aleatorio</string>
|
||||
<string name="download.menu_shuffle_on">Modo aleatorio activado</string>
|
||||
<string name="download.menu_shuffle_off">Modo aleatorio desactivado</string>
|
||||
@ -361,7 +361,7 @@
|
||||
<string name="share_default_greeting">Echa un vistazo a esta música que te comparto desde %s</string>
|
||||
<string name="share_via">Compartir canciones vía</string>
|
||||
<string name="menu.share">Compartir</string>
|
||||
<string name="download.menu_show_artist">Mostrar artista</string>
|
||||
<string name="download.menu_show_artist">Ir al artista</string>
|
||||
<string name="albumArt">Portadas de álbumes</string>
|
||||
<string name="common_multiple_years">Múltiples años</string>
|
||||
<string name="settings.show_confirmation_dialog">Mostrar diálogo de confirmación</string>
|
||||
|
@ -1,2 +1,27 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources></resources>
|
||||
<resources>
|
||||
<string name="background_task.loading">Cargando…</string>
|
||||
<string name="background_task.network_error">Ocorreu un erro de rede. Por favor comproba a dirección do servidor ou téntao de novo mais tarde.</string>
|
||||
<string name="background_task.unsupported_api">A API do servidor v%1$s non admite esta función.</string>
|
||||
<string name="background_task.no_network">Este programa require acceso á rede. Por favor acende a Wi-Fi ou a rede móbil.</string>
|
||||
<string name="background_task.not_found">Recurso non atopado. Por favor comproba a dirección do servidor.</string>
|
||||
<string name="background_task.parse_error">Non se entende a resposta. Por favor comproba a dirección do servidor.</string>
|
||||
<string name="background_task.ssl_cert_error">Erro do certificado HTTPS: %1$s.</string>
|
||||
<string name="background_task.ssl_error">Excepción de conexión SSL. Comprobe o certificado do servidor.</string>
|
||||
<string name="background_task.wait">Por favor agarde…</string>
|
||||
<string name="button_bar.browse">Biblioteca</string>
|
||||
<string name="button_bar.chat">Chat</string>
|
||||
<string name="button_bar.now_playing">Reproducindo agora</string>
|
||||
<string name="buttons.play">Reproducir</string>
|
||||
<string name="buttons.pause">Pausar</string>
|
||||
<string name="buttons.repeat">Repetir</string>
|
||||
<string name="buttons.shuffle">Mesturar</string>
|
||||
<string name="buttons.stop">Parar</string>
|
||||
<string name="buttons.next">Seguinte</string>
|
||||
<string name="buttons.previous">Anterior</string>
|
||||
<string name="podcasts.label">Podcast</string>
|
||||
<string name="podcasts_channels.empty">Non hai canles de Podcasts rexistrados</string>
|
||||
<string name="button_bar.podcasts">Podcast</string>
|
||||
<string name="button_bar.search">Buscar</string>
|
||||
<string name="chat.send_a_message">Enviar unha mensaxe</string>
|
||||
</resources>
|
@ -1,7 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<string name="background_task.loading">Ładowanie…</string>
|
||||
<string name="background_task.loading">Ładowanie…</string>
|
||||
<string name="background_task.network_error">Wystąpił błąd sieci. Proszę sprawdzić adres serwera i spróbować później.</string>
|
||||
<string name="background_task.unsupported_api">Server api v%1$s does not support this function.</string>
|
||||
<string name="background_task.no_network">Ta aplikacja wymaga dostępu do sieci. Proszę włączyć wi-fi lub dane komórkowe.</string>
|
||||
@ -9,7 +8,7 @@
|
||||
<string name="background_task.parse_error">Brak prawidłowej odpowiedzi. Proszę sprawdzić adres serwera.</string>
|
||||
<string name="background_task.ssl_cert_error">Błąd certyfikatu HTTPS: %1$s.</string>
|
||||
<string name="background_task.ssl_error">Błąd połączenia SSL. Proszę sprawdzić certyfikat serwera.</string>
|
||||
<string name="background_task.wait">Proszę czekać…</string>
|
||||
<string name="background_task.wait">Proszę czekać…</string>
|
||||
<string name="button_bar.bookmarks">Zakładki</string>
|
||||
<string name="button_bar.browse">Biblioteka</string>
|
||||
<string name="button_bar.chat">Czat</string>
|
||||
@ -39,7 +38,7 @@
|
||||
<string name="common.save">Zapisz</string>
|
||||
<string name="common.unpin">Odepnij</string>
|
||||
<string name="common.various_artists">Różni artyści</string>
|
||||
<string name="delete_playlist">Czy chcesz usunąć %1$s?</string>
|
||||
<string name="delete_playlist">Czy chcesz usunąć %1$s</string>
|
||||
<string name="download.bookmark_removed" formatted="false">Zakładka usunięta.</string>
|
||||
<string name="download.bookmark_set_at_position" formatted="false">Zakładka ustawiona na %s.</string>
|
||||
<string name="playlist.empty">Playlista jest pusta</string>
|
||||
@ -62,7 +61,7 @@
|
||||
<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_name">Wprowadź nazwę playlisty:</string>
|
||||
<string name="download.playlist_saving">Trwa zapis playlisty \"%s\"…</string>
|
||||
<string name="download.playlist_saving">Trwa zapis playlisty \"%s\"…</string>
|
||||
<string name="download.playlist_title">Zapisz playlistę</string>
|
||||
<string name="download.repeat_all">Powtarzaj wszystko</string>
|
||||
<string name="download.repeat_off">Powtarzanie wyłączone</string>
|
||||
@ -96,7 +95,7 @@
|
||||
<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.settings">Ustawienia</string>
|
||||
<string name="menu.refresh">Refresh</string>
|
||||
<string name="menu.refresh">Odśwież</string>
|
||||
<string name="music_library.label">Biblioteka mediów</string>
|
||||
<string name="music_library.label_offline">Media offline</string>
|
||||
<string name="playlist.label">Playlisty</string>
|
||||
@ -172,8 +171,8 @@
|
||||
<string name="settings.display_bitrate_summary">Dołącza bitrate i typ pliku do nazwy artysty</string>
|
||||
<string name="settings.hide_media_summary">Ukrywa pliki muzyczne przed innymi aplikacjami.</string>
|
||||
<string name="settings.hide_media_title">Ukryj pliki</string>
|
||||
<string name="settings.hide_media_toast">Efekt widoczny będzie po następnym skanowaniu muzyki przez system Android</string>
|
||||
<string name="settings.invalid_url">Proszę wprowadzić prawidłowy URL</string>
|
||||
<string name="settings.hide_media_toast">Efekt widoczny będzie po następnym skanowaniu muzyki przez system Android.</string>
|
||||
<string name="settings.invalid_url">Proszę wprowadzić prawidłowy URL.</string>
|
||||
<string name="settings.max_albums">Maksymalna ilość wyników - albumy</string>
|
||||
<string name="settings.max_artists">Maksymalna ilość wyników - artyści</string>
|
||||
<string name="settings.max_bitrate_112">112 Kbps</string>
|
||||
@ -205,10 +204,10 @@
|
||||
<string name="settings.network_title">Sieć</string>
|
||||
<string name="settings.other_title">Inne ustawienia</string>
|
||||
<string name="settings.playback_control_title">Ustawienia sterowania odtwarzaniem</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.resume_on_bluetooth_device">Wznów po podłączeniu urządzenia Bluetooth</string>
|
||||
<string name="settings.playback.pause_on_bluetooth_device">Wstrzymaj, gdy urządzenie Bluetooth jest odłączone</string>
|
||||
<string name="settings.playback.bluetooth_all">Wszystkie urządzenia Bluetooth</string>
|
||||
<string name="settings.playback.bluetooth_a2dp">Tylko urządzenia audio (A2DP)</string>
|
||||
<string name="settings.playback.bluetooth_disabled">Wyłączone</string>
|
||||
<string name="settings.playback.resume_play_on_headphones_plug.title">Wznawiaj po podłączeniu słuchawek</string>
|
||||
<string name="settings.playback.resume_play_on_headphones_plug.summary">Aplikacja wznowi zatrzymane odtwarzanie po podpięciu słuchawek.</string>
|
||||
@ -298,18 +297,18 @@
|
||||
<string name="menu.share">Udostępnianie</string>
|
||||
<string name="download.menu_show_artist">Wyświetlaj artystę</string>
|
||||
<string name="common_multiple_years">Z różnych lat</string>
|
||||
<string name="server_selector.label">Configured servers</string>
|
||||
<string name="server_selector.delete_confirmation">Are you sure you want to delete the server?</string>
|
||||
<string name="server_editor.label">Editing server</string>
|
||||
<string name="server_selector.label">Skonfigurowane serwery</string>
|
||||
<string name="server_selector.delete_confirmation">Czy na pewno chcesz usunąć ten serwer\?</string>
|
||||
<string name="server_editor.label">Edycja serwera</string>
|
||||
<string name="server_editor.new_label">Dodaj serwer</string>
|
||||
<string name="server_editor.leave_confirmation">Are you sure you want to leave and lose your changes?</string>
|
||||
<string name="server_editor.required">This field is required</string>
|
||||
<string name="server_menu.edit">Edit</string>
|
||||
<string name="server_editor.leave_confirmation">Czy na pewno chcesz wyjść i utracić dokonane zmiany\?</string>
|
||||
<string name="server_editor.required">To pole jest wymagane</string>
|
||||
<string name="server_menu.edit">Edytuj</string>
|
||||
<string name="server_menu.delete">Usuń</string>
|
||||
<string name="server_menu.move_up">Move up</string>
|
||||
<string name="server_menu.move_down">Move down</string>
|
||||
<string name="server_menu.move_up">Przesuń się w górę</string>
|
||||
<string name="server_menu.move_down">Przesuń się w dół</string>
|
||||
<string name="server_editor.authentication">Authentication</string>
|
||||
<string name="server_editor.advanced">Advanced settings</string>
|
||||
<string name="server_editor.advanced">Ustawienia zaawansowane</string>
|
||||
<plurals name="select_album_n_songs">
|
||||
<item quantity="one">%d utwór</item>
|
||||
<item quantity="few">%d utwory</item>
|
||||
@ -327,7 +326,148 @@
|
||||
<string name="api.subsonic.trial_period_is_over">Okres próbny się zakończył.</string>
|
||||
<string name="api.subsonic.upgrade_client">Brak zgodności wersji. Uaktualnij aplikację Ultrasonic na Androida.</string>
|
||||
<string name="api.subsonic.upgrade_server">Brak zgodności wersji. Uaktualnij serwer Subsonic.</string>
|
||||
|
||||
<!-- Subsonic features -->
|
||||
<string name="settings.five_star_rating_title">Użyj pięciu gwiazdek dla utworów</string>
|
||||
</resources>
|
||||
<string name="settings.show_confirmation_dialog_summary">Pokaż okno potwierdzające usunięcie lub odpięcie utworów</string>
|
||||
<string name="language.en">Angielski</string>
|
||||
<string name="settings.scrobble_summary">Pamiętaj o ustawieniu nazwy użytkownika i hasła do usługi Scrobble na serwerze</string>
|
||||
<string name="settings.use_id3_offline">Użyj metody ID3 także kiedy nie masz połączenia</string>
|
||||
<string name="settings.debug.log_keep">Zatrzymaj pliki</string>
|
||||
<string name="download.menu_shuffle_off">Wyłączony tryb losowy</string>
|
||||
<string name="buttons.stop">Zatrzymaj</string>
|
||||
<string name="language.fr">Francuski</string>
|
||||
<string name="common.unpin_selection_confirmation">Czy na pewno chcesz odpiąć zaznaczone pozycje\?</string>
|
||||
<string name="settings.custom_cache_location">Użyj niestandardowej lokacji pamięci podręcznej</string>
|
||||
<string name="common.select_all">Wybierz wszystko</string>
|
||||
<string name="download.menu_shuffle_on">Włączony tryb losowy</string>
|
||||
<string name="buttons.next">Następne</string>
|
||||
<string name="main.albums_by_year">Chronologicznie</string>
|
||||
<string name="main.welcome_cancel">Otwórz ustawienia</string>
|
||||
<string name="language.cs">Czeski</string>
|
||||
<string name="chat.send_button">Wyślij</string>
|
||||
<string name="select_album.n_selected">Zaznaczono %d utworów</string>
|
||||
<string name="share_on_server">Stwórz udostępnienie na serwerze</string>
|
||||
<string name="language.de">Niemiecki</string>
|
||||
<string name="about.report">Zgłoś błąd</string>
|
||||
<string name="notification.downloading_title">Pobieranie w tle…</string>
|
||||
<string name="settings.preload_1000">1000 piosenek</string>
|
||||
<string name="supported_server_features">Wspierane funkcje</string>
|
||||
<string name="language.pl">Polski</string>
|
||||
<string name="common.artist">Artysta</string>
|
||||
<string name="language.nl">Holenderski</string>
|
||||
<string name="language.hu">Węgierski</string>
|
||||
<string name="settings.debug.log_summary">Zapisanych jest %1$s plików z logami, które zajmują ~%2$s MB miejsca w katalogu %3$s. Czy chcesz je zachować\?</string>
|
||||
<string name="buttons.previous">Poprzednie</string>
|
||||
<plurals name="select_album_n_songs_deleted">
|
||||
<item quantity="one">Usunięto %d utwór</item>
|
||||
<item quantity="few">Usunięto %d utwory</item>
|
||||
<item quantity="many">Usunięto %d utworów</item>
|
||||
<item quantity="other">Usunięto %d utworów</item>
|
||||
</plurals>
|
||||
<string name="buttons.repeat">Powtarzaj</string>
|
||||
<string name="download.empty">Nic nie jest pobierane</string>
|
||||
<string name="language.ru">Rosyjski</string>
|
||||
<string name="download.playerstate_loading">Byforowanie…</string>
|
||||
<string name="main.setup_server">%s - Ustaw serwer</string>
|
||||
<string name="settings.preload_50">50 piosenek</string>
|
||||
<string name="language.zh_CN">Chiński (Chiny)</string>
|
||||
<string name="settings.override_language">Nadpisz język</string>
|
||||
<string name="buttons.play">Odtwórz</string>
|
||||
<string name="language.default">Domyślne systemowe</string>
|
||||
<string name="menu.downloads">Pobrane</string>
|
||||
<string name="settings.display_bitrate">Wyświetlaj bitrate i typ pliku</string>
|
||||
<string name="language.pt_BR">Portugalski (Brazylia)</string>
|
||||
<string name="settings.debug.log_path">Plik z logami jest dostępny w %1$s/%2$s</string>
|
||||
<string name="settings.debug.log_deleted">Usunięte pliki z logami.</string>
|
||||
<string name="foreground_exception_text">Naciśnij przycisk odtwarzania na powiadomieniu o mediach, jeśli jest ono nadal obecne, w przeciwnym razie otwórz aplikację, aby rozpocząć odtwarzanie i ponownie podłącz sesję do kontrolera</string>
|
||||
<string name="language.it">Włoski</string>
|
||||
<string name="language.pt">Portugalski</string>
|
||||
<string name="settings.server_color">Kolor serwera</string>
|
||||
<string name="buttons.pause">Pauza</string>
|
||||
<string name="settings.show_artist_picture">Pokaż obraz wykonawcy na liście</string>
|
||||
<string name="common.title">Tytuł</string>
|
||||
<string name="common.delete_selection_confirmation">Czy na pewno chcesz usunąć zaznaczone pozycje\?</string>
|
||||
<string name="albumArt">Okładka albumu</string>
|
||||
<string name="common.album">Album</string>
|
||||
<string name="settings.preload_500">500 piosenek</string>
|
||||
<string name="settings.share_on_server_summary">Udostępnianie spowoduje utworzenie go na serwerze i udostępnienie jego adresu URL. Jeśli ta opcja jest wyłączona, udostępniane są tylko szczegóły utworu</string>
|
||||
<string name="settings.download_transition">Pokaż Obecnie odtwarzane po kliknięciu przycisku Odtwarzaj</string>
|
||||
<string name="language.es">Hiszpański</string>
|
||||
<string name="settings.override_language_summary">Wymagane jest ponowne uruchomienie aplikacji po zmianie języka</string>
|
||||
<string name="foreground_exception_title">Nie można wznowić odtwarzania</string>
|
||||
<string name="chat.user_avatar">Awatar</string>
|
||||
<string name="language.zh_TW">Chiński (Tajwan)</string>
|
||||
<string name="settings.theme_day_night">Dzień i noc</string>
|
||||
<string name="settings.theme_black">Czarny</string>
|
||||
<string name="settings.summary.force_plain_text_password">Zmusza to aplikację do wysyłania hasła w postaci niezaszyfrowanej. Przydatne, jeśli serwer Subsonic nie obsługuje nowego interfejsu API uwierzytelniania dla użytkowników.</string>
|
||||
<string name="settings.show_now_playing_details_summary">Pokaż więcej informacji o utworze w sekcji Obecnie odtwarzane (gatunek, rok, przepustowość)</string>
|
||||
<string name="settings.show_now_playing_details">Pokaż szczegóły w sekcji Obecnie odtwarzane</string>
|
||||
<string name="settings.wifi_required_title">Pobieraj tylko przez Wi-Fi</string>
|
||||
<string name="settings.sharing_always_ask_for_details_summary">Zawsze pytaj o opis i czas wygaśnięcia podczas tworzenia udostępnienia na serwerze</string>
|
||||
<string name="settings.debug.log_delete">Usuń pliki</string>
|
||||
<plurals name="select_album_n_songs_pinned">
|
||||
<item quantity="one">%d utwór zaznaczony do przypięcia</item>
|
||||
<item quantity="few">%d utwory zaznaczone do przypięcia</item>
|
||||
<item quantity="many">%d utworów zaznaczonych do przypięcia</item>
|
||||
<item quantity="other">%d utworów zaznaczonych do przypięcia</item>
|
||||
</plurals>
|
||||
<plurals name="select_album_n_songs_downloaded">
|
||||
<item quantity="one">%d utworów zaznaczonych do pobrania</item>
|
||||
<item quantity="few">%d utwory zaznaczone do pobrania</item>
|
||||
<item quantity="many">%d utworów zaznaczonych do pobrania</item>
|
||||
<item quantity="other">%d utworów zaznaczonych do pobrania</item>
|
||||
</plurals>
|
||||
<string name="about.webpage">Odwiedź stronę internetową</string>
|
||||
<plurals name="select_album_n_songs_unpinned">
|
||||
<item quantity="one">Odpięto %d utwór</item>
|
||||
<item quantity="few">Odpięto %d utwory</item>
|
||||
<item quantity="many">Odpięto %d utworów</item>
|
||||
<item quantity="other">Odpięto %d utworów</item>
|
||||
</plurals>
|
||||
<string name="settings.use_hw_offload_title">Użyj odtwarzania sprzętowwego (eksperymentalne)</string>
|
||||
<string name="jukebox">Jukebox</string>
|
||||
<string name="select_album.no_network">Uwaga: Brak dostępnych sieci do użycia.
|
||||
\n Jeżeli używasz danych mobilnych, potrzebne może być włączenie płatnych połączeń w ustawieniach.</string>
|
||||
<string name="settings.download_transition_summary">Przełącz na Obecnie odtwarzane po rozpoczęciu odtwarzania w widoku multimediów</string>
|
||||
<string name="settings.increment_time">Odstępy między wyszukaniami</string>
|
||||
<string name="settings.parallel_downloads">Ilość równocześnie pobieranych piosenek</string>
|
||||
<string name="settings.preload_100">100 piosenek</string>
|
||||
<string name="settings.scrobble_title">Scrobbluj moje odtworzenia</string>
|
||||
<string name="settings.use_id3_offline_summary">Jeśli włączysz to ustawienie, będzie ono wyświetlać tylko muzykę pobraną za pomocą Ultrasonic w wersji 4.0 lub nowszej. Wcześniejsze pobrane pliki nie zawierają wymaganych metadanych. Możesz przełączać się między trybami Przypinania i Zapisywania, aby wyzwolić pobieranie brakujących metadanych.</string>
|
||||
<string name="settings.show_artist_picture_summary">Wyświetla obraz wykonawcy na liście wykonawców, jeśli jest dostępny</string>
|
||||
<string name="settings.wifi_required_summary">Pobieraj tylko podczas połączeń niepłatnuch</string>
|
||||
<string name="download.share_song">Udostępnij obecnie odtwarzaną piosenkę</string>
|
||||
<string name="settings.show_confirmation_dialog">Pokaż okno potwierdzające</string>
|
||||
<string name="settings.debug.title">Opcje debugowania</string>
|
||||
<string name="settings.debug.log_to_file">Zapisz logi debugowania do pliku</string>
|
||||
<string name="notification.permission_required">Powiadomienia są wymagane do odtwarzania multimediów. Możesz przyznać uprawnienie do nich w dowolnym momencie w ustawieniach Androida.</string>
|
||||
<string name="server_editor.disabled_feature">Jedna lub więcej funkcji zostało wyłączonych ponieważ serwer ich nie obsługiwał.
|
||||
\nMożesz uruchomić ten test ponownie kiedykolwiek.</string>
|
||||
<string name="server_menu.demo">Serwer demonstracyjny</string>
|
||||
<string name="settings.five_star_rating_description">Użyj systemu pięciu gwiazdek do oceniania utworów zamiast po prostu dodawać lub usuwać utwory z ulubionych.</string>
|
||||
<string name="list_view">Lista</string>
|
||||
<string name="grid_view">Okładka</string>
|
||||
<string name="settings.use_hw_offload_description">Spróbuj odtworzyć pliki multimedialne za pomocą układu dekodującego w telefonie. Może to zmniejszyć zużycie baterii!</string>
|
||||
<plurals name="select_album_n_songs_added">
|
||||
<item quantity="one">Dodano %d utwór na koniec kolejki odtwarzania</item>
|
||||
<item quantity="few">Dodano %d utwory na koniec kolejki odtwarzania</item>
|
||||
<item quantity="many">Dodano %d utworów na koniec kolejki odtwarzania</item>
|
||||
<item quantity="other">Dodano %d utworów na koniec kolejki odtwarzania</item>
|
||||
</plurals>
|
||||
<plurals name="select_album_n_songs_play_next">
|
||||
<item quantity="one">Wstawiono %d utwór po bieżącym utworze</item>
|
||||
<item quantity="few">Wstawiono %d utwory po bieżącym utworze</item>
|
||||
<item quantity="many">Wstawiono %d utworów po bieżącym utworze</item>
|
||||
<item quantity="other">Wstawiono %d utworów po bieżącym utworze</item>
|
||||
</plurals>
|
||||
<string name="about.text"><b>Ultrasonic</b> to darmowy i otwarty klient strumieniowego przesyłania muzyki dla serwerów kompatybilnych z API Subsonic (wersja 1.7.0 lub nowsza).
|
||||
\n
|
||||
\nDzięki <b>Ultrasonic</b> możesz łatwo przesyłać strumieniowo lub pobierać muzykę z komputera domowego na telefon za pomocą serwera multimediów kompatybilnego z Subsonic. Oprogramowanie serwera Subsonic wymaga oddzielnej konfiguracji od Ultrasonic.
|
||||
\n
|
||||
\nDomyślnie Ultrasonic nie jest skonfigurowane. Po skonfigurowaniu własnego serwera zmień ustawienia serwera, aby połączyć się z komputerem.</string>
|
||||
<string name="main.welcome_text_demo">Aby używać Ultrasonic z własną muzyką, potrzebujesz <b>własnego serwera</b>.
|
||||
\n
|
||||
\n➤ Jeśli chcesz najpierw wypróbować aplikację, możesz teraz dodać serwer demonstracyjny.
|
||||
\n
|
||||
\n➤ W przeciwnym razie możesz skonfigurować serwer w <b>ustawieniach</b>.</string>
|
||||
</resources>
|
@ -3,11 +3,11 @@
|
||||
<string name="background_task.loading">加载中…</string>
|
||||
<string name="background_task.network_error">发生网络错误。请检查服务器地址或稍后重试。</string>
|
||||
<string name="background_task.unsupported_api">服务端 API v%1$s 不支持此功能。</string>
|
||||
<string name="background_task.no_network">此软件需要连接网络,请打开Wi-Fi或移动网络。</string>
|
||||
<string name="background_task.no_network">此软件需要连接网络,请打开 Wi-Fi 或移动网络。</string>
|
||||
<string name="background_task.not_found">未找到资源,请检查服务器地址。</string>
|
||||
<string name="background_task.parse_error">未知回复内容,请检查服务器地址</string>
|
||||
<string name="background_task.ssl_cert_error">HTTPS 证书错误: %1$s.</string>
|
||||
<string name="background_task.ssl_error">SSL连接异常。请检查服务器证书。</string>
|
||||
<string name="background_task.parse_error">未知回复内容,请检查服务器地址。</string>
|
||||
<string name="background_task.ssl_cert_error">HTTPS 证书错误:%1$s.</string>
|
||||
<string name="background_task.ssl_error">SSL 连接异常。请检查服务器证书。</string>
|
||||
<string name="background_task.wait">请稍等…</string>
|
||||
<string name="button_bar.bookmarks">书签</string>
|
||||
<string name="button_bar.browse">媒体库</string>
|
||||
@ -50,20 +50,20 @@
|
||||
<string name="common.unpin">取消固定</string>
|
||||
<string name="common.delete_selection_confirmation">您真的要删除当前选择吗?</string>
|
||||
<string name="common.various_artists">群星</string>
|
||||
<string name="delete_playlist">确定要删除 %1$s吗</string>
|
||||
<string name="delete_playlist">确定要删除 %1$s 吗</string>
|
||||
<string name="download.bookmark_removed" formatted="false">书签已删除。</string>
|
||||
<string name="download.bookmark_set_at_position" formatted="false">书签设置为 %s。</string>
|
||||
<string name="download.bookmark_set_at_position" formatted="false">书签设置在 %s。</string>
|
||||
<string name="download.empty">未下载任何内容</string>
|
||||
<string name="playlist.empty">空的播放列表</string>
|
||||
<string name="download.jukebox_not_authorized">不允许远程控制. 请在您的服务器上的 <b>Users > Settings</b> 打开点唱机模式。</string>
|
||||
<string name="download.jukebox_off">关闭远程控制,音乐将在手机上播放</string>
|
||||
<string name="download.jukebox_offline">离线模式不支持远程控制</string>
|
||||
<string name="download.jukebox_on">打开远程控制,音乐将在服务端播放。</string>
|
||||
<string name="download.jukebox_server_too_old">远程控制不支持,请升级您的 Subsonic服务器。</string>
|
||||
<string name="download.jukebox_not_authorized">不允许远程控制. 请在您 Subsonic 服务器上的 <b>用户 > 设置</b> 中打开点唱机模式。</string>
|
||||
<string name="download.jukebox_off">已关闭远程控制,音乐将在手机上播放。</string>
|
||||
<string name="download.jukebox_offline">离线模式不支持远程控制。</string>
|
||||
<string name="download.jukebox_on">已打开远程控制,音乐将在服务端播放。</string>
|
||||
<string name="download.jukebox_server_too_old">远程控制不支持,请升级您的 Subsonic 服务器。</string>
|
||||
<string name="download.jukebox_volume">远程音量</string>
|
||||
<string name="download.menu_equalizer">均衡器</string>
|
||||
<string name="download.menu_jukebox_off">关闭 Jukebox</string>
|
||||
<string name="download.menu_jukebox_on">开启 Jukebox</string>
|
||||
<string name="download.menu_jukebox_off">关闭点唱机</string>
|
||||
<string name="download.menu_jukebox_on">开启点唱机</string>
|
||||
<string name="download.menu_lyrics">歌词</string>
|
||||
<string name="download.menu_save">保存播放列表</string>
|
||||
<string name="download.menu_screen_off">关闭屏幕常亮</string>
|
||||
@ -86,7 +86,7 @@
|
||||
<string name="equalizer.label">均衡器</string>
|
||||
<string name="equalizer.preset">选择预设</string>
|
||||
<string name="error.label">错误</string>
|
||||
<string name="jukebox.is_default">默认自动点唱机</string>
|
||||
<string name="jukebox.is_default">默认使用点唱机</string>
|
||||
<string name="lyrics.nomatch">找不到歌词</string>
|
||||
<string name="language.default">系统默认</string>
|
||||
<string name="main.albums_alphaByArtist">按艺术家排序</string>
|
||||
@ -101,7 +101,7 @@
|
||||
<string name="main.artists_title">艺术家</string>
|
||||
<string name="main.genres_title">流派</string>
|
||||
<string name="main.offline">离线</string>
|
||||
<string name="main.setup_server">%s - 已设置服务器</string>
|
||||
<string name="main.setup_server">%s - 设置服务器</string>
|
||||
<string name="main.songs_random">随机</string>
|
||||
<string name="main.songs_starred">收藏夹</string>
|
||||
<string name="main.songs_title">歌曲</string>
|
||||
@ -110,13 +110,13 @@
|
||||
\n
|
||||
\n➤ 如果您想试试此应用, 可以添加试用服务器。
|
||||
\n
|
||||
\n➤ 可在 <b>设置</b>中编辑服务器配置信息。</string>
|
||||
\n➤ 可在 <b>设置</b> 中编辑服务器配置信息。</string>
|
||||
<string name="main.welcome_title">欢迎!</string>
|
||||
<string name="main.welcome_cancel">点击前往设置</string>
|
||||
<string name="menu.about">关于</string>
|
||||
<string name="menu.common">公共</string>
|
||||
<string name="menu.deleted_playlist">已删除播放列表 %s</string>
|
||||
<string name="menu.deleted_playlist_error">播放列表删除失败%s</string>
|
||||
<string name="menu.deleted_playlist_error">播放列表删除失败 %s</string>
|
||||
<string name="menu.downloads">下载</string>
|
||||
<string name="menu.exit">退出</string>
|
||||
<string name="menu.settings">设置</string>
|
||||
@ -136,8 +136,9 @@
|
||||
<string name="search.title">搜索</string>
|
||||
<string name="select_album.empty">找不到歌曲</string>
|
||||
<string name="select_album.n_selected">已选择 %d 首曲目</string>
|
||||
<string name="select_album.no_network">警告:当前没有可用的网络.\n 如果您要使用移动数据,您需要在设置中允许使用流量进行下载</string>
|
||||
<string name="select_album.no_sdcard">错误:没有SD卡</string>
|
||||
<string name="select_album.no_network">警告:当前没有可用的网络。
|
||||
\n 如果您要使用移动数据,您需要在设置中允许使用流量进行下载。</string>
|
||||
<string name="select_album.no_sdcard">错误:没有SD卡。</string>
|
||||
<string name="select_album.play_all">播放全部</string>
|
||||
<string name="select_artist.all_folders">所有文件夹</string>
|
||||
<string name="select_artist.folder">选择文件夹</string>
|
||||
@ -198,11 +199,11 @@
|
||||
<string name="settings.disc_sort_summary">按光盘编号和曲目编号对歌曲列表进行排序</string>
|
||||
<string name="settings.display_bitrate">展示比特率和文件后缀</string>
|
||||
<string name="settings.display_bitrate_summary">在艺术家姓名后追加比特率和文件后缀</string>
|
||||
<string name="settings.download_transition">正在播放</string>
|
||||
<string name="settings.hide_media_summary">隐藏来自其他应用的音乐</string>
|
||||
<string name="settings.download_transition">播放时显示正在播放界面</string>
|
||||
<string name="settings.hide_media_summary">隐藏来自其他应用的音乐。</string>
|
||||
<string name="settings.hide_media_title">隐藏其他来源</string>
|
||||
<string name="settings.hide_media_toast">在安卓系统下次扫描音乐时生效。</string>
|
||||
<string name="settings.invalid_url">请填写有效的URL。</string>
|
||||
<string name="settings.invalid_url">请填写有效的 URL。</string>
|
||||
<string name="settings.max_albums">最大专辑</string>
|
||||
<string name="settings.max_artists">最大艺术家</string>
|
||||
<string name="settings.max_bitrate_112">112 Kbps</string>
|
||||
@ -215,9 +216,9 @@
|
||||
<string name="settings.max_bitrate_64">64 Kbps</string>
|
||||
<string name="settings.max_bitrate_80">80 Kbps</string>
|
||||
<string name="settings.max_bitrate_96">96 Kbps</string>
|
||||
<string name="settings.max_bitrate_mobile">最大比特率-移动网络</string>
|
||||
<string name="settings.max_bitrate_mobile">最大比特率 - 移动网络</string>
|
||||
<string name="settings.max_bitrate_unlimited">不限制</string>
|
||||
<string name="settings.max_bitrate_wifi">最大比特率-WIFI</string>
|
||||
<string name="settings.max_bitrate_wifi">最大比特率 - WIFI</string>
|
||||
<string name="settings.max_songs">最大歌曲</string>
|
||||
<string name="settings.media_button_summary">响应手机、耳机和蓝牙设备的媒体按钮</string>
|
||||
<string name="settings.media_button_title">媒体按钮</string>
|
||||
@ -249,7 +250,7 @@
|
||||
<string name="settings.preload_5">5 首歌</string>
|
||||
<string name="settings.preload_unlimited">不限制</string>
|
||||
<string name="settings.scrobble_summary">请记得在服务器上的 Scrobble 服务中设置您的用户名和密码</string>
|
||||
<string name="settings.scrobble_title">Scrobble我的播放列表</string>
|
||||
<string name="settings.scrobble_title">Scrobble 我的播放列表</string>
|
||||
<string name="settings.search_1">1</string>
|
||||
<string name="settings.search_10">10</string>
|
||||
<string name="settings.search_100">100</string>
|
||||
@ -278,8 +279,8 @@
|
||||
<string name="settings.show_track_number">显示曲目编号</string>
|
||||
<string name="settings.show_track_number_summary">显示歌曲时包括曲目编号</string>
|
||||
<string name="settings.test_connection_title">测试连接</string>
|
||||
<string name="settings.theme_light">Light</string>
|
||||
<string name="settings.theme_dark">Dark</string>
|
||||
<string name="settings.theme_light">亮色</string>
|
||||
<string name="settings.theme_dark">暗色</string>
|
||||
<string name="settings.theme_black">Black</string>
|
||||
<string name="settings.theme_title">主题</string>
|
||||
<string name="settings.title.allow_self_signed_certificate">允许自签名 HTTPS 证书</string>
|
||||
@ -292,7 +293,7 @@
|
||||
<string name="settings.show_artist_picture_summary">如果可用,在艺术家列表中显示艺术家图片</string>
|
||||
<string name="main.video" tools:ignore="UnusedResources">视频</string>
|
||||
<string name="settings.wifi_required_summary">仅未计量的网络用于下载媒体</string>
|
||||
<string name="settings.wifi_required_title">仅使用Wi-Fi进行下载</string>
|
||||
<string name="settings.wifi_required_title">仅使用 Wi-Fi 进行下载</string>
|
||||
<string name="song_details.kbps">%d kbps</string>
|
||||
<string name="util.bytes_format.byte">0 B</string>
|
||||
<string name="util.bytes_format.gigabyte">0.00 GB</string>
|
||||
@ -330,7 +331,7 @@
|
||||
<string name="save_as_defaults">保存为默认</string>
|
||||
<string name="share_comment">评论</string>
|
||||
<string name="settings.share_expiration">有效期</string>
|
||||
<string name="download_song_removed">%s已从播放列表中移除</string>
|
||||
<string name="download_song_removed">%s 已从播放列表中移除</string>
|
||||
<string name="download.share_playlist">分享播放列表</string>
|
||||
<string name="download.share_song">分享当前曲目</string>
|
||||
<string name="settings.share_greeting_default">默认分享问候语</string>
|
||||
@ -338,14 +339,14 @@
|
||||
<string name="share_via">分享歌曲通过</string>
|
||||
<string name="menu.share">分享</string>
|
||||
<string name="download.menu_show_artist">显示艺术家</string>
|
||||
<string name="common_multiple_years">Multiple Years</string>
|
||||
<string name="common_multiple_years">数年</string>
|
||||
<string name="settings.debug.title">调试选项</string>
|
||||
<string name="settings.debug.log_to_file">将调试日志写入文件</string>
|
||||
<string name="settings.debug.log_path">日志文件可在 %1$s/%2$s 获取</string>
|
||||
<string name="settings.debug.log_summary">%3$s 目录中有 %1$s 个日志文件占用了 ~%2$s MB 空间。您想保留这些吗?</string>
|
||||
<string name="settings.debug.log_keep">保留文件</string>
|
||||
<string name="settings.debug.log_delete">删除文件</string>
|
||||
<string name="settings.debug.log_deleted">删除日志文件</string>
|
||||
<string name="settings.debug.log_deleted">删除日志文件。</string>
|
||||
<string name="notification.downloading_title">在后台下载媒体…</string>
|
||||
<string name="server_selector.label">配置服务器</string>
|
||||
<string name="server_selector.delete_confirmation">您确定要删除此服务器吗?</string>
|
||||
@ -373,45 +374,45 @@
|
||||
<item quantity="other">%d 首曲目</item>
|
||||
</plurals>
|
||||
<plurals name="select_album_n_songs_pinned">
|
||||
<item quantity="other">已选择 %d 首歌曲进行固定。</item>
|
||||
<item quantity="other">已选择 %d 首歌曲进行固定</item>
|
||||
</plurals>
|
||||
<plurals name="select_album_n_songs_downloaded">
|
||||
<item quantity="other">已选择要下载 %d 首歌曲。</item>
|
||||
<item quantity="other">已选择要下载 %d 首歌曲</item>
|
||||
</plurals>
|
||||
<plurals name="select_album_n_songs_unpinned">
|
||||
<item quantity="other">已选择 %d 首歌曲取消固定。</item>
|
||||
<item quantity="other">已选择 %d 首歌曲取消固定</item>
|
||||
</plurals>
|
||||
<plurals name="select_album_n_songs_deleted">
|
||||
<item quantity="other">%d 首歌曲被删除</item>
|
||||
</plurals>
|
||||
<plurals name="select_album_n_songs_added">
|
||||
<item quantity="other">已将 %d 首歌曲添加到播放队列的末尾。</item>
|
||||
<item quantity="other">已将 %d 首歌曲添加到播放队列的末尾</item>
|
||||
</plurals>
|
||||
<plurals name="select_album_n_songs_play_next">
|
||||
<item quantity="other">在当前歌曲之后插入了 %d 首歌曲。</item>
|
||||
<item quantity="other">在当前歌曲之后插入了 %d 首歌曲</item>
|
||||
</plurals>
|
||||
<!-- Subsonic api errors -->
|
||||
<string name="api.subsonic.generic">一般 API 错误: %1$s</string>
|
||||
<string name="api.subsonic.generic">一般 API 错误:%1$s</string>
|
||||
<string name="api.subsonic.generic.no.message">服务器未发送任何信息</string>
|
||||
<string name="api.subsonic.token_auth_not_supported_for_ldap">LDAP用户不支持以token形式授权连接。</string>
|
||||
<string name="api.subsonic.not_authenticated">用户名或密码错误</string>
|
||||
<string name="api.subsonic.not_authorized">授权失败,请在 Subsonic server 检查用户权限。</string>
|
||||
<string name="api.subsonic.token_auth_not_supported_for_ldap">LDAP 用户不支持以 token 形式授权连接。</string>
|
||||
<string name="api.subsonic.not_authenticated">用户名或密码错误。</string>
|
||||
<string name="api.subsonic.not_authorized">授权失败,请在 Subsonic 服务器上检查用户权限。</string>
|
||||
<string name="api.subsonic.param_missing">缺少必需的参数。</string>
|
||||
<string name="api.subsonic.requested_data_was_not_found">未找到请求的数据。</string>
|
||||
<string name="api.subsonic.trial_period_is_over">试用期结束</string>
|
||||
<string name="api.subsonic.trial_period_is_over">试用期结束。</string>
|
||||
<string name="api.subsonic.upgrade_client">版本不兼容,请升级 Ultrasonic 应用。</string>
|
||||
<string name="api.subsonic.upgrade_server">不兼容的版本。请升级Subsonic 服务。</string>
|
||||
<string name="api.subsonic.upgrade_server">版本不兼容,请升级 Subsonic 服务器。</string>
|
||||
<!-- Subsonic features -->
|
||||
<string name="settings.five_star_rating_title">为歌曲使用五星评分</string>
|
||||
<string name="settings.preload_500">500 首歌</string>
|
||||
<string name="settings.use_id3_offline_summary">如果您启用此设置,它将只显示您使用 Ultrasonic 4.0 或更高版本下载的音乐。较早的下载没有下载必要的元数据。您可以在 Pin 和 Save 模式之间切换,以触发缺失元数据的下载。</string>
|
||||
<string name="settings.use_id3_offline_summary">如果您启用此设置,它将只显示您使用 Ultrasonic 4.0 或更高版本下载的音乐。较早的下载没有下载必要的元数据。您可以在固定和保存模式之间切换,以触发缺失元数据的下载。</string>
|
||||
<string name="foreground_exception_text">如果媒体通知仍然存在,请按媒体通知中的播放按钮;否则请打开应用程序开始播放,并重新连接会话到控制器</string>
|
||||
<string name="foreground_exception_title">无法恢复播放</string>
|
||||
<string name="chat.user_avatar">头像图片</string>
|
||||
<string name="common.unpin_selection_confirmation">你真的想取消固定当前选择吗\?</string>
|
||||
<string name="language.zh_CN">简体中文(中国大陆)</string>
|
||||
<string name="language.zh_TW">繁体中文(中国台湾)</string>
|
||||
<string name="language.en"></string>
|
||||
<string name="language.en">英语</string>
|
||||
<string name="main.albums_by_year">按时间排序</string>
|
||||
<string name="settings.download_transition_summary">在媒体库中开始播放后切换到正在播放页面</string>
|
||||
<string name="settings.increment_time">快进/快退间隔</string>
|
||||
@ -421,7 +422,7 @@
|
||||
<string name="settings.preload_50">50 首歌</string>
|
||||
<string name="settings.preload_100">100 首歌</string>
|
||||
<string name="settings.preload_1000">1000 首歌</string>
|
||||
<string name="settings.summary.force_plain_text_password">这会强制应用程序始终以未加密的方式发送密码。如果 Subsonic 服务器不支持新的用户身份验证 API,则该选项非常有用。</string>
|
||||
<string name="settings.summary.force_plain_text_password">这会强制应用程序始终以未加密的方式发送密码。如果此 Subsonic 服务器不支持新的用户身份验证 API,则该选项非常有用。</string>
|
||||
<string name="settings.show_now_playing_details_summary">在正在播放中展示更多歌曲细节(流派,年份,比特率)</string>
|
||||
<string name="settings.show_now_playing_details">在正在播放中展示更多歌曲细节</string>
|
||||
<string name="settings.use_id3_offline">离线时也使用 ID3 方法</string>
|
||||
@ -431,8 +432,21 @@
|
||||
<string name="notification.permission_required">需要通知权限才能进行媒体播放。您可以随时在 Android 设置中授予权限。</string>
|
||||
<string name="settings.five_star_rating_description">对歌曲使用五星评级系统,而不是简单的星标/取消星标。</string>
|
||||
<string name="settings.use_hw_offload_title">使用硬件回放(实验性)</string>
|
||||
<string name="settings.use_hw_offload_description">尝试使用手机上的媒体解码器芯片来播放媒体。这可以改善电池使用情况。</string>
|
||||
<string name="settings.use_hw_offload_description">尝试使用手机上的媒体解码器芯片来播放媒体。这可以改善电池使用情况。部分用户报告启用该选项后播放会有问题!</string>
|
||||
<string name="list_view">列表</string>
|
||||
<string name="grid_view">封面</string>
|
||||
<string name="supported_server_features">已支持的功能</string>
|
||||
<string name="language.es">西班牙语</string>
|
||||
<string name="language.fr">法语</string>
|
||||
<string name="language.it">意大利语</string>
|
||||
<string name="language.ru">俄语</string>
|
||||
<string name="language.nl">荷兰语</string>
|
||||
<string name="settings.theme_day_night">日与夜</string>
|
||||
<string name="language.hu">匈牙利语</string>
|
||||
<string name="language.pl">波兰语</string>
|
||||
<string name="jukebox">点唱机</string>
|
||||
<string name="language.pt">葡萄牙语</string>
|
||||
<string name="language.cs">捷克语</string>
|
||||
<string name="language.de">德语</string>
|
||||
<string name="language.pt_BR">葡萄牙语(巴西)</string>
|
||||
</resources>
|
@ -1,6 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<string name="background_task.loading">載入中…</string>
|
||||
<string name="button_bar.bookmarks">書籤</string>
|
||||
<string name="button_bar.browse">媒體庫</string>
|
||||
@ -32,7 +31,7 @@
|
||||
<string name="common.select_all">全選</string>
|
||||
<string name="common.title">標題</string>
|
||||
<string name="common.various_artists">各式歌手</string>
|
||||
<string name="delete_playlist">您想刪除 %1$s 嗎?</string>
|
||||
<string name="delete_playlist">您想刪除 %1$s 嗎</string>
|
||||
<string name="download.bookmark_removed" formatted="false">書籤已移除。</string>
|
||||
<string name="download.empty">無下載</string>
|
||||
<string name="playlist.empty">播放清單是空的</string>
|
||||
@ -130,4 +129,108 @@
|
||||
<string name="time_span_disabled">已停用</string>
|
||||
<string name="share_comment">註記</string>
|
||||
<string name="server_menu.delete">刪除</string>
|
||||
</resources>
|
||||
<string name="download.menu_show_album">顯示專輯</string>
|
||||
<string name="language.zh_CN">簡體中文(中國)</string>
|
||||
<string name="download.menu_save">儲存播放清單</string>
|
||||
<string name="download.bookmark_set_at_position" formatted="false">書籤設置在 %s。</string>
|
||||
<string name="download.jukebox_server_too_old">不支援遠端控制,請升級您的 Subsonic 伺服器。</string>
|
||||
<string name="podcasts.label">播客</string>
|
||||
<string name="common.unpin_selection_confirmation">您真的要取消固定目前選取的項目嗎?</string>
|
||||
<string name="common.appname">Ultrasonic</string>
|
||||
<string name="background_task.ssl_error">SSL 連線異常。請檢查伺服器憑證。</string>
|
||||
<string name="common.pin">固定</string>
|
||||
<string name="chat.send_button">傳送</string>
|
||||
<string name="button_bar.chat">聊天</string>
|
||||
<string name="download.jukebox_volume">遠端音量</string>
|
||||
<string name="chat.user_avatar">頭像</string>
|
||||
<string name="common.delete_selection_confirmation">您真的要刪除目前選取的項目嗎?</string>
|
||||
<string name="background_task.parse_error">無法理解答覆,請檢查伺服器位址。</string>
|
||||
<string name="language.nl">荷蘭語</string>
|
||||
<string name="download.jukebox_off">已關閉遠端控制,音樂將在手機上播放。</string>
|
||||
<string name="language.de">德語</string>
|
||||
<string name="background_task.wait">請稍候…</string>
|
||||
<string name="common.unpin">取消固定</string>
|
||||
<string name="download.playlist_name">輸入播放清單名稱:</string>
|
||||
<string name="main.albums_by_year">依照時間排列</string>
|
||||
<string name="download.playlist_saving">正在儲存播放清單 \"%s\"…</string>
|
||||
<string name="language.default">系統預設</string>
|
||||
<string name="background_task.ssl_cert_error">HTTPS 憑證錯誤:%1$s.</string>
|
||||
<string name="language.pl">波蘭語</string>
|
||||
<string name="podcasts_channels.empty">沒有已註冊的播客頻道</string>
|
||||
<string name="chat.send_a_message">傳送訊息</string>
|
||||
<string name="download.menu_screen_on">打開熒幕常亮</string>
|
||||
<string name="language.it">義大利語</string>
|
||||
<string name="language.fr">法語</string>
|
||||
<string name="background_task.no_network">此程式需要連結網路。請打開 Wi-Fi 或行動網路。</string>
|
||||
<string name="main.albums_highest">最高評分</string>
|
||||
<string name="main.setup_server">%s - 設定伺服器</string>
|
||||
<string name="language.ru">俄語</string>
|
||||
<string name="language.es">西班牙語</string>
|
||||
<string name="button_bar.podcasts">播客</string>
|
||||
<string name="download.playlist_error">儲存播放清單失敗,請稍後再試。</string>
|
||||
<string name="download.menu_screen_off">關閉熒幕常亮</string>
|
||||
<string name="search.more">顯示更多</string>
|
||||
<string name="download.menu_jukebox_on">打開點唱機</string>
|
||||
<string name="equalizer.preset">選擇預設</string>
|
||||
<string name="search.songs">歌曲</string>
|
||||
<string name="download.jukebox_offline">離線模式下無法使用遠端控制。</string>
|
||||
<string name="download.playerstate_playing_shuffle">隨機播放</string>
|
||||
<string name="common.ok">確定</string>
|
||||
<string name="download.menu_jukebox_off">關閉點唱機</string>
|
||||
<string name="lyrics.nomatch">未找到歌詞</string>
|
||||
<string name="language.pt_BR">葡萄牙語(巴西)</string>
|
||||
<string name="download.repeat_all">全部循環播放</string>
|
||||
<string name="background_task.not_found">找不到資源,請檢查伺服器位址。</string>
|
||||
<string name="download.repeat_single">單曲循環播放</string>
|
||||
<string name="language.hu">匈牙利語</string>
|
||||
<string name="main.welcome_cancel">前往設定</string>
|
||||
<string name="language.zh_TW">繁體中文(臺灣)</string>
|
||||
<string name="download.repeat_off">關閉循環播放</string>
|
||||
<string name="jukebox.is_default">預設使用點唱機</string>
|
||||
<string name="background_task.network_error">發生網路錯誤。請檢查伺服器位址或稍後重試。</string>
|
||||
<string name="download.jukebox_on">已打開遠端控制,音樂將在伺服器上播放。</string>
|
||||
<string name="download.playlist_title">儲存播放清單</string>
|
||||
<string name="language.cs">捷克語</string>
|
||||
<string name="language.en">英語</string>
|
||||
<string name="language.pt">葡萄牙語</string>
|
||||
<string name="main.songs_title">歌曲</string>
|
||||
<string name="main.welcome_title">歡迎!</string>
|
||||
<string name="menu.deleted_playlist">已刪除播放清單 %s</string>
|
||||
<string name="main.welcome_text_demo">要使用 Ultrasonic 播放您的音樂,需要您 <b>自己的伺服器</b>。
|
||||
\n
|
||||
\n➤ 如果您想嘗試此應用程式, 可以添加一個演示伺服器。
|
||||
\n
|
||||
\n➤ 此外,可在 <b>設定</b> 中配置您的伺服器。</string>
|
||||
<string name="menu.deleted_playlist_error">播放清單刪除失敗 %s</string>
|
||||
<string name="search.no_match">沒有匹配的結果,請重試</string>
|
||||
<string name="select_album.empty">找不到媒體</string>
|
||||
<string name="select_album.n_selected">已選取 %d 首曲目</string>
|
||||
<string name="background_task.unsupported_api">伺服器 API v%1$s 不支援此功能。</string>
|
||||
<string name="download.playlist_done">已儲存播放清單。</string>
|
||||
<string name="playlist.updated_info">更新了 %s 的播放清單資訊</string>
|
||||
<string name="playlist.updated_info_error">更新 %s 的播放清單資訊失敗</string>
|
||||
<string name="menu.refresh">刷新</string>
|
||||
<string name="music_library.label_offline">離線媒體</string>
|
||||
<string name="playlist.update_info">更新資訊</string>
|
||||
<string name="download.jukebox_not_authorized">不允許遠端控制。請在您在 Subsonic 伺服器上的 <b>使用者 > 設定</b> 中啟用點唱機模式。</string>
|
||||
<string name="settings.directory_cache_time">目錄快取時間</string>
|
||||
<string name="settings.disc_sort_summary">依光碟編號和曲目編號對歌曲清單進行排序</string>
|
||||
<string name="settings.clear_search_history">清空搜尋記錄</string>
|
||||
<string name="settings.chat_refresh">聊天訊息刷新時間間隔</string>
|
||||
<string name="settings.display_bitrate_summary">在藝術家名稱後附加位元速率和檔案後綴</string>
|
||||
<string name="settings.appearance_title">外觀</string>
|
||||
<string name="settings.clear_bookmark_summary">歌曲播放完畢後清除書籤</string>
|
||||
<string name="settings.disc_sort">依光碟排序歌曲</string>
|
||||
<string name="select_album.no_sdcard">錯誤:無可用的 SD 卡。</string>
|
||||
<string name="select_album.no_network">警告:目前沒有可用的網路。
|
||||
\n 如果您要使用行動數據,您需要在設定中允許使用計量付費網路連線下載。</string>
|
||||
<string name="select_album.play_all">播放全部</string>
|
||||
<string name="select_artist.all_folders">所有資料夾</string>
|
||||
<string name="select_artist.folder">選擇資料夾</string>
|
||||
<string name="select_playlist.empty">伺服器上沒有已保存的播放清單</string>
|
||||
<string name="settings.hide_media_title">隱藏其他來源</string>
|
||||
<string name="settings.hide_media_summary">隱藏來自其他應用程式的音樂檔案。</string>
|
||||
<string name="settings.hide_media_toast">在 Android 系統下次掃描裝置內音樂時生效。</string>
|
||||
<string name="settings.download_transition">播放時顯示正在播放介面</string>
|
||||
<string name="settings.download_transition_summary">在媒體庫介面開始播放後切換到正在播放介面</string>
|
||||
</resources>
|
@ -71,7 +71,7 @@
|
||||
<string name="download.menu_save">Save Playlist</string>
|
||||
<string name="download.menu_screen_off">Screen Off</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">Go to Album</string>
|
||||
<string name="download.menu_shuffle">Shuffle</string>
|
||||
<string name="download.menu_shuffle_on">Shuffle mode enabled</string>
|
||||
<string name="download.menu_shuffle_off">Shuffle mode disabled</string>
|
||||
@ -367,7 +367,7 @@
|
||||
<string name="share_default_greeting">Check out this music I shared from %s</string>
|
||||
<string name="share_via">Share songs via</string>
|
||||
<string name="menu.share">Share</string>
|
||||
<string name="download.menu_show_artist">Show Artist</string>
|
||||
<string name="download.menu_show_artist">Go to Artist</string>
|
||||
<string name="albumArt">Album artwork</string>
|
||||
<string name="common_multiple_years">Multiple Years</string>
|
||||
<string name="settings.show_confirmation_dialog">Show confirmation dialog</string>
|
||||
|
Loading…
x
Reference in New Issue
Block a user