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: script:
- curl --silent "https://gitlab.com/gitlab-org/incubation-engineering/mobile-devops/download-secure-files/-/raw/main/installer" | bash - 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 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: rules:
# Run when releasing a new tag # Run when releasing a new tag
- if: $CI_COMMIT_TAG && $CI_PROJECT_ID == $ROOT_PROJECT_ID - 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. // Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript { buildscript {
apply from: 'gradle/versions.gradle' apply from: 'gradle/versions.gradle'
@ -10,6 +12,7 @@ buildscript {
repositories { repositories {
google() google()
mavenCentral() mavenCentral()
gradlePluginPortal()
maven { url "https://plugins.gradle.org/m2/" } maven { url "https://plugins.gradle.org/m2/" }
} }
dependencies { dependencies {
@ -26,21 +29,29 @@ allprojects {
buildscript { buildscript {
repositories { repositories {
mavenCentral() mavenCentral()
gradlePluginPortal()
google() google()
} }
} }
repositories { repositories {
mavenCentral() mavenCentral()
gradlePluginPortal()
google() google()
} }
// Set Kotlin JVM target to the same for all subprojects // Set Kotlin JVM target to the same for all subprojects
tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).all { tasks.withType(KotlinCompile).configureEach {
kotlinOptions { kotlinOptions {
jvmTarget = "17" jvmTarget = "17"
} }
} }
tasks.withType(JavaCompile).tap {
configureEach {
options.compilerArgs.add("-Xlint:deprecation")
}
}
} }
wrapper { wrapper {

View File

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

View File

@ -1,3 +1,7 @@
plugins {
alias libs.plugins.ksp
}
apply from: bootstrap.kotlinModule apply from: bootstrap.kotlinModule
dependencies { 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" gradle = "8.1.1"
navigation = "2.6.0" navigation = "2.6.0"
gradlePlugin = "8.0.2" gradlePlugin = "8.1.0"
androidxcore = "1.10.1" androidxcore = "1.10.1"
ktlint = "0.43.2" ktlint = "0.43.2"
ktlintGradle = "11.4.2" ktlintGradle = "11.5.0"
detekt = "1.23.0" detekt = "1.23.0"
preferences = "1.2.0" preferences = "1.2.1"
media3 = "1.0.2" media3 = "1.1.0"
androidSupport = "1.6.0" androidSupport = "1.6.0"
materialDesign = "1.9.0" materialDesign = "1.9.0"
@ -17,7 +17,8 @@ constraintLayout = "2.1.4"
multidex = "2.0.1" multidex = "2.0.1"
room = "2.5.2" room = "2.5.2"
kotlin = "1.8.22" kotlin = "1.8.22"
kotlinxCoroutines = "1.7.1" ksp = "1.8.22-1.0.11"
kotlinxCoroutines = "1.7.3"
viewModelKtx = "2.6.1" viewModelKtx = "2.6.1"
swipeRefresh = "1.1.0" 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 ## KEEP ON 2.13 branch (https://github.com/FasterXML/jackson-databind/issues/3658#issuecomment-1312633064) for compatibility with API 24
jackson = "2.13.5" jackson = "2.13.5"
okhttp = "4.11.0" okhttp = "4.11.0"
koin = "3.3.2" koin = "3.4.3"
picasso = "2.8" picasso = "2.8"
junit4 = "4.13.2" junit4 = "4.13.2"
junit5 = "5.9.3" junit5 = "5.10.0"
mockito = "5.4.0" mockito = "5.4.0"
mockitoKotlin = "5.0.0" mockitoKotlin = "5.0.0"
kluent = "1.73" kluent = "1.73"
@ -100,3 +101,6 @@ kluentAndroid = { module = "org.amshove.kluent:kluent-android", versio
mockWebServer = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" } mockWebServer = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" }
apacheCodecs = { module = "commons-codec:commons-codec", version.ref = "apacheCodecs" } apacheCodecs = { module = "commons-codec:commons-codec", version.ref = "apacheCodecs" }
robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } 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 distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists 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 networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists

View File

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

View File

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

5
gradlew vendored
View File

@ -130,10 +130,13 @@ location of your Java installation."
fi fi
else else
JAVACMD=java 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 Please set the JAVA_HOME variable in your environment to match the
location of your Java installation." location of your Java installation."
fi
fi fi
# Increase the maximum file descriptors if we can. # 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: 'com.android.application'
apply plugin: 'org.jetbrains.kotlin.android' apply plugin: 'org.jetbrains.kotlin.android'
apply plugin: 'org.jetbrains.kotlin.kapt'
apply plugin: "androidx.navigation.safeargs.kotlin" apply plugin: "androidx.navigation.safeargs.kotlin"
apply from: "../gradle_scripts/code_quality.gradle" apply from: "../gradle_scripts/code_quality.gradle"
@ -9,8 +12,8 @@ android {
defaultConfig { defaultConfig {
applicationId "org.moire.ultrasonic" applicationId "org.moire.ultrasonic"
versionCode 126 versionCode 128
versionName "4.6.3" versionName "4.7.0"
minSdkVersion versions.minSdk minSdkVersion versions.minSdk
targetSdkVersion versions.targetSdk targetSdkVersion versions.targetSdk
@ -64,20 +67,20 @@ android {
targetCompatibility JavaVersion.VERSION_17 targetCompatibility JavaVersion.VERSION_17
} }
kapt { ksp {
arguments { arg("room.schemaLocation", "$rootDir/ultrasonic/schemas")
arg("room.schemaLocation", "$rootDir/ultrasonic/schemas".toString())
}
} }
lint { lint {
baseline = file("lint-baseline.xml") baseline = file("lint-baseline.xml")
abortOnError true abortOnError true
warningsAsErrors true warningsAsErrors true
disable 'IconMissingDensityFolder', 'VectorPath'
ignore 'MissingTranslation', 'UnusedQuantity', 'MissingQuantity'
warning 'ImpliedQuantity' warning 'ImpliedQuantity'
disable 'IconMissingDensityFolder', 'VectorPath'
disable 'MissingTranslation', 'UnusedQuantity', 'MissingQuantity'
disable 'ObsoleteLintCustomCheck' disable 'ObsoleteLintCustomCheck'
// We manage dependencies on Gitlab with RenovateBot
disable 'GradleDependency'
textReport true textReport true
checkDependencies true checkDependencies true
} }
@ -85,7 +88,7 @@ android {
} }
tasks.withType(Test) { tasks.withType(Test).configureEach {
useJUnitPlatform() useJUnitPlatform()
} }
@ -129,7 +132,7 @@ dependencies {
implementation libs.rxAndroid implementation libs.rxAndroid
implementation libs.multiType implementation libs.multiType
kapt libs.room ksp libs.room
testImplementation libs.kotlinReflect testImplementation libs.kotlinReflect
testImplementation libs.junit testImplementation libs.junit
@ -141,6 +144,5 @@ dependencies {
testImplementation libs.robolectric testImplementation libs.robolectric
implementation libs.timber implementation libs.timber
} }

View File

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

View File

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

View File

@ -56,7 +56,7 @@ class BookmarksFragment : TrackCollectionFragment() {
super.setupButtons(view) super.setupButtons(view)
playNowButton!!.setOnClickListener { 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.Artist
import org.moire.ultrasonic.domain.GenericEntry import org.moire.ultrasonic.domain.GenericEntry
import org.moire.ultrasonic.domain.Identifiable import org.moire.ultrasonic.domain.Identifiable
import org.moire.ultrasonic.service.MediaPlayerManager
import org.moire.ultrasonic.service.RxBus import org.moire.ultrasonic.service.RxBus
import org.moire.ultrasonic.service.plusAssign import org.moire.ultrasonic.service.plusAssign
import org.moire.ultrasonic.subsonic.DownloadAction import org.moire.ultrasonic.subsonic.DownloadAction
@ -133,27 +134,24 @@ abstract class EntryListFragment<T : GenericEntry> : MultiListFragment<T>() {
downloadHandler.fetchTracksAndAddToController( downloadHandler.fetchTracksAndAddToController(
fragment, fragment,
item.id, item.id,
append = false, insertionMode = MediaPlayerManager.InsertionMode.CLEAR,
autoPlay = true, autoPlay = true,
playNext = false,
isArtist = isArtist isArtist = isArtist
) )
R.id.menu_play_next -> R.id.menu_play_next ->
downloadHandler.fetchTracksAndAddToController( downloadHandler.fetchTracksAndAddToController(
fragment, fragment,
item.id, item.id,
append = false, insertionMode = MediaPlayerManager.InsertionMode.AFTER_CURRENT,
autoPlay = true, autoPlay = true,
playNext = true,
isArtist = isArtist isArtist = isArtist
) )
R.id.menu_play_last -> R.id.menu_play_last ->
downloadHandler.fetchTracksAndAddToController( downloadHandler.fetchTracksAndAddToController(
fragment, fragment,
item.id, item.id,
append = true, insertionMode = MediaPlayerManager.InsertionMode.APPEND,
autoPlay = false, autoPlay = false,
playNext = false,
isArtist = isArtist isArtist = isArtist
) )
R.id.menu_pin -> R.id.menu_pin ->

View File

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

View File

@ -159,7 +159,7 @@ open class TrackCollectionFragment(
// Change the buttons if the status of any selected track changes // Change the buttons if the status of any selected track changes
rxBusSubscription += RxBus.trackDownloadStateObservable.subscribe { rxBusSubscription += RxBus.trackDownloadStateObservable.subscribe {
if (it.progress != null) return@subscribe if (it.progress != null) return@subscribe
val selectedSongs = getSelectedSongs() val selectedSongs = getSelectedTracks()
if (!selectedSongs.any { song -> song.id == it.id }) return@subscribe if (!selectedSongs.any { song -> song.id == it.id }) return@subscribe
triggerButtonUpdate(selectedSongs) triggerButtonUpdate(selectedSongs)
} }
@ -211,23 +211,15 @@ open class TrackCollectionFragment(
} }
playNowButton?.setOnClickListener { playNowButton?.setOnClickListener {
playNow(false) playNow(MediaPlayerManager.InsertionMode.CLEAR, toast = true)
} }
playNextButton?.setOnClickListener { playNextButton?.setOnClickListener {
downloadHandler.addTracksToMediaController( playNow(MediaPlayerManager.InsertionMode.AFTER_CURRENT, toast = true)
songs = getSelectedSongs(),
append = true,
playNext = true,
autoPlay = false,
shuffle = false,
playlistName = navArgs.playlistName,
this@TrackCollectionFragment
)
} }
playLastButton!!.setOnClickListener { playLastButton!!.setOnClickListener {
playNow(true) playNow(MediaPlayerManager.InsertionMode.APPEND, toast = true)
} }
pinButton?.setOnClickListener { pinButton?.setOnClickListener {
@ -291,7 +283,7 @@ open class TrackCollectionFragment(
return true return true
} else if (item.itemId == R.id.menu_item_share) { } else if (item.itemId == R.id.menu_item_share) {
shareHandler.createShare( shareHandler.createShare(
this@TrackCollectionFragment, getSelectedSongs(), this@TrackCollectionFragment, getSelectedTracks(),
refreshListView, cancellationToken!!, refreshListView, cancellationToken!!,
navArgs.id navArgs.id
) )
@ -308,20 +300,37 @@ open class TrackCollectionFragment(
} }
private fun playNow( private fun playNow(
append: Boolean, insertionMode: MediaPlayerManager.InsertionMode,
selectedSongs: List<Track> = getSelectedSongs() selectedTracks: List<Track> = getSelectedTracks(),
toast: Boolean = false
) { ) {
if (selectedSongs.isNotEmpty()) { if (selectedTracks.isNotEmpty()) {
downloadHandler.addTracksToMediaController( downloadHandler.addTracksToMediaController(
songs = selectedSongs, songs = selectedTracks,
append = append, insertionMode = insertionMode,
playNext = false, autoPlay = (insertionMode == MediaPlayerManager.InsertionMode.CLEAR),
autoPlay = !append,
playlistName = null, playlistName = null,
fragment = this fragment = this
) )
} else { } 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 var hasSubFolders = false
for (item in viewAdapter.getCurrentList()) { for (item in viewAdapter.getCurrentList()) {
@ -355,18 +367,16 @@ open class TrackCollectionFragment(
downloadHandler.fetchTracksAndAddToController( downloadHandler.fetchTracksAndAddToController(
fragment = this, fragment = this,
id = navArgs.id!!, id = navArgs.id!!,
append = append, insertionMode = insertionMode,
autoPlay = !append, autoPlay = (insertionMode != MediaPlayerManager.InsertionMode.APPEND),
shuffle = shuffle, shuffle = shuffle,
playNext = false,
isArtist = isArtist isArtist = isArtist
) )
} else { } else {
downloadHandler.addTracksToMediaController( downloadHandler.addTracksToMediaController(
songs = getAllSongs(), songs = getAllSongs(),
append = append, insertionMode = insertionMode,
playNext = false, autoPlay = (insertionMode != MediaPlayerManager.InsertionMode.APPEND),
autoPlay = !append,
shuffle = shuffle, shuffle = shuffle,
playlistName = navArgs.playlistName, playlistName = navArgs.playlistName,
fragment = this fragment = this
@ -397,7 +407,7 @@ open class TrackCollectionFragment(
} }
@Synchronized @Synchronized
fun triggerButtonUpdate(selection: List<Track> = getSelectedSongs()) { fun triggerButtonUpdate(selection: List<Track> = getSelectedTracks()) {
listModel.calculateButtonState(selection, ::updateButtonState) listModel.calculateButtonState(selection, ::updateButtonState)
} }
@ -414,14 +424,14 @@ open class TrackCollectionFragment(
playNowButton?.isVisible = show.all playNowButton?.isVisible = show.all
playNextButton?.isVisible = show.all && multipleSelection playNextButton?.isVisible = show.all && multipleSelection
playLastButton?.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 unpinButton?.isVisible = show.all && show.unpin
downloadButton?.isVisible = show.all && show.download && !isOffline() downloadButton?.isVisible = show.all && show.download && !isOffline()
deleteButton?.isVisible = show.all && show.delete 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 var songs = tracks
if (songs.isEmpty()) { 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( downloadHandler.justDownload(
action = DownloadAction.DELETE, action = DownloadAction.DELETE,
fragment = this, 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( downloadHandler.justDownload(
action = DownloadAction.UNPIN, action = DownloadAction.UNPIN,
fragment = this, fragment = this,
@ -502,10 +512,7 @@ open class TrackCollectionFragment(
val playAll = navArgs.autoPlay val playAll = navArgs.autoPlay
if (playAll && songCount > 0) { if (playAll && songCount > 0) {
playAll( playAll(navArgs.shuffle, MediaPlayerManager.InsertionMode.CLEAR)
navArgs.shuffle,
false
)
} }
listModel.currentListIsSortable = true listModel.currentListIsSortable = true
@ -513,7 +520,7 @@ open class TrackCollectionFragment(
Timber.i("Processed list") 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. // Walk through selected set and get the Entries based on the saved ids.
return viewAdapter.getCurrentList().mapNotNull { return viewAdapter.getCurrentList().mapNotNull {
if (it is Track && viewAdapter.isSelected(it.longId)) if (it is Track && viewAdapter.isSelected(it.longId))
@ -608,20 +615,13 @@ open class TrackCollectionFragment(
when (menuItem.itemId) { when (menuItem.itemId) {
R.id.song_menu_play_now -> { R.id.song_menu_play_now -> {
playNow(false, songs) playNow(MediaPlayerManager.InsertionMode.CLEAR, songs, true)
} }
R.id.song_menu_play_next -> { R.id.song_menu_play_next -> {
downloadHandler.addTracksToMediaController( playNow(MediaPlayerManager.InsertionMode.AFTER_CURRENT, songs, true)
songs = songs,
append = true,
playNext = true,
autoPlay = false,
playlistName = navArgs.playlistName,
fragment = this@TrackCollectionFragment
)
} }
R.id.song_menu_play_last -> { R.id.song_menu_play_last -> {
playNow(true, songs) playNow(MediaPlayerManager.InsertionMode.APPEND, songs, true)
} }
R.id.song_menu_pin -> { R.id.song_menu_pin -> {
downloadBackground(true, songs) 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.api.subsonic.ApiNotSupportedException
import org.moire.ultrasonic.domain.Share import org.moire.ultrasonic.domain.Share
import org.moire.ultrasonic.fragment.FragmentTitle import org.moire.ultrasonic.fragment.FragmentTitle
import org.moire.ultrasonic.service.MediaPlayerManager
import org.moire.ultrasonic.service.MusicServiceFactory import org.moire.ultrasonic.service.MusicServiceFactory
import org.moire.ultrasonic.service.OfflineException import org.moire.ultrasonic.service.OfflineException
import org.moire.ultrasonic.subsonic.DownloadAction import org.moire.ultrasonic.subsonic.DownloadAction
@ -165,10 +166,9 @@ class SharesFragment : Fragment(), KoinComponent {
this, this,
share.id, share.id,
share.name, share.name,
append = false, insertionMode = MediaPlayerManager.InsertionMode.CLEAR,
autoPlay = true, autoPlay = true,
shuffle = false, shuffle = false
playNext = false,
) )
} }
R.id.share_menu_play_shuffled -> { R.id.share_menu_play_shuffled -> {
@ -176,10 +176,9 @@ class SharesFragment : Fragment(), KoinComponent {
this, this,
share.id, share.id,
share.name, share.name,
append = false, insertionMode = MediaPlayerManager.InsertionMode.CLEAR,
autoPlay = true, autoPlay = true,
shuffle = true, shuffle = true,
playNext = false,
) )
} }
R.id.share_menu_delete -> { R.id.share_menu_delete -> {

View File

@ -11,7 +11,7 @@ import android.annotation.SuppressLint
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.net.Uri 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.ListenableFuture
import com.google.common.util.concurrent.ListeningExecutorService import com.google.common.util.concurrent.ListeningExecutorService
import com.google.common.util.concurrent.MoreExecutors import com.google.common.util.concurrent.MoreExecutors

View File

@ -7,16 +7,16 @@
package org.moire.ultrasonic.playback package org.moire.ultrasonic.playback
import android.annotation.SuppressLint
import android.os.Bundle import android.os.Bundle
import androidx.media3.common.HeartRating import androidx.media3.common.HeartRating
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata import androidx.media3.common.MediaMetadata.MEDIA_TYPE_FOLDER_ALBUMS
import androidx.media3.common.MediaMetadata.FOLDER_TYPE_ALBUMS import androidx.media3.common.MediaMetadata.MEDIA_TYPE_FOLDER_ARTISTS
import androidx.media3.common.MediaMetadata.FOLDER_TYPE_ARTISTS import androidx.media3.common.MediaMetadata.MEDIA_TYPE_FOLDER_MIXED
import androidx.media3.common.MediaMetadata.FOLDER_TYPE_MIXED import androidx.media3.common.MediaMetadata.MEDIA_TYPE_FOLDER_PLAYLISTS
import androidx.media3.common.MediaMetadata.FOLDER_TYPE_PLAYLISTS import androidx.media3.common.MediaMetadata.MEDIA_TYPE_MIXED
import androidx.media3.common.MediaMetadata.FOLDER_TYPE_TITLES import androidx.media3.common.MediaMetadata.MEDIA_TYPE_PLAYLIST
import androidx.media3.common.Player
import androidx.media3.common.Rating import androidx.media3.common.Rating
import androidx.media3.common.StarRating import androidx.media3.common.StarRating
import androidx.media3.session.CommandButton 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.SearchCriteria
import org.moire.ultrasonic.domain.SearchResult import org.moire.ultrasonic.domain.SearchResult
import org.moire.ultrasonic.domain.Track import org.moire.ultrasonic.domain.Track
import org.moire.ultrasonic.service.MediaPlayerManager
import org.moire.ultrasonic.service.MusicServiceFactory import org.moire.ultrasonic.service.MusicServiceFactory
import org.moire.ultrasonic.service.RatingManager import org.moire.ultrasonic.service.RatingManager
import org.moire.ultrasonic.util.Util import org.moire.ultrasonic.util.Util
@ -96,11 +95,8 @@ const val PLAY_COMMAND = "play "
* MediaBrowserService implementation for e.g. Android Auto * MediaBrowserService implementation for e.g. Android Auto
*/ */
@Suppress("TooManyFunctions", "LargeClass", "UnusedPrivateMember") @Suppress("TooManyFunctions", "LargeClass", "UnusedPrivateMember")
@SuppressLint("UnsafeOptInUsageError") class AutoMediaBrowserCallback : MediaLibraryService.MediaLibrarySession.Callback, KoinComponent {
class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) :
MediaLibraryService.MediaLibrarySession.Callback, KoinComponent {
private val mediaPlayerManager by inject<MediaPlayerManager>()
private val activeServerProvider: ActiveServerProvider by inject() private val activeServerProvider: ActiveServerProvider by inject()
private val serviceJob = SupervisorJob() private val serviceJob = SupervisorJob()
@ -116,22 +112,59 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) :
private val isOffline get() = ActiveServerProvider.isOffline() private val isOffline get() = ActiveServerProvider.isOffline()
private val musicFolderId get() = activeServerProvider.getActiveServer().musicFolderId private val musicFolderId get() = activeServerProvider.getActiveServer().musicFolderId
private var customCommands: List<CommandButton> private val placeholderButton = getPlaceholderButton()
internal var customLayout = ImmutableList.of<CommandButton>()
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 { init {
customCommands = val shuffleCommand = SessionCommand(PlaybackService.CUSTOM_COMMAND_SHUFFLE, Bundle.EMPTY)
listOf( shuffleButton = getShuffleCommandButton(shuffleCommand)
// This button is used for an unstarred track, and its action will star the track
getHeartCommandButton( val repeatCommand = SessionCommand(PlaybackService.CUSTOM_COMMAND_REPEAT_MODE, Bundle.EMPTY)
SessionCommand(PlaybackService.CUSTOM_COMMAND_TOGGLE_HEART_ON, Bundle.EMPTY) repeatOffButton = getRepeatModeButton(repeatCommand, Player.REPEAT_MODE_OFF)
), repeatOneButton = getRepeatModeButton(repeatCommand, Player.REPEAT_MODE_ONE)
// This button is used for an starred track, and its action will unstar the track repeatAllButton = getRepeatModeButton(repeatCommand, Player.REPEAT_MODE_ALL)
getHeartCommandButton(
SessionCommand(PlaybackService.CUSTOM_COMMAND_TOGGLE_HEART_OFF, Bundle.EMPTY) allCustomCommands = listOf(
) heartButtonToggleOn,
) heartButtonToggleOff,
customLayout = ImmutableList.of(customCommands[0]) shuffleButton,
repeatOffButton,
repeatOneButton,
repeatAllButton
)
defaultCustomCommands = listOf(heartButtonToggleOn, shuffleButton, repeatOffButton)
} }
/** /**
@ -175,8 +208,8 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) :
"Root Folder", "Root Folder",
MEDIA_ROOT_ID, MEDIA_ROOT_ID,
isPlayable = false, isPlayable = false,
folderType = FOLDER_TYPE_MIXED, isBrowsable = true,
mediaType = MediaMetadata.MEDIA_TYPE_FOLDER_MIXED mediaType = MEDIA_TYPE_FOLDER_MIXED
), ),
params params
) )
@ -188,14 +221,17 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) :
controller: MediaSession.ControllerInfo controller: MediaSession.ControllerInfo
): MediaSession.ConnectionResult { ): MediaSession.ConnectionResult {
Timber.i("onConnect") Timber.i("onConnect")
val connectionResult = super.onConnect(session, controller) val connectionResult = super.onConnect(session, controller)
val availableSessionCommands = connectionResult.availableSessionCommands.buildUpon() val availableSessionCommands = connectionResult.availableSessionCommands.buildUpon()
for (commandButton in customCommands) { for (commandButton in allCustomCommands) {
// Add custom command to available session commands. // Add custom command to available session commands.
commandButton.sessionCommand?.let { availableSessionCommands.add(it) } commandButton.sessionCommand?.let { availableSessionCommands.add(it) }
} }
session.player.repeatMode = Player.REPEAT_MODE_ALL
return MediaSession.ConnectionResult.accept( return MediaSession.ConnectionResult.accept(
availableSessionCommands.build(), availableSessionCommands.build(),
connectionResult.availablePlayerCommands connectionResult.availablePlayerCommands
@ -203,26 +239,72 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) :
} }
override fun onPostConnect(session: MediaSession, controller: MediaSession.ControllerInfo) { 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) // Let Media3 controller (for instance the MediaNotificationProvider)
// know about the custom layout right after it connected. // 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 { private fun getHeartCommandButton(sessionCommand: SessionCommand, willHeart: Boolean) =
val willHeart = CommandButton.Builder()
(sessionCommand.customAction == PlaybackService.CUSTOM_COMMAND_TOGGLE_HEART_ON) .setDisplayName(
return CommandButton.Builder() if (willHeart)
.setDisplayName("Love") "Love"
else
"Dislike"
)
.setIconResId( .setIconResId(
if (willHeart) R.drawable.ic_star_hollow if (willHeart)
else R.drawable.ic_star_full 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) .setSessionCommand(sessionCommand)
.setEnabled(true) .setEnabled(true)
.build() .build()
}
override fun onGetItem( override fun onGetItem(
session: MediaLibraryService.MediaLibrarySession, session: MediaLibraryService.MediaLibrarySession,
@ -266,18 +348,32 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) :
customCommand: SessionCommand, customCommand: SessionCommand,
args: Bundle args: Bundle
): ListenableFuture<SessionResult> { ): ListenableFuture<SessionResult> {
Timber.i("onCustomCommand") Timber.i("onCustomCommand %s", customCommand.customAction)
var customCommandFuture: ListenableFuture<SessionResult>? = null var customCommandFuture: ListenableFuture<SessionResult>? = null
when (customCommand.customAction) { when (customCommand.customAction) {
PlaybackService.CUSTOM_COMMAND_TOGGLE_HEART_ON -> { PlaybackService.CUSTOM_COMMAND_TOGGLE_HEART_ON -> {
customCommandFuture = onSetRating(session, controller, HeartRating(true)) customCommandFuture = onSetRating(session, controller, HeartRating(true))
updateCustomHeartButton(session, true) updateCustomHeartButton(session, isHeart = true)
} }
PlaybackService.CUSTOM_COMMAND_TOGGLE_HEART_OFF -> { PlaybackService.CUSTOM_COMMAND_TOGGLE_HEART_OFF -> {
customCommandFuture = onSetRating(session, controller, HeartRating(false)) 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 -> { else -> {
Timber.d( Timber.d(
"CustomCommand not recognized %s with extra %s", "CustomCommand not recognized %s with extra %s",
@ -286,16 +382,23 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) :
) )
} }
} }
if (customCommandFuture != null)
return customCommandFuture return customCommandFuture
return super.onCustomCommand(session, controller, customCommand, args) ?: super.onCustomCommand(
session,
controller,
customCommand,
args
)
} }
override fun onSetRating( override fun onSetRating(
session: MediaSession, session: MediaSession,
controller: MediaSession.ControllerInfo, controller: MediaSession.ControllerInfo,
rating: Rating rating: Rating
): ListenableFuture<SessionResult> { ): ListenableFuture<SessionResult> {
val mediaItem = session.player.currentMediaItem val mediaItem = session.player.currentMediaItem
if (mediaItem != null) { if (mediaItem != null) {
if (rating is HeartRating) { if (rating is HeartRating) {
mediaItem.toTrack().starred = rating.isHeart mediaItem.toTrack().starred = rating.isHeart
@ -309,6 +412,7 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) :
rating rating
) )
} }
return super.onSetRating(session, controller, rating) return super.onSetRating(session, controller, rating)
} }
@ -378,6 +482,8 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) :
private fun onAddLegacyAutoItems( private fun onAddLegacyAutoItems(
mediaItems: MutableList<MediaItem> mediaItems: MutableList<MediaItem>
): ListenableFuture<List<MediaItem>> { ): ListenableFuture<List<MediaItem>> {
Timber.i("onAddLegacyAutoItems %s", mediaItems.first().mediaId)
val mediaIdParts = mediaItems.first().mediaId.split('|') val mediaIdParts = mediaItems.first().mediaId.split('|')
val tracks = when (mediaIdParts.first()) { val tracks = when (mediaIdParts.first()) {
@ -385,10 +491,12 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) :
MEDIA_PLAYLIST_SONG_ITEM -> playPlaylistSong( MEDIA_PLAYLIST_SONG_ITEM -> playPlaylistSong(
mediaIdParts[1], mediaIdParts[2], mediaIdParts[3] mediaIdParts[1], mediaIdParts[2], mediaIdParts[3]
) )
MEDIA_ALBUM_ITEM -> playAlbum(mediaIdParts[1], mediaIdParts[2]) MEDIA_ALBUM_ITEM -> playAlbum(mediaIdParts[1], mediaIdParts[2])
MEDIA_ALBUM_SONG_ITEM -> playAlbumSong( MEDIA_ALBUM_SONG_ITEM -> playAlbumSong(
mediaIdParts[1], mediaIdParts[2], mediaIdParts[3] mediaIdParts[1], mediaIdParts[2], mediaIdParts[3]
) )
MEDIA_SONG_STARRED_ID -> playStarredSongs() MEDIA_SONG_STARRED_ID -> playStarredSongs()
MEDIA_SONG_STARRED_ITEM -> playStarredSong(mediaIdParts[1]) MEDIA_SONG_STARRED_ITEM -> playStarredSong(mediaIdParts[1])
MEDIA_SONG_RANDOM_ID -> playRandomSongs() MEDIA_SONG_RANDOM_ID -> playRandomSongs()
@ -400,57 +508,59 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) :
MEDIA_PODCAST_EPISODE_ITEM -> playPodcastEpisode( MEDIA_PODCAST_EPISODE_ITEM -> playPodcastEpisode(
mediaIdParts[1], mediaIdParts[2] mediaIdParts[1], mediaIdParts[2]
) )
MEDIA_SEARCH_SONG_ITEM -> playSearch(mediaIdParts[1]) MEDIA_SEARCH_SONG_ITEM -> playSearch(mediaIdParts[1])
else -> null else -> null
} }
if (tracks != null) { return tracks
return Futures.immediateFuture( ?.let {
tracks.map { track -> track.toMediaItem() } Futures.immediateFuture(
.toMutableList() it.map { track -> track.toMediaItem() }
) .toMutableList()
} )
}
// Fallback to the original list ?: Futures.immediateFuture(mediaItems)
return Futures.immediateFuture(mediaItems)
} }
@Suppress("ReturnCount", "ComplexMethod") @Suppress("ReturnCount", "ComplexMethod")
fun onLoadChildren( private fun onLoadChildren(
parentId: String, parentId: String,
): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> { ): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> {
Timber.d("AutoMediaBrowserService onLoadChildren called. ParentId: %s", parentId) Timber.d("AutoMediaBrowserService onLoadChildren called. ParentId: %s", parentId)
val parentIdParts = parentId.split('|') val parentIdParts = parentId.split('|')
when (parentIdParts.first()) { return when (parentIdParts.first()) {
MEDIA_ROOT_ID -> return getRootItems() MEDIA_ROOT_ID -> getRootItems()
MEDIA_LIBRARY_ID -> return getLibrary() MEDIA_LIBRARY_ID -> getLibrary()
MEDIA_ARTIST_ID -> return getArtists() MEDIA_ARTIST_ID -> getArtists()
MEDIA_ARTIST_SECTION -> return getArtists(parentIdParts[1]) MEDIA_ARTIST_SECTION -> getArtists(parentIdParts[1])
MEDIA_ALBUM_ID -> return getAlbums(AlbumListType.SORTED_BY_NAME) MEDIA_ALBUM_ID -> getAlbums(AlbumListType.SORTED_BY_NAME)
MEDIA_ALBUM_PAGE_ID -> return getAlbums( MEDIA_ALBUM_PAGE_ID -> getAlbums(
AlbumListType.fromName(parentIdParts[1]), parentIdParts[2].toInt() AlbumListType.fromName(parentIdParts[1]), parentIdParts[2].toInt()
) )
MEDIA_PLAYLIST_ID -> return getPlaylists()
MEDIA_ALBUM_FREQUENT_ID -> return getAlbums(AlbumListType.FREQUENT) MEDIA_PLAYLIST_ID -> getPlaylists()
MEDIA_ALBUM_NEWEST_ID -> return getAlbums(AlbumListType.NEWEST) MEDIA_ALBUM_FREQUENT_ID -> getAlbums(AlbumListType.FREQUENT)
MEDIA_ALBUM_RECENT_ID -> return getAlbums(AlbumListType.RECENT) MEDIA_ALBUM_NEWEST_ID -> getAlbums(AlbumListType.NEWEST)
MEDIA_ALBUM_RANDOM_ID -> return getAlbums(AlbumListType.RANDOM) MEDIA_ALBUM_RECENT_ID -> getAlbums(AlbumListType.RECENT)
MEDIA_ALBUM_STARRED_ID -> return getAlbums(AlbumListType.STARRED) MEDIA_ALBUM_RANDOM_ID -> getAlbums(AlbumListType.RANDOM)
MEDIA_SONG_RANDOM_ID -> return getRandomSongs() MEDIA_ALBUM_STARRED_ID -> getAlbums(AlbumListType.STARRED)
MEDIA_SONG_STARRED_ID -> return getStarredSongs() MEDIA_SONG_RANDOM_ID -> getRandomSongs()
MEDIA_SHARE_ID -> return getShares() MEDIA_SONG_STARRED_ID -> getStarredSongs()
MEDIA_BOOKMARK_ID -> return getBookmarks() MEDIA_SHARE_ID -> getShares()
MEDIA_PODCAST_ID -> return getPodcasts() MEDIA_BOOKMARK_ID -> getBookmarks()
MEDIA_PLAYLIST_ITEM -> return getPlaylist(parentIdParts[1], parentIdParts[2]) MEDIA_PODCAST_ID -> getPodcasts()
MEDIA_ARTIST_ITEM -> return getAlbumsForArtist( MEDIA_PLAYLIST_ITEM -> getPlaylist(parentIdParts[1], parentIdParts[2])
MEDIA_ARTIST_ITEM -> getAlbumsForArtist(
parentIdParts[1], parentIdParts[2] parentIdParts[1], parentIdParts[2]
) )
MEDIA_ALBUM_ITEM -> return getSongsForAlbum(parentIdParts[1], parentIdParts[2])
MEDIA_SHARE_ITEM -> return getSongsForShare(parentIdParts[1]) MEDIA_ALBUM_ITEM -> getSongsForAlbum(parentIdParts[1], parentIdParts[2])
MEDIA_PODCAST_ITEM -> return getPodcastEpisodes(parentIdParts[1]) MEDIA_SHARE_ITEM -> getSongsForShare(parentIdParts[1])
else -> return Futures.immediateFuture(LibraryResult.ofItemList(listOf(), null)) MEDIA_PODCAST_ITEM -> getPodcastEpisodes(parentIdParts[1])
else -> Futures.immediateFuture(LibraryResult.ofItemList(listOf(), null))
} }
} }
@ -483,8 +593,7 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) :
mediaItems.add( mediaItems.add(
album.title ?: "", album.title ?: "",
listOf(MEDIA_ALBUM_ITEM, album.id, album.name) listOf(MEDIA_ALBUM_ITEM, album.id, album.name)
.joinToString("|"), .joinToString("|")
FOLDER_TYPE_ALBUMS
) )
} }
@ -534,10 +643,12 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) :
MEDIA_PLAYLIST_SONG_ITEM -> playPlaylistSong( MEDIA_PLAYLIST_SONG_ITEM -> playPlaylistSong(
mediaIdParts[1], mediaIdParts[2], mediaIdParts[3] mediaIdParts[1], mediaIdParts[2], mediaIdParts[3]
) )
MEDIA_ALBUM_ITEM -> playAlbum(mediaIdParts[1], mediaIdParts[2]) MEDIA_ALBUM_ITEM -> playAlbum(mediaIdParts[1], mediaIdParts[2])
MEDIA_ALBUM_SONG_ITEM -> playAlbumSong( MEDIA_ALBUM_SONG_ITEM -> playAlbumSong(
mediaIdParts[1], mediaIdParts[2], mediaIdParts[3] mediaIdParts[1], mediaIdParts[2], mediaIdParts[3]
) )
MEDIA_SONG_STARRED_ID -> playStarredSongs() MEDIA_SONG_STARRED_ID -> playStarredSongs()
MEDIA_SONG_STARRED_ITEM -> playStarredSong(mediaIdParts[1]) MEDIA_SONG_STARRED_ITEM -> playStarredSong(mediaIdParts[1])
MEDIA_SONG_RANDOM_ID -> playRandomSongs() MEDIA_SONG_RANDOM_ID -> playRandomSongs()
@ -549,6 +660,7 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) :
MEDIA_PODCAST_EPISODE_ITEM -> playPodcastEpisode( MEDIA_PODCAST_EPISODE_ITEM -> playPodcastEpisode(
mediaIdParts[1], mediaIdParts[2] mediaIdParts[1], mediaIdParts[2]
) )
MEDIA_SEARCH_SONG_ITEM -> playSearch(mediaIdParts[1]) MEDIA_SEARCH_SONG_ITEM -> playSearch(mediaIdParts[1])
else -> { else -> {
listOf() listOf()
@ -573,7 +685,8 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) :
R.string.music_library_label, R.string.music_library_label,
MEDIA_LIBRARY_ID, MEDIA_LIBRARY_ID,
null, null,
folderType = FOLDER_TYPE_MIXED, isBrowsable = true,
mediaType = MEDIA_TYPE_FOLDER_MIXED,
icon = R.drawable.ic_library icon = R.drawable.ic_library
) )
@ -581,7 +694,8 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) :
R.string.main_artists_title, R.string.main_artists_title,
MEDIA_ARTIST_ID, MEDIA_ARTIST_ID,
null, null,
folderType = FOLDER_TYPE_ARTISTS, isBrowsable = true,
mediaType = MEDIA_TYPE_FOLDER_ARTISTS,
icon = R.drawable.ic_artist icon = R.drawable.ic_artist
) )
@ -590,7 +704,8 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) :
R.string.main_albums_title, R.string.main_albums_title,
MEDIA_ALBUM_ID, MEDIA_ALBUM_ID,
null, null,
folderType = FOLDER_TYPE_ALBUMS, isBrowsable = true,
mediaType = MEDIA_TYPE_FOLDER_ALBUMS,
icon = R.drawable.ic_menu_browse icon = R.drawable.ic_menu_browse
) )
@ -598,7 +713,8 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) :
R.string.playlist_label, R.string.playlist_label,
MEDIA_PLAYLIST_ID, MEDIA_PLAYLIST_ID,
null, null,
folderType = FOLDER_TYPE_PLAYLISTS, isBrowsable = true,
mediaType = MEDIA_TYPE_FOLDER_PLAYLISTS,
icon = R.drawable.ic_menu_playlists icon = R.drawable.ic_menu_playlists
) )
@ -613,14 +729,16 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) :
R.string.main_songs_random, R.string.main_songs_random,
MEDIA_SONG_RANDOM_ID, MEDIA_SONG_RANDOM_ID,
R.string.main_songs_title, R.string.main_songs_title,
folderType = FOLDER_TYPE_TITLES isBrowsable = true,
mediaType = MEDIA_TYPE_PLAYLIST
) )
mediaItems.add( mediaItems.add(
R.string.main_songs_starred, R.string.main_songs_starred,
MEDIA_SONG_STARRED_ID, MEDIA_SONG_STARRED_ID,
R.string.main_songs_title, R.string.main_songs_title,
folderType = FOLDER_TYPE_TITLES isBrowsable = true,
mediaType = MEDIA_TYPE_PLAYLIST
) )
// Albums // Albums
@ -634,28 +752,28 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) :
R.string.main_albums_recent, R.string.main_albums_recent,
MEDIA_ALBUM_RECENT_ID, MEDIA_ALBUM_RECENT_ID,
R.string.main_albums_title, R.string.main_albums_title,
folderType = FOLDER_TYPE_ALBUMS mediaType = MEDIA_TYPE_FOLDER_ALBUMS,
) )
mediaItems.add( mediaItems.add(
R.string.main_albums_frequent, R.string.main_albums_frequent,
MEDIA_ALBUM_FREQUENT_ID, MEDIA_ALBUM_FREQUENT_ID,
R.string.main_albums_title, R.string.main_albums_title,
folderType = FOLDER_TYPE_ALBUMS mediaType = MEDIA_TYPE_FOLDER_ALBUMS,
) )
mediaItems.add( mediaItems.add(
R.string.main_albums_random, R.string.main_albums_random,
MEDIA_ALBUM_RANDOM_ID, MEDIA_ALBUM_RANDOM_ID,
R.string.main_albums_title, R.string.main_albums_title,
folderType = FOLDER_TYPE_ALBUMS mediaType = MEDIA_TYPE_FOLDER_ALBUMS,
) )
mediaItems.add( mediaItems.add(
R.string.main_albums_starred, R.string.main_albums_starred,
MEDIA_ALBUM_STARRED_ID, MEDIA_ALBUM_STARRED_ID,
R.string.main_albums_title, R.string.main_albums_title,
folderType = FOLDER_TYPE_ALBUMS mediaType = MEDIA_TYPE_FOLDER_ALBUMS,
) )
// Other // Other
@ -704,8 +822,7 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) :
index.add(currentSection) index.add(currentSection)
mediaItems.add( mediaItems.add(
currentSection, currentSection,
listOf(MEDIA_ARTIST_SECTION, currentSection).joinToString("|"), listOf(MEDIA_ARTIST_SECTION, currentSection).joinToString("|")
FOLDER_TYPE_ARTISTS
) )
} }
} }
@ -713,8 +830,7 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) :
artists.map { artist -> artists.map { artist ->
mediaItems.add( mediaItems.add(
artist.name ?: "", artist.name ?: "",
listOf(childMediaId, artist.id, artist.name).joinToString("|"), listOf(childMediaId, artist.id, artist.name).joinToString("|")
FOLDER_TYPE_ARTISTS
) )
} }
} }
@ -744,8 +860,7 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) :
mediaItems.add( mediaItems.add(
album.title ?: "", album.title ?: "",
listOf(MEDIA_ALBUM_ITEM, album.id, album.name) listOf(MEDIA_ALBUM_ITEM, album.id, album.name)
.joinToString("|"), .joinToString("|")
FOLDER_TYPE_ALBUMS
) )
} }
return@future LibraryResult.ofItemList(mediaItems, null) return@future LibraryResult.ofItemList(mediaItems, null)
@ -768,15 +883,24 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) :
mediaItems.addPlayAllItem(listOf(MEDIA_ALBUM_ITEM, id, name).joinToString("|")) mediaItems.addPlayAllItem(listOf(MEDIA_ALBUM_ITEM, id, name).joinToString("|"))
// TODO: Paging is not implemented for songs, is it necessary at all? // 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 -> items.map { item ->
if (item.isDirectory) if (item.isDirectory)
mediaItems.add( mediaItems.add(
item.title ?: "", item.title ?: "",
listOf(MEDIA_ALBUM_ITEM, item.id, item.name).joinToString("|"), listOf(MEDIA_ALBUM_ITEM, item.id, item.name).joinToString("|")
FOLDER_TYPE_TITLES
) )
else else if (item is Track)
mediaItems.add( mediaItems.add(
item.toMediaItem( item.toMediaItem(
listOf( listOf(
@ -789,6 +913,7 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) :
) )
} }
} }
return@future LibraryResult.ofItemList(mediaItems, null) return@future LibraryResult.ofItemList(mediaItems, null)
} }
} }
@ -822,8 +947,7 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) :
mediaItems.add( mediaItems.add(
album.title ?: "", album.title ?: "",
listOf(MEDIA_ALBUM_ITEM, album.id, album.name) listOf(MEDIA_ALBUM_ITEM, album.id, album.name)
.joinToString("|"), .joinToString("|")
FOLDER_TYPE_ALBUMS
) )
} }
@ -851,7 +975,7 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) :
playlist.name, playlist.name,
listOf(MEDIA_PLAYLIST_ITEM, playlist.id, playlist.name) listOf(MEDIA_PLAYLIST_ITEM, playlist.id, playlist.name)
.joinToString("|"), .joinToString("|"),
FOLDER_TYPE_PLAYLISTS mediaType = MEDIA_TYPE_PLAYLIST,
) )
} }
return@future LibraryResult.ofItemList(mediaItems, null) return@future LibraryResult.ofItemList(mediaItems, null)
@ -945,7 +1069,7 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) :
mediaItems.add( mediaItems.add(
podcast.title ?: "", podcast.title ?: "",
listOf(MEDIA_PODCAST_ITEM, podcast.id).joinToString("|"), listOf(MEDIA_PODCAST_ITEM, podcast.id).joinToString("|"),
FOLDER_TYPE_MIXED mediaType = MEDIA_TYPE_FOLDER_MIXED,
) )
} }
return@future LibraryResult.ofItemList(mediaItems, null) return@future LibraryResult.ofItemList(mediaItems, null)
@ -1048,7 +1172,7 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) :
share.name ?: "", share.name ?: "",
listOf(MEDIA_SHARE_ITEM, share.id) listOf(MEDIA_SHARE_ITEM, share.id)
.joinToString("|"), .joinToString("|"),
FOLDER_TYPE_MIXED mediaType = MEDIA_TYPE_FOLDER_MIXED,
) )
} }
return@future LibraryResult.ofItemList(mediaItems, null) return@future LibraryResult.ofItemList(mediaItems, null)
@ -1226,14 +1350,16 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) :
private fun MutableList<MediaItem>.add( private fun MutableList<MediaItem>.add(
title: String, title: String,
mediaId: String, mediaId: String,
folderType: Int mediaType: Int = MEDIA_TYPE_MIXED,
isBrowsable: Boolean = false
) { ) {
val mediaItem = buildMediaItem( val mediaItem = buildMediaItem(
title, title,
mediaId, mediaId,
isPlayable = false, isPlayable = false,
folderType = folderType isBrowsable = isBrowsable,
mediaType = mediaType
) )
this.add(mediaItem) this.add(mediaItem)
@ -1244,8 +1370,8 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) :
resId: Int, resId: Int,
mediaId: String, mediaId: String,
groupNameId: Int?, groupNameId: Int?,
browsable: Boolean = true, isBrowsable: Boolean = true,
folderType: Int = FOLDER_TYPE_MIXED, mediaType: Int = MEDIA_TYPE_FOLDER_MIXED,
icon: Int? = null icon: Int? = null
) { ) {
val applicationContext = UApp.applicationContext() val applicationContext = UApp.applicationContext()
@ -1253,14 +1379,15 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) :
val mediaItem = buildMediaItem( val mediaItem = buildMediaItem(
applicationContext.getString(resId), applicationContext.getString(resId),
mediaId, mediaId,
isPlayable = !browsable, isPlayable = !isBrowsable,
folderType = folderType, isBrowsable = isBrowsable,
imageUri = if (icon != null) {
Util.getUriToDrawable(applicationContext, icon)
} else null,
group = if (groupNameId != null) { group = if (groupNameId != null) {
applicationContext.getString(groupNameId) applicationContext.getString(groupNameId)
} else null, } else null,
imageUri = if (icon != null) { mediaType = mediaType
Util.getUriToDrawable(applicationContext, icon)
} else null
) )
this.add(mediaItem) this.add(mediaItem)
@ -1294,14 +1421,102 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) :
} }
} }
fun updateCustomHeartButton( private fun Player.setNextRepeatMode() {
session: MediaSession, repeatMode =
isHeart: Boolean when (repeatMode) {
) { Player.REPEAT_MODE_OFF -> Player.REPEAT_MODE_ALL
val command = if (isHeart) customCommands[1] else customCommands[0] Player.REPEAT_MODE_ALL -> Player.REPEAT_MODE_ONE
// Change the custom layout to contain the right heart button else -> Player.REPEAT_MODE_OFF
customLayout = ImmutableList.of(command) }
// Send the updated custom layout to controllers. }
session.setCustomLayout(customLayout)
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() { private fun initializeSessionAndPlayer() {
if (isStarted) return if (isStarted) return
setMediaNotificationProvider(CustomNotificationProvider(UApp.applicationContext()))
// TODO: Remove minor code duplication with updateBackend()
val desiredBackend = if (activeServerProvider.getActiveServer().jukeboxByDefault) { val desiredBackend = if (activeServerProvider.getActiveServer().jukeboxByDefault) {
Timber.i("Jukebox enabled by default")
MediaPlayerManager.PlayerBackend.JUKEBOX MediaPlayerManager.PlayerBackend.JUKEBOX
} else { } else {
MediaPlayerManager.PlayerBackend.LOCAL MediaPlayerManager.PlayerBackend.LOCAL
} }
player = if (activeServerProvider.getActiveServer().jukeboxByDefault) { player = createNewBackend(desiredBackend)
Timber.i("Jukebox enabled by default")
getJukeboxPlayer()
} else {
getLocalPlayer()
}
actualBackend = desiredBackend actualBackend = desiredBackend
// Create browser interface // Create browser interface
librarySessionCallback = AutoMediaBrowserCallback(this) librarySessionCallback = AutoMediaBrowserCallback()
// This will need to use the AutoCalls // This will need to use the AutoCalls
mediaLibrarySession = MediaLibrarySession.Builder(this, player, librarySessionCallback) mediaLibrarySession = MediaLibrarySession.Builder(this, player, librarySessionCallback)
@ -157,10 +150,8 @@ class PlaybackService :
.setBitmapLoader(ArtworkBitmapLoader()) .setBitmapLoader(ArtworkBitmapLoader())
.build() .build()
if (!librarySessionCallback.customLayout.isEmpty()) { // Send custom layout to legacy session.
// Send custom layout to legacy session. mediaLibrarySession.setCustomLayout(librarySessionCallback.defaultCustomCommands)
mediaLibrarySession.setCustomLayout(librarySessionCallback.customLayout)
}
// Set a listener to update the API client when the active server has changed // Set a listener to update the API client when the active server has changed
rxBusSubscription += RxBus.activeServerChangedObservable.subscribe { rxBusSubscription += RxBus.activeServerChangedObservable.subscribe {
@ -213,11 +204,7 @@ class PlaybackService :
player.removeListener(listener) player.removeListener(listener)
player.release() player.release()
player = if (newBackend == MediaPlayerManager.PlayerBackend.JUKEBOX) { player = createNewBackend(newBackend)
getJukeboxPlayer()
} else {
getLocalPlayer()
}
// Add fresh listeners // Add fresh listeners
player.addListener(listener) player.addListener(listener)
@ -227,6 +214,14 @@ class PlaybackService :
actualBackend = newBackend actualBackend = newBackend
} }
private fun createNewBackend(newBackend: MediaPlayerManager.PlayerBackend): Player {
return if (newBackend == MediaPlayerManager.PlayerBackend.JUKEBOX) {
getJukeboxPlayer()
} else {
getLocalPlayer()
}
}
private fun getJukeboxPlayer(): Player { private fun getJukeboxPlayer(): Player {
return JukeboxMediaPlayer() return JukeboxMediaPlayer()
} }
@ -425,6 +420,12 @@ class PlaybackService :
"org.moire.ultrasonic.HEART_ON" "org.moire.ultrasonic.HEART_ON"
const val CUSTOM_COMMAND_TOGGLE_HEART_OFF = const val CUSTOM_COMMAND_TOGGLE_HEART_OFF =
"org.moire.ultrasonic.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 private const val NOTIFICATION_ID = 3009
} }
} }

View File

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

View File

@ -67,6 +67,18 @@ abstract class JukeboxUnimplementedFunctions : Player {
TODO("Not yet implemented") 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) { override fun setPlayWhenReady(playWhenReady: Boolean) {
TODO("Not yet implemented") TODO("Not yet implemented")
} }
@ -134,11 +146,6 @@ abstract class JukeboxUnimplementedFunctions : Player {
override fun setPlaybackSpeed(speed: Float) { override fun setPlaybackSpeed(speed: Float) {
TODO("Not yet implemented") TODO("Not yet implemented")
} }
override fun stop(reset: Boolean) {
TODO("Not yet implemented")
}
override fun getCurrentTracks(): Tracks { override fun getCurrentTracks(): Tracks {
// TODO Dummy information is returned for now, this seems to work // TODO Dummy information is returned for now, this seems to work
return Tracks.EMPTY 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. // 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 // 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. // the right event, and can start playback only then.
if (autoPlay) { if (autoPlay && controller?.isPlaying != true) {
if (isShufflePlayEnabled) { if (isShufflePlayEnabled) {
deferredPlay = { deferredPlay = {
val start = controller?.currentTimeline val start = controller?.currentTimeline

View File

@ -68,27 +68,27 @@ class DownloadHandler(
} }
successString = when (action) { successString = when (action) {
DownloadAction.DOWNLOAD -> fragment.resources.getQuantityString( DownloadAction.DOWNLOAD -> fragment.resources.getQuantityString(
R.plurals.select_album_n_songs_downloaded, R.plurals.n_songs_to_be_downloaded,
tracksToDownload.size, tracksToDownload.size,
tracksToDownload.size tracksToDownload.size
) )
DownloadAction.UNPIN -> { DownloadAction.UNPIN -> {
fragment.resources.getQuantityString( fragment.resources.getQuantityString(
R.plurals.select_album_n_songs_unpinned, R.plurals.n_songs_unpinned,
tracksToDownload.size, tracksToDownload.size,
tracksToDownload.size tracksToDownload.size
) )
} }
DownloadAction.PIN -> { DownloadAction.PIN -> {
fragment.resources.getQuantityString( fragment.resources.getQuantityString(
R.plurals.select_album_n_songs_pinned, R.plurals.n_songs_pinned,
tracksToDownload.size, tracksToDownload.size,
tracksToDownload.size tracksToDownload.size
) )
} }
DownloadAction.DELETE -> { DownloadAction.DELETE -> {
fragment.resources.getQuantityString( fragment.resources.getQuantityString(
R.plurals.select_album_n_songs_deleted, R.plurals.n_songs_deleted,
tracksToDownload.size, tracksToDownload.size,
tracksToDownload.size tracksToDownload.size
) )
@ -104,10 +104,9 @@ class DownloadHandler(
name: String? = "", name: String? = "",
isShare: Boolean = false, isShare: Boolean = false,
isDirectory: Boolean = true, isDirectory: Boolean = true,
append: Boolean, insertionMode: MediaPlayerManager.InsertionMode,
autoPlay: Boolean, autoPlay: Boolean,
shuffle: Boolean = false, shuffle: Boolean = false,
playNext: Boolean,
isArtist: Boolean = false isArtist: Boolean = false
) { ) {
var successString: String? = null var successString: String? = null
@ -119,26 +118,28 @@ class DownloadHandler(
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
addTracksToMediaController( addTracksToMediaController(
songs = songs, songs = songs,
append = append, insertionMode = insertionMode,
playNext = playNext,
autoPlay = autoPlay, autoPlay = autoPlay,
shuffle = shuffle, shuffle = shuffle,
playlistName = null, playlistName = null,
fragment = fragment fragment = fragment
) )
// Play Now doesn't get a Toast :) // Play Now doesn't get a Toast :)
if (playNext) { successString = when (insertionMode) {
successString = fragment.resources.getQuantityString( MediaPlayerManager.InsertionMode.AFTER_CURRENT ->
R.plurals.select_album_n_songs_play_next, fragment.resources.getQuantityString(
songs.size, R.plurals.n_songs_added_after_current,
songs.size songs.size,
) songs.size
} else if (append) { )
successString = fragment.resources.getQuantityString( MediaPlayerManager.InsertionMode.APPEND ->
R.plurals.select_album_n_songs_added, fragment.resources.getQuantityString(
songs.size, R.plurals.n_songs_added_to_end,
songs.size songs.size,
) songs.size
)
else -> null
} }
} }
}) { successString } }) { successString }
@ -146,8 +147,7 @@ class DownloadHandler(
fun addTracksToMediaController( fun addTracksToMediaController(
songs: List<Track>, songs: List<Track>,
append: Boolean, insertionMode: MediaPlayerManager.InsertionMode,
playNext: Boolean,
autoPlay: Boolean, autoPlay: Boolean,
shuffle: Boolean = false, shuffle: Boolean = false,
playlistName: String? = null, playlistName: String? = null,
@ -157,12 +157,6 @@ class DownloadHandler(
networkAndStorageChecker.warnIfNetworkOrStorageUnavailable() networkAndStorageChecker.warnIfNetworkOrStorageUnavailable()
val insertionMode = when {
append -> MediaPlayerManager.InsertionMode.APPEND
playNext -> MediaPlayerManager.InsertionMode.AFTER_CURRENT
else -> MediaPlayerManager.InsertionMode.CLEAR
}
if (playlistName != null) { if (playlistName != null) {
mediaPlayerManager.suggestedPlaylistName = playlistName mediaPlayerManager.suggestedPlaylistName = playlistName
} }
@ -173,7 +167,10 @@ class DownloadHandler(
shuffle, shuffle,
insertionMode insertionMode
) )
if (Settings.shouldTransitionOnPlayback && (!append || autoPlay)) {
if (Settings.shouldTransitionOnPlayback &&
insertionMode == MediaPlayerManager.InsertionMode.CLEAR
) {
fragment.findNavController().popBackStack(R.id.playerFragment, true) fragment.findNavController().popBackStack(R.id.playerFragment, true)
fragment.findNavController().navigate(R.id.playerFragment) 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.HeartRating
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata 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 androidx.media3.common.StarRating
import java.text.DateFormat import java.text.DateFormat
import java.text.ParseException import java.text.ParseException
@ -22,7 +23,7 @@ import java.util.Date
import org.moire.ultrasonic.domain.Track import org.moire.ultrasonic.domain.Track
import org.moire.ultrasonic.provider.AlbumArtContentProvider 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 // for a single string value
private const val DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE = private const val DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE =
"android.media.browse.CONTENT_STYLE_GROUP_TITLE_HINT" "android.media.browse.CONTENT_STYLE_GROUP_TITLE_HINT"
@ -76,15 +77,16 @@ fun Track.toMediaItem(
title = title ?: "", title = title ?: "",
mediaId = mediaId, mediaId = mediaId,
isPlayable = !isDirectory, isPlayable = !isDirectory,
folderType = if (isDirectory) MediaMetadata.FOLDER_TYPE_TITLES isBrowsable = isDirectory,
else MediaMetadata.FOLDER_TYPE_NONE,
album = album, album = album,
artist = artist, artist = artist,
genre = genre, genre = genre,
sourceUri = uri.toUri(), sourceUri = uri.toUri(),
imageUri = artworkUri, imageUri = artworkUri,
starred = starred, starred = starred,
group = null group = null,
mediaType = if (isDirectory) MEDIA_TYPE_FOLDER_MIXED
else MEDIA_TYPE_MUSIC
) )
val metadataBuilder = mediaItem.mediaMetadata.buildUpon() val metadataBuilder = mediaItem.mediaMetadata.buildUpon()
@ -204,14 +206,6 @@ private fun safeParseDate(created: String?): Date? {
} else null } 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. * Build a new MediaItem from a list of attributes.
* Especially useful to create folder entries in the Auto interface. * Especially useful to create folder entries in the Auto interface.
@ -222,7 +216,7 @@ fun buildMediaItem(
title: String, title: String,
mediaId: String, mediaId: String,
isPlayable: Boolean, isPlayable: Boolean,
folderType: @MediaMetadata.FolderType Int, isBrowsable: Boolean = false,
album: String? = null, album: String? = null,
artist: String? = null, artist: String? = null,
genre: String? = null, genre: String? = null,
@ -241,17 +235,13 @@ fun buildMediaItem(
.setAlbumArtist(artist) .setAlbumArtist(artist)
.setGenre(genre) .setGenre(genre)
.setUserRating(HeartRating(starred)) .setUserRating(HeartRating(starred))
.setFolderType(folderType) .setIsBrowsable(isBrowsable)
.setIsPlayable(isPlayable) .setIsPlayable(isPlayable)
if (imageUri != null) { if (imageUri != null) {
metadataBuilder.setArtworkUri(imageUri) metadataBuilder.setArtworkUri(imageUri)
} }
if (folderType > FOLDER_TYPE_NONE) {
metadataBuilder.setIsBrowsable(true)
}
if (mediaType != null) { if (mediaType != null) {
metadataBuilder.setMediaType(mediaType) metadataBuilder.setMediaType(mediaType)
} }

View File

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

View File

@ -383,31 +383,31 @@
<string name="server_menu.demo">Demo Server</string> <string name="server_menu.demo">Demo Server</string>
<string name="about.webpage">Website besuchen</string> <string name="about.webpage">Website besuchen</string>
<string name="about.report">Einen Fehler melden</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="one">%d Titel</item>
<item quantity="other">%d Titel</item> <item quantity="other">%d Titel</item>
</plurals> </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="one">%d Titel zum Anheften ausgewählt</item>
<item quantity="other">%d Titel zum Anheften ausgewählt</item> <item quantity="other">%d Titel zum Anheften ausgewählt</item>
</plurals> </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="one">%d Titel zum herunterladen ausgewählt</item>
<item quantity="other">%d Titel zum herunterladen ausgewählt</item> <item quantity="other">%d Titel zum herunterladen ausgewählt</item>
</plurals> </plurals>
<plurals name="select_album_n_songs_unpinned"> <plurals name="n_songs_unpinned">
<item quantity="one">%d Titel losgelöst</item> <item quantity="one">%d Titel losgelöst</item>
<item quantity="other">%d Titel losgelöst</item> <item quantity="other">%d Titel losgelöst</item>
</plurals> </plurals>
<plurals name="select_album_n_songs_deleted"> <plurals name="n_songs_deleted">
<item quantity="one">%d Titel gelöscht</item> <item quantity="one">%d Titel gelöscht</item>
<item quantity="other">%d Titel gelöscht</item> <item quantity="other">%d Titel gelöscht</item>
</plurals> </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="one">%d Titel am Ende hinzugefügt</item>
<item quantity="other">%d Titel am Ende hinzugefügt</item> <item quantity="other">%d Titel am Ende hinzugefügt</item>
</plurals> </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="one">%d Titel nach aktuellen Titel hinzugefügt</item>
<item quantity="other">%d Titel nach aktuellen Titel hinzugefügt</item> <item quantity="other">%d Titel nach aktuellen Titel hinzugefügt</item>
</plurals> </plurals>

View File

@ -390,37 +390,37 @@
<string name="about.webpage">Visitar la página web</string> <string name="about.webpage">Visitar la página web</string>
<string name="about.report">Informar de un error</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> <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="one">%d canción</item>
<item quantity="many">%d canciones</item> <item quantity="many">%d canciones</item>
<item quantity="other">%d canciones</item> <item quantity="other">%d canciones</item>
</plurals> </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="one">%d canción seleccionada para ser anclada</item>
<item quantity="many">%d canciones seleccionadas para ser ancladas</item> <item quantity="many">%d canciones seleccionadas para ser ancladas</item>
<item quantity="other">%d canciones seleccionadas para ser ancladas</item> <item quantity="other">%d canciones seleccionadas para ser ancladas</item>
</plurals> </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="one">%d canción seleccionada para ser descargada</item>
<item quantity="many">%d canciones seleccionadas para ser descargadas</item> <item quantity="many">%d canciones seleccionadas para ser descargadas</item>
<item quantity="other">%d canciones seleccionadas para ser descargadas</item> <item quantity="other">%d canciones seleccionadas para ser descargadas</item>
</plurals> </plurals>
<plurals name="select_album_n_songs_unpinned"> <plurals name="n_songs_unpinned">
<item quantity="one">%d canción desanclada</item> <item quantity="one">%d canción desanclada</item>
<item quantity="many">%d canciones desancladas</item> <item quantity="many">%d canciones desancladas</item>
<item quantity="other">%d canciones desancladas</item> <item quantity="other">%d canciones desancladas</item>
</plurals> </plurals>
<plurals name="select_album_n_songs_deleted"> <plurals name="n_songs_deleted">
<item quantity="one">%d canción eliminada</item> <item quantity="one">%d canción eliminada</item>
<item quantity="many">%d canciones eliminadas</item> <item quantity="many">%d canciones eliminadas</item>
<item quantity="other">%d canciones eliminadas</item> <item quantity="other">%d canciones eliminadas</item>
</plurals> </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="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="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> <item quantity="other">%d canciones añadidas al final de la cola de reproducción</item>
</plurals> </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="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="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> <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.webpage">Visiter la page web</string>
<string name="about.report">Signaler un bug</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> <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="one">%d titre</item>
<item quantity="many">%d titres</item> <item quantity="many">%d titres</item>
<item quantity="other">%d titres</item> <item quantity="other">%d titres</item>
@ -400,12 +400,12 @@
<string name="settings.preload_50">50 morceaux</string> <string name="settings.preload_50">50 morceaux</string>
<string name="chat.user_avatar">Image d\'avatar</string> <string name="chat.user_avatar">Image d\'avatar</string>
<string name="settings.theme_day_night">Jour et nuit</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="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="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> <item quantity="other">%d morceaux ajoutés à la file d\'attente de fin de lecture</item>
</plurals> </plurals>
<plurals name="select_album_n_songs_deleted"> <plurals name="n_songs_deleted">
<item quantity="one">%d morceau supprimé</item> <item quantity="one">%d morceau supprimé</item>
<item quantity="many">%d morceaux supprimés</item> <item quantity="many">%d morceaux supprimés</item>
<item quantity="other">%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.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="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> <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="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="many">%d chansons sélectionnées pour téléchargement</item>
<item quantity="other">%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>
<plurals name="select_album_n_songs_unpinned"> <plurals name="n_songs_unpinned">
<item quantity="one">%d chanson désépinglée</item> <item quantity="one">%d chanson désépinglée</item>
<item quantity="many">%d chansons désépinglées</item> <item quantity="many">%d chansons désépinglées</item>
<item quantity="other">%d chansons désépinglées</item> <item quantity="other">%d chansons désépinglées</item>
</plurals> </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="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="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> <item quantity="other">%d chansons insérées après la chanson en cours</item>
</plurals> </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="one">%d chanson sélectionnée à épingler</item>
<item quantity="many">%d chansons sélectionnées à épingler</item> <item quantity="many">%d chansons sélectionnées à épingler</item>
<item quantity="other">%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.play_last">Reproducir última</string>
<string name="common.pin">Ancorar</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> <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="one">%d canción seleccionada para ser ancorada</item>
<item quantity="other">%d cancións seleccionadas para ser ancoradas</item> <item quantity="other">%d cancións seleccionadas para ser ancoradas</item>
</plurals> </plurals>
<string name="settings.max_bitrate_pinning">Tasa de bits máxima: ao fixar unha canción de forma permanente</string> <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="one">%d canción desancorada</item>
<item quantity="other">%d cancións desancoradas</item> <item quantity="other">%d cancións desancoradas</item>
</plurals> </plurals>

View File

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

View File

@ -302,25 +302,25 @@
<string name="server_menu.demo">デモサーバー</string> <string name="server_menu.demo">デモサーバー</string>
<string name="about.webpage">Webページにアクセス</string> <string name="about.webpage">Webページにアクセス</string>
<string name="about.report">バグを報告</string> <string name="about.report">バグを報告</string>
<plurals name="select_album_n_songs"> <plurals name="n_songs">
<item quantity="other">%d 曲</item> <item quantity="other">%d 曲</item>
</plurals> </plurals>
<plurals name="select_album_n_songs_downloaded"> <plurals name="n_songs_to_be_downloaded">
<item quantity="other">%d 曲がダウンロード選択されました</item> <item quantity="other">%d 曲がダウンロード選択されました</item>
</plurals> </plurals>
<plurals name="select_album_n_songs_unpinned"> <plurals name="n_songs_unpinned">
<item quantity="other">%d 曲が固定解除されました</item> <item quantity="other">%d 曲が固定解除されました</item>
</plurals> </plurals>
<plurals name="select_album_n_songs_pinned"> <plurals name="n_songs_pinned">
<item quantity="other">%d 曲が固定されるよう選択されました</item> <item quantity="other">%d 曲が固定されるよう選択されました</item>
</plurals> </plurals>
<plurals name="select_album_n_songs_deleted"> <plurals name="n_songs_deleted">
<item quantity="other">%d 曲が削除されました</item> <item quantity="other">%d 曲が削除されました</item>
</plurals> </plurals>
<plurals name="select_album_n_songs_added"> <plurals name="n_songs_added_to_end">
<item quantity="other">%d 曲が再生キューの末尾に追加されました</item> <item quantity="other">%d 曲が再生キューの末尾に追加されました</item>
</plurals> </plurals>
<plurals name="select_album_n_songs_play_next"> <plurals name="n_songs_added_after_current">
<item quantity="other">%d 曲が現在再生中の曲の次に追加されました</item> <item quantity="other">%d 曲が現在再生中の曲の次に追加されました</item>
</plurals> </plurals>
<string name="api.subsonic.generic">一般APIエラー: %1$s</string> <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="share_set_share_options">Sett delingsinnstillinger</string>
<string name="button_bar.shares">Delinger</string> <string name="button_bar.shares">Delinger</string>
<string name="download.toggle_playlist">Veksle spilleliste</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="one">%d spor lagt til etter nåværende spor</item>
<item quantity="other">%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>
<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="one">%d spor lagt til på slutten av spillekøen</item>
<item quantity="other">%d spor lagt til på slutten av spillekøen</item> <item quantity="other">%d spor lagt til på slutten av spillekøen</item>
</plurals> </plurals>
@ -290,19 +290,19 @@
<string name="settings.debug.log_to_file">Skriv avlusningslogg til fil</string> <string name="settings.debug.log_to_file">Skriv avlusningslogg til fil</string>
<string name="about.report">Rapporter en feil</string> <string name="about.report">Rapporter en feil</string>
<string name="api.subsonic.generic">Generisk API-feil: %1$s</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="one">%d spor</item>
<item quantity="other">%d spor</item> <item quantity="other">%d spor</item>
</plurals> </plurals>
<plurals name="select_album_n_songs_pinned"> <plurals name="n_songs_pinned">
<item quantity="one">%d spor å feste</item> <item quantity="one">%d spor å feste</item>
<item quantity="other">%d spor å feste</item> <item quantity="other">%d spor å feste</item>
</plurals> </plurals>
<plurals name="select_album_n_songs_unpinned"> <plurals name="n_songs_unpinned">
<item quantity="one">%d spor løsnet</item> <item quantity="one">%d spor løsnet</item>
<item quantity="other">%d spor løsnet</item> <item quantity="other">%d spor løsnet</item>
</plurals> </plurals>
<plurals name="select_album_n_songs_deleted"> <plurals name="n_songs_deleted">
<item quantity="one">%d spor slettet</item> <item quantity="one">%d spor slettet</item>
<item quantity="other">%d spor løsnet</item> <item quantity="other">%d spor løsnet</item>
</plurals> </plurals>
@ -446,7 +446,7 @@
<string name="grid_view">Omslag</string> <string name="grid_view">Omslag</string>
<string name="supported_server_features">Støttede funksjoner</string> <string name="supported_server_features">Støttede funksjoner</string>
<string name="jukebox">Jukebox</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="one">%d spor valgt for nedlasting</item>
<item quantity="other">%d spor valgt for nedlasting</item> <item quantity="other">%d spor valgt for nedlasting</item>
</plurals> </plurals>

View File

@ -391,31 +391,31 @@
<string name="about.webpage">Website openen</string> <string name="about.webpage">Website openen</string>
<string name="about.report">Bug melden</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> <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="one">%d nummer</item>
<item quantity="other">%d nummers</item> <item quantity="other">%d nummers</item>
</plurals> </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="one">%d vast te maken nummer geselecteerd</item>
<item quantity="other">%d vast te maken nummers geselecteerd</item> <item quantity="other">%d vast te maken nummers geselecteerd</item>
</plurals> </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="one">%d te downloaden nummer geselecteerd</item>
<item quantity="other">%d te downloaden nummers geselecteerd</item> <item quantity="other">%d te downloaden nummers geselecteerd</item>
</plurals> </plurals>
<plurals name="select_album_n_songs_unpinned"> <plurals name="n_songs_unpinned">
<item quantity="one">%d nummer losgemaakt</item> <item quantity="one">%d nummer losgemaakt</item>
<item quantity="other">%d nummers losgemaakt</item> <item quantity="other">%d nummers losgemaakt</item>
</plurals> </plurals>
<plurals name="select_album_n_songs_deleted"> <plurals name="n_songs_deleted">
<item quantity="one">%d nummer verwijderd</item> <item quantity="one">%d nummer verwijderd</item>
<item quantity="other">%d nummers verwijderd</item> <item quantity="other">%d nummers verwijderd</item>
</plurals> </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="one">%d nummer toegevoegd aan het einde van afspeelwachtrij</item>
<item quantity="other">%d nummers toegevoegd aan het einde van afspeelwachtrij</item> <item quantity="other">%d nummers toegevoegd aan het einde van afspeelwachtrij</item>
</plurals> </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="one">%d nummer ingevoegd na het huidige nummer</item>
<item quantity="other">%d nummers ingevoegd na het huidige nummer</item> <item quantity="other">%d nummers ingevoegd na het huidige nummer</item>
</plurals> </plurals>

View File

@ -307,7 +307,7 @@
<string name="server_menu.move_down">Przesuń się w dół</string> <string name="server_menu.move_down">Przesuń się w dół</string>
<string name="server_editor.authentication">Authentication</string> <string name="server_editor.authentication">Authentication</string>
<string name="server_editor.advanced">Ustawienia zaawansowane</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="one">%d utwór</item>
<item quantity="few">%d utwory</item> <item quantity="few">%d utwory</item>
<item quantity="many">%d utworów</item> <item quantity="many">%d utworów</item>
@ -356,7 +356,7 @@
<string name="language.hu">Węgierski</string> <string name="language.hu">Węgierski</string>
<string name="settings.debug.log_summary">Zapisanych jest %1$s plików z logami, które zajmują ~%2$s MB miejsca w katalogu %3$s. Czy chcesz je zachować\?</string> <string name="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> <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="one">Usunięto %d utwór</item>
<item quantity="few">Usunięto %d utwory</item> <item quantity="few">Usunięto %d utwory</item>
<item quantity="many">Usunięto %d utworów</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.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.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> <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="one">%d utwór zaznaczony do przypięcia</item>
<item quantity="few">%d utwory zaznaczone 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="many">%d utworów zaznaczonych do przypięcia</item>
<item quantity="other">%d utworów zaznaczonych do przypięcia</item> <item quantity="other">%d utworów zaznaczonych do przypięcia</item>
</plurals> </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="one">%d utworów zaznaczonych do pobrania</item>
<item quantity="few">%d utwory zaznaczone do pobrania</item> <item quantity="few">%d utwory zaznaczone do pobrania</item>
<item quantity="many">%d utworów zaznaczonych do pobrania</item> <item quantity="many">%d utworów zaznaczonych do pobrania</item>
<item quantity="other">%d utworów zaznaczonych do pobrania</item> <item quantity="other">%d utworów zaznaczonych do pobrania</item>
</plurals> </plurals>
<string name="about.webpage">Odwiedź stronę internetową</string> <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="one">Odpięto %d utwór</item>
<item quantity="few">Odpięto %d utwory</item> <item quantity="few">Odpięto %d utwory</item>
<item quantity="many">Odpięto %d utworów</item> <item quantity="many">Odpięto %d utworów</item>
@ -446,13 +446,13 @@
<string name="list_view">Lista</string> <string name="list_view">Lista</string>
<string name="grid_view">Okładka</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> <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="one">Dodano %d utwór na koniec kolejki odtwarzania</item>
<item quantity="few">Dodano %d utwory 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="many">Dodano %d utworów na koniec kolejki odtwarzania</item>
<item quantity="other">Dodano %d utworów na koniec kolejki odtwarzania</item> <item quantity="other">Dodano %d utworów na koniec kolejki odtwarzania</item>
</plurals> </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="one">Wstawiono %d utwór po bieżącym utworze</item>
<item quantity="few">Wstawiono %d utwory po bieżącym utworze</item> <item quantity="few">Wstawiono %d utwory po bieżącym utworze</item>
<item quantity="many">Wstawiono %d utworów po bieżącym utworze</item> <item quantity="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.webpage">Visitar a página web</string>
<string name="about.report">Reportar um erro</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> <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="one">%d música</item>
<item quantity="many">%d músicas</item> <item quantity="many">%d músicas</item>
<item quantity="other">%d músicas</item> <item quantity="other">%d músicas</item>
</plurals> </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="one">%d música selecionada para ser fixada</item>
<item quantity="many">%d músicas selecionadas para serem fixadas</item> <item quantity="many">%d músicas selecionadas para serem fixadas</item>
<item quantity="other">%d músicas selecionadas para serem fixadas</item> <item quantity="other">%d músicas selecionadas para serem fixadas</item>
</plurals> </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="one">%d música selecionada para ser baixada</item>
<item quantity="many">%d músicas selecionadas para serem baixadas</item> <item quantity="many">%d músicas selecionadas para serem baixadas</item>
<item quantity="other">%d músicas selecionadas para serem baixadas</item> <item quantity="other">%d músicas selecionadas para serem baixadas</item>
</plurals> </plurals>
<plurals name="select_album_n_songs_unpinned"> <plurals name="n_songs_unpinned">
<item quantity="one">%d música desfixada</item> <item quantity="one">%d música desfixada</item>
<item quantity="many">%d músicas desfixadas</item> <item quantity="many">%d músicas desfixadas</item>
<item quantity="other">%d músicas desfixadas</item> <item quantity="other">%d músicas desfixadas</item>
</plurals> </plurals>
<plurals name="select_album_n_songs_deleted"> <plurals name="n_songs_deleted">
<item quantity="one">%d música excluída</item> <item quantity="one">%d música excluída</item>
<item quantity="many">%d músicas excluídas</item> <item quantity="many">%d músicas excluídas</item>
<item quantity="other">%d músicas excluídas</item> <item quantity="other">%d músicas excluídas</item>
</plurals> </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="one">%d música adicionada ao final da playlist</item>
<item quantity="many">%d músicas adicionadas 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> <item quantity="other">%d músicas adicionadas ao final da playlist</item>
</plurals> </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="one">%d música adicionada após a atual</item>
<item quantity="many">%d músicas adicionadas 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> <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_menu.move_down">Move down</string>
<string name="server_editor.authentication">Authentication</string> <string name="server_editor.authentication">Authentication</string>
<string name="server_editor.advanced">Configurações avançadas</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="one">%d música</item>
<item quantity="many">%d músicas</item> <item quantity="many">%d músicas</item>
<item quantity="other">%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="server_menu.demo">Servidor Demonstração</string>
<string name="about.webpage">Visitar a página web</string> <string name="about.webpage">Visitar a página web</string>
<string name="about.report">Reportar um erro</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="one">%d música selecionada para ser fixada</item>
<item quantity="many">%d músicas selecionadas para serem fixadas</item> <item quantity="many">%d músicas selecionadas para serem fixadas</item>
<item quantity="other">%d músicas selecionadas para serem fixadas</item> <item quantity="other">%d músicas selecionadas para serem fixadas</item>
</plurals> </plurals>
<plurals name="select_album_n_songs_unpinned"> <plurals name="n_songs_unpinned">
<item quantity="one">%d música desfixada</item> <item quantity="one">%d música desfixada</item>
<item quantity="many">%d músicas desfixadas</item> <item quantity="many">%d músicas desfixadas</item>
<item quantity="other">%d músicas desfixadas</item> <item quantity="other">%d músicas desfixadas</item>
</plurals> </plurals>
<plurals name="select_album_n_songs_deleted"> <plurals name="n_songs_deleted">
<item quantity="one">%d música excluída</item> <item quantity="one">%d música excluída</item>
<item quantity="many">%d músicas excluídas</item> <item quantity="many">%d músicas excluídas</item>
<item quantity="other">%d músicas excluídas</item> <item quantity="other">%d músicas excluídas</item>
</plurals> </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="one">%d música adicionada ao final da playlist</item>
<item quantity="many">%d músicas adicionadas 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> <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. \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 \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> \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="one">%d música selecionada a ser descarregada</item>
<item quantity="many">%d músicas selecionadas para serem baixadas</item> <item quantity="many">%d músicas selecionadas para serem baixadas</item>
<item quantity="other">%d músicas selecionadas para serem baixadas</item> <item quantity="other">%d músicas selecionadas para serem baixadas</item>
</plurals> </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="one">%d música adicionada após a atual</item>
<item quantity="many">%d músicas adicionadas 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> <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_editor.disabled_feature">Одна или несколько функций были отключены, потому что сервер их не поддерживает.\nВы можете запустить этот тест снова в любое время.</string>
<string name="server_menu.demo">Демо-сервер</string> <string name="server_menu.demo">Демо-сервер</string>
<plurals name="select_album_n_songs"> <plurals name="n_songs">
<item quantity="one">%d песня</item> <item quantity="one">%d песня</item>
<item quantity="few">%d песни</item> <item quantity="few">%d песни</item>
<item quantity="many">%d песен</item> <item quantity="many">%d песен</item>

View File

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

View File

@ -402,31 +402,35 @@
<string name="about.webpage.url" translatable="false">https://ultrasonic.gitlab.io/</string> <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> <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="one">%d song</item>
<item quantity="other">%d songs</item> <item quantity="other">%d songs</item>
</plurals> </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="one">%d song selected to be pinned</item>
<item quantity="other">%d songs selected to be pinned</item> <item quantity="other">%d songs selected to be pinned</item>
</plurals> </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="one">%d song selected to be downloaded</item>
<item quantity="other">%d songs selected to be downloaded</item> <item quantity="other">%d songs selected to be downloaded</item>
</plurals> </plurals>
<plurals name="select_album_n_songs_unpinned"> <plurals name="n_songs_unpinned">
<item quantity="one">%d song unpinned</item> <item quantity="one">%d song unpinned</item>
<item quantity="other">%d songs unpinned</item> <item quantity="other">%d songs unpinned</item>
</plurals> </plurals>
<plurals name="select_album_n_songs_deleted"> <plurals name="n_songs_deleted">
<item quantity="one">%d song deleted</item> <item quantity="one">%d song deleted</item>
<item quantity="other">%d songs deleted</item> <item quantity="other">%d songs deleted</item>
</plurals> </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="one">%d song added to the end of play queue</item>
<item quantity="other">%d songs added to the end of play queue</item> <item quantity="other">%d songs added to the end of play queue</item>
</plurals> </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="one">%d song inserted after current song</item>
<item quantity="other">%d songs inserted after current song</item> <item quantity="other">%d songs inserted after current song</item>
</plurals> </plurals>