Merge branch '470' into 'master'

4.7.0 Release canditate

See merge request ultrasonic/ultrasonic!1091
This commit is contained in:
birdbird 2023-08-03 16:31:12 +00:00
commit b87203bfe4
43 changed files with 650 additions and 425 deletions

View File

@ -138,7 +138,7 @@ RoboTest:
script:
- curl --silent "https://gitlab.com/gitlab-org/incubation-engineering/mobile-devops/download-secure-files/-/raw/main/installer" | bash
- gcloud auth activate-service-account --key-file .secure_files/firebase-key.json
- gcloud firebase test android run --project ultrasonic-61089 --type robo --app ultrasonic-release/${PACKAGE_APK} --robo-directives click:button1= --device model=Nexus6,version=21,locale=en,orientation=portrait --device model=Pixel3,version=28,locale=fr,orientation=landscape --device model=Pixel5,version=30,locale=zh,orientation=portrait
- gcloud firebase test android run --project ultrasonic-61089 --type robo --app ultrasonic-release/${PACKAGE_APK} --robo-directives click:button1= --device model=Nexus6,version=21,locale=en,orientation=portrait --device model=Pixel3,version=28,locale=fr,orientation=landscape
rules:
# Run when releasing a new tag
- if: $CI_COMMIT_TAG && $CI_PROJECT_ID == $ROOT_PROJECT_ID

View File

@ -1,3 +1,5 @@
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
apply from: 'gradle/versions.gradle'
@ -10,6 +12,7 @@ buildscript {
repositories {
google()
mavenCentral()
gradlePluginPortal()
maven { url "https://plugins.gradle.org/m2/" }
}
dependencies {
@ -26,21 +29,29 @@ allprojects {
buildscript {
repositories {
mavenCentral()
gradlePluginPortal()
google()
}
}
repositories {
mavenCentral()
gradlePluginPortal()
google()
}
// Set Kotlin JVM target to the same for all subprojects
tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).all {
tasks.withType(KotlinCompile).configureEach {
kotlinOptions {
jvmTarget = "17"
}
}
tasks.withType(JavaCompile).tap {
configureEach {
options.compilerArgs.add("-Xlint:deprecation")
}
}
}
wrapper {

View File

@ -1,11 +1,14 @@
plugins {
alias libs.plugins.ksp
}
apply from: bootstrap.androidModule
apply plugin: 'kotlin-kapt'
dependencies {
implementation libs.core
implementation libs.roomRuntime
implementation libs.roomKtx
kapt libs.room
ksp libs.room
}
android {

View File

@ -1,3 +1,7 @@
plugins {
alias libs.plugins.ksp
}
apply from: bootstrap.kotlinModule
dependencies {

View File

@ -0,0 +1,9 @@
### Features
- Added custom buttons for shuffling the current queue and setting repeat mode (Android Auto)
- Properly handling nested directory structures (Android Auto)
- Add a toast when adding tracks to the playlist
- Allow pinning when offline
### Dependencies
- Update koin
- Update media3 to v1.1.0

View File

@ -3,13 +3,13 @@
gradle = "8.1.1"
navigation = "2.6.0"
gradlePlugin = "8.0.2"
gradlePlugin = "8.1.0"
androidxcore = "1.10.1"
ktlint = "0.43.2"
ktlintGradle = "11.4.2"
ktlintGradle = "11.5.0"
detekt = "1.23.0"
preferences = "1.2.0"
media3 = "1.0.2"
preferences = "1.2.1"
media3 = "1.1.0"
androidSupport = "1.6.0"
materialDesign = "1.9.0"
@ -17,7 +17,8 @@ constraintLayout = "2.1.4"
multidex = "2.0.1"
room = "2.5.2"
kotlin = "1.8.22"
kotlinxCoroutines = "1.7.1"
ksp = "1.8.22-1.0.11"
kotlinxCoroutines = "1.7.3"
viewModelKtx = "2.6.1"
swipeRefresh = "1.1.0"
@ -25,11 +26,11 @@ retrofit = "2.9.0"
## KEEP ON 2.13 branch (https://github.com/FasterXML/jackson-databind/issues/3658#issuecomment-1312633064) for compatibility with API 24
jackson = "2.13.5"
okhttp = "4.11.0"
koin = "3.3.2"
koin = "3.4.3"
picasso = "2.8"
junit4 = "4.13.2"
junit5 = "5.9.3"
junit5 = "5.10.0"
mockito = "5.4.0"
mockitoKotlin = "5.0.0"
kluent = "1.73"
@ -100,3 +101,6 @@ kluentAndroid = { module = "org.amshove.kluent:kluent-android", versio
mockWebServer = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" }
apacheCodecs = { module = "commons-codec:commons-codec", version.ref = "apacheCodecs" }
robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" }
[plugins]
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }

Binary file not shown.

View File

@ -1,6 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.1.1-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.2.1-all.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

View File

@ -4,7 +4,7 @@
apply plugin: 'com.android.library'
apply plugin: 'org.jetbrains.kotlin.android'
apply from: "${project.rootDir}/gradle_scripts/code_quality.gradle"
apply plugin: 'org.jetbrains.kotlin.kapt'
apply plugin: 'com.google.devtools.ksp'
android {
compileSdkVersion versions.compileSdk

View File

@ -2,7 +2,7 @@
* This module provides a base for for pure kotlin modules
*/
apply plugin: 'kotlin'
apply plugin: 'org.jetbrains.kotlin.kapt'
apply plugin: 'com.google.devtools.ksp'
apply from: "${project.rootDir}/gradle_scripts/code_quality.gradle"
sourceSets {
@ -12,7 +12,6 @@ sourceSets {
test.resources.srcDirs += "${projectDir}/src/integrationTest/resources"
}
dependencies {
api libs.kotlinStdlib

5
gradlew vendored
View File

@ -130,10 +130,13 @@ location of your Java installation."
fi
else
JAVACMD=java
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.

View File

@ -1,6 +1,9 @@
plugins {
alias libs.plugins.ksp
}
apply plugin: 'com.android.application'
apply plugin: 'org.jetbrains.kotlin.android'
apply plugin: 'org.jetbrains.kotlin.kapt'
apply plugin: "androidx.navigation.safeargs.kotlin"
apply from: "../gradle_scripts/code_quality.gradle"
@ -9,8 +12,8 @@ android {
defaultConfig {
applicationId "org.moire.ultrasonic"
versionCode 126
versionName "4.6.3"
versionCode 128
versionName "4.7.0"
minSdkVersion versions.minSdk
targetSdkVersion versions.targetSdk
@ -64,20 +67,20 @@ android {
targetCompatibility JavaVersion.VERSION_17
}
kapt {
arguments {
arg("room.schemaLocation", "$rootDir/ultrasonic/schemas".toString())
}
ksp {
arg("room.schemaLocation", "$rootDir/ultrasonic/schemas")
}
lint {
baseline = file("lint-baseline.xml")
abortOnError true
warningsAsErrors true
disable 'IconMissingDensityFolder', 'VectorPath'
ignore 'MissingTranslation', 'UnusedQuantity', 'MissingQuantity'
warning 'ImpliedQuantity'
disable 'IconMissingDensityFolder', 'VectorPath'
disable 'MissingTranslation', 'UnusedQuantity', 'MissingQuantity'
disable 'ObsoleteLintCustomCheck'
// We manage dependencies on Gitlab with RenovateBot
disable 'GradleDependency'
textReport true
checkDependencies true
}
@ -85,7 +88,7 @@ android {
}
tasks.withType(Test) {
tasks.withType(Test).configureEach {
useJUnitPlatform()
}
@ -129,7 +132,7 @@ dependencies {
implementation libs.rxAndroid
implementation libs.multiType
kapt libs.room
ksp libs.room
testImplementation libs.kotlinReflect
testImplementation libs.junit
@ -141,6 +144,5 @@ dependencies {
testImplementation libs.robolectric
implementation libs.timber
}

View File

@ -488,8 +488,7 @@ class NavigationActivity : AppCompatActivity() {
val downloadHandler: DownloadHandler by inject()
downloadHandler.addTracksToMediaController(
songs = musicDirectory.getTracks(),
append = false,
playNext = false,
insertionMode = MediaPlayerManager.InsertionMode.CLEAR,
autoPlay = true,
shuffle = false,
fragment = currentFragment,

View File

@ -98,7 +98,7 @@ class HeaderViewBinder(
holder.yearView.text = year
val songs = resources.getQuantityString(
R.plurals.select_album_n_songs, item.childCount,
R.plurals.n_songs, item.childCount,
item.childCount
)
holder.songCountView.text = songs

View File

@ -56,7 +56,7 @@ class BookmarksFragment : TrackCollectionFragment() {
super.setupButtons(view)
playNowButton!!.setOnClickListener {
playNow(getSelectedSongs())
playNow(getSelectedTracks())
}
}

View File

@ -20,6 +20,7 @@ import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.domain.Artist
import org.moire.ultrasonic.domain.GenericEntry
import org.moire.ultrasonic.domain.Identifiable
import org.moire.ultrasonic.service.MediaPlayerManager
import org.moire.ultrasonic.service.RxBus
import org.moire.ultrasonic.service.plusAssign
import org.moire.ultrasonic.subsonic.DownloadAction
@ -133,27 +134,24 @@ abstract class EntryListFragment<T : GenericEntry> : MultiListFragment<T>() {
downloadHandler.fetchTracksAndAddToController(
fragment,
item.id,
append = false,
insertionMode = MediaPlayerManager.InsertionMode.CLEAR,
autoPlay = true,
playNext = false,
isArtist = isArtist
)
R.id.menu_play_next ->
downloadHandler.fetchTracksAndAddToController(
fragment,
item.id,
append = false,
insertionMode = MediaPlayerManager.InsertionMode.AFTER_CURRENT,
autoPlay = true,
playNext = true,
isArtist = isArtist
)
R.id.menu_play_last ->
downloadHandler.fetchTracksAndAddToController(
fragment,
item.id,
append = true,
insertionMode = MediaPlayerManager.InsertionMode.APPEND,
autoPlay = false,
playNext = false,
isArtist = isArtist
)
R.id.menu_pin ->

View File

@ -253,7 +253,7 @@ class SearchFragment : MultiListFragment<Identifiable>(), KoinComponent {
insertionMode = MediaPlayerManager.InsertionMode.APPEND
)
mediaPlayerManager.play(mediaPlayerManager.mediaItemCount - 1)
toast(context, resources.getQuantityString(R.plurals.select_album_n_songs_added, 1, 1))
toast(context, resources.getQuantityString(R.plurals.n_songs_added_to_end, 1, 1))
}
private fun onVideoSelected(track: Track) {
@ -307,8 +307,7 @@ class SearchFragment : MultiListFragment<Identifiable>(), KoinComponent {
songs.add(item)
downloadHandler.addTracksToMediaController(
songs = songs,
append = false,
playNext = false,
insertionMode = MediaPlayerManager.InsertionMode.CLEAR,
autoPlay = true,
shuffle = false,
fragment = this,
@ -319,8 +318,7 @@ class SearchFragment : MultiListFragment<Identifiable>(), KoinComponent {
songs.add(item)
downloadHandler.addTracksToMediaController(
songs = songs,
append = true,
playNext = true,
insertionMode = MediaPlayerManager.InsertionMode.AFTER_CURRENT,
autoPlay = false,
shuffle = false,
fragment = this,
@ -331,8 +329,7 @@ class SearchFragment : MultiListFragment<Identifiable>(), KoinComponent {
songs.add(item)
downloadHandler.addTracksToMediaController(
songs = songs,
append = true,
playNext = false,
insertionMode = MediaPlayerManager.InsertionMode.APPEND,
autoPlay = false,
shuffle = false,
fragment = this,
@ -344,7 +341,7 @@ class SearchFragment : MultiListFragment<Identifiable>(), KoinComponent {
toast(
context,
resources.getQuantityString(
R.plurals.select_album_n_songs_pinned,
R.plurals.n_songs_pinned,
songs.size,
songs.size
)
@ -356,7 +353,7 @@ class SearchFragment : MultiListFragment<Identifiable>(), KoinComponent {
toast(
context,
resources.getQuantityString(
R.plurals.select_album_n_songs_downloaded,
R.plurals.n_songs_to_be_downloaded,
songs.size,
songs.size
)
@ -368,7 +365,7 @@ class SearchFragment : MultiListFragment<Identifiable>(), KoinComponent {
toast(
context,
resources.getQuantityString(
R.plurals.select_album_n_songs_unpinned,
R.plurals.n_songs_unpinned,
songs.size,
songs.size
)

View File

@ -159,7 +159,7 @@ open class TrackCollectionFragment(
// Change the buttons if the status of any selected track changes
rxBusSubscription += RxBus.trackDownloadStateObservable.subscribe {
if (it.progress != null) return@subscribe
val selectedSongs = getSelectedSongs()
val selectedSongs = getSelectedTracks()
if (!selectedSongs.any { song -> song.id == it.id }) return@subscribe
triggerButtonUpdate(selectedSongs)
}
@ -211,23 +211,15 @@ open class TrackCollectionFragment(
}
playNowButton?.setOnClickListener {
playNow(false)
playNow(MediaPlayerManager.InsertionMode.CLEAR, toast = true)
}
playNextButton?.setOnClickListener {
downloadHandler.addTracksToMediaController(
songs = getSelectedSongs(),
append = true,
playNext = true,
autoPlay = false,
shuffle = false,
playlistName = navArgs.playlistName,
this@TrackCollectionFragment
)
playNow(MediaPlayerManager.InsertionMode.AFTER_CURRENT, toast = true)
}
playLastButton!!.setOnClickListener {
playNow(true)
playNow(MediaPlayerManager.InsertionMode.APPEND, toast = true)
}
pinButton?.setOnClickListener {
@ -291,7 +283,7 @@ open class TrackCollectionFragment(
return true
} else if (item.itemId == R.id.menu_item_share) {
shareHandler.createShare(
this@TrackCollectionFragment, getSelectedSongs(),
this@TrackCollectionFragment, getSelectedTracks(),
refreshListView, cancellationToken!!,
navArgs.id
)
@ -308,20 +300,37 @@ open class TrackCollectionFragment(
}
private fun playNow(
append: Boolean,
selectedSongs: List<Track> = getSelectedSongs()
insertionMode: MediaPlayerManager.InsertionMode,
selectedTracks: List<Track> = getSelectedTracks(),
toast: Boolean = false
) {
if (selectedSongs.isNotEmpty()) {
if (selectedTracks.isNotEmpty()) {
downloadHandler.addTracksToMediaController(
songs = selectedSongs,
append = append,
playNext = false,
autoPlay = !append,
songs = selectedTracks,
insertionMode = insertionMode,
autoPlay = (insertionMode == MediaPlayerManager.InsertionMode.CLEAR),
playlistName = null,
fragment = this
)
} else {
playAll(false, append)
playAll(false, insertionMode)
}
if (toast) {
val stringInt = when (insertionMode) {
MediaPlayerManager.InsertionMode.CLEAR ->
R.plurals.n_songs_added_play_now
MediaPlayerManager.InsertionMode.AFTER_CURRENT ->
R.plurals.n_songs_added_after_current
MediaPlayerManager.InsertionMode.APPEND ->
R.plurals.n_songs_added_to_end
}
val msg = resources.getQuantityString(
stringInt,
selectedTracks.size,
selectedTracks.size
)
Util.toast(requireContext(), msg)
}
}
@ -338,7 +347,10 @@ open class TrackCollectionFragment(
}
}
private fun playAll(shuffle: Boolean = false, append: Boolean = false) {
private fun playAll(
shuffle: Boolean = false,
insertionMode: MediaPlayerManager.InsertionMode = MediaPlayerManager.InsertionMode.CLEAR
) {
var hasSubFolders = false
for (item in viewAdapter.getCurrentList()) {
@ -355,18 +367,16 @@ open class TrackCollectionFragment(
downloadHandler.fetchTracksAndAddToController(
fragment = this,
id = navArgs.id!!,
append = append,
autoPlay = !append,
insertionMode = insertionMode,
autoPlay = (insertionMode != MediaPlayerManager.InsertionMode.APPEND),
shuffle = shuffle,
playNext = false,
isArtist = isArtist
)
} else {
downloadHandler.addTracksToMediaController(
songs = getAllSongs(),
append = append,
playNext = false,
autoPlay = !append,
insertionMode = insertionMode,
autoPlay = (insertionMode != MediaPlayerManager.InsertionMode.APPEND),
shuffle = shuffle,
playlistName = navArgs.playlistName,
fragment = this
@ -397,7 +407,7 @@ open class TrackCollectionFragment(
}
@Synchronized
fun triggerButtonUpdate(selection: List<Track> = getSelectedSongs()) {
fun triggerButtonUpdate(selection: List<Track> = getSelectedTracks()) {
listModel.calculateButtonState(selection, ::updateButtonState)
}
@ -414,14 +424,14 @@ open class TrackCollectionFragment(
playNowButton?.isVisible = show.all
playNextButton?.isVisible = show.all && multipleSelection
playLastButton?.isVisible = show.all && multipleSelection
pinButton?.isVisible = show.all && !isOffline() && show.pin
pinButton?.isVisible = show.all && show.pin
unpinButton?.isVisible = show.all && show.unpin
downloadButton?.isVisible = show.all && show.download && !isOffline()
deleteButton?.isVisible = show.all && show.delete
}
}
private fun downloadBackground(save: Boolean, tracks: List<Track> = getSelectedSongs()) {
private fun downloadBackground(save: Boolean, tracks: List<Track> = getSelectedTracks()) {
var songs = tracks
if (songs.isEmpty()) {
@ -436,7 +446,7 @@ open class TrackCollectionFragment(
)
}
internal fun delete(songs: List<Track> = getSelectedSongs()) {
internal fun delete(songs: List<Track> = getSelectedTracks()) {
downloadHandler.justDownload(
action = DownloadAction.DELETE,
fragment = this,
@ -444,7 +454,7 @@ open class TrackCollectionFragment(
)
}
internal fun unpin(songs: List<Track> = getSelectedSongs()) {
internal fun unpin(songs: List<Track> = getSelectedTracks()) {
downloadHandler.justDownload(
action = DownloadAction.UNPIN,
fragment = this,
@ -502,10 +512,7 @@ open class TrackCollectionFragment(
val playAll = navArgs.autoPlay
if (playAll && songCount > 0) {
playAll(
navArgs.shuffle,
false
)
playAll(navArgs.shuffle, MediaPlayerManager.InsertionMode.CLEAR)
}
listModel.currentListIsSortable = true
@ -513,7 +520,7 @@ open class TrackCollectionFragment(
Timber.i("Processed list")
}
internal fun getSelectedSongs(): List<Track> {
internal fun getSelectedTracks(): List<Track> {
// Walk through selected set and get the Entries based on the saved ids.
return viewAdapter.getCurrentList().mapNotNull {
if (it is Track && viewAdapter.isSelected(it.longId))
@ -608,20 +615,13 @@ open class TrackCollectionFragment(
when (menuItem.itemId) {
R.id.song_menu_play_now -> {
playNow(false, songs)
playNow(MediaPlayerManager.InsertionMode.CLEAR, songs, true)
}
R.id.song_menu_play_next -> {
downloadHandler.addTracksToMediaController(
songs = songs,
append = true,
playNext = true,
autoPlay = false,
playlistName = navArgs.playlistName,
fragment = this@TrackCollectionFragment
)
playNow(MediaPlayerManager.InsertionMode.AFTER_CURRENT, songs, true)
}
R.id.song_menu_play_last -> {
playNow(true, songs)
playNow(MediaPlayerManager.InsertionMode.APPEND, songs, true)
}
R.id.song_menu_pin -> {
downloadBackground(true, songs)

View File

@ -35,6 +35,7 @@ import org.moire.ultrasonic.R
import org.moire.ultrasonic.api.subsonic.ApiNotSupportedException
import org.moire.ultrasonic.domain.Share
import org.moire.ultrasonic.fragment.FragmentTitle
import org.moire.ultrasonic.service.MediaPlayerManager
import org.moire.ultrasonic.service.MusicServiceFactory
import org.moire.ultrasonic.service.OfflineException
import org.moire.ultrasonic.subsonic.DownloadAction
@ -165,10 +166,9 @@ class SharesFragment : Fragment(), KoinComponent {
this,
share.id,
share.name,
append = false,
insertionMode = MediaPlayerManager.InsertionMode.CLEAR,
autoPlay = true,
shuffle = false,
playNext = false,
shuffle = false
)
}
R.id.share_menu_play_shuffled -> {
@ -176,10 +176,9 @@ class SharesFragment : Fragment(), KoinComponent {
this,
share.id,
share.name,
append = false,
insertionMode = MediaPlayerManager.InsertionMode.CLEAR,
autoPlay = true,
shuffle = true,
playNext = false,
)
}
R.id.share_menu_delete -> {

View File

@ -11,7 +11,7 @@ import android.annotation.SuppressLint
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
import androidx.media3.session.BitmapLoader
import androidx.media3.common.util.BitmapLoader
import com.google.common.util.concurrent.ListenableFuture
import com.google.common.util.concurrent.ListeningExecutorService
import com.google.common.util.concurrent.MoreExecutors

View File

@ -7,16 +7,16 @@
package org.moire.ultrasonic.playback
import android.annotation.SuppressLint
import android.os.Bundle
import androidx.media3.common.HeartRating
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.common.MediaMetadata.FOLDER_TYPE_ALBUMS
import androidx.media3.common.MediaMetadata.FOLDER_TYPE_ARTISTS
import androidx.media3.common.MediaMetadata.FOLDER_TYPE_MIXED
import androidx.media3.common.MediaMetadata.FOLDER_TYPE_PLAYLISTS
import androidx.media3.common.MediaMetadata.FOLDER_TYPE_TITLES
import androidx.media3.common.MediaMetadata.MEDIA_TYPE_FOLDER_ALBUMS
import androidx.media3.common.MediaMetadata.MEDIA_TYPE_FOLDER_ARTISTS
import androidx.media3.common.MediaMetadata.MEDIA_TYPE_FOLDER_MIXED
import androidx.media3.common.MediaMetadata.MEDIA_TYPE_FOLDER_PLAYLISTS
import androidx.media3.common.MediaMetadata.MEDIA_TYPE_MIXED
import androidx.media3.common.MediaMetadata.MEDIA_TYPE_PLAYLIST
import androidx.media3.common.Player
import androidx.media3.common.Rating
import androidx.media3.common.StarRating
import androidx.media3.session.CommandButton
@ -45,7 +45,6 @@ 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.MediaPlayerManager
import org.moire.ultrasonic.service.MusicServiceFactory
import org.moire.ultrasonic.service.RatingManager
import org.moire.ultrasonic.util.Util
@ -96,11 +95,8 @@ const val PLAY_COMMAND = "play "
* MediaBrowserService implementation for e.g. Android Auto
*/
@Suppress("TooManyFunctions", "LargeClass", "UnusedPrivateMember")
@SuppressLint("UnsafeOptInUsageError")
class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) :
MediaLibraryService.MediaLibrarySession.Callback, KoinComponent {
class AutoMediaBrowserCallback : MediaLibraryService.MediaLibrarySession.Callback, KoinComponent {
private val mediaPlayerManager by inject<MediaPlayerManager>()
private val activeServerProvider: ActiveServerProvider by inject()
private val serviceJob = SupervisorJob()
@ -116,22 +112,59 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) :
private val isOffline get() = ActiveServerProvider.isOffline()
private val musicFolderId get() = activeServerProvider.getActiveServer().musicFolderId
private var customCommands: List<CommandButton>
internal var customLayout = ImmutableList.of<CommandButton>()
private val placeholderButton = getPlaceholderButton()
private var heartIsCurrentlyOn = false
// This button is used for an unstarred track, and its action will star the track
private val heartButtonToggleOn =
getHeartCommandButton(
SessionCommand(
PlaybackService.CUSTOM_COMMAND_TOGGLE_HEART_ON,
Bundle.EMPTY
),
willHeart = true
)
// This button is used for an starred track, and its action will star the track
private val heartButtonToggleOff =
getHeartCommandButton(
SessionCommand(
PlaybackService.CUSTOM_COMMAND_TOGGLE_HEART_OFF,
Bundle.EMPTY
),
willHeart = false
)
private val shuffleButton: CommandButton
private val repeatOffButton: CommandButton
private val repeatOneButton: CommandButton
private val repeatAllButton: CommandButton
private val allCustomCommands: List<CommandButton>
val defaultCustomCommands: List<CommandButton>
init {
customCommands =
listOf(
// This button is used for an unstarred track, and its action will star the track
getHeartCommandButton(
SessionCommand(PlaybackService.CUSTOM_COMMAND_TOGGLE_HEART_ON, Bundle.EMPTY)
),
// This button is used for an starred track, and its action will unstar the track
getHeartCommandButton(
SessionCommand(PlaybackService.CUSTOM_COMMAND_TOGGLE_HEART_OFF, Bundle.EMPTY)
)
)
customLayout = ImmutableList.of(customCommands[0])
val shuffleCommand = SessionCommand(PlaybackService.CUSTOM_COMMAND_SHUFFLE, Bundle.EMPTY)
shuffleButton = getShuffleCommandButton(shuffleCommand)
val repeatCommand = SessionCommand(PlaybackService.CUSTOM_COMMAND_REPEAT_MODE, Bundle.EMPTY)
repeatOffButton = getRepeatModeButton(repeatCommand, Player.REPEAT_MODE_OFF)
repeatOneButton = getRepeatModeButton(repeatCommand, Player.REPEAT_MODE_ONE)
repeatAllButton = getRepeatModeButton(repeatCommand, Player.REPEAT_MODE_ALL)
allCustomCommands = listOf(
heartButtonToggleOn,
heartButtonToggleOff,
shuffleButton,
repeatOffButton,
repeatOneButton,
repeatAllButton
)
defaultCustomCommands = listOf(heartButtonToggleOn, shuffleButton, repeatOffButton)
}
/**
@ -175,8 +208,8 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) :
"Root Folder",
MEDIA_ROOT_ID,
isPlayable = false,
folderType = FOLDER_TYPE_MIXED,
mediaType = MediaMetadata.MEDIA_TYPE_FOLDER_MIXED
isBrowsable = true,
mediaType = MEDIA_TYPE_FOLDER_MIXED
),
params
)
@ -188,14 +221,17 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) :
controller: MediaSession.ControllerInfo
): MediaSession.ConnectionResult {
Timber.i("onConnect")
val connectionResult = super.onConnect(session, controller)
val availableSessionCommands = connectionResult.availableSessionCommands.buildUpon()
for (commandButton in customCommands) {
for (commandButton in allCustomCommands) {
// Add custom command to available session commands.
commandButton.sessionCommand?.let { availableSessionCommands.add(it) }
}
session.player.repeatMode = Player.REPEAT_MODE_ALL
return MediaSession.ConnectionResult.accept(
availableSessionCommands.build(),
connectionResult.availablePlayerCommands
@ -203,26 +239,72 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) :
}
override fun onPostConnect(session: MediaSession, controller: MediaSession.ControllerInfo) {
if (!customLayout.isEmpty() && controller.controllerVersion != 0) {
if (controller.controllerVersion != 0) {
// Let Media3 controller (for instance the MediaNotificationProvider)
// know about the custom layout right after it connected.
session.setCustomLayout(customLayout)
with(session) {
setCustomLayout(session.buildCustomCommands(canShuffle = canShuffle()))
}
}
}
private fun getHeartCommandButton(sessionCommand: SessionCommand): CommandButton {
val willHeart =
(sessionCommand.customAction == PlaybackService.CUSTOM_COMMAND_TOGGLE_HEART_ON)
return CommandButton.Builder()
.setDisplayName("Love")
private fun getHeartCommandButton(sessionCommand: SessionCommand, willHeart: Boolean) =
CommandButton.Builder()
.setDisplayName(
if (willHeart)
"Love"
else
"Dislike"
)
.setIconResId(
if (willHeart) R.drawable.ic_star_hollow
else R.drawable.ic_star_full
if (willHeart)
R.drawable.ic_star_hollow
else
R.drawable.ic_star_full
)
.setSessionCommand(sessionCommand)
.setEnabled(true)
.build()
private fun getShuffleCommandButton(sessionCommand: SessionCommand) =
CommandButton.Builder()
.setDisplayName("Shuffle")
.setIconResId(R.drawable.media_shuffle)
.setSessionCommand(sessionCommand)
.setEnabled(true)
.build()
private fun getPlaceholderButton() = CommandButton.Builder()
.setDisplayName("Placeholder")
.setIconResId(android.R.color.transparent)
.setSessionCommand(
SessionCommand(
PlaybackService.CUSTOM_COMMAND_PLACEHOLDER,
Bundle.EMPTY
)
)
.setEnabled(false)
.build()
private fun getRepeatModeButton(sessionCommand: SessionCommand, repeatMode: Int) =
CommandButton.Builder()
.setDisplayName(
when (repeatMode) {
Player.REPEAT_MODE_ONE -> "Repeat One"
Player.REPEAT_MODE_ALL -> "Repeat All"
else -> "Repeat None"
}
)
.setIconResId(
when (repeatMode) {
Player.REPEAT_MODE_ONE -> R.drawable.media_repeat_one
Player.REPEAT_MODE_ALL -> R.drawable.media_repeat_all
else -> R.drawable.media_repeat_off
}
)
.setSessionCommand(sessionCommand)
.setEnabled(true)
.build()
}
override fun onGetItem(
session: MediaLibraryService.MediaLibrarySession,
@ -266,18 +348,32 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) :
customCommand: SessionCommand,
args: Bundle
): ListenableFuture<SessionResult> {
Timber.i("onCustomCommand")
Timber.i("onCustomCommand %s", customCommand.customAction)
var customCommandFuture: ListenableFuture<SessionResult>? = null
when (customCommand.customAction) {
PlaybackService.CUSTOM_COMMAND_TOGGLE_HEART_ON -> {
customCommandFuture = onSetRating(session, controller, HeartRating(true))
updateCustomHeartButton(session, true)
updateCustomHeartButton(session, isHeart = true)
}
PlaybackService.CUSTOM_COMMAND_TOGGLE_HEART_OFF -> {
customCommandFuture = onSetRating(session, controller, HeartRating(false))
updateCustomHeartButton(session, false)
updateCustomHeartButton(session, isHeart = false)
}
PlaybackService.CUSTOM_COMMAND_SHUFFLE -> {
customCommandFuture = Futures.immediateFuture(SessionResult(RESULT_SUCCESS))
shuffleCurrentPlaylist(session.player)
}
PlaybackService.CUSTOM_COMMAND_REPEAT_MODE -> {
customCommandFuture = Futures.immediateFuture(SessionResult(RESULT_SUCCESS))
session.player.setNextRepeatMode()
session.updateCustomCommands()
}
else -> {
Timber.d(
"CustomCommand not recognized %s with extra %s",
@ -286,16 +382,23 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) :
)
}
}
if (customCommandFuture != null)
return customCommandFuture
return super.onCustomCommand(session, controller, customCommand, args)
return customCommandFuture
?: super.onCustomCommand(
session,
controller,
customCommand,
args
)
}
override fun onSetRating(
session: MediaSession,
controller: MediaSession.ControllerInfo,
rating: Rating
): ListenableFuture<SessionResult> {
val mediaItem = session.player.currentMediaItem
if (mediaItem != null) {
if (rating is HeartRating) {
mediaItem.toTrack().starred = rating.isHeart
@ -309,6 +412,7 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) :
rating
)
}
return super.onSetRating(session, controller, rating)
}
@ -378,6 +482,8 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) :
private fun onAddLegacyAutoItems(
mediaItems: MutableList<MediaItem>
): ListenableFuture<List<MediaItem>> {
Timber.i("onAddLegacyAutoItems %s", mediaItems.first().mediaId)
val mediaIdParts = mediaItems.first().mediaId.split('|')
val tracks = when (mediaIdParts.first()) {
@ -385,10 +491,12 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) :
MEDIA_PLAYLIST_SONG_ITEM -> playPlaylistSong(
mediaIdParts[1], mediaIdParts[2], mediaIdParts[3]
)
MEDIA_ALBUM_ITEM -> playAlbum(mediaIdParts[1], mediaIdParts[2])
MEDIA_ALBUM_SONG_ITEM -> playAlbumSong(
mediaIdParts[1], mediaIdParts[2], mediaIdParts[3]
)
MEDIA_SONG_STARRED_ID -> playStarredSongs()
MEDIA_SONG_STARRED_ITEM -> playStarredSong(mediaIdParts[1])
MEDIA_SONG_RANDOM_ID -> playRandomSongs()
@ -400,57 +508,59 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) :
MEDIA_PODCAST_EPISODE_ITEM -> playPodcastEpisode(
mediaIdParts[1], mediaIdParts[2]
)
MEDIA_SEARCH_SONG_ITEM -> playSearch(mediaIdParts[1])
else -> null
}
if (tracks != null) {
return Futures.immediateFuture(
tracks.map { track -> track.toMediaItem() }
.toMutableList()
)
}
// Fallback to the original list
return Futures.immediateFuture(mediaItems)
return tracks
?.let {
Futures.immediateFuture(
it.map { track -> track.toMediaItem() }
.toMutableList()
)
}
?: Futures.immediateFuture(mediaItems)
}
@Suppress("ReturnCount", "ComplexMethod")
fun onLoadChildren(
private fun onLoadChildren(
parentId: String,
): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> {
Timber.d("AutoMediaBrowserService onLoadChildren called. ParentId: %s", parentId)
val parentIdParts = parentId.split('|')
when (parentIdParts.first()) {
MEDIA_ROOT_ID -> return getRootItems()
MEDIA_LIBRARY_ID -> return getLibrary()
MEDIA_ARTIST_ID -> return getArtists()
MEDIA_ARTIST_SECTION -> return getArtists(parentIdParts[1])
MEDIA_ALBUM_ID -> return getAlbums(AlbumListType.SORTED_BY_NAME)
MEDIA_ALBUM_PAGE_ID -> return getAlbums(
return when (parentIdParts.first()) {
MEDIA_ROOT_ID -> getRootItems()
MEDIA_LIBRARY_ID -> getLibrary()
MEDIA_ARTIST_ID -> getArtists()
MEDIA_ARTIST_SECTION -> getArtists(parentIdParts[1])
MEDIA_ALBUM_ID -> getAlbums(AlbumListType.SORTED_BY_NAME)
MEDIA_ALBUM_PAGE_ID -> getAlbums(
AlbumListType.fromName(parentIdParts[1]), parentIdParts[2].toInt()
)
MEDIA_PLAYLIST_ID -> return getPlaylists()
MEDIA_ALBUM_FREQUENT_ID -> return getAlbums(AlbumListType.FREQUENT)
MEDIA_ALBUM_NEWEST_ID -> return getAlbums(AlbumListType.NEWEST)
MEDIA_ALBUM_RECENT_ID -> return getAlbums(AlbumListType.RECENT)
MEDIA_ALBUM_RANDOM_ID -> return getAlbums(AlbumListType.RANDOM)
MEDIA_ALBUM_STARRED_ID -> return getAlbums(AlbumListType.STARRED)
MEDIA_SONG_RANDOM_ID -> return getRandomSongs()
MEDIA_SONG_STARRED_ID -> return getStarredSongs()
MEDIA_SHARE_ID -> return getShares()
MEDIA_BOOKMARK_ID -> return getBookmarks()
MEDIA_PODCAST_ID -> return getPodcasts()
MEDIA_PLAYLIST_ITEM -> return getPlaylist(parentIdParts[1], parentIdParts[2])
MEDIA_ARTIST_ITEM -> return getAlbumsForArtist(
MEDIA_PLAYLIST_ID -> getPlaylists()
MEDIA_ALBUM_FREQUENT_ID -> getAlbums(AlbumListType.FREQUENT)
MEDIA_ALBUM_NEWEST_ID -> getAlbums(AlbumListType.NEWEST)
MEDIA_ALBUM_RECENT_ID -> getAlbums(AlbumListType.RECENT)
MEDIA_ALBUM_RANDOM_ID -> getAlbums(AlbumListType.RANDOM)
MEDIA_ALBUM_STARRED_ID -> getAlbums(AlbumListType.STARRED)
MEDIA_SONG_RANDOM_ID -> getRandomSongs()
MEDIA_SONG_STARRED_ID -> getStarredSongs()
MEDIA_SHARE_ID -> getShares()
MEDIA_BOOKMARK_ID -> getBookmarks()
MEDIA_PODCAST_ID -> getPodcasts()
MEDIA_PLAYLIST_ITEM -> getPlaylist(parentIdParts[1], parentIdParts[2])
MEDIA_ARTIST_ITEM -> getAlbumsForArtist(
parentIdParts[1], parentIdParts[2]
)
MEDIA_ALBUM_ITEM -> return getSongsForAlbum(parentIdParts[1], parentIdParts[2])
MEDIA_SHARE_ITEM -> return getSongsForShare(parentIdParts[1])
MEDIA_PODCAST_ITEM -> return getPodcastEpisodes(parentIdParts[1])
else -> return Futures.immediateFuture(LibraryResult.ofItemList(listOf(), null))
MEDIA_ALBUM_ITEM -> getSongsForAlbum(parentIdParts[1], parentIdParts[2])
MEDIA_SHARE_ITEM -> getSongsForShare(parentIdParts[1])
MEDIA_PODCAST_ITEM -> getPodcastEpisodes(parentIdParts[1])
else -> Futures.immediateFuture(LibraryResult.ofItemList(listOf(), null))
}
}
@ -483,8 +593,7 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) :
mediaItems.add(
album.title ?: "",
listOf(MEDIA_ALBUM_ITEM, album.id, album.name)
.joinToString("|"),
FOLDER_TYPE_ALBUMS
.joinToString("|")
)
}
@ -534,10 +643,12 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) :
MEDIA_PLAYLIST_SONG_ITEM -> playPlaylistSong(
mediaIdParts[1], mediaIdParts[2], mediaIdParts[3]
)
MEDIA_ALBUM_ITEM -> playAlbum(mediaIdParts[1], mediaIdParts[2])
MEDIA_ALBUM_SONG_ITEM -> playAlbumSong(
mediaIdParts[1], mediaIdParts[2], mediaIdParts[3]
)
MEDIA_SONG_STARRED_ID -> playStarredSongs()
MEDIA_SONG_STARRED_ITEM -> playStarredSong(mediaIdParts[1])
MEDIA_SONG_RANDOM_ID -> playRandomSongs()
@ -549,6 +660,7 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) :
MEDIA_PODCAST_EPISODE_ITEM -> playPodcastEpisode(
mediaIdParts[1], mediaIdParts[2]
)
MEDIA_SEARCH_SONG_ITEM -> playSearch(mediaIdParts[1])
else -> {
listOf()
@ -573,7 +685,8 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) :
R.string.music_library_label,
MEDIA_LIBRARY_ID,
null,
folderType = FOLDER_TYPE_MIXED,
isBrowsable = true,
mediaType = MEDIA_TYPE_FOLDER_MIXED,
icon = R.drawable.ic_library
)
@ -581,7 +694,8 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) :
R.string.main_artists_title,
MEDIA_ARTIST_ID,
null,
folderType = FOLDER_TYPE_ARTISTS,
isBrowsable = true,
mediaType = MEDIA_TYPE_FOLDER_ARTISTS,
icon = R.drawable.ic_artist
)
@ -590,7 +704,8 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) :
R.string.main_albums_title,
MEDIA_ALBUM_ID,
null,
folderType = FOLDER_TYPE_ALBUMS,
isBrowsable = true,
mediaType = MEDIA_TYPE_FOLDER_ALBUMS,
icon = R.drawable.ic_menu_browse
)
@ -598,7 +713,8 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) :
R.string.playlist_label,
MEDIA_PLAYLIST_ID,
null,
folderType = FOLDER_TYPE_PLAYLISTS,
isBrowsable = true,
mediaType = MEDIA_TYPE_FOLDER_PLAYLISTS,
icon = R.drawable.ic_menu_playlists
)
@ -613,14 +729,16 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) :
R.string.main_songs_random,
MEDIA_SONG_RANDOM_ID,
R.string.main_songs_title,
folderType = FOLDER_TYPE_TITLES
isBrowsable = true,
mediaType = MEDIA_TYPE_PLAYLIST
)
mediaItems.add(
R.string.main_songs_starred,
MEDIA_SONG_STARRED_ID,
R.string.main_songs_title,
folderType = FOLDER_TYPE_TITLES
isBrowsable = true,
mediaType = MEDIA_TYPE_PLAYLIST
)
// Albums
@ -634,28 +752,28 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) :
R.string.main_albums_recent,
MEDIA_ALBUM_RECENT_ID,
R.string.main_albums_title,
folderType = FOLDER_TYPE_ALBUMS
mediaType = MEDIA_TYPE_FOLDER_ALBUMS,
)
mediaItems.add(
R.string.main_albums_frequent,
MEDIA_ALBUM_FREQUENT_ID,
R.string.main_albums_title,
folderType = FOLDER_TYPE_ALBUMS
mediaType = MEDIA_TYPE_FOLDER_ALBUMS,
)
mediaItems.add(
R.string.main_albums_random,
MEDIA_ALBUM_RANDOM_ID,
R.string.main_albums_title,
folderType = FOLDER_TYPE_ALBUMS
mediaType = MEDIA_TYPE_FOLDER_ALBUMS,
)
mediaItems.add(
R.string.main_albums_starred,
MEDIA_ALBUM_STARRED_ID,
R.string.main_albums_title,
folderType = FOLDER_TYPE_ALBUMS
mediaType = MEDIA_TYPE_FOLDER_ALBUMS,
)
// Other
@ -704,8 +822,7 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) :
index.add(currentSection)
mediaItems.add(
currentSection,
listOf(MEDIA_ARTIST_SECTION, currentSection).joinToString("|"),
FOLDER_TYPE_ARTISTS
listOf(MEDIA_ARTIST_SECTION, currentSection).joinToString("|")
)
}
}
@ -713,8 +830,7 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) :
artists.map { artist ->
mediaItems.add(
artist.name ?: "",
listOf(childMediaId, artist.id, artist.name).joinToString("|"),
FOLDER_TYPE_ARTISTS
listOf(childMediaId, artist.id, artist.name).joinToString("|")
)
}
}
@ -744,8 +860,7 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) :
mediaItems.add(
album.title ?: "",
listOf(MEDIA_ALBUM_ITEM, album.id, album.name)
.joinToString("|"),
FOLDER_TYPE_ALBUMS
.joinToString("|")
)
}
return@future LibraryResult.ofItemList(mediaItems, null)
@ -768,15 +883,24 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) :
mediaItems.addPlayAllItem(listOf(MEDIA_ALBUM_ITEM, id, name).joinToString("|"))
// TODO: Paging is not implemented for songs, is it necessary at all?
val items = songs.getTracks().take(DISPLAY_LIMIT)
val items = songs.getChildren().take(DISPLAY_LIMIT).toMutableList()
items.sortWith { o1, o2 ->
if (o1.isDirectory && o2.isDirectory)
(o1.title ?: "").compareTo(o2.title ?: "")
else if (o1.isDirectory)
-1
else
1
}
items.map { item ->
if (item.isDirectory)
mediaItems.add(
item.title ?: "",
listOf(MEDIA_ALBUM_ITEM, item.id, item.name).joinToString("|"),
FOLDER_TYPE_TITLES
listOf(MEDIA_ALBUM_ITEM, item.id, item.name).joinToString("|")
)
else
else if (item is Track)
mediaItems.add(
item.toMediaItem(
listOf(
@ -789,6 +913,7 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) :
)
}
}
return@future LibraryResult.ofItemList(mediaItems, null)
}
}
@ -822,8 +947,7 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) :
mediaItems.add(
album.title ?: "",
listOf(MEDIA_ALBUM_ITEM, album.id, album.name)
.joinToString("|"),
FOLDER_TYPE_ALBUMS
.joinToString("|")
)
}
@ -851,7 +975,7 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) :
playlist.name,
listOf(MEDIA_PLAYLIST_ITEM, playlist.id, playlist.name)
.joinToString("|"),
FOLDER_TYPE_PLAYLISTS
mediaType = MEDIA_TYPE_PLAYLIST,
)
}
return@future LibraryResult.ofItemList(mediaItems, null)
@ -945,7 +1069,7 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) :
mediaItems.add(
podcast.title ?: "",
listOf(MEDIA_PODCAST_ITEM, podcast.id).joinToString("|"),
FOLDER_TYPE_MIXED
mediaType = MEDIA_TYPE_FOLDER_MIXED,
)
}
return@future LibraryResult.ofItemList(mediaItems, null)
@ -1048,7 +1172,7 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) :
share.name ?: "",
listOf(MEDIA_SHARE_ITEM, share.id)
.joinToString("|"),
FOLDER_TYPE_MIXED
mediaType = MEDIA_TYPE_FOLDER_MIXED,
)
}
return@future LibraryResult.ofItemList(mediaItems, null)
@ -1226,14 +1350,16 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) :
private fun MutableList<MediaItem>.add(
title: String,
mediaId: String,
folderType: Int
mediaType: Int = MEDIA_TYPE_MIXED,
isBrowsable: Boolean = false
) {
val mediaItem = buildMediaItem(
title,
mediaId,
isPlayable = false,
folderType = folderType
isBrowsable = isBrowsable,
mediaType = mediaType
)
this.add(mediaItem)
@ -1244,8 +1370,8 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) :
resId: Int,
mediaId: String,
groupNameId: Int?,
browsable: Boolean = true,
folderType: Int = FOLDER_TYPE_MIXED,
isBrowsable: Boolean = true,
mediaType: Int = MEDIA_TYPE_FOLDER_MIXED,
icon: Int? = null
) {
val applicationContext = UApp.applicationContext()
@ -1253,14 +1379,15 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) :
val mediaItem = buildMediaItem(
applicationContext.getString(resId),
mediaId,
isPlayable = !browsable,
folderType = folderType,
isPlayable = !isBrowsable,
isBrowsable = isBrowsable,
imageUri = if (icon != null) {
Util.getUriToDrawable(applicationContext, icon)
} else null,
group = if (groupNameId != null) {
applicationContext.getString(groupNameId)
} else null,
imageUri = if (icon != null) {
Util.getUriToDrawable(applicationContext, icon)
} else null
mediaType = mediaType
)
this.add(mediaItem)
@ -1294,14 +1421,102 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) :
}
}
fun updateCustomHeartButton(
session: MediaSession,
isHeart: Boolean
) {
val command = if (isHeart) customCommands[1] else customCommands[0]
// Change the custom layout to contain the right heart button
customLayout = ImmutableList.of(command)
// Send the updated custom layout to controllers.
session.setCustomLayout(customLayout)
private fun Player.setNextRepeatMode() {
repeatMode =
when (repeatMode) {
Player.REPEAT_MODE_OFF -> Player.REPEAT_MODE_ALL
Player.REPEAT_MODE_ALL -> Player.REPEAT_MODE_ONE
else -> Player.REPEAT_MODE_OFF
}
}
private fun MediaSession.updateCustomCommands() {
setCustomLayout(
buildCustomCommands(
heartIsCurrentlyOn,
canShuffle()
)
)
}
fun updateCustomHeartButton(session: MediaSession, isHeart: Boolean) {
with(session) {
setCustomLayout(
buildCustomCommands(
isHeart = isHeart,
canShuffle = canShuffle()
)
)
}
}
private fun MediaSession.canShuffle() =
player.mediaItemCount > 2
private fun MediaSession.buildCustomCommands(
isHeart: Boolean = false,
canShuffle: Boolean = false
): ImmutableList<CommandButton> {
Timber.d("building custom commands (isHeart = %s, canShuffle = %s)", isHeart, canShuffle)
heartIsCurrentlyOn = isHeart
return ImmutableList.copyOf(
buildList {
// placeholder must come first here because if there is no next button the first
// custom command button is place right next to the play/pause button
if (
player.repeatMode != Player.REPEAT_MODE_ALL &&
player.currentMediaItemIndex == player.mediaItemCount - 1
)
add(placeholderButton)
// due to the previous placeholder this heart button will always appear to the left
// of the default playback items
add(
if (isHeart)
heartButtonToggleOff
else
heartButtonToggleOn
)
// both the shuffle and the active repeat mode button will end up in the overflow
// menu if both are available at the same time
if (canShuffle)
add(shuffleButton)
add(
when (player.repeatMode) {
Player.REPEAT_MODE_ONE -> repeatOneButton
Player.REPEAT_MODE_ALL -> repeatAllButton
else -> repeatOffButton
}
)
}.asIterable()
)
}
private fun shuffleCurrentPlaylist(player: Player) {
Timber.d("shuffleCurrentPlaylist")
// 3 was chosen because that leaves at least two other songs to be shuffled around
@Suppress("MagicNumber")
if (player.mediaItemCount < 3)
return
val mediaItemsToShuffle = mutableListOf<MediaItem>()
for (i in 0 until player.currentMediaItemIndex) {
mediaItemsToShuffle += player.getMediaItemAt(i)
}
for (i in player.currentMediaItemIndex + 1 until player.mediaItemCount) {
mediaItemsToShuffle += player.getMediaItemAt(i)
}
player.removeMediaItems(player.currentMediaItemIndex + 1, player.mediaItemCount)
player.removeMediaItems(0, player.currentMediaItemIndex)
player.addMediaItems(mediaItemsToShuffle.shuffled())
}
}

View File

@ -1,40 +0,0 @@
/*
* CustomNotificationProvider.kt
* Copyright (C) 2009-2022 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
package org.moire.ultrasonic.playback
import android.content.Context
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.session.CommandButton
import androidx.media3.session.DefaultMediaNotificationProvider
import androidx.media3.session.MediaSession
import com.google.common.collect.ImmutableList
import org.koin.core.component.KoinComponent
@UnstableApi
class CustomNotificationProvider(ctx: Context) :
DefaultMediaNotificationProvider(ctx),
KoinComponent {
// By default the skip buttons are not shown in compact view.
// We add the COMMAND_KEY_COMPACT_VIEW_INDEX to show them
// See also: https://github.com/androidx/media/issues/410
override fun getMediaButtons(
session: MediaSession,
playerCommands: Player.Commands,
customLayout: ImmutableList<CommandButton>,
playWhenReady: Boolean
): ImmutableList<CommandButton> {
val commands = super.getMediaButtons(session, playerCommands, customLayout, playWhenReady)
commands.forEachIndexed { index, command ->
command.extras.putInt(COMMAND_KEY_COMPACT_VIEW_INDEX, index)
}
return commands
}
}

View File

@ -130,26 +130,19 @@ class PlaybackService :
private fun initializeSessionAndPlayer() {
if (isStarted) return
setMediaNotificationProvider(CustomNotificationProvider(UApp.applicationContext()))
// TODO: Remove minor code duplication with updateBackend()
val desiredBackend = if (activeServerProvider.getActiveServer().jukeboxByDefault) {
Timber.i("Jukebox enabled by default")
MediaPlayerManager.PlayerBackend.JUKEBOX
} else {
MediaPlayerManager.PlayerBackend.LOCAL
}
player = if (activeServerProvider.getActiveServer().jukeboxByDefault) {
Timber.i("Jukebox enabled by default")
getJukeboxPlayer()
} else {
getLocalPlayer()
}
player = createNewBackend(desiredBackend)
actualBackend = desiredBackend
// Create browser interface
librarySessionCallback = AutoMediaBrowserCallback(this)
librarySessionCallback = AutoMediaBrowserCallback()
// This will need to use the AutoCalls
mediaLibrarySession = MediaLibrarySession.Builder(this, player, librarySessionCallback)
@ -157,10 +150,8 @@ class PlaybackService :
.setBitmapLoader(ArtworkBitmapLoader())
.build()
if (!librarySessionCallback.customLayout.isEmpty()) {
// Send custom layout to legacy session.
mediaLibrarySession.setCustomLayout(librarySessionCallback.customLayout)
}
// Send custom layout to legacy session.
mediaLibrarySession.setCustomLayout(librarySessionCallback.defaultCustomCommands)
// Set a listener to update the API client when the active server has changed
rxBusSubscription += RxBus.activeServerChangedObservable.subscribe {
@ -213,11 +204,7 @@ class PlaybackService :
player.removeListener(listener)
player.release()
player = if (newBackend == MediaPlayerManager.PlayerBackend.JUKEBOX) {
getJukeboxPlayer()
} else {
getLocalPlayer()
}
player = createNewBackend(newBackend)
// Add fresh listeners
player.addListener(listener)
@ -227,6 +214,14 @@ class PlaybackService :
actualBackend = newBackend
}
private fun createNewBackend(newBackend: MediaPlayerManager.PlayerBackend): Player {
return if (newBackend == MediaPlayerManager.PlayerBackend.JUKEBOX) {
getJukeboxPlayer()
} else {
getLocalPlayer()
}
}
private fun getJukeboxPlayer(): Player {
return JukeboxMediaPlayer()
}
@ -425,6 +420,12 @@ class PlaybackService :
"org.moire.ultrasonic.HEART_ON"
const val CUSTOM_COMMAND_TOGGLE_HEART_OFF =
"org.moire.ultrasonic.HEART_OFF"
const val CUSTOM_COMMAND_SHUFFLE =
"org.moire.ultrasonic.SHUFFLE"
const val CUSTOM_COMMAND_PLACEHOLDER =
"org.moire.ultrasonic.PLACEHOLDER"
const val CUSTOM_COMMAND_REPEAT_MODE =
"org.moire.ultrasonic.REPEAT_MODE"
private const val NOTIFICATION_ID = 3009
}
}

View File

@ -82,7 +82,10 @@ class JukeboxMediaPlayer : JukeboxUnimplementedFunctions(), Player {
companion object {
// This is quite important, by setting the DeviceInfo the player is recognized by
// Android as being a remote playback surface
val DEVICE_INFO = DeviceInfo(DeviceInfo.PLAYBACK_TYPE_REMOTE, 0, 10)
val DEVICE_INFO = DeviceInfo.Builder(DeviceInfo.PLAYBACK_TYPE_REMOTE)
.setMinVolume(0)
.setMaxVolume(10)
.build()
val running = AtomicBoolean()
const val MAX_GAIN = 10
}
@ -206,14 +209,14 @@ class JukeboxMediaPlayer : JukeboxUnimplementedFunctions(), Player {
Player.COMMAND_CHANGE_MEDIA_ITEMS,
Player.COMMAND_GET_TIMELINE,
Player.COMMAND_GET_DEVICE_VOLUME,
Player.COMMAND_ADJUST_DEVICE_VOLUME,
Player.COMMAND_SET_DEVICE_VOLUME
Player.COMMAND_ADJUST_DEVICE_VOLUME_WITH_FLAGS,
Player.COMMAND_SET_DEVICE_VOLUME_WITH_FLAGS,
)
if (isPlaying) commandsBuilder.add(Player.COMMAND_STOP)
if (playlist.isNotEmpty()) {
commandsBuilder.addAll(
Player.COMMAND_GET_CURRENT_MEDIA_ITEM,
Player.COMMAND_GET_MEDIA_ITEMS_METADATA,
Player.COMMAND_GET_METADATA,
Player.COMMAND_PLAY_PAUSE,
Player.COMMAND_PREPARE,
Player.COMMAND_SEEK_BACK,
@ -284,6 +287,10 @@ class JukeboxMediaPlayer : JukeboxUnimplementedFunctions(), Player {
override fun setShuffleModeEnabled(shuffleModeEnabled: Boolean) {}
override fun setDeviceVolume(volume: Int) {
setDeviceVolume(volume, 0)
}
override fun setDeviceVolume(volume: Int, flags: Int) {
gain = volume
tasks.remove(SetGain::class.java)
tasks.add(SetGain(floatGain))
@ -299,17 +306,32 @@ class JukeboxMediaPlayer : JukeboxUnimplementedFunctions(), Player {
}
}
@Deprecated("Deprecated in Java")
override fun increaseDeviceVolume() {
increaseDeviceVolume(C.VOLUME_FLAG_SHOW_UI)
}
override fun increaseDeviceVolume(flags: Int) {
gain = (gain + 1).coerceAtMost(MAX_GAIN)
deviceVolume = gain
}
@Deprecated("Deprecated in Java")
override fun decreaseDeviceVolume() {
decreaseDeviceVolume(C.VOLUME_FLAG_SHOW_UI)
}
override fun decreaseDeviceVolume(flags: Int) {
gain = (gain - 1).coerceAtLeast(0)
deviceVolume = gain
}
@Deprecated("Deprecated in Java")
override fun setDeviceMuted(muted: Boolean) {
setDeviceMuted(muted, C.VOLUME_FLAG_SHOW_UI)
}
override fun setDeviceMuted(muted: Boolean, flags: Int) {
gain = 0
deviceVolume = gain
}

View File

@ -67,6 +67,18 @@ abstract class JukeboxUnimplementedFunctions : Player {
TODO("Not yet implemented")
}
override fun replaceMediaItem(index: Int, mediaItem: MediaItem) {
TODO("Not yet implemented")
}
override fun replaceMediaItems(
fromIndex: Int,
toIndex: Int,
mediaItems: MutableList<MediaItem>
) {
TODO("Not yet implemented")
}
override fun setPlayWhenReady(playWhenReady: Boolean) {
TODO("Not yet implemented")
}
@ -134,11 +146,6 @@ abstract class JukeboxUnimplementedFunctions : Player {
override fun setPlaybackSpeed(speed: Float) {
TODO("Not yet implemented")
}
override fun stop(reset: Boolean) {
TODO("Not yet implemented")
}
override fun getCurrentTracks(): Tracks {
// TODO Dummy information is returned for now, this seems to work
return Tracks.EMPTY

View File

@ -460,7 +460,7 @@ class MediaPlayerManager(
// We can't just use play(0,0) then all random playlists will start with the first track.
// 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) {
if (autoPlay && controller?.isPlaying != true) {
if (isShufflePlayEnabled) {
deferredPlay = {
val start = controller?.currentTimeline

View File

@ -68,27 +68,27 @@ class DownloadHandler(
}
successString = when (action) {
DownloadAction.DOWNLOAD -> fragment.resources.getQuantityString(
R.plurals.select_album_n_songs_downloaded,
R.plurals.n_songs_to_be_downloaded,
tracksToDownload.size,
tracksToDownload.size
)
DownloadAction.UNPIN -> {
fragment.resources.getQuantityString(
R.plurals.select_album_n_songs_unpinned,
R.plurals.n_songs_unpinned,
tracksToDownload.size,
tracksToDownload.size
)
}
DownloadAction.PIN -> {
fragment.resources.getQuantityString(
R.plurals.select_album_n_songs_pinned,
R.plurals.n_songs_pinned,
tracksToDownload.size,
tracksToDownload.size
)
}
DownloadAction.DELETE -> {
fragment.resources.getQuantityString(
R.plurals.select_album_n_songs_deleted,
R.plurals.n_songs_deleted,
tracksToDownload.size,
tracksToDownload.size
)
@ -104,10 +104,9 @@ class DownloadHandler(
name: String? = "",
isShare: Boolean = false,
isDirectory: Boolean = true,
append: Boolean,
insertionMode: MediaPlayerManager.InsertionMode,
autoPlay: Boolean,
shuffle: Boolean = false,
playNext: Boolean,
isArtist: Boolean = false
) {
var successString: String? = null
@ -119,26 +118,28 @@ class DownloadHandler(
withContext(Dispatchers.Main) {
addTracksToMediaController(
songs = songs,
append = append,
playNext = playNext,
insertionMode = insertionMode,
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
)
successString = when (insertionMode) {
MediaPlayerManager.InsertionMode.AFTER_CURRENT ->
fragment.resources.getQuantityString(
R.plurals.n_songs_added_after_current,
songs.size,
songs.size
)
MediaPlayerManager.InsertionMode.APPEND ->
fragment.resources.getQuantityString(
R.plurals.n_songs_added_to_end,
songs.size,
songs.size
)
else -> null
}
}
}) { successString }
@ -146,8 +147,7 @@ class DownloadHandler(
fun addTracksToMediaController(
songs: List<Track>,
append: Boolean,
playNext: Boolean,
insertionMode: MediaPlayerManager.InsertionMode,
autoPlay: Boolean,
shuffle: Boolean = false,
playlistName: String? = null,
@ -157,12 +157,6 @@ class DownloadHandler(
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
val insertionMode = when {
append -> MediaPlayerManager.InsertionMode.APPEND
playNext -> MediaPlayerManager.InsertionMode.AFTER_CURRENT
else -> MediaPlayerManager.InsertionMode.CLEAR
}
if (playlistName != null) {
mediaPlayerManager.suggestedPlaylistName = playlistName
}
@ -173,7 +167,10 @@ class DownloadHandler(
shuffle,
insertionMode
)
if (Settings.shouldTransitionOnPlayback && (!append || autoPlay)) {
if (Settings.shouldTransitionOnPlayback &&
insertionMode == MediaPlayerManager.InsertionMode.CLEAR
) {
fragment.findNavController().popBackStack(R.id.playerFragment, true)
fragment.findNavController().navigate(R.id.playerFragment)
}

View File

@ -14,7 +14,8 @@ import androidx.core.net.toUri
import androidx.media3.common.HeartRating
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.common.MediaMetadata.FOLDER_TYPE_NONE
import androidx.media3.common.MediaMetadata.MEDIA_TYPE_FOLDER_MIXED
import androidx.media3.common.MediaMetadata.MEDIA_TYPE_MUSIC
import androidx.media3.common.StarRating
import java.text.DateFormat
import java.text.ParseException
@ -22,7 +23,7 @@ import java.util.Date
import org.moire.ultrasonic.domain.Track
import org.moire.ultrasonic.provider.AlbumArtContentProvider
// Copied from androidx.media.utils.MediaConstants in order to avoid importing a whole dependecy
// Copied from androidx.media.utils.MediaConstants in order to avoid importing a whole dependency
// for a single string value
private const val DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE =
"android.media.browse.CONTENT_STYLE_GROUP_TITLE_HINT"
@ -76,15 +77,16 @@ fun Track.toMediaItem(
title = title ?: "",
mediaId = mediaId,
isPlayable = !isDirectory,
folderType = if (isDirectory) MediaMetadata.FOLDER_TYPE_TITLES
else MediaMetadata.FOLDER_TYPE_NONE,
isBrowsable = isDirectory,
album = album,
artist = artist,
genre = genre,
sourceUri = uri.toUri(),
imageUri = artworkUri,
starred = starred,
group = null
group = null,
mediaType = if (isDirectory) MEDIA_TYPE_FOLDER_MIXED
else MEDIA_TYPE_MUSIC
)
val metadataBuilder = mediaItem.mediaMetadata.buildUpon()
@ -204,14 +206,6 @@ private fun safeParseDate(created: String?): Date? {
} else null
}
fun MediaItem.setPin(pin: Boolean) {
this.mediaMetadata.extras?.putBoolean("pin", pin)
}
fun MediaItem.shouldBePinned(): Boolean {
return this.mediaMetadata.extras?.getBoolean("pin") ?: false
}
/**
* Build a new MediaItem from a list of attributes.
* Especially useful to create folder entries in the Auto interface.
@ -222,7 +216,7 @@ fun buildMediaItem(
title: String,
mediaId: String,
isPlayable: Boolean,
folderType: @MediaMetadata.FolderType Int,
isBrowsable: Boolean = false,
album: String? = null,
artist: String? = null,
genre: String? = null,
@ -241,17 +235,13 @@ fun buildMediaItem(
.setAlbumArtist(artist)
.setGenre(genre)
.setUserRating(HeartRating(starred))
.setFolderType(folderType)
.setIsBrowsable(isBrowsable)
.setIsPlayable(isPlayable)
if (imageUri != null) {
metadataBuilder.setArtworkUri(imageUri)
}
if (folderType > FOLDER_TYPE_NONE) {
metadataBuilder.setIsBrowsable(true)
}
if (mediaType != null) {
metadataBuilder.setMediaType(mediaType)
}

View File

@ -318,7 +318,7 @@
<string name="server_menu.move_down">Posunout níž</string>
<string name="server_editor.authentication">Ověření</string>
<string name="server_editor.advanced">Rozšířené možnosti</string>
<plurals name="select_album_n_songs">
<plurals name="n_songs">
<item quantity="one">%d skladba</item>
<item quantity="few">%d skladby</item>
<item quantity="many">%d skladeb</item>

View File

@ -383,31 +383,31 @@
<string name="server_menu.demo">Demo Server</string>
<string name="about.webpage">Website besuchen</string>
<string name="about.report">Einen Fehler melden</string>
<plurals name="select_album_n_songs">
<plurals name="n_songs">
<item quantity="one">%d Titel</item>
<item quantity="other">%d Titel</item>
</plurals>
<plurals name="select_album_n_songs_pinned">
<plurals name="n_songs_pinned">
<item quantity="one">%d Titel zum Anheften ausgewählt</item>
<item quantity="other">%d Titel zum Anheften ausgewählt</item>
</plurals>
<plurals name="select_album_n_songs_downloaded">
<plurals name="n_songs_to_be_downloaded">
<item quantity="one">%d Titel zum herunterladen ausgewählt</item>
<item quantity="other">%d Titel zum herunterladen ausgewählt</item>
</plurals>
<plurals name="select_album_n_songs_unpinned">
<plurals name="n_songs_unpinned">
<item quantity="one">%d Titel losgelöst</item>
<item quantity="other">%d Titel losgelöst</item>
</plurals>
<plurals name="select_album_n_songs_deleted">
<plurals name="n_songs_deleted">
<item quantity="one">%d Titel gelöscht</item>
<item quantity="other">%d Titel gelöscht</item>
</plurals>
<plurals name="select_album_n_songs_added">
<plurals name="n_songs_added_to_end">
<item quantity="one">%d Titel am Ende hinzugefügt</item>
<item quantity="other">%d Titel am Ende hinzugefügt</item>
</plurals>
<plurals name="select_album_n_songs_play_next">
<plurals name="n_songs_added_after_current">
<item quantity="one">%d Titel nach aktuellen Titel hinzugefügt</item>
<item quantity="other">%d Titel nach aktuellen Titel hinzugefügt</item>
</plurals>

View File

@ -390,37 +390,37 @@
<string name="about.webpage">Visitar la página web</string>
<string name="about.report">Informar de un error</string>
<string name="about.text"><b>Ultrasonic</b> es un cliente Android de streaming de música gratuito y de código abierto para servidores compatibles con la API de Subsonic (versión 1.7.0 o superior).\n\nCon <b>Ultrasonic</b> puede transmitir o descargar fácilmente música desde su ordenador de casa a su teléfono Android utilizando su servidor multimedia compatible con Subsonic. El software del servidor Subsonic requiere una configuración adicional aparte de Ultrasonic.\n\nPor defecto, Ultrasonic no está configurado. Una vez que hayas configurado tu propio servidor, cambia la configuración del servidor para que se conecte a tu propio ordenador.</string>
<plurals name="select_album_n_songs">
<plurals name="n_songs">
<item quantity="one">%d canción</item>
<item quantity="many">%d canciones</item>
<item quantity="other">%d canciones</item>
</plurals>
<plurals name="select_album_n_songs_pinned">
<plurals name="n_songs_pinned">
<item quantity="one">%d canción seleccionada para ser anclada</item>
<item quantity="many">%d canciones seleccionadas para ser ancladas</item>
<item quantity="other">%d canciones seleccionadas para ser ancladas</item>
</plurals>
<plurals name="select_album_n_songs_downloaded">
<plurals name="n_songs_to_be_downloaded">
<item quantity="one">%d canción seleccionada para ser descargada</item>
<item quantity="many">%d canciones seleccionadas para ser descargadas</item>
<item quantity="other">%d canciones seleccionadas para ser descargadas</item>
</plurals>
<plurals name="select_album_n_songs_unpinned">
<plurals name="n_songs_unpinned">
<item quantity="one">%d canción desanclada</item>
<item quantity="many">%d canciones desancladas</item>
<item quantity="other">%d canciones desancladas</item>
</plurals>
<plurals name="select_album_n_songs_deleted">
<plurals name="n_songs_deleted">
<item quantity="one">%d canción eliminada</item>
<item quantity="many">%d canciones eliminadas</item>
<item quantity="other">%d canciones eliminadas</item>
</plurals>
<plurals name="select_album_n_songs_added">
<plurals name="n_songs_added_to_end">
<item quantity="one">%d canción añadida al final de la cola de reproducción</item>
<item quantity="many">%d canciones añadidas al final de la cola de reproducción</item>
<item quantity="other">%d canciones añadidas al final de la cola de reproducción</item>
</plurals>
<plurals name="select_album_n_songs_play_next">
<plurals name="n_songs_added_after_current">
<item quantity="one">%d canción insertada después de la canción actual</item>
<item quantity="many">%d canciones insertadas después de la canción actual</item>
<item quantity="other">%d canciones insertadas después de la canción actual</item>

View File

@ -372,7 +372,7 @@
<string name="about.webpage">Visiter la page web</string>
<string name="about.report">Signaler un bug</string>
<string name="about.text"><b>Ultrasonic</b> est un client Android de streaming musical gratuit et open-source pour les serveurs compatibles avec l\'API Subsonic (version 1.7.0 ou supérieure). Avec <b>Ultrasonic</b>, vous pouvez facilement diffuser ou télécharger de la musique depuis votre ordinateur personnel vers votre téléphone Android en utilisant votre serveur multimédia compatible Subsonic. Le logiciel du serveur Subsonic nécessite une configuration supplémentaire distincte d\'Ultrasonic. Par défaut, Ultrasonic n\'est pas configuré. Une fois que vous avez mis en place votre propre serveur, veuillez modifier la configuration du serveur afin qu\'il se connecte à votre ordinateur.</string>
<plurals name="select_album_n_songs">
<plurals name="n_songs">
<item quantity="one">%d titre</item>
<item quantity="many">%d titres</item>
<item quantity="other">%d titres</item>
@ -400,12 +400,12 @@
<string name="settings.preload_50">50 morceaux</string>
<string name="chat.user_avatar">Image d\'avatar</string>
<string name="settings.theme_day_night">Jour et nuit</string>
<plurals name="select_album_n_songs_added">
<plurals name="n_songs_added_to_end">
<item quantity="one">%d morceau ajouté à la file d\'attente de fin de lecture</item>
<item quantity="many">"%d morceaux ajoutés à la file d\'attente de fin de lecture"</item>
<item quantity="other">%d morceaux ajoutés à la file d\'attente de fin de lecture</item>
</plurals>
<plurals name="select_album_n_songs_deleted">
<plurals name="n_songs_deleted">
<item quantity="one">%d morceau supprimé</item>
<item quantity="many">%d morceaux supprimés</item>
<item quantity="other">%d morceaux supprimés</item>
@ -429,22 +429,22 @@
<string name="settings.parallel_downloads">Combien de chansons peuvent être téléchargées en parallèle</string>
<string name="settings.show_now_playing_details_summary">Afficher plus de détails sur la chanson dans la lecture en cours (genre, année, débit)</string>
<string name="list_view">Liste</string>
<plurals name="select_album_n_songs_downloaded">
<plurals name="n_songs_to_be_downloaded">
<item quantity="one">%d chanson sélectionnée pour téléchargement</item>
<item quantity="many">%d chansons sélectionnées pour téléchargement</item>
<item quantity="other">%d chansons sélectionnées pour téléchargement</item>
</plurals>
<plurals name="select_album_n_songs_unpinned">
<plurals name="n_songs_unpinned">
<item quantity="one">%d chanson désépinglée</item>
<item quantity="many">%d chansons désépinglées</item>
<item quantity="other">%d chansons désépinglées</item>
</plurals>
<plurals name="select_album_n_songs_play_next">
<plurals name="n_songs_added_after_current">
<item quantity="one">%d chanson insérée après la chanson en cours</item>
<item quantity="many">%d chansons insérées après la chanson en cours</item>
<item quantity="other">%d chansons insérées après la chanson en cours</item>
</plurals>
<plurals name="select_album_n_songs_pinned">
<plurals name="n_songs_pinned">
<item quantity="one">%d chanson sélectionnée à épingler</item>
<item quantity="many">%d chansons sélectionnées à épingler</item>
<item quantity="other">%d chansons sélectionnées à épingler</item>

View File

@ -51,12 +51,12 @@
<string name="common.play_last">Reproducir última</string>
<string name="common.pin">Ancorar</string>
<string name="settings.show_confirmation_dialog_summary">Mostra un cadro de diálogo de confirmación antes de eliminar ou desancorar as cancións</string>
<plurals name="select_album_n_songs_pinned">
<plurals name="n_songs_pinned">
<item quantity="one">%d canción seleccionada para ser ancorada</item>
<item quantity="other">%d cancións seleccionadas para ser ancoradas</item>
</plurals>
<string name="settings.max_bitrate_pinning">Tasa de bits máxima: ao fixar unha canción de forma permanente</string>
<plurals name="select_album_n_songs_unpinned">
<plurals name="n_songs_unpinned">
<item quantity="one">%d canción desancorada</item>
<item quantity="other">%d cancións desancoradas</item>
</plurals>

View File

@ -326,7 +326,7 @@
<string name="server_menu.move_down">Lejjebb mozgat</string>
<string name="server_editor.authentication">Bejelentkezés</string>
<string name="server_editor.advanced">Haladó beállítások</string>
<plurals name="select_album_n_songs">
<plurals name="n_songs">
<item quantity="one">%d dal</item>
<item quantity="other">%d dal</item>
</plurals>

View File

@ -302,25 +302,25 @@
<string name="server_menu.demo">デモサーバー</string>
<string name="about.webpage">Webページにアクセス</string>
<string name="about.report">バグを報告</string>
<plurals name="select_album_n_songs">
<plurals name="n_songs">
<item quantity="other">%d 曲</item>
</plurals>
<plurals name="select_album_n_songs_downloaded">
<plurals name="n_songs_to_be_downloaded">
<item quantity="other">%d 曲がダウンロード選択されました</item>
</plurals>
<plurals name="select_album_n_songs_unpinned">
<plurals name="n_songs_unpinned">
<item quantity="other">%d 曲が固定解除されました</item>
</plurals>
<plurals name="select_album_n_songs_pinned">
<plurals name="n_songs_pinned">
<item quantity="other">%d 曲が固定されるよう選択されました</item>
</plurals>
<plurals name="select_album_n_songs_deleted">
<plurals name="n_songs_deleted">
<item quantity="other">%d 曲が削除されました</item>
</plurals>
<plurals name="select_album_n_songs_added">
<plurals name="n_songs_added_to_end">
<item quantity="other">%d 曲が再生キューの末尾に追加されました</item>
</plurals>
<plurals name="select_album_n_songs_play_next">
<plurals name="n_songs_added_after_current">
<item quantity="other">%d 曲が現在再生中の曲の次に追加されました</item>
</plurals>
<string name="api.subsonic.generic">一般APIエラー: %1$s</string>

View File

@ -123,11 +123,11 @@
<string name="share_set_share_options">Sett delingsinnstillinger</string>
<string name="button_bar.shares">Delinger</string>
<string name="download.toggle_playlist">Veksle spilleliste</string>
<plurals name="select_album_n_songs_play_next">
<plurals name="n_songs_added_after_current">
<item quantity="one">%d spor lagt til etter nåværende spor</item>
<item quantity="other">%d spor lagt til etter nåværende spor</item>
</plurals>
<plurals name="select_album_n_songs_added">
<plurals name="n_songs_added_to_end">
<item quantity="one">%d spor lagt til på slutten av spillekøen</item>
<item quantity="other">%d spor lagt til på slutten av spillekøen</item>
</plurals>
@ -290,19 +290,19 @@
<string name="settings.debug.log_to_file">Skriv avlusningslogg til fil</string>
<string name="about.report">Rapporter en feil</string>
<string name="api.subsonic.generic">Generisk API-feil: %1$s</string>
<plurals name="select_album_n_songs">
<plurals name="n_songs">
<item quantity="one">%d spor</item>
<item quantity="other">%d spor</item>
</plurals>
<plurals name="select_album_n_songs_pinned">
<plurals name="n_songs_pinned">
<item quantity="one">%d spor å feste</item>
<item quantity="other">%d spor å feste</item>
</plurals>
<plurals name="select_album_n_songs_unpinned">
<plurals name="n_songs_unpinned">
<item quantity="one">%d spor løsnet</item>
<item quantity="other">%d spor løsnet</item>
</plurals>
<plurals name="select_album_n_songs_deleted">
<plurals name="n_songs_deleted">
<item quantity="one">%d spor slettet</item>
<item quantity="other">%d spor løsnet</item>
</plurals>
@ -446,7 +446,7 @@
<string name="grid_view">Omslag</string>
<string name="supported_server_features">Støttede funksjoner</string>
<string name="jukebox">Jukebox</string>
<plurals name="select_album_n_songs_downloaded">
<plurals name="n_songs_to_be_downloaded">
<item quantity="one">%d spor valgt for nedlasting</item>
<item quantity="other">%d spor valgt for nedlasting</item>
</plurals>

View File

@ -391,31 +391,31 @@
<string name="about.webpage">Website openen</string>
<string name="about.report">Bug melden</string>
<string name="about.text"><b>Ultrasonic</b> is een gratis, open source muziekstreamingclient voor Android, die gebruikmaakt van servers die compatibel zijn met de Subsonic-api (versie 1.7.0 of hoger).\n\nMet <b>Ultrasonic</b> kun je eenvoudig muziek streamen of downloaden van je computer naar je Android-telefoon met behulp van een met Subsonic compatibele mediaserver. Let op: de Subsonic-serversoftware vereist aanvullende configuratie.\n\nStandaard is Ultrasonic niet ingesteld. Zet je eigen server op en wijzig de serverconfiguratie in die van je eigen.</string>
<plurals name="select_album_n_songs">
<plurals name="n_songs">
<item quantity="one">%d nummer</item>
<item quantity="other">%d nummers</item>
</plurals>
<plurals name="select_album_n_songs_pinned">
<plurals name="n_songs_pinned">
<item quantity="one">%d vast te maken nummer geselecteerd</item>
<item quantity="other">%d vast te maken nummers geselecteerd</item>
</plurals>
<plurals name="select_album_n_songs_downloaded">
<plurals name="n_songs_to_be_downloaded">
<item quantity="one">%d te downloaden nummer geselecteerd</item>
<item quantity="other">%d te downloaden nummers geselecteerd</item>
</plurals>
<plurals name="select_album_n_songs_unpinned">
<plurals name="n_songs_unpinned">
<item quantity="one">%d nummer losgemaakt</item>
<item quantity="other">%d nummers losgemaakt</item>
</plurals>
<plurals name="select_album_n_songs_deleted">
<plurals name="n_songs_deleted">
<item quantity="one">%d nummer verwijderd</item>
<item quantity="other">%d nummers verwijderd</item>
</plurals>
<plurals name="select_album_n_songs_added">
<plurals name="n_songs_added_to_end">
<item quantity="one">%d nummer toegevoegd aan het einde van afspeelwachtrij</item>
<item quantity="other">%d nummers toegevoegd aan het einde van afspeelwachtrij</item>
</plurals>
<plurals name="select_album_n_songs_play_next">
<plurals name="n_songs_added_after_current">
<item quantity="one">%d nummer ingevoegd na het huidige nummer</item>
<item quantity="other">%d nummers ingevoegd na het huidige nummer</item>
</plurals>

View File

@ -307,7 +307,7 @@
<string name="server_menu.move_down">Przesuń się w dół</string>
<string name="server_editor.authentication">Authentication</string>
<string name="server_editor.advanced">Ustawienia zaawansowane</string>
<plurals name="select_album_n_songs">
<plurals name="n_songs">
<item quantity="one">%d utwór</item>
<item quantity="few">%d utwory</item>
<item quantity="many">%d utworów</item>
@ -356,7 +356,7 @@
<string name="language.hu">Węgierski</string>
<string name="settings.debug.log_summary">Zapisanych jest %1$s plików z logami, które zajmują ~%2$s MB miejsca w katalogu %3$s. Czy chcesz je zachować\?</string>
<string name="buttons.previous">Poprzednie</string>
<plurals name="select_album_n_songs_deleted">
<plurals name="n_songs_deleted">
<item quantity="one">Usunięto %d utwór</item>
<item quantity="few">Usunięto %d utwory</item>
<item quantity="many">Usunięto %d utworów</item>
@ -403,20 +403,20 @@
<string name="settings.wifi_required_title">Pobieraj tylko przez Wi-Fi</string>
<string name="settings.sharing_always_ask_for_details_summary">Zawsze pytaj o opis i czas wygaśnięcia podczas tworzenia udostępnienia na serwerze</string>
<string name="settings.debug.log_delete">Usuń pliki</string>
<plurals name="select_album_n_songs_pinned">
<plurals name="n_songs_pinned">
<item quantity="one">%d utwór zaznaczony do przypięcia</item>
<item quantity="few">%d utwory zaznaczone do przypięcia</item>
<item quantity="many">%d utworów zaznaczonych do przypięcia</item>
<item quantity="other">%d utworów zaznaczonych do przypięcia</item>
</plurals>
<plurals name="select_album_n_songs_downloaded">
<plurals name="n_songs_to_be_downloaded">
<item quantity="one">%d utworów zaznaczonych do pobrania</item>
<item quantity="few">%d utwory zaznaczone do pobrania</item>
<item quantity="many">%d utworów zaznaczonych do pobrania</item>
<item quantity="other">%d utworów zaznaczonych do pobrania</item>
</plurals>
<string name="about.webpage">Odwiedź stronę internetową</string>
<plurals name="select_album_n_songs_unpinned">
<plurals name="n_songs_unpinned">
<item quantity="one">Odpięto %d utwór</item>
<item quantity="few">Odpięto %d utwory</item>
<item quantity="many">Odpięto %d utworów</item>
@ -446,13 +446,13 @@
<string name="list_view">Lista</string>
<string name="grid_view">Okładka</string>
<string name="settings.use_hw_offload_description">Spróbuj odtworzyć pliki multimedialne za pomocą układu dekodującego w telefonie. Może to zmniejszyć zużycie baterii!</string>
<plurals name="select_album_n_songs_added">
<plurals name="n_songs_added_to_end">
<item quantity="one">Dodano %d utwór na koniec kolejki odtwarzania</item>
<item quantity="few">Dodano %d utwory na koniec kolejki odtwarzania</item>
<item quantity="many">Dodano %d utworów na koniec kolejki odtwarzania</item>
<item quantity="other">Dodano %d utworów na koniec kolejki odtwarzania</item>
</plurals>
<plurals name="select_album_n_songs_play_next">
<plurals name="n_songs_added_after_current">
<item quantity="one">Wstawiono %d utwór po bieżącym utworze</item>
<item quantity="few">Wstawiono %d utwory po bieżącym utworze</item>
<item quantity="many">Wstawiono %d utworów po bieżącym utworze</item>

View File

@ -388,37 +388,37 @@
<string name="about.webpage">Visitar a página web</string>
<string name="about.report">Reportar um erro</string>
<string name="about.text"><b>Ultrasonic</b> é um cliente gratuito e open-source para Android de streaming de música para API de servidores compatíveis com Subsonic (version 1.7.0 ou maior).\n\nCom <b>Ultrasonic</b> você pode facilmente reproduzir online ou baixar música de seu computador doméstico para seu telefone Android usando um servidor de mídia compatível com Subsonic. O software do servidor Subsonic necessita uma configuração independente do Ultrasonic.\n\nPor padrão, o servidor Ultrasonic não é configurado. Uma vez que você configurou seu próprio servidor, altere a configuração no Ultrasonic para poder conectá-lo.</string>
<plurals name="select_album_n_songs">
<plurals name="n_songs">
<item quantity="one">%d música</item>
<item quantity="many">%d músicas</item>
<item quantity="other">%d músicas</item>
</plurals>
<plurals name="select_album_n_songs_pinned">
<plurals name="n_songs_pinned">
<item quantity="one">%d música selecionada para ser fixada</item>
<item quantity="many">%d músicas selecionadas para serem fixadas</item>
<item quantity="other">%d músicas selecionadas para serem fixadas</item>
</plurals>
<plurals name="select_album_n_songs_downloaded">
<plurals name="n_songs_to_be_downloaded">
<item quantity="one">%d música selecionada para ser baixada</item>
<item quantity="many">%d músicas selecionadas para serem baixadas</item>
<item quantity="other">%d músicas selecionadas para serem baixadas</item>
</plurals>
<plurals name="select_album_n_songs_unpinned">
<plurals name="n_songs_unpinned">
<item quantity="one">%d música desfixada</item>
<item quantity="many">%d músicas desfixadas</item>
<item quantity="other">%d músicas desfixadas</item>
</plurals>
<plurals name="select_album_n_songs_deleted">
<plurals name="n_songs_deleted">
<item quantity="one">%d música excluída</item>
<item quantity="many">%d músicas excluídas</item>
<item quantity="other">%d músicas excluídas</item>
</plurals>
<plurals name="select_album_n_songs_added">
<plurals name="n_songs_added_to_end">
<item quantity="one">%d música adicionada ao final da playlist</item>
<item quantity="many">%d músicas adicionadas ao final da playlist</item>
<item quantity="other">%d músicas adicionadas ao final da playlist</item>
</plurals>
<plurals name="select_album_n_songs_play_next">
<plurals name="n_songs_added_after_current">
<item quantity="one">%d música adicionada após a atual</item>
<item quantity="many">%d músicas adicionadas após a atual</item>
<item quantity="other">%d músicas adicionadas após a atual</item>

View File

@ -307,7 +307,7 @@
<string name="server_menu.move_down">Move down</string>
<string name="server_editor.authentication">Authentication</string>
<string name="server_editor.advanced">Configurações avançadas</string>
<plurals name="select_album_n_songs">
<plurals name="n_songs">
<item quantity="one">%d música</item>
<item quantity="many">%d músicas</item>
<item quantity="other">%d músicas</item>
@ -330,22 +330,22 @@
<string name="server_menu.demo">Servidor Demonstração</string>
<string name="about.webpage">Visitar a página web</string>
<string name="about.report">Reportar um erro</string>
<plurals name="select_album_n_songs_pinned">
<plurals name="n_songs_pinned">
<item quantity="one">%d música selecionada para ser fixada</item>
<item quantity="many">%d músicas selecionadas para serem fixadas</item>
<item quantity="other">%d músicas selecionadas para serem fixadas</item>
</plurals>
<plurals name="select_album_n_songs_unpinned">
<plurals name="n_songs_unpinned">
<item quantity="one">%d música desfixada</item>
<item quantity="many">%d músicas desfixadas</item>
<item quantity="other">%d músicas desfixadas</item>
</plurals>
<plurals name="select_album_n_songs_deleted">
<plurals name="n_songs_deleted">
<item quantity="one">%d música excluída</item>
<item quantity="many">%d músicas excluídas</item>
<item quantity="other">%d músicas excluídas</item>
</plurals>
<plurals name="select_album_n_songs_added">
<plurals name="n_songs_added_to_end">
<item quantity="one">%d música adicionada ao final da playlist</item>
<item quantity="many">%d músicas adicionadas ao final da playlist</item>
<item quantity="other">%d músicas adicionadas ao final da playlist</item>
@ -447,12 +447,12 @@
\nCom <b>Ultrasonic</b>, pode facilmente reproduzir online ou descarregar música do seu computador doméstico para o seu telefone Android usando um servidor de mídia compatível com Subsonic. O software do servidor Subsonic necessita uma configuração independente do Ultrasonic.
\n
\nPor padrão, o servidor Ultrasonic não é configurado. Uma vez que configurou o seu próprio servidor, altere a configuração no Ultrasonic para poder conectá-lo.</string>
<plurals name="select_album_n_songs_downloaded">
<plurals name="n_songs_to_be_downloaded">
<item quantity="one">%d música selecionada a ser descarregada</item>
<item quantity="many">%d músicas selecionadas para serem baixadas</item>
<item quantity="other">%d músicas selecionadas para serem baixadas</item>
</plurals>
<plurals name="select_album_n_songs_play_next">
<plurals name="n_songs_added_after_current">
<item quantity="one">%d música adicionada após a atual</item>
<item quantity="many">%d músicas adicionadas após a atual</item>
<item quantity="other">%d músicas adicionadas após a atual</item>

View File

@ -347,7 +347,7 @@
<string name="server_editor.disabled_feature">Одна или несколько функций были отключены, потому что сервер их не поддерживает.\nВы можете запустить этот тест снова в любое время.</string>
<string name="server_menu.demo">Демо-сервер</string>
<plurals name="select_album_n_songs">
<plurals name="n_songs">
<item quantity="one">%d песня</item>
<item quantity="few">%d песни</item>
<item quantity="many">%d песен</item>

View File

@ -368,25 +368,25 @@
\n通过使用 <b>Ultrasonic</b> 你可以轻松的从你的电脑上的 Subsonic 兼容服务端流式传输或者下载音乐。 Subsonic 服务端与 Ultrasonic 都需要额外的配置才可使用。
\n
\n默认情况下Ultrasonic 并未进行配置,当服务端配置完成后,请确保配置允许客户端连接到你的计算机。</string>
<plurals name="select_album_n_songs">
<plurals name="n_songs">
<item quantity="other">%d 首曲目</item>
</plurals>
<plurals name="select_album_n_songs_pinned">
<plurals name="n_songs_pinned">
<item quantity="other">已选择 %d 首歌曲进行固定</item>
</plurals>
<plurals name="select_album_n_songs_downloaded">
<plurals name="n_songs_to_be_downloaded">
<item quantity="other">已选择要下载 %d 首歌曲</item>
</plurals>
<plurals name="select_album_n_songs_unpinned">
<plurals name="n_songs_unpinned">
<item quantity="other">已选择 %d 首歌曲取消固定</item>
</plurals>
<plurals name="select_album_n_songs_deleted">
<plurals name="n_songs_deleted">
<item quantity="other">%d 首歌曲被删除</item>
</plurals>
<plurals name="select_album_n_songs_added">
<plurals name="n_songs_added_to_end">
<item quantity="other">已将 %d 首歌曲添加到播放队列的末尾</item>
</plurals>
<plurals name="select_album_n_songs_play_next">
<plurals name="n_songs_added_after_current">
<item quantity="other">在当前歌曲之后插入了 %d 首歌曲</item>
</plurals>
<!-- Subsonic api errors -->

View File

@ -402,31 +402,35 @@
<string name="about.webpage.url" translatable="false">https://ultrasonic.gitlab.io/</string>
<string name="about.report.url" translatable="false">https://gitlab.com/ultrasonic/ultrasonic/issues</string>
<plurals name="select_album_n_songs">
<plurals name="n_songs">
<item quantity="one">%d song</item>
<item quantity="other">%d songs</item>
</plurals>
<plurals name="select_album_n_songs_pinned">
<plurals name="n_songs_pinned">
<item quantity="one">%d song selected to be pinned</item>
<item quantity="other">%d songs selected to be pinned</item>
</plurals>
<plurals name="select_album_n_songs_downloaded">
<plurals name="n_songs_to_be_downloaded">
<item quantity="one">%d song selected to be downloaded</item>
<item quantity="other">%d songs selected to be downloaded</item>
</plurals>
<plurals name="select_album_n_songs_unpinned">
<plurals name="n_songs_unpinned">
<item quantity="one">%d song unpinned</item>
<item quantity="other">%d songs unpinned</item>
</plurals>
<plurals name="select_album_n_songs_deleted">
<plurals name="n_songs_deleted">
<item quantity="one">%d song deleted</item>
<item quantity="other">%d songs deleted</item>
</plurals>
<plurals name="select_album_n_songs_added">
<plurals name="n_songs_added_play_now">
<item quantity="one">%d song added to the play queue</item>
<item quantity="other">%d songs added to the play queue</item>
</plurals>
<plurals name="n_songs_added_to_end">
<item quantity="one">%d song added to the end of play queue</item>
<item quantity="other">%d songs added to the end of play queue</item>
</plurals>
<plurals name="select_album_n_songs_play_next">
<plurals name="n_songs_added_after_current">
<item quantity="one">%d song inserted after current song</item>
<item quantity="other">%d songs inserted after current song</item>
</plurals>