diff --git a/build.gradle b/build.gradle index a4f4bb4b..d9cf0212 100644 --- a/build.gradle +++ b/build.gradle @@ -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 diff --git a/core/subsonic-api/build.gradle b/core/subsonic-api/build.gradle index a70a11a2..9f044c21 100644 --- a/core/subsonic-api/build.gradle +++ b/core/subsonic-api/build.gradle @@ -13,7 +13,6 @@ dependencies { testImplementation libs.kotlinJunit testImplementation libs.mockito - testImplementation libs.mockitoInline testImplementation libs.mockitoKotlin testImplementation libs.kluent testImplementation libs.mockWebServer diff --git a/fastlane/metadata/android/en-US/changelogs/119.txt b/fastlane/metadata/android/en-US/changelogs/119.txt new file mode 100644 index 00000000..be4a20f9 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/119.txt @@ -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 + diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt index fcf6a32d..18d5f968 100644 --- a/fastlane/metadata/android/en-US/full_description.txt +++ b/fastlane/metadata/android/en-US/full_description.txt @@ -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!!! diff --git a/gradle.properties b/gradle.properties index a7c0d4f8..7ab3793a 100644 --- a/gradle.properties +++ b/gradle.properties @@ -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 + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5661132f..dc011630 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index ccebba77..c1962a79 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 761b8f08..8707e8b5 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/gradlew b/gradlew index 79a61d42..aeb74cbb 100755 --- a/gradlew +++ b/gradlew @@ -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 diff --git a/ultrasonic/build.gradle b/ultrasonic/build.gradle index e367497a..365c50b7 100644 --- a/ultrasonic/build.gradle +++ b/ultrasonic/build.gradle @@ -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 diff --git a/ultrasonic/src/main/AndroidManifest.xml b/ultrasonic/src/main/AndroidManifest.xml index 8be19a15..a9b35248 100644 --- a/ultrasonic/src/main/AndroidManifest.xml +++ b/ultrasonic/src/main/AndroidManifest.xml @@ -7,7 +7,6 @@ - diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/AlbumRowDelegate.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/AlbumRowDelegate.kt index dfbce65a..c54cb32a 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/AlbumRowDelegate.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/AlbumRowDelegate.kt @@ -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 { diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/TrackViewBinder.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/TrackViewBinder.kt index 62612082..bca305aa 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/TrackViewBinder.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/TrackViewBinder.kt @@ -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) { diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/TrackViewHolder.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/TrackViewHolder.kt index 630c00cf..79b74069 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/TrackViewHolder.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/TrackViewHolder.kt @@ -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 { diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/data/RatingUpdate.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/data/RatingUpdate.kt new file mode 100644 index 00000000..93faedee --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/data/RatingUpdate.kt @@ -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 +) diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/EntryListFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/EntryListFragment.kt index 8b0f1241..80a78416 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/EntryListFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/EntryListFragment.kt @@ -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 : MultiListFragment() { ): 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 diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/NowPlayingFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/NowPlayingFragment.kt index 7fb0c8c4..eb192af7 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/NowPlayingFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/NowPlayingFragment.kt @@ -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) { diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/PlayerFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/PlayerFragment.kt index 4857f708..2dd60683 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/PlayerFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/PlayerFragment.kt @@ -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 { - 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") diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/SearchFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/SearchFragment.kt index 323f8d05..9dfcc1ef 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/SearchFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/SearchFragment.kt @@ -309,7 +309,6 @@ class SearchFragment : MultiListFragment(), KoinComponent { } mediaPlayerController.addToPlaylist( listOf(song), - cachePermanently = false, autoPlay = false, shuffle = false, insertionMode = MediaPlayerController.InsertionMode.APPEND @@ -367,40 +366,37 @@ class SearchFragment : MultiListFragment(), 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 ) } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/TrackCollectionFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/TrackCollectionFragment.kt index 6664f17e..7d38ba24 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/TrackCollectionFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/TrackCollectionFragment.kt @@ -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 = 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 = getSelectedSongs()) { + var songs = tracks if (songs.isEmpty()) { songs = getAllSongs() } - downloadBackground(save, songs) - } - - private fun downloadBackground( - save: Boolean, - songs: List - ) { - 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 = 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 = 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) -> 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 -> { diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/legacy/PlaylistsFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/legacy/PlaylistsFragment.kt index ff2f8a73..3a077b45 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/legacy/PlaylistsFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/legacy/PlaylistsFragment.kt @@ -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 -> { diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/legacy/SharesFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/legacy/SharesFragment.kt index eae31252..49c33749 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/legacy/SharesFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/legacy/SharesFragment.kt @@ -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::class.java - ) + private val downloadHandler = inject() 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 -> { diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/AutoMediaBrowserCallback.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/AutoMediaBrowserCallback.kt index fcf0b9f8..8921bb31 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/AutoMediaBrowserCallback.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/AutoMediaBrowserCallback.kt @@ -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 { 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) } } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/PlaybackService.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/PlaybackService.kt index 019263db..7603f94f 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/PlaybackService.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/PlaybackService.kt @@ -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() diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerController.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerController.kt index b7f81c09..2be0f26f 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerController.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerController.kt @@ -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, - cachePermanently: Boolean, autoPlay: Boolean, shuffle: Boolean, insertionMode: InsertionMode @@ -399,11 +420,11 @@ class MediaPlayerController( val mediaItems: List = 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? { - if (currentMediaItem == null) return null - val song = currentMediaItem!!.toTrack() - - return (controller as? MediaController)?.setRating( - HeartRating(!song.starred) - )?.let { - Futures.addCallback( - it, - object : FutureCallback { - 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 get() { return Util.getPlayListFromTimeline( diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerLifecycleSupport.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerLifecycleSupport.kt index d412d0e6..3c5d4df3 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerLifecycleSupport.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerLifecycleSupport.kt @@ -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() private val mediaPlayerController by inject() 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() diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MusicService.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MusicService.kt index eb3c8bb9..2d37a648 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MusicService.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MusicService.kt @@ -39,10 +39,10 @@ interface MusicService { fun getGenres(refresh: Boolean): List @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) diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/RatingManager.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/RatingManager.kt new file mode 100644 index 00000000..dd2bd38b --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/RatingManager.kt @@ -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() + } + } +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/RxBus.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/RxBus.kt index 48a1061f..9c87c519 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/RxBus.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/RxBus.kt @@ -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 = + PublishSubject.create() + val shufflePlayObservable: Observable = + shufflePlayPublisher + var activeServerChangingPublisher: PublishSubject = PublishSubject.create() - // Subscribers should be called synchronously, not on another thread var activeServerChangingObservable: Observable = activeServerChangingPublisher @@ -71,6 +76,18 @@ class RxBus { val trackDownloadStateObservable: Observable = trackDownloadStatePublisher.observeOn(mainThread()) + // Sends a RatingUpdate which was just triggered by the user + val ratingSubmitter: PublishSubject = + PublishSubject.create() + val ratingSubmitterObservable: Observable = + ratingSubmitter + + // Sends a RatingUpdate which was successfully submitted to the server or database + val ratingPublished: PublishSubject = + PublishSubject.create() + val ratingPublishedObservable: Observable = + ratingPublished + // Commands val dismissNowPlayingCommandPublisher: PublishSubject = PublishSubject.create() diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/subsonic/DownloadHandler.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/subsonic/DownloadHandler.kt index ce14c811..c327207b 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/subsonic/DownloadHandler.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/subsonic/DownloadHandler.kt @@ -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, - playlistName: String?, + id: String? = null, + name: String? = "", + isShare: Boolean = false, + isDirectory: Boolean = true, + isArtist: Boolean = false, + tracks: List? = 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 = 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 = 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, - background: Boolean, - unpin: Boolean, + fun addTracksToMediaController( + songs: List, 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 +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/CoroutinePatterns.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/CoroutinePatterns.kt new file mode 100644 index 00000000..c0a95bd9 --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/CoroutinePatterns.kt @@ -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() + } + } +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Settings.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Settings.kt index 501573eb..65dbd92b 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Settings.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Settings.kt @@ -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( diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Storage.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Storage.kt index 73022b81..f6b5b9d2 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Storage.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Storage.kt @@ -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 { diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Util.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Util.kt index 80517991..40739ee7 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Util.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Util.kt @@ -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 { @@ -770,13 +770,13 @@ object Util { if (timeline.windowCount < 1) return emptyList() val playlist: MutableList = 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 } diff --git a/ultrasonic/src/main/res/menu/nowplaying.xml b/ultrasonic/src/main/res/menu/nowplaying.xml index 67b7f3ff..39126689 100644 --- a/ultrasonic/src/main/res/menu/nowplaying.xml +++ b/ultrasonic/src/main/res/menu/nowplaying.xml @@ -15,6 +15,13 @@ app:showAsAction="ifRoom|withText" a:title="@string/download.menu_star"/> + + + Guardar lista de reproducción Pantalla apagada Pantalla encendida - Mostrar Álbum + Ir al álbum Aleatorio Modo aleatorio activado Modo aleatorio desactivado @@ -361,7 +361,7 @@ Echa un vistazo a esta música que te comparto desde %s Compartir canciones vía Compartir - Mostrar artista + Ir al artista Portadas de álbumes Múltiples años Mostrar diálogo de confirmación diff --git a/ultrasonic/src/main/res/values-gl/strings.xml b/ultrasonic/src/main/res/values-gl/strings.xml index a6b3daec..86dcf5c1 100644 --- a/ultrasonic/src/main/res/values-gl/strings.xml +++ b/ultrasonic/src/main/res/values-gl/strings.xml @@ -1,2 +1,27 @@ - \ No newline at end of file + + Cargando… + Ocorreu un erro de rede. Por favor comproba a dirección do servidor ou téntao de novo mais tarde. + A API do servidor v%1$s non admite esta función. + Este programa require acceso á rede. Por favor acende a Wi-Fi ou a rede móbil. + Recurso non atopado. Por favor comproba a dirección do servidor. + Non se entende a resposta. Por favor comproba a dirección do servidor. + Erro do certificado HTTPS: %1$s. + Excepción de conexión SSL. Comprobe o certificado do servidor. + Por favor agarde… + Biblioteca + Chat + Reproducindo agora + Reproducir + Pausar + Repetir + Mesturar + Parar + Seguinte + Anterior + Podcast + Non hai canles de Podcasts rexistrados + Podcast + Buscar + Enviar unha mensaxe + \ No newline at end of file diff --git a/ultrasonic/src/main/res/values-pl/strings.xml b/ultrasonic/src/main/res/values-pl/strings.xml index 1b84f3e0..c75f5e26 100644 --- a/ultrasonic/src/main/res/values-pl/strings.xml +++ b/ultrasonic/src/main/res/values-pl/strings.xml @@ -1,7 +1,6 @@ - - Ładowanie… + Ładowanie… Wystąpił błąd sieci. Proszę sprawdzić adres serwera i spróbować później. Server api v%1$s does not support this function. Ta aplikacja wymaga dostępu do sieci. Proszę włączyć wi-fi lub dane komórkowe. @@ -9,7 +8,7 @@ Brak prawidłowej odpowiedzi. Proszę sprawdzić adres serwera. Błąd certyfikatu HTTPS: %1$s. Błąd połączenia SSL. Proszę sprawdzić certyfikat serwera. - Proszę czekać… + Proszę czekać… Zakładki Biblioteka Czat @@ -39,7 +38,7 @@ Zapisz Odepnij Różni artyści - Czy chcesz usunąć %1$s? + Czy chcesz usunąć %1$s Zakładka usunięta. Zakładka ustawiona na %s. Playlista jest pusta @@ -62,7 +61,7 @@ Playlista została zapisana. Błąd zapisu playlisty. Proszę spróbować później. Wprowadź nazwę playlisty: - Trwa zapis playlisty \"%s\"… + Trwa zapis playlisty \"%s\"… Zapisz playlistę Powtarzaj wszystko Powtarzanie wyłączone @@ -96,7 +95,7 @@ Usunięcie playlisty %s nie powiodło się Zakończ Ustawienia - Refresh + Odśwież Biblioteka mediów Media offline Playlisty @@ -172,8 +171,8 @@ Dołącza bitrate i typ pliku do nazwy artysty Ukrywa pliki muzyczne przed innymi aplikacjami. Ukryj pliki - Efekt widoczny będzie po następnym skanowaniu muzyki przez system Android - Proszę wprowadzić prawidłowy URL + Efekt widoczny będzie po następnym skanowaniu muzyki przez system Android. + Proszę wprowadzić prawidłowy URL. Maksymalna ilość wyników - albumy Maksymalna ilość wyników - artyści 112 Kbps @@ -205,10 +204,10 @@ Sieć Inne ustawienia Ustawienia sterowania odtwarzaniem - Resume when a Bluetooth device is connected - Pause when a Bluetooth device is disconnected - All Bluetooth devices - Only audio (A2DP) devices + Wznów po podłączeniu urządzenia Bluetooth + Wstrzymaj, gdy urządzenie Bluetooth jest odłączone + Wszystkie urządzenia Bluetooth + Tylko urządzenia audio (A2DP) Wyłączone Wznawiaj po podłączeniu słuchawek Aplikacja wznowi zatrzymane odtwarzanie po podpięciu słuchawek. @@ -298,18 +297,18 @@ Udostępnianie Wyświetlaj artystę Z różnych lat - Configured servers - Are you sure you want to delete the server? - Editing server + Skonfigurowane serwery + Czy na pewno chcesz usunąć ten serwer\? + Edycja serwera Dodaj serwer - Are you sure you want to leave and lose your changes? - This field is required - Edit + Czy na pewno chcesz wyjść i utracić dokonane zmiany\? + To pole jest wymagane + Edytuj Usuń - Move up - Move down + Przesuń się w górę + Przesuń się w dół Authentication - Advanced settings + Ustawienia zaawansowane %d utwór %d utwory @@ -327,7 +326,148 @@ Okres próbny się zakończył. Brak zgodności wersji. Uaktualnij aplikację Ultrasonic na Androida. Brak zgodności wersji. Uaktualnij serwer Subsonic. - Użyj pięciu gwiazdek dla utworów - + Pokaż okno potwierdzające usunięcie lub odpięcie utworów + Angielski + Pamiętaj o ustawieniu nazwy użytkownika i hasła do usługi Scrobble na serwerze + Użyj metody ID3 także kiedy nie masz połączenia + Zatrzymaj pliki + Wyłączony tryb losowy + Zatrzymaj + Francuski + Czy na pewno chcesz odpiąć zaznaczone pozycje\? + Użyj niestandardowej lokacji pamięci podręcznej + Wybierz wszystko + Włączony tryb losowy + Następne + Chronologicznie + Otwórz ustawienia + Czeski + Wyślij + Zaznaczono %d utworów + Stwórz udostępnienie na serwerze + Niemiecki + Zgłoś błąd + Pobieranie w tle… + 1000 piosenek + Wspierane funkcje + Polski + Artysta + Holenderski + Węgierski + Zapisanych jest %1$s plików z logami, które zajmują ~%2$s MB miejsca w katalogu %3$s. Czy chcesz je zachować\? + Poprzednie + + Usunięto %d utwór + Usunięto %d utwory + Usunięto %d utworów + Usunięto %d utworów + + Powtarzaj + Nic nie jest pobierane + Rosyjski + Byforowanie… + %s - Ustaw serwer + 50 piosenek + Chiński (Chiny) + Nadpisz język + Odtwórz + Domyślne systemowe + Pobrane + Wyświetlaj bitrate i typ pliku + Portugalski (Brazylia) + Plik z logami jest dostępny w %1$s/%2$s + Usunięte pliki z logami. + 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 + Włoski + Portugalski + Kolor serwera + Pauza + Pokaż obraz wykonawcy na liście + Tytuł + Czy na pewno chcesz usunąć zaznaczone pozycje\? + Okładka albumu + Album + 500 piosenek + 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 + Pokaż Obecnie odtwarzane po kliknięciu przycisku Odtwarzaj + Hiszpański + Wymagane jest ponowne uruchomienie aplikacji po zmianie języka + Nie można wznowić odtwarzania + Awatar + Chiński (Tajwan) + Dzień i noc + Czarny + 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. + Pokaż więcej informacji o utworze w sekcji Obecnie odtwarzane (gatunek, rok, przepustowość) + Pokaż szczegóły w sekcji Obecnie odtwarzane + Pobieraj tylko przez Wi-Fi + Zawsze pytaj o opis i czas wygaśnięcia podczas tworzenia udostępnienia na serwerze + Usuń pliki + + %d utwór zaznaczony do przypięcia + %d utwory zaznaczone do przypięcia + %d utworów zaznaczonych do przypięcia + %d utworów zaznaczonych do przypięcia + + + %d utworów zaznaczonych do pobrania + %d utwory zaznaczone do pobrania + %d utworów zaznaczonych do pobrania + %d utworów zaznaczonych do pobrania + + Odwiedź stronę internetową + + Odpięto %d utwór + Odpięto %d utwory + Odpięto %d utworów + Odpięto %d utworów + + Użyj odtwarzania sprzętowwego (eksperymentalne) + Jukebox + 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. + Przełącz na Obecnie odtwarzane po rozpoczęciu odtwarzania w widoku multimediów + Odstępy między wyszukaniami + Ilość równocześnie pobieranych piosenek + 100 piosenek + Scrobbluj moje odtworzenia + 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. + Wyświetla obraz wykonawcy na liście wykonawców, jeśli jest dostępny + Pobieraj tylko podczas połączeń niepłatnuch + Udostępnij obecnie odtwarzaną piosenkę + Pokaż okno potwierdzające + Opcje debugowania + Zapisz logi debugowania do pliku + Powiadomienia są wymagane do odtwarzania multimediów. Możesz przyznać uprawnienie do nich w dowolnym momencie w ustawieniach Androida. + Jedna lub więcej funkcji zostało wyłączonych ponieważ serwer ich nie obsługiwał. +\nMożesz uruchomić ten test ponownie kiedykolwiek. + Serwer demonstracyjny + Użyj systemu pięciu gwiazdek do oceniania utworów zamiast po prostu dodawać lub usuwać utwory z ulubionych. + Lista + Okładka + Spróbuj odtworzyć pliki multimedialne za pomocą układu dekodującego w telefonie. Może to zmniejszyć zużycie baterii! + + Dodano %d utwór na koniec kolejki odtwarzania + Dodano %d utwory na koniec kolejki odtwarzania + Dodano %d utworów na koniec kolejki odtwarzania + Dodano %d utworów na koniec kolejki odtwarzania + + + Wstawiono %d utwór po bieżącym utworze + Wstawiono %d utwory po bieżącym utworze + Wstawiono %d utworów po bieżącym utworze + Wstawiono %d utworów po bieżącym utworze + + Ultrasonic 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 Ultrasonic 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. + Aby używać Ultrasonic z własną muzyką, potrzebujesz własnego serwera. +\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 ustawieniach. + \ No newline at end of file diff --git a/ultrasonic/src/main/res/values-zh-rCN/strings.xml b/ultrasonic/src/main/res/values-zh-rCN/strings.xml index 82215162..fea8126c 100644 --- a/ultrasonic/src/main/res/values-zh-rCN/strings.xml +++ b/ultrasonic/src/main/res/values-zh-rCN/strings.xml @@ -3,11 +3,11 @@ 加载中… 发生网络错误。请检查服务器地址或稍后重试。 服务端 API v%1$s 不支持此功能。 - 此软件需要连接网络,请打开Wi-Fi或移动网络。 + 此软件需要连接网络,请打开 Wi-Fi 或移动网络。 未找到资源,请检查服务器地址。 - 未知回复内容,请检查服务器地址 - HTTPS 证书错误: %1$s. - SSL连接异常。请检查服务器证书。 + 未知回复内容,请检查服务器地址。 + HTTPS 证书错误:%1$s. + SSL 连接异常。请检查服务器证书。 请稍等… 书签 媒体库 @@ -50,20 +50,20 @@ 取消固定 您真的要删除当前选择吗? 群星 - 确定要删除 %1$s吗 + 确定要删除 %1$s 吗 书签已删除。 - 书签设置为 %s。 + 书签设置在 %s。 未下载任何内容 空的播放列表 - 不允许远程控制. 请在您的服务器上的 Users > Settings 打开点唱机模式。 - 关闭远程控制,音乐将在手机上播放 - 离线模式不支持远程控制 - 打开远程控制,音乐将在服务端播放。 - 远程控制不支持,请升级您的 Subsonic服务器。 + 不允许远程控制. 请在您 Subsonic 服务器上的 用户 > 设置 中打开点唱机模式。 + 已关闭远程控制,音乐将在手机上播放。 + 离线模式不支持远程控制。 + 已打开远程控制,音乐将在服务端播放。 + 远程控制不支持,请升级您的 Subsonic 服务器。 远程音量 均衡器 - 关闭 Jukebox - 开启 Jukebox + 关闭点唱机 + 开启点唱机 歌词 保存播放列表 关闭屏幕常亮 @@ -86,7 +86,7 @@ 均衡器 选择预设 错误 - 默认自动点唱机 + 默认使用点唱机 找不到歌词 系统默认 按艺术家排序 @@ -101,7 +101,7 @@ 艺术家 流派 离线 - %s - 已设置服务器 + %s - 设置服务器 随机 收藏夹 歌曲 @@ -110,13 +110,13 @@ \n \n➤ 如果您想试试此应用, 可以添加试用服务器。 \n -\n➤ 可在 设置中编辑服务器配置信息。 +\n➤ 可在 设置 中编辑服务器配置信息。 欢迎! 点击前往设置 关于 公共 已删除播放列表 %s - 播放列表删除失败%s + 播放列表删除失败 %s 下载 退出 设置 @@ -136,8 +136,9 @@ 搜索 找不到歌曲 已选择 %d 首曲目 - 警告:当前没有可用的网络.\n 如果您要使用移动数据,您需要在设置中允许使用流量进行下载 - 错误:没有SD卡 + 警告:当前没有可用的网络。 +\n 如果您要使用移动数据,您需要在设置中允许使用流量进行下载。 + 错误:没有SD卡。 播放全部 所有文件夹 选择文件夹 @@ -198,11 +199,11 @@ 按光盘编号和曲目编号对歌曲列表进行排序 展示比特率和文件后缀 在艺术家姓名后追加比特率和文件后缀 - 正在播放 - 隐藏来自其他应用的音乐 + 播放时显示正在播放界面 + 隐藏来自其他应用的音乐。 隐藏其他来源 在安卓系统下次扫描音乐时生效。 - 请填写有效的URL。 + 请填写有效的 URL。 最大专辑 最大艺术家 112 Kbps @@ -215,9 +216,9 @@ 64 Kbps 80 Kbps 96 Kbps - 最大比特率-移动网络 + 最大比特率 - 移动网络 不限制 - 最大比特率-WIFI + 最大比特率 - WIFI 最大歌曲 响应手机、耳机和蓝牙设备的媒体按钮 媒体按钮 @@ -249,7 +250,7 @@ 5 首歌 不限制 请记得在服务器上的 Scrobble 服务中设置您的用户名和密码 - Scrobble我的播放列表 + Scrobble 我的播放列表 1 10 100 @@ -278,8 +279,8 @@ 显示曲目编号 显示歌曲时包括曲目编号 测试连接 - Light - Dark + 亮色 + 暗色 Black 主题 允许自签名 HTTPS 证书 @@ -292,7 +293,7 @@ 如果可用,在艺术家列表中显示艺术家图片 视频 仅未计量的网络用于下载媒体 - 仅使用Wi-Fi进行下载 + 仅使用 Wi-Fi 进行下载 %d kbps 0 B 0.00 GB @@ -330,7 +331,7 @@ 保存为默认 评论 有效期 - %s已从播放列表中移除 + %s 已从播放列表中移除 分享播放列表 分享当前曲目 默认分享问候语 @@ -338,14 +339,14 @@ 分享歌曲通过 分享 显示艺术家 - Multiple Years + 数年 调试选项 将调试日志写入文件 日志文件可在 %1$s/%2$s 获取 %3$s 目录中有 %1$s 个日志文件占用了 ~%2$s MB 空间。您想保留这些吗? 保留文件 删除文件 - 删除日志文件 + 删除日志文件。 在后台下载媒体… 配置服务器 您确定要删除此服务器吗? @@ -373,45 +374,45 @@ %d 首曲目 - 已选择 %d 首歌曲进行固定。 + 已选择 %d 首歌曲进行固定 - 已选择要下载 %d 首歌曲。 + 已选择要下载 %d 首歌曲 - 已选择 %d 首歌曲取消固定。 + 已选择 %d 首歌曲取消固定 %d 首歌曲被删除 - 已将 %d 首歌曲添加到播放队列的末尾。 + 已将 %d 首歌曲添加到播放队列的末尾 - 在当前歌曲之后插入了 %d 首歌曲。 + 在当前歌曲之后插入了 %d 首歌曲 - 一般 API 错误: %1$s + 一般 API 错误:%1$s 服务器未发送任何信息 - LDAP用户不支持以token形式授权连接。 - 用户名或密码错误 - 授权失败,请在 Subsonic server 检查用户权限。 + LDAP 用户不支持以 token 形式授权连接。 + 用户名或密码错误。 + 授权失败,请在 Subsonic 服务器上检查用户权限。 缺少必需的参数。 未找到请求的数据。 - 试用期结束 + 试用期结束。 版本不兼容,请升级 Ultrasonic 应用。 - 不兼容的版本。请升级Subsonic 服务。 + 版本不兼容,请升级 Subsonic 服务器。 为歌曲使用五星评分 500 首歌 - 如果您启用此设置,它将只显示您使用 Ultrasonic 4.0 或更高版本下载的音乐。较早的下载没有下载必要的元数据。您可以在 Pin 和 Save 模式之间切换,以触发缺失元数据的下载。 + 如果您启用此设置,它将只显示您使用 Ultrasonic 4.0 或更高版本下载的音乐。较早的下载没有下载必要的元数据。您可以在固定和保存模式之间切换,以触发缺失元数据的下载。 如果媒体通知仍然存在,请按媒体通知中的播放按钮;否则请打开应用程序开始播放,并重新连接会话到控制器 无法恢复播放 头像图片 你真的想取消固定当前选择吗\? 简体中文(中国大陆) 繁体中文(中国台湾) - + 英语 按时间排序 在媒体库中开始播放后切换到正在播放页面 快进/快退间隔 @@ -421,7 +422,7 @@ 50 首歌 100 首歌 1000 首歌 - 这会强制应用程序始终以未加密的方式发送密码。如果 Subsonic 服务器不支持新的用户身份验证 API,则该选项非常有用。 + 这会强制应用程序始终以未加密的方式发送密码。如果此 Subsonic 服务器不支持新的用户身份验证 API,则该选项非常有用。 在正在播放中展示更多歌曲细节(流派,年份,比特率) 在正在播放中展示更多歌曲细节 离线时也使用 ID3 方法 @@ -431,8 +432,21 @@ 需要通知权限才能进行媒体播放。您可以随时在 Android 设置中授予权限。 对歌曲使用五星评级系统,而不是简单的星标/取消星标。 使用硬件回放(实验性) - 尝试使用手机上的媒体解码器芯片来播放媒体。这可以改善电池使用情况。 + 尝试使用手机上的媒体解码器芯片来播放媒体。这可以改善电池使用情况。部分用户报告启用该选项后播放会有问题! 列表 封面 已支持的功能 + 西班牙语 + 法语 + 意大利语 + 俄语 + 荷兰语 + 日与夜 + 匈牙利语 + 波兰语 + 点唱机 + 葡萄牙语 + 捷克语 + 德语 + 葡萄牙语(巴西) \ No newline at end of file diff --git a/ultrasonic/src/main/res/values-zh-rTW/strings.xml b/ultrasonic/src/main/res/values-zh-rTW/strings.xml index b9c989d4..8fdfccfd 100644 --- a/ultrasonic/src/main/res/values-zh-rTW/strings.xml +++ b/ultrasonic/src/main/res/values-zh-rTW/strings.xml @@ -1,6 +1,5 @@ - 載入中… 書籤 媒體庫 @@ -32,7 +31,7 @@ 全選 標題 各式歌手 - 您想刪除 %1$s 嗎? + 您想刪除 %1$s 嗎 書籤已移除。 無下載 播放清單是空的 @@ -130,4 +129,108 @@ 已停用 註記 刪除 - + 顯示專輯 + 簡體中文(中國) + 儲存播放清單 + 書籤設置在 %s。 + 不支援遠端控制,請升級您的 Subsonic 伺服器。 + 播客 + 您真的要取消固定目前選取的項目嗎? + Ultrasonic + SSL 連線異常。請檢查伺服器憑證。 + 固定 + 傳送 + 聊天 + 遠端音量 + 頭像 + 您真的要刪除目前選取的項目嗎? + 無法理解答覆,請檢查伺服器位址。 + 荷蘭語 + 已關閉遠端控制,音樂將在手機上播放。 + 德語 + 請稍候… + 取消固定 + 輸入播放清單名稱: + 依照時間排列 + 正在儲存播放清單 \"%s\"… + 系統預設 + HTTPS 憑證錯誤:%1$s. + 波蘭語 + 沒有已註冊的播客頻道 + 傳送訊息 + 打開熒幕常亮 + 義大利語 + 法語 + 此程式需要連結網路。請打開 Wi-Fi 或行動網路。 + 最高評分 + %s - 設定伺服器 + 俄語 + 西班牙語 + 播客 + 儲存播放清單失敗,請稍後再試。 + 關閉熒幕常亮 + 顯示更多 + 打開點唱機 + 選擇預設 + 歌曲 + 離線模式下無法使用遠端控制。 + 隨機播放 + 確定 + 關閉點唱機 + 未找到歌詞 + 葡萄牙語(巴西) + 全部循環播放 + 找不到資源,請檢查伺服器位址。 + 單曲循環播放 + 匈牙利語 + 前往設定 + 繁體中文(臺灣) + 關閉循環播放 + 預設使用點唱機 + 發生網路錯誤。請檢查伺服器位址或稍後重試。 + 已打開遠端控制,音樂將在伺服器上播放。 + 儲存播放清單 + 捷克語 + 英語 + 葡萄牙語 + 歌曲 + 歡迎! + 已刪除播放清單 %s + 要使用 Ultrasonic 播放您的音樂,需要您 自己的伺服器。 +\n +\n➤ 如果您想嘗試此應用程式, 可以添加一個演示伺服器。 +\n +\n➤ 此外,可在 設定 中配置您的伺服器。 + 播放清單刪除失敗 %s + 沒有匹配的結果,請重試 + 找不到媒體 + 已選取 %d 首曲目 + 伺服器 API v%1$s 不支援此功能。 + 已儲存播放清單。 + 更新了 %s 的播放清單資訊 + 更新 %s 的播放清單資訊失敗 + 刷新 + 離線媒體 + 更新資訊 + 不允許遠端控制。請在您在 Subsonic 伺服器上的 使用者 > 設定 中啟用點唱機模式。 + 目錄快取時間 + 依光碟編號和曲目編號對歌曲清單進行排序 + 清空搜尋記錄 + 聊天訊息刷新時間間隔 + 在藝術家名稱後附加位元速率和檔案後綴 + 外觀 + 歌曲播放完畢後清除書籤 + 依光碟排序歌曲 + 錯誤:無可用的 SD 卡。 + 警告:目前沒有可用的網路。 +\n 如果您要使用行動數據,您需要在設定中允許使用計量付費網路連線下載。 + 播放全部 + 所有資料夾 + 選擇資料夾 + 伺服器上沒有已保存的播放清單 + 隱藏其他來源 + 隱藏來自其他應用程式的音樂檔案。 + 在 Android 系統下次掃描裝置內音樂時生效。 + 播放時顯示正在播放介面 + 在媒體庫介面開始播放後切換到正在播放介面 + \ No newline at end of file diff --git a/ultrasonic/src/main/res/values/strings.xml b/ultrasonic/src/main/res/values/strings.xml index 36cf28c0..12c534f1 100644 --- a/ultrasonic/src/main/res/values/strings.xml +++ b/ultrasonic/src/main/res/values/strings.xml @@ -71,7 +71,7 @@ Save Playlist Screen Off Screen On - Show Album + Go to Album Shuffle Shuffle mode enabled Shuffle mode disabled @@ -367,7 +367,7 @@ Check out this music I shared from %s Share songs via Share - Show Artist + Go to Artist Album artwork Multiple Years Show confirmation dialog