Merge branch '440' into 'master'

Release 4.4.0

See merge request ultrasonic/ultrasonic!1005
This commit is contained in:
birdbird 2023-05-15 07:15:56 +00:00
commit 9786cf2abf
41 changed files with 1243 additions and 841 deletions

View File

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

View File

@ -13,7 +13,6 @@ dependencies {
testImplementation libs.kotlinJunit
testImplementation libs.mockito
testImplementation libs.mockitoInline
testImplementation libs.mockitoKotlin
testImplementation libs.kluent
testImplementation libs.mockWebServer

View 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

View File

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

View File

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

View File

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

Binary file not shown.

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 &gt; 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>用户 &gt; 设置</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>

View File

@ -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>使用者 &gt; 設定</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>

View File

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