localize user-facing text into string resources

Move every exposed UI string into res/values/strings.xml (+ plurals) and
read it via stringResource/pluralStringResource or getString, with 27
locale files carried over from the Flutter app's Weblate translations.
Only strings whose English is unchanged reuse an old translation; reworded
ones get fresh keys.

Persisted download failures become a DownloadError enum mapped to string
resources (stored by name, unknown legacy values degrade to Failed), the
per-app locale config is generated by AGP, and StringResourcesTest guards
locale keys and placeholders.
This commit is contained in:
austinried
2026-10-04 00:30:07 +00:00
parent 88cd3764bf
commit 24757bda3f
74 changed files with 2206 additions and 361 deletions
+5
View File
@@ -31,6 +31,10 @@ android {
compose = true compose = true
} }
androidResources {
generateLocaleConfig = true
}
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_17 sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17
@@ -39,6 +43,7 @@ android {
lint { lint {
checkReleaseBuilds = false checkReleaseBuilds = false
disable += setOf("MissingTranslation", "MissingQuantity")
} }
testOptions { testOptions {
@@ -9,6 +9,7 @@ import com.subtracks.data.model.Artist
import com.subtracks.data.model.ArtistSearch import com.subtracks.data.model.ArtistSearch
import com.subtracks.data.model.ArtworkSeed import com.subtracks.data.model.ArtworkSeed
import com.subtracks.data.model.Disc import com.subtracks.data.model.Disc
import com.subtracks.data.model.DownloadErrorConverter
import com.subtracks.data.model.DownloadStatusConverter import com.subtracks.data.model.DownloadStatusConverter
import com.subtracks.data.model.PlaybackCursor import com.subtracks.data.model.PlaybackCursor
import com.subtracks.data.model.Playlist import com.subtracks.data.model.Playlist
@@ -48,7 +49,7 @@ import com.subtracks.data.model.UpNextEntry
version = 26, version = 26,
exportSchema = true, exportSchema = true,
) )
@ColumnTypeConverters(QueueKindConverter::class, DownloadStatusConverter::class) @ColumnTypeConverters(QueueKindConverter::class, DownloadStatusConverter::class, DownloadErrorConverter::class)
abstract class SubtracksDatabase : RoomDatabase() { abstract class SubtracksDatabase : RoomDatabase() {
abstract fun libraryDao(): LibraryDao abstract fun libraryDao(): LibraryDao
@@ -1,5 +1,7 @@
package com.subtracks.data.download package com.subtracks.data.download
import com.subtracks.data.model.DownloadError
data class EngineRequest( data class EngineRequest(
val uri: String, val uri: String,
val path: String, val path: String,
@@ -13,7 +15,7 @@ data class EngineDownload(
val status: EngineStatus, val status: EngineStatus,
val bytes: Long = 0, val bytes: Long = 0,
val total: Long = 0, val total: Long = 0,
val error: String? = null, val error: DownloadError? = null,
) )
interface DownloadEngine { interface DownloadEngine {
@@ -53,7 +53,7 @@ class DownloadNotifier(
.setSilent(true) .setSilent(true)
.setCategory(NotificationCompat.CATEGORY_PROGRESS) .setCategory(NotificationCompat.CATEGORY_PROGRESS)
.setProgress(PROGRESS_MAX, ((content.progress ?: 0f) * PROGRESS_MAX).toInt(), content.progress == null) .setProgress(PROGRESS_MAX, ((content.progress ?: 0f) * PROGRESS_MAX).toInt(), content.progress == null)
.addAction(0, context.getString(R.string.download_cancel), cancelIntent()) .addAction(0, context.getString(R.string.actions_cancel), cancelIntent())
.setContentIntent(launchIntent()) .setContentIntent(launchIntent())
.build() .build()
@@ -75,7 +75,7 @@ class DownloadNotifier(
val manager = context.getSystemService(NotificationManager::class.java) ?: return val manager = context.getSystemService(NotificationManager::class.java) ?: return
if (manager.getNotificationChannel(CHANNEL_ID) != null) return if (manager.getNotificationChannel(CHANNEL_ID) != null) return
manager.createNotificationChannel( manager.createNotificationChannel(
NotificationChannel(CHANNEL_ID, context.getString(R.string.download_channel), NotificationManager.IMPORTANCE_LOW).apply { NotificationChannel(CHANNEL_ID, context.getString(R.string.downloads_title), NotificationManager.IMPORTANCE_LOW).apply {
description = context.getString(R.string.download_channel_description) description = context.getString(R.string.download_channel_description)
setShowBadge(false) setShowBadge(false)
}, },
@@ -4,6 +4,7 @@ import android.app.DownloadManager
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import android.os.Environment import android.os.Environment
import com.subtracks.data.model.DownloadError
class SystemDownloadEngine( class SystemDownloadEngine(
private val context: Context, private val context: Context,
@@ -60,13 +61,13 @@ internal fun engineStatus(status: Int): EngineStatus =
else -> EngineStatus.Pending else -> EngineStatus.Pending
} }
internal fun failureMessage(reason: Int): String = internal fun failureMessage(reason: Int): DownloadError =
when (reason) { when (reason) {
DownloadManager.ERROR_INSUFFICIENT_SPACE -> "Not enough space to download" DownloadManager.ERROR_INSUFFICIENT_SPACE -> DownloadError.NoSpace
DownloadManager.ERROR_DEVICE_NOT_FOUND -> "Storage is unavailable" DownloadManager.ERROR_DEVICE_NOT_FOUND -> DownloadError.StorageUnavailable
DownloadManager.ERROR_FILE_ERROR, DownloadManager.ERROR_FILE_ALREADY_EXISTS -> "Could not write the download" DownloadManager.ERROR_FILE_ERROR, DownloadManager.ERROR_FILE_ALREADY_EXISTS -> DownloadError.WriteFailed
DownloadManager.ERROR_HTTP_DATA_ERROR, DownloadManager.ERROR_UNHANDLED_HTTP_CODE -> "The server refused to send this track" DownloadManager.ERROR_HTTP_DATA_ERROR, DownloadManager.ERROR_UNHANDLED_HTTP_CODE -> DownloadError.ServerRefused
DownloadManager.ERROR_TOO_MANY_REDIRECTS -> "The server redirected too many times" DownloadManager.ERROR_TOO_MANY_REDIRECTS -> DownloadError.TooManyRedirects
DownloadManager.ERROR_CANNOT_RESUME -> "The download could not be resumed" DownloadManager.ERROR_CANNOT_RESUME -> DownloadError.ResumeFailed
else -> "Download failed" else -> DownloadError.Failed
} }
@@ -1,13 +1,33 @@
package com.subtracks.data.model package com.subtracks.data.model
import androidx.annotation.StringRes
import androidx.room3.ColumnInfo import androidx.room3.ColumnInfo
import androidx.room3.ColumnTypeConverter import androidx.room3.ColumnTypeConverter
import androidx.room3.Entity import androidx.room3.Entity
import androidx.room3.ForeignKey import androidx.room3.ForeignKey
import androidx.room3.Index import androidx.room3.Index
import com.subtracks.R
enum class DownloadStatus { Queued, Running, Completed, Failed } enum class DownloadStatus { Queued, Running, Completed, Failed }
enum class DownloadError(
@param:StringRes val messageRes: Int,
) {
NoSpace(R.string.error_download_no_space),
StorageUnavailable(R.string.error_download_storage_unavailable),
WriteFailed(R.string.error_download_write_failed),
ServerRefused(R.string.error_download_server_refused),
TooManyRedirects(R.string.error_download_too_many_redirects),
ResumeFailed(R.string.error_download_resume_failed),
Failed(R.string.error_download_failed),
FileMissing(R.string.error_download_file_missing),
ServerError(R.string.error_download_server_error),
NoAddress(R.string.error_download_no_address),
NothingLeft(R.string.error_download_nothing_left),
Stopped(R.string.error_download_stopped),
InactiveSource(R.string.error_download_inactive_source),
}
@Entity( @Entity(
tableName = "song_downloads", tableName = "song_downloads",
primaryKeys = ["sourceId", "songId"], primaryKeys = ["sourceId", "songId"],
@@ -34,7 +54,7 @@ data class SongDownload(
val engineId: Long? = null, val engineId: Long? = null,
@ColumnInfo(defaultValue = "0") val bytes: Long = 0, @ColumnInfo(defaultValue = "0") val bytes: Long = 0,
@ColumnInfo(defaultValue = "0") val total: Long = 0, @ColumnInfo(defaultValue = "0") val total: Long = 0,
val error: String? = null, val error: DownloadError? = null,
@ColumnInfo(defaultValue = "0") val downloadedAt: Long = 0, @ColumnInfo(defaultValue = "0") val downloadedAt: Long = 0,
) { ) {
val progress: Float? val progress: Float?
@@ -104,3 +124,11 @@ class DownloadStatusConverter {
@ColumnTypeConverter @ColumnTypeConverter
fun toDownloadStatus(value: String): DownloadStatus = DownloadStatus.entries.firstOrNull { it.name == value } ?: DownloadStatus.Failed fun toDownloadStatus(value: String): DownloadStatus = DownloadStatus.entries.firstOrNull { it.name == value } ?: DownloadStatus.Failed
} }
class DownloadErrorConverter {
@ColumnTypeConverter
fun fromDownloadError(error: DownloadError): String = error.name
@ColumnTypeConverter
fun toDownloadError(value: String): DownloadError = DownloadError.entries.firstOrNull { it.name == value } ?: DownloadError.Failed
}
@@ -10,6 +10,7 @@ import com.subtracks.data.download.EngineRequest
import com.subtracks.data.download.EngineStatus import com.subtracks.data.download.EngineStatus
import com.subtracks.data.download.looksLikeAudio import com.subtracks.data.download.looksLikeAudio
import com.subtracks.data.model.BulkDownloadAction import com.subtracks.data.model.BulkDownloadAction
import com.subtracks.data.model.DownloadError
import com.subtracks.data.model.DownloadList import com.subtracks.data.model.DownloadList
import com.subtracks.data.model.DownloadStatus import com.subtracks.data.model.DownloadStatus
import com.subtracks.data.model.DownloadedSong import com.subtracks.data.model.DownloadedSong
@@ -174,7 +175,7 @@ class DownloadRepository(
throw e throw e
} catch (_: Exception) { } catch (_: Exception) {
// Leave it failed rather than waiting, or the next tick would retry it forever. // Leave it failed rather than waiting, or the next tick would retry it forever.
markFailed(row, STOPPED) markFailed(row, DownloadError.Stopped)
return@forEach return@forEach
} }
db.downloadDao().upsert(row.copy(engineId = engineId)) db.downloadDao().upsert(row.copy(engineId = engineId))
@@ -459,8 +460,8 @@ class DownloadRepository(
DownloadStatus.Completed -> { DownloadStatus.Completed -> {
val file = file(row.sourceId, row.songId) val file = file(row.sourceId, row.songId)
when { when {
!file.exists() -> markFailed(row, MISSING_FILE) !file.exists() -> markFailed(row, DownloadError.FileMissing)
!file.looksLikeAudio() -> markFailed(row, SERVER_ERROR) !file.looksLikeAudio() -> markFailed(row, DownloadError.ServerError)
} }
} }
@@ -470,7 +471,7 @@ class DownloadRepository(
} }
DownloadStatus.Running -> { DownloadStatus.Running -> {
markFailed(row, STOPPED) markFailed(row, DownloadError.Stopped)
} }
DownloadStatus.Failed -> { DownloadStatus.Failed -> {
@@ -566,8 +567,8 @@ class DownloadRepository(
error = error =
when { when {
state.error != null -> state.error state.error != null -> state.error
errorPage -> SERVER_ERROR errorPage -> DownloadError.ServerError
status == DownloadStatus.Failed -> "Download failed" status == DownloadStatus.Failed -> DownloadError.Failed
else -> null else -> null
}, },
downloadedAt = downloadedAt =
@@ -582,11 +583,11 @@ class DownloadRepository(
private suspend fun markFailed( private suspend fun markFailed(
row: SongDownload, row: SongDownload,
message: String, error: DownloadError,
) { ) {
row.engineId?.let { engine.cancel(listOf(it)) } row.engineId?.let { engine.cancel(listOf(it)) }
file(row.sourceId, row.songId).delete() file(row.sourceId, row.songId).delete()
db.downloadDao().upsert(row.copy(status = DownloadStatus.Failed, engineId = null, error = message)) db.downloadDao().upsert(row.copy(status = DownloadStatus.Failed, engineId = null, error = error))
} }
private fun ensurePolling() { private fun ensurePolling() {
@@ -654,8 +655,6 @@ class DownloadRepository(
const val POLL_MS = 1_000L const val POLL_MS = 1_000L
const val IN_FLIGHT_LIMIT = 8 const val IN_FLIGHT_LIMIT = 8
const val DELETE_CHUNK = 900 const val DELETE_CHUNK = 900
const val MISSING_FILE = "The downloaded file is missing"
const val SERVER_ERROR = "The server sent an error instead of the track"
const val NO_ADDRESS = "Can't download: the server address is unavailable" const val NO_ADDRESS = "Can't download: the server address is unavailable"
const val EMPTY_LIST = "Nothing left to download from this list" const val EMPTY_LIST = "Nothing left to download from this list"
const val STOPPED = "The download stopped unexpectedly" const val STOPPED = "The download stopped unexpectedly"
@@ -5,6 +5,7 @@ import android.os.Environment
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.widget.Toast import android.widget.Toast
import com.subtracks.R
import com.subtracks.data.db.createAndroidDatabase import com.subtracks.data.db.createAndroidDatabase
import com.subtracks.data.download.ArtworkFetcher import com.subtracks.data.download.ArtworkFetcher
import com.subtracks.data.download.ArtworkStore import com.subtracks.data.download.ArtworkStore
@@ -115,8 +116,16 @@ fun appModule(
viewModel { HomeViewModel(get(), get(), get(), get()) } viewModel { HomeViewModel(get(), get(), get(), get()) }
viewModel { params -> HomeListViewModel(get(), get(), get(), params.get()) } viewModel { params -> HomeListViewModel(get(), get(), get(), params.get()) }
viewModel { SettingsViewModel(get(), get()) } viewModel { SettingsViewModel(get(), get()) }
viewModel { DownloadsViewModel(get(), get(), get()) } viewModel {
viewModel { params -> AddSourceViewModel(get(), get(), get(), params.getOrNull()) } DownloadsViewModel(
get(),
get(),
get(),
context.getString(R.string.unknown_artist),
context.getString(R.string.unknown_album),
)
}
viewModel { params -> AddSourceViewModel(get(), get(), get(), params.getOrNull(), context.resources) }
viewModel { params -> AlbumDetailViewModel(get(), get(), get(), get(), params.get()) } viewModel { params -> AlbumDetailViewModel(get(), get(), get(), get(), params.get()) }
viewModel { params -> ArtistDetailViewModel(get(), get(), get(), get(), params.get()) } viewModel { params -> ArtistDetailViewModel(get(), get(), get(), get(), params.get()) }
viewModel { params -> PlaylistDetailViewModel(get(), get(), get(), get(), params.get()) } viewModel { params -> PlaylistDetailViewModel(get(), get(), get(), get(), params.get()) }
@@ -45,6 +45,7 @@ import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelStore import androidx.lifecycle.ViewModelStore
@@ -58,6 +59,7 @@ import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument import androidx.navigation.navArgument
import com.subtracks.R
import com.subtracks.data.model.Song import com.subtracks.data.model.Song
import com.subtracks.data.repo.DownloadRepository import com.subtracks.data.repo.DownloadRepository
import com.subtracks.data.repo.LibraryRepository import com.subtracks.data.repo.LibraryRepository
@@ -474,7 +476,7 @@ private fun MainNavigation() {
) { entry -> ) { entry ->
val section = HomeSection.fromRoute(entry.arguments?.getString("section")) ?: return@composable val section = HomeSection.fromRoute(entry.arguments?.getString("section")) ?: return@composable
HomeListRoute( HomeListRoute(
request = HomeListRequest(title = section.title, section = section), request = HomeListRequest(title = stringResource(section.title), section = section),
onBack = { navController.popBackStack() }, onBack = { navController.popBackStack() },
onAlbumClick = { album -> onAlbumClick = { album ->
navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt)) navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
@@ -522,7 +524,7 @@ private fun MainNavigation() {
) { entry -> ) { entry ->
val decade = entry.arguments?.getLong("decade") ?: return@composable val decade = entry.arguments?.getLong("decade") ?: return@composable
HomeListRoute( HomeListRoute(
request = HomeListRequest(title = "${decade}s", decade = decade), request = HomeListRequest(title = stringResource(R.string.home_decade, decade), decade = decade),
onBack = { navController.popBackStack() }, onBack = { navController.popBackStack() },
onAlbumClick = { album -> onAlbumClick = { album ->
navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt)) navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
@@ -548,7 +550,7 @@ private fun MainNavigation() {
OfflineListKind.entries.firstOrNull { it.name == entry.arguments?.getString("kind") } OfflineListKind.entries.firstOrNull { it.name == entry.arguments?.getString("kind") }
?: return@composable ?: return@composable
HomeListRoute( HomeListRoute(
request = HomeListRequest(title = kind.title, downloaded = kind), request = HomeListRequest(title = stringResource(kind.title), downloaded = kind),
onBack = { navController.popBackStack() }, onBack = { navController.popBackStack() },
onAlbumClick = { album -> onAlbumClick = { album ->
navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt)) navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
@@ -38,6 +38,7 @@ import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.FilterQuality import androidx.compose.ui.graphics.FilterQuality
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -49,6 +50,7 @@ import coil3.request.ImageRequest
import coil3.request.SuccessResult import coil3.request.SuccessResult
import coil3.request.crossfade import coil3.request.crossfade
import coil3.size.Dimension import coil3.size.Dimension
import com.subtracks.R
import com.subtracks.data.model.CoverArtRef import com.subtracks.data.model.CoverArtRef
import com.subtracks.ui.theme.artworkColorsFromSeed import com.subtracks.ui.theme.artworkColorsFromSeed
import com.subtracks.ui.theme.placeholderSeed import com.subtracks.ui.theme.placeholderSeed
@@ -360,13 +362,13 @@ fun FilteredEmptyState(
verticalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp),
) { ) {
Text( Text(
text = "Filters are hiding everything.", text = stringResource(R.string.components_filters_hiding),
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
) )
Button(onClick = onClearFilters) { Button(onClick = onClearFilters) {
Text("Clear filters") Text(stringResource(R.string.components_clear_filters))
} }
} }
} }
@@ -7,6 +7,8 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import com.subtracks.R
import com.subtracks.data.model.DownloadList import com.subtracks.data.model.DownloadList
data class PendingDownloadDelete( data class PendingDownloadDelete(
@@ -26,17 +28,17 @@ fun DeleteDownloadsDialog(
val freed = formatBytes(LocalContext.current, bytes) val freed = formatBytes(LocalContext.current, bytes)
AlertDialog( AlertDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
title = { Text("Delete downloads") }, title = { Text(stringResource(R.string.resources_song_list_delete_all_title)) },
text = { Text("$name\n\nDeleting will free $freed") }, text = { Text(stringResource(R.string.resources_song_list_delete_all_content, name, freed)) },
confirmButton = { confirmButton = {
TextButton( TextButton(
onClick = { onClick = {
onConfirm() onConfirm()
onDismiss() onDismiss()
}, },
) { Text("Delete") } ) { Text(stringResource(R.string.actions_delete)) }
}, },
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }, dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.actions_cancel)) } },
) )
} }
@@ -13,9 +13,11 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.blur import androidx.compose.ui.draw.blur
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.subtracks.R
import com.subtracks.data.model.ListDownloadStatus import com.subtracks.data.model.ListDownloadStatus
const val DOWNLOADING_INDICATOR_TAG = "Downloading" const val DOWNLOADING_INDICATOR_TAG = "Downloading"
@@ -53,6 +55,7 @@ private fun DownloadRing(
progress: () -> Float, progress: () -> Float,
shadow: Boolean, shadow: Boolean,
) { ) {
val downloadingDescription = stringResource(R.string.status_downloading)
Box(Modifier.size(20.dp), contentAlignment = Alignment.Center) { Box(Modifier.size(20.dp), contentAlignment = Alignment.Center) {
if (shadow) { if (shadow) {
CircularProgressIndicator( CircularProgressIndicator(
@@ -68,7 +71,7 @@ private fun DownloadRing(
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
trackColor = MaterialTheme.colorScheme.surfaceContainerHighest, trackColor = MaterialTheme.colorScheme.surfaceContainerHighest,
strokeWidth = 2.5.dp, strokeWidth = 2.5.dp,
modifier = Modifier.fillMaxSize().semantics { contentDescription = DOWNLOADING_INDICATOR_TAG }, modifier = Modifier.fillMaxSize().semantics { contentDescription = downloadingDescription },
) )
} }
} }
@@ -62,11 +62,13 @@ import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.layout.positionInParent import androidx.compose.ui.layout.positionInParent
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.subtracks.R
import com.subtracks.data.model.BulkDownloadAction import com.subtracks.data.model.BulkDownloadAction
import com.subtracks.data.model.CoverArtRef import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.ListDownloadStatus import com.subtracks.data.model.ListDownloadStatus
@@ -179,7 +181,10 @@ fun HeroDetailScaffold(
}, },
navigationIcon = { navigationIcon = {
IconButton(onClick = onBack, modifier = Modifier.graphicsLayer { alpha = barFraction }) { IconButton(onClick = onBack, modifier = Modifier.graphicsLayer { alpha = barFraction }) {
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Back") Icon(
Icons.AutoMirrored.Rounded.ArrowBack,
contentDescription = stringResource(R.string.navigation_back),
)
} }
}, },
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent), colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent),
@@ -223,11 +228,12 @@ fun ListDownloadStatus.actions(): List<BulkDownloadAction> =
fun ListDownloadStatus.action(): BulkDownloadAction = actions().first() fun ListDownloadStatus.action(): BulkDownloadAction = actions().first()
@Composable
private fun downloadActionLabel(action: BulkDownloadAction): String = private fun downloadActionLabel(action: BulkDownloadAction): String =
when (action) { when (action) {
BulkDownloadAction.Download -> "Download" BulkDownloadAction.Download -> stringResource(R.string.actions_download)
BulkDownloadAction.Cancel -> "Cancel download" BulkDownloadAction.Cancel -> stringResource(R.string.actions_download_cancel)
BulkDownloadAction.Delete -> "Delete download" BulkDownloadAction.Delete -> stringResource(R.string.delete_download)
} }
@Composable @Composable
@@ -280,7 +286,7 @@ fun HeroHeader(
modifier = modifier =
Modifier Modifier
.heightIn(min = 32.dp) .heightIn(min = 32.dp)
.clickable(onClickLabel = "Open artist") { onSubtitleClick() }, .clickable(onClickLabel = stringResource(R.string.open_artist)) { onSubtitleClick() },
contentAlignment = Alignment.TopCenter, contentAlignment = Alignment.TopCenter,
) { ) {
HeroSubtitle(subtitle, Modifier.padding(top = 4.dp)) HeroSubtitle(subtitle, Modifier.padding(top = 4.dp))
@@ -312,7 +318,7 @@ fun HeroHeader(
onClick = { onDownloadAction(action) }, onClick = { onDownloadAction(action) },
onClickLabel = downloadActionLabel(action), onClickLabel = downloadActionLabel(action),
onLongClick = if (canDelete) ({ onDownloadAction(BulkDownloadAction.Delete) }) else null, onLongClick = if (canDelete) ({ onDownloadAction(BulkDownloadAction.Delete) }) else null,
onLongClickLabel = if (canDelete) "Delete download" else null, onLongClickLabel = if (canDelete) stringResource(R.string.delete_download) else null,
), ),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
@@ -320,7 +326,7 @@ fun HeroHeader(
BulkDownloadAction.Delete -> { BulkDownloadAction.Delete -> {
Icon( Icon(
imageVector = Icons.Rounded.DownloadDone, imageVector = Icons.Rounded.DownloadDone,
contentDescription = "Delete download", contentDescription = stringResource(R.string.delete_download),
modifier = Modifier.size(28.dp), modifier = Modifier.size(28.dp),
) )
} }
@@ -332,12 +338,20 @@ fun HeroHeader(
modifier = Modifier.size(26.dp), modifier = Modifier.size(26.dp),
strokeWidth = 2.dp, strokeWidth = 2.dp,
) )
Icon(Icons.Rounded.Close, contentDescription = "Cancel download", modifier = Modifier.size(16.dp)) Icon(
Icons.Rounded.Close,
contentDescription = stringResource(R.string.actions_download_cancel),
modifier = Modifier.size(16.dp),
)
} }
} }
BulkDownloadAction.Download -> { BulkDownloadAction.Download -> {
Icon(Icons.Rounded.Download, contentDescription = "Download", modifier = Modifier.size(24.dp)) Icon(
Icons.Rounded.Download,
contentDescription = stringResource(R.string.actions_download),
modifier = Modifier.size(24.dp),
)
} }
} }
} }
@@ -360,7 +374,11 @@ fun HeroHeader(
shape = RoundedCornerShape(topStart = 24.dp, bottomStart = 24.dp), shape = RoundedCornerShape(topStart = 24.dp, bottomStart = 24.dp),
modifier = Modifier.width(68.dp).height(48.dp), modifier = Modifier.width(68.dp).height(48.dp),
) { ) {
Icon(Icons.Rounded.PlayArrow, contentDescription = "Play", modifier = Modifier.size(30.dp)) Icon(
Icons.Rounded.PlayArrow,
contentDescription = stringResource(R.string.controls_play),
modifier = Modifier.size(30.dp),
)
} }
FilledIconButton( FilledIconButton(
onClick = onShuffle, onClick = onShuffle,
@@ -368,7 +386,11 @@ fun HeroHeader(
shape = RoundedCornerShape(topEnd = 24.dp, bottomEnd = 24.dp), shape = RoundedCornerShape(topEnd = 24.dp, bottomEnd = 24.dp),
modifier = Modifier.width(68.dp).height(48.dp), modifier = Modifier.width(68.dp).height(48.dp),
) { ) {
Icon(Icons.Rounded.Shuffle, contentDescription = "Shuffle play", modifier = Modifier.size(30.dp)) Icon(
Icons.Rounded.Shuffle,
contentDescription = stringResource(R.string.controls_shuffle_play),
modifier = Modifier.size(30.dp),
)
} }
} }
Box( Box(
@@ -380,7 +402,7 @@ fun HeroHeader(
} }
} }
IconButton(onClick = onMore, modifier = Modifier.padding(end = 8.dp)) { IconButton(onClick = onMore, modifier = Modifier.padding(end = 8.dp)) {
Icon(Icons.Rounded.MoreHoriz, contentDescription = "More options") Icon(Icons.Rounded.MoreHoriz, contentDescription = stringResource(R.string.more_options))
} }
} }
} }
@@ -42,8 +42,10 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.subtracks.R
import com.subtracks.data.model.BulkDownloadAction import com.subtracks.data.model.BulkDownloadAction
import com.subtracks.data.model.CoverArtRef import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.DownloadList import com.subtracks.data.model.DownloadList
@@ -184,10 +186,10 @@ fun ItemContextMenu(
fun queueItems() { fun queueItems() {
if (queueRef == null) return if (queueRef == null) return
actions.playNext?.let { next -> actions.playNext?.let { next ->
MenuItem(Icons.Rounded.SkipNext, "Play next") { dismiss { next(queueRef) } } MenuItem(Icons.Rounded.SkipNext, stringResource(R.string.play_next)) { dismiss { next(queueRef) } }
} }
actions.addToQueue?.let { add -> actions.addToQueue?.let { add ->
MenuItem(Icons.AutoMirrored.Rounded.QueueMusic, "Add to queue") { dismiss { add(queueRef) } } MenuItem(Icons.AutoMirrored.Rounded.QueueMusic, stringResource(R.string.add_to_queue)) { dismiss { add(queueRef) } }
} }
} }
@@ -209,20 +211,26 @@ fun ItemContextMenu(
) )
target.song.albumId?.let { albumId -> target.song.albumId?.let { albumId ->
actions.viewAlbum?.let { view -> actions.viewAlbum?.let { view ->
MenuItem(Icons.Rounded.Album, target.song.album?.takeIf { it.isNotBlank() } ?: "Album") { MenuItem(
Icons.Rounded.Album,
target.song.album?.takeIf { it.isNotBlank() } ?: stringResource(R.string.resources_filter_album),
) {
dismiss { view(albumId) } dismiss { view(albumId) }
} }
} }
} }
target.song.artistId?.let { artistId -> target.song.artistId?.let { artistId ->
actions.viewArtist?.let { view -> actions.viewArtist?.let { view ->
MenuItem(Icons.Rounded.Person, target.song.artist?.takeIf { it.isNotBlank() } ?: "Artist") { MenuItem(
Icons.Rounded.Person,
target.song.artist?.takeIf { it.isNotBlank() } ?: stringResource(R.string.resources_filter_artist),
) {
dismiss { view(artistId) } dismiss { view(artistId) }
} }
} }
} }
onInfo?.let { info -> onInfo?.let { info ->
MenuItem(Icons.Rounded.Info, "Info") { dismiss { info(target.song) } } MenuItem(Icons.Rounded.Info, stringResource(R.string.info)) { dismiss { info(target.song) } }
} }
} }
@@ -236,7 +244,10 @@ fun ItemContextMenu(
) )
target.album.artistId?.let { artistId -> target.album.artistId?.let { artistId ->
actions.viewArtist?.let { view -> actions.viewArtist?.let { view ->
MenuItem(Icons.Rounded.Person, target.album.albumArtist?.takeIf { it.isNotBlank() } ?: "Artist") { MenuItem(
Icons.Rounded.Person,
target.album.albumArtist?.takeIf { it.isNotBlank() } ?: stringResource(R.string.resources_filter_artist),
) {
dismiss { view(artistId) } dismiss { view(artistId) }
} }
} }
@@ -303,25 +314,25 @@ private fun DownloadItem(
when (download?.status) { when (download?.status) {
DownloadStatus.Completed -> { DownloadStatus.Completed -> {
actions.deleteDownload?.let { delete -> actions.deleteDownload?.let { delete ->
MenuItem(Icons.Rounded.Delete, "Delete download") { dismiss { delete(song) } } MenuItem(Icons.Rounded.Delete, stringResource(R.string.delete_download)) { dismiss { delete(song) } }
} }
} }
DownloadStatus.Queued, DownloadStatus.Running -> { DownloadStatus.Queued, DownloadStatus.Running -> {
actions.cancelDownload?.let { cancel -> actions.cancelDownload?.let { cancel ->
MenuItem(Icons.Rounded.Cancel, "Cancel download") { dismiss { cancel(song) } } MenuItem(Icons.Rounded.Cancel, stringResource(R.string.actions_download_cancel)) { dismiss { cancel(song) } }
} }
} }
DownloadStatus.Failed -> { DownloadStatus.Failed -> {
actions.download?.let { start -> actions.download?.let { start ->
MenuItem(Icons.Rounded.Download, "Retry download") { dismiss { start(song) } } MenuItem(Icons.Rounded.Download, stringResource(R.string.retry_download)) { dismiss { start(song) } }
} }
} }
null -> { null -> {
actions.download?.let { start -> actions.download?.let { start ->
MenuItem(Icons.Rounded.Download, "Download") { dismiss { start(song) } } MenuItem(Icons.Rounded.Download, stringResource(R.string.actions_download)) { dismiss { start(song) } }
} }
} }
} }
@@ -343,9 +354,21 @@ private fun BulkDownloadItem(
} }
for (action in (status ?: ListDownloadStatus()).actions()) { for (action in (status ?: ListDownloadStatus()).actions()) {
when (action) { when (action) {
BulkDownloadAction.Delete -> MenuItem(Icons.Rounded.Delete, "Delete downloads") { dismiss { bulk(target, action) } } BulkDownloadAction.Delete -> {
BulkDownloadAction.Cancel -> MenuItem(Icons.Rounded.Cancel, "Cancel downloads") { dismiss { bulk(target, action) } } MenuItem(Icons.Rounded.Delete, stringResource(R.string.resources_song_list_delete_all_title)) {
BulkDownloadAction.Download -> MenuItem(Icons.Rounded.Download, "Download") { dismiss { bulk(target, action) } } dismiss { bulk(target, action) }
}
}
BulkDownloadAction.Cancel -> {
MenuItem(Icons.Rounded.Cancel, stringResource(R.string.cancel_downloads)) { dismiss { bulk(target, action) } }
}
BulkDownloadAction.Download -> {
MenuItem(Icons.Rounded.Download, stringResource(R.string.actions_download)) {
dismiss { bulk(target, action) }
}
}
} }
} }
} }
@@ -390,6 +413,8 @@ private fun MenuItem(
private fun starIcon(starred: Long?): ImageVector = if (starred == null) Icons.Rounded.StarBorder else Icons.Rounded.Star private fun starIcon(starred: Long?): ImageVector = if (starred == null) Icons.Rounded.StarBorder else Icons.Rounded.Star
private fun starLabel(starred: Long?): String = if (starred == null) "Star" else "Unstar" @Composable
private fun starLabel(starred: Long?): String =
if (starred == null) stringResource(R.string.actions_star) else stringResource(R.string.actions_unstar)
private const val STAR_DISMISS_DELAY_MS = 200L private const val STAR_DISMISS_DELAY_MS = 200L
@@ -21,7 +21,9 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.subtracks.R
import com.subtracks.data.media.readAudioEncoding import com.subtracks.data.media.readAudioEncoding
import com.subtracks.data.model.AudioEncoding import com.subtracks.data.model.AudioEncoding
import com.subtracks.data.model.DownloadStatus import com.subtracks.data.model.DownloadStatus
@@ -48,29 +50,45 @@ fun SongInfoDialog(
val averageBps = sizeBytes?.let { bytes -> song.duration?.takeIf { it > 0 }?.let { seconds -> (bytes * 8 / seconds).toInt() } } val averageBps = sizeBytes?.let { bytes -> song.duration?.takeIf { it > 0 }?.let { seconds -> (bytes * 8 / seconds).toInt() } }
AlertDialog( AlertDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
title = { Text("Info") }, title = { Text(stringResource(R.string.info)) },
text = { text = {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
InfoRow("Title", song.title) InfoRow(stringResource(R.string.resources_sort_by_title), song.title)
InfoRow("Artist", song.artist) InfoRow(stringResource(R.string.resources_filter_artist), song.artist)
InfoRow("Album", song.album) InfoRow(stringResource(R.string.resources_filter_album), song.album)
InfoRow("Duration", song.duration?.let(DateUtils::formatElapsedTime)) InfoRow(stringResource(R.string.info_duration), song.duration?.let(DateUtils::formatElapsedTime))
InfoRow("Track", song.track?.let { track -> song.disc?.let { "$it.$track" } ?: track.toString() }) InfoRow(
InfoRow("Genre", genres.joinToString(", ").ifBlank { null }) stringResource(R.string.info_track),
InfoRow("Starred", if (song.starred != null) "Yes" else null) song.track?.let { track ->
song.disc?.let { disc -> stringResource(R.string.info_track_number, disc, track) }
?: track.toString()
},
)
InfoRow(stringResource(R.string.resources_filter_genre), genres.joinToString(", ").ifBlank { null })
InfoRow(stringResource(R.string.info_starred), if (song.starred != null) stringResource(R.string.info_yes) else null)
if (download != null) { if (download != null) {
InfoRow("Download", downloadStatus(download.status)) InfoRow(stringResource(R.string.actions_download), downloadStatus(download.status))
InfoRow("Size", sizeBytes?.let { Formatter.formatFileSize(context, it) }) InfoRow(stringResource(R.string.info_size), sizeBytes?.let { Formatter.formatFileSize(context, it) })
download.error?.let { InfoRow("Error", it) } download.error?.let { InfoRow(stringResource(R.string.info_error), stringResource(it.messageRes)) }
} }
InfoRow("Format", shown?.format) InfoRow(stringResource(R.string.info_format), shown?.format)
// A decoder often reports no bitrate; the file's own size over the track length does. // A decoder often reports no bitrate; the file's own size over the track length does.
InfoRow("Bitrate", (shown?.bitrate?.takeIf { it > 0 } ?: averageBps)?.let { "${it / 1000} kbps" }) InfoRow(
InfoRow("Sample rate", shown?.sampleRate?.takeIf { it > 0 }?.let { "$it Hz" }) stringResource(R.string.info_bitrate),
InfoRow("Channels", shown?.channels?.takeIf { it > 0 }?.toString()) (shown?.bitrate?.takeIf { it > 0 } ?: averageBps)?.let { stringResource(R.string.bitrate_value, it / 1000) },
)
InfoRow(
stringResource(R.string.info_sample_rate),
shown
?.sampleRate
?.takeIf {
it > 0
}?.let { stringResource(R.string.info_sample_rate_value, it) },
)
InfoRow(stringResource(R.string.info_channels), shown?.channels?.takeIf { it > 0 }?.toString())
} }
}, },
confirmButton = { TextButton(onClick = onDismiss) { Text("Close") } }, confirmButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.info_close)) } },
) )
} }
@@ -100,10 +118,11 @@ private fun InfoRow(
private val INFO_LABEL_WIDTH = 80.dp private val INFO_LABEL_WIDTH = 80.dp
@Composable
private fun downloadStatus(status: DownloadStatus): String = private fun downloadStatus(status: DownloadStatus): String =
when (status) { when (status) {
DownloadStatus.Completed -> "Downloaded" DownloadStatus.Completed -> stringResource(R.string.status_downloaded)
DownloadStatus.Queued -> "Queued" DownloadStatus.Queued -> stringResource(R.string.status_queued)
DownloadStatus.Running -> "Downloading" DownloadStatus.Running -> stringResource(R.string.status_downloading)
DownloadStatus.Failed -> "Failed" DownloadStatus.Failed -> stringResource(R.string.status_failed)
} }
@@ -38,9 +38,12 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.subtracks.R
import com.subtracks.data.model.AudioEncoding import com.subtracks.data.model.AudioEncoding
import com.subtracks.data.model.DownloadList import com.subtracks.data.model.DownloadList
import com.subtracks.data.model.DownloadStatus import com.subtracks.data.model.DownloadStatus
@@ -86,7 +89,7 @@ fun DownloadsRoute(
when (val dialog = pending) { when (val dialog = pending) {
is DownloadDeleteTarget.All -> { is DownloadDeleteTarget.All -> {
DeleteDownloadsDialog( DeleteDownloadsDialog(
name = "all downloads", name = stringResource(R.string.downloads_all),
bytes = tree.bytes, bytes = tree.bytes,
onConfirm = viewModel::deleteAll, onConfirm = viewModel::deleteAll,
onDismiss = { pending = null }, onDismiss = { pending = null },
@@ -123,17 +126,17 @@ fun DownloadsScreen(
modifier = modifier, modifier = modifier,
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { Text("Downloads") }, title = { Text(stringResource(R.string.downloads_title)) },
navigationIcon = { navigationIcon = {
IconButton(onClick = onBack) { IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Back") Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = stringResource(R.string.navigation_back))
} }
}, },
) )
}, },
) { padding -> ) { padding ->
if (tree.songs == 0) { if (tree.songs == 0) {
EmptyState("No downloads yet.", Modifier.padding(padding)) EmptyState(stringResource(R.string.downloads_empty), Modifier.padding(padding))
return@Scaffold return@Scaffold
} }
LazyColumn( LazyColumn(
@@ -150,7 +153,13 @@ fun DownloadsScreen(
val active = activeDownloads(songs) val active = activeDownloads(songs)
NodeRow( NodeRow(
name = artist.name, name = artist.name,
subtitle = "${artist.albums.size} albums · ${size(artist.bytes)}", subtitle =
pluralStringResource(
R.plurals.downloads_albums_size,
artist.albums.size,
artist.albums.size,
size(artist.bytes),
),
expanded = artistKey in expanded, expanded = artistKey in expanded,
indent = 0, indent = 0,
progress = nodeProgress(songs), progress = nodeProgress(songs),
@@ -174,7 +183,13 @@ fun DownloadsScreen(
val active = activeDownloads(album.songs) val active = activeDownloads(album.songs)
NodeRow( NodeRow(
name = album.name, name = album.name,
subtitle = "${album.songs.size} songs · ${size(album.bytes)}", subtitle =
pluralStringResource(
R.plurals.downloads_songs_size,
album.songs.size,
album.songs.size,
size(album.bytes),
),
expanded = albumKey in expanded, expanded = albumKey in expanded,
indent = 1, indent = 1,
progress = nodeProgress(album.songs), progress = nodeProgress(album.songs),
@@ -219,11 +234,11 @@ private fun SummaryRow(
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
) { ) {
Text( Text(
text = "${tree.songs} songs · ${size(tree.bytes)}", text = pluralStringResource(R.plurals.downloads_songs_size, tree.songs, tree.songs, size(tree.bytes)),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
TextButton(onClick = { onDelete(DownloadDeleteTarget.All) }) { Text("Delete all") } TextButton(onClick = { onDelete(DownloadDeleteTarget.All) }) { Text(stringResource(R.string.delete_all)) }
} }
} }
@@ -255,18 +270,18 @@ private fun NodeRow(
IconButton(onClick = onToggle) { IconButton(onClick = onToggle) {
Icon( Icon(
imageVector = if (expanded) Icons.Rounded.ExpandLess else Icons.Rounded.ExpandMore, imageVector = if (expanded) Icons.Rounded.ExpandLess else Icons.Rounded.ExpandMore,
contentDescription = if (expanded) "Collapse" else "Expand", contentDescription = if (expanded) stringResource(R.string.collapse) else stringResource(R.string.expand),
) )
} }
}, },
trailingContent = { trailingContent = {
if (progress != null) { if (progress != null) {
IconButton(onClick = onCancel) { IconButton(onClick = onCancel) {
Icon(Icons.Rounded.Close, contentDescription = "Cancel download of $name") Icon(Icons.Rounded.Close, contentDescription = stringResource(R.string.download_cancel_of, name))
} }
} else { } else {
IconButton(onClick = onDelete) { IconButton(onClick = onDelete) {
Icon(Icons.Rounded.Delete, contentDescription = "Delete downloads of $name") Icon(Icons.Rounded.Delete, contentDescription = stringResource(R.string.download_delete_all_of, name))
} }
} }
}, },
@@ -317,11 +332,11 @@ private fun SongRow(
trailingContent = { trailingContent = {
if (active) { if (active) {
IconButton(onClick = onCancel) { IconButton(onClick = onCancel) {
Icon(Icons.Rounded.Close, contentDescription = "Cancel download of ${song.title}") Icon(Icons.Rounded.Close, contentDescription = stringResource(R.string.download_cancel_of, song.title))
} }
} else { } else {
IconButton(onClick = onDelete) { IconButton(onClick = onDelete) {
Icon(Icons.Rounded.Delete, contentDescription = "Delete download of ${song.title}") Icon(Icons.Rounded.Delete, contentDescription = stringResource(R.string.download_delete_of, song.title))
} }
} }
}, },
@@ -336,25 +351,27 @@ private fun songSubtitle(
encoding: AudioEncoding?, encoding: AudioEncoding?,
): String { ): String {
val size = size(song.size) val size = size(song.size)
val bitrate = encoding?.bitrate?.takeIf { it > 0 }
val bitrateText = if (bitrate != null) stringResource(R.string.bitrate_value, bitrate / 1000) else null
return when (song.status) { return when (song.status) {
DownloadStatus.Completed -> { DownloadStatus.Completed -> {
listOfNotNull( listOfNotNull(
encoding?.format, encoding?.format,
encoding?.bitrate?.takeIf { it > 0 }?.let { "${it / 1000} kbps" }, bitrateText,
size, size,
).joinToString(" · ") ).joinToString(" · ")
} }
DownloadStatus.Queued -> { DownloadStatus.Queued -> {
"Queued · $size" stringResource(R.string.download_status_summary, stringResource(R.string.status_queued), size)
} }
DownloadStatus.Running -> { DownloadStatus.Running -> {
"Downloading · $size" stringResource(R.string.download_status_summary, stringResource(R.string.status_downloading), size)
} }
DownloadStatus.Failed -> { DownloadStatus.Failed -> {
"Failed · $size" stringResource(R.string.download_status_summary, stringResource(R.string.status_failed), size)
} }
} }
} }
@@ -45,13 +45,15 @@ class DownloadsViewModel(
private val libraryRepository: LibraryRepository, private val libraryRepository: LibraryRepository,
private val downloadRepository: DownloadRepository, private val downloadRepository: DownloadRepository,
private val playbackController: PlaybackController, private val playbackController: PlaybackController,
private val unknownArtist: String,
private val unknownAlbum: String,
) : ViewModel() { ) : ViewModel() {
private val sourceId = libraryRepository.activeSourceId.filterNotNull() private val sourceId = libraryRepository.activeSourceId.filterNotNull()
val tree: StateFlow<DownloadTree> = val tree: StateFlow<DownloadTree> =
sourceId sourceId
.flatMapLatest { downloadRepository.downloadedSongs(it) } .flatMapLatest { downloadRepository.downloadedSongs(it) }
.map(::buildTree) .map { buildTree(it, unknownArtist, unknownAlbum) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), DownloadTree()) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), DownloadTree())
fun deleteAll() { fun deleteAll() {
@@ -89,7 +91,11 @@ class DownloadsViewModel(
downloadRepository.localFile(song.sourceId, song.songId)?.let { readAudioEncoding(it) } downloadRepository.localFile(song.sourceId, song.songId)?.let { readAudioEncoding(it) }
} }
internal fun buildTree(songs: List<DownloadedSong>): DownloadTree = internal fun buildTree(
songs: List<DownloadedSong>,
unknownArtist: String = "Unknown artist",
unknownAlbum: String = "Unknown album",
): DownloadTree =
DownloadTree( DownloadTree(
artists = artists =
songs songs
@@ -97,7 +103,7 @@ internal fun buildTree(songs: List<DownloadedSong>): DownloadTree =
.map { (artistKey, artistSongs) -> .map { (artistKey, artistSongs) ->
DownloadArtistNode( DownloadArtistNode(
id = artistKey.first, id = artistKey.first,
name = artistKey.second.ifBlank { "Unknown artist" }, name = artistKey.second.ifBlank { unknownArtist },
bytes = artistSongs.sumOf { it.size }, bytes = artistSongs.sumOf { it.size },
albums = albums =
artistSongs artistSongs
@@ -105,7 +111,7 @@ internal fun buildTree(songs: List<DownloadedSong>): DownloadTree =
.map { (albumKey, albumSongs) -> .map { (albumKey, albumSongs) ->
DownloadAlbumNode( DownloadAlbumNode(
id = albumKey.first, id = albumKey.first,
name = albumKey.second.ifBlank { "Unknown album" }, name = albumKey.second.ifBlank { unknownAlbum },
bytes = albumSongs.sumOf { it.size }, bytes = albumSongs.sumOf { it.size },
songs = albumSongs.sortedBy { it.title.lowercase() }, songs = albumSongs.sortedBy { it.title.lowercase() },
) )
@@ -1,5 +1,6 @@
package com.subtracks.ui.home package com.subtracks.ui.home
import android.content.res.Resources
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@@ -34,6 +35,9 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -41,6 +45,7 @@ import androidx.paging.LoadState
import androidx.paging.compose.LazyPagingItems import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.collectAsLazyPagingItems import androidx.paging.compose.collectAsLazyPagingItems
import androidx.paging.compose.itemKey import androidx.paging.compose.itemKey
import com.subtracks.R
import com.subtracks.data.model.Album import com.subtracks.data.model.Album
import com.subtracks.data.model.AlbumSongItem import com.subtracks.data.model.AlbumSongItem
import com.subtracks.data.model.Artist import com.subtracks.data.model.Artist
@@ -133,6 +138,7 @@ fun HomeListScreen(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val now = remember { System.currentTimeMillis() } val now = remember { System.currentTimeMillis() }
val resources = LocalContext.current.resources
Scaffold( Scaffold(
modifier = modifier, modifier = modifier,
topBar = { topBar = {
@@ -140,7 +146,7 @@ fun HomeListScreen(
title = { Text(request.title) }, title = { Text(request.title) },
navigationIcon = { navigationIcon = {
IconButton(onClick = onBack) { IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Back") Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = stringResource(R.string.navigation_back))
} }
}, },
) )
@@ -153,7 +159,7 @@ fun HomeListScreen(
AlbumsList( AlbumsList(
albums = albums, albums = albums,
coverArt = coverArt, coverArt = coverArt,
info = { albumListInfo(request, it, now) }, info = { albumListInfo(request, it, now, resources) },
onAlbumClick = onAlbumClick, onAlbumClick = onAlbumClick,
onLongClick = onLongClick, onLongClick = onLongClick,
modifier = Modifier.padding(padding), modifier = Modifier.padding(padding),
@@ -164,7 +170,7 @@ fun HomeListScreen(
ArtistsList( ArtistsList(
artists = artists, artists = artists,
coverArt = coverArt, coverArt = coverArt,
info = { artistListInfo(request, it, now) }, info = { artistListInfo(request, it, now, resources) },
onArtistClick = onArtistClick, onArtistClick = onArtistClick,
onLongClick = onLongClick, onLongClick = onLongClick,
modifier = Modifier.padding(padding), modifier = Modifier.padding(padding),
@@ -207,7 +213,7 @@ fun HomeListScreen(
HomeSection.Decades -> { HomeSection.Decades -> {
PlainList( PlainList(
items = decades, items = decades,
label = { "${it}s" }, label = { stringResource(R.string.home_decade, it) },
onClick = onDecadeClick, onClick = onDecadeClick,
modifier = Modifier.padding(padding), modifier = Modifier.padding(padding),
) )
@@ -217,7 +223,7 @@ fun HomeListScreen(
ArtistsList( ArtistsList(
artists = artists, artists = artists,
coverArt = coverArt, coverArt = coverArt,
info = { artistListInfo(request, it, now) }, info = { artistListInfo(request, it, now, resources) },
onArtistClick = onArtistClick, onArtistClick = onArtistClick,
onLongClick = onLongClick, onLongClick = onLongClick,
modifier = Modifier.padding(padding), modifier = Modifier.padding(padding),
@@ -254,7 +260,7 @@ fun HomeListScreen(
AlbumsList( AlbumsList(
albums = albums, albums = albums,
coverArt = coverArt, coverArt = coverArt,
info = { albumListInfo(request, it, now) }, info = { albumListInfo(request, it, now, resources) },
onAlbumClick = onAlbumClick, onAlbumClick = onAlbumClick,
onLongClick = onLongClick, onLongClick = onLongClick,
modifier = Modifier.padding(padding), modifier = Modifier.padding(padding),
@@ -269,7 +275,7 @@ fun HomeListScreen(
@Composable @Composable
private fun <T> PlainList( private fun <T> PlainList(
items: List<T>, items: List<T>,
label: (T) -> String, label: @Composable (T) -> String,
onClick: (T) -> Unit, onClick: (T) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
@@ -470,7 +476,7 @@ private fun ArtistListRow(
) )
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Text( Text(
text = "${artist.albumCount} ${if (artist.albumCount == 1L) "album" else "albums"}", text = pluralStringResource(R.plurals.resources_album_count, artist.albumCount.toInt(), artist.albumCount),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1, maxLines = 1,
@@ -494,10 +500,11 @@ internal fun artistListInfo(
request: HomeListRequest, request: HomeListRequest,
artist: Artist, artist: Artist,
now: Long, now: Long,
resources: Resources,
): String? = ): String? =
when (request.section) { when (request.section) {
HomeSection.RecentlyPlayedArtists -> artist.played?.let { relativeTimeLabel(it, now) } HomeSection.RecentlyPlayedArtists -> artist.played?.let { relativeTimeLabel(it, now, resources) }
HomeSection.MostPlayedArtists -> playCountLabel(artist.playCount) HomeSection.MostPlayedArtists -> playCountLabel(resources, artist.playCount)
else -> null else -> null
} }
@@ -505,18 +512,19 @@ internal fun albumListInfo(
request: HomeListRequest, request: HomeListRequest,
album: Album, album: Album,
now: Long, now: Long,
resources: Resources,
): String? = ): String? =
when { when {
request.section == HomeSection.RecentlyAddedAlbums -> { request.section == HomeSection.RecentlyAddedAlbums -> {
relativeTimeLabel(album.created, now) relativeTimeLabel(album.created, now, resources)
} }
request.section == HomeSection.RecentlyPlayedAlbums || request.section == HomeSection.Rediscover -> { request.section == HomeSection.RecentlyPlayedAlbums || request.section == HomeSection.Rediscover -> {
album.played?.let { relativeTimeLabel(it, now) } album.played?.let { relativeTimeLabel(it, now, resources) }
} }
request.section == HomeSection.MostPlayedAlbums -> { request.section == HomeSection.MostPlayedAlbums -> {
playCountLabel(album.playCount) playCountLabel(resources, album.playCount)
} }
request.decade != null -> { request.decade != null -> {
@@ -528,26 +536,30 @@ internal fun albumListInfo(
} }
} }
private fun playCountLabel(count: Long): String? = if (count <= 0L) null else "$count ${if (count == 1L) "play" else "plays"}" private fun playCountLabel(
resources: Resources,
count: Long,
): String? = if (count <= 0L) null else resources.getQuantityString(R.plurals.home_play_count, count.toInt(), count)
internal fun relativeTimeLabel( internal fun relativeTimeLabel(
epochSeconds: Long, epochSeconds: Long,
nowMillis: Long, nowMillis: Long,
resources: Resources,
): String? { ): String? {
if (epochSeconds <= 0L) return null if (epochSeconds <= 0L) return null
val elapsedMillis = nowMillis - epochSeconds * 1000L val elapsedMillis = nowMillis - epochSeconds * 1000L
if (elapsedMillis < 0L) return "Just now" if (elapsedMillis < 0L) return resources.getString(R.string.home_time_just_now)
val days = elapsedMillis / 86_400_000L val days = elapsedMillis / 86_400_000L
return when { return when {
elapsedMillis < 60_000L -> "Just now" elapsedMillis < 60_000L -> resources.getString(R.string.home_time_just_now)
days == 0L -> "Today" days == 0L -> resources.getString(R.string.home_time_today)
days == 1L -> "Yesterday" days == 1L -> resources.getString(R.string.home_time_yesterday)
days < 7L -> "$days days ago" days < 7L -> resources.getQuantityString(R.plurals.home_time_days_ago, days.toInt(), days)
days < 14L -> "Last week" days < 14L -> resources.getString(R.string.home_time_last_week)
days < 30L -> "${days / 7L} weeks ago" days < 30L -> resources.getQuantityString(R.plurals.home_time_weeks_ago, (days / 7L).toInt(), days / 7L)
days < 60L -> "Last month" days < 60L -> resources.getString(R.string.home_time_last_month)
days < 365L -> "${days / 30L} months ago" days < 365L -> resources.getQuantityString(R.plurals.home_time_months_ago, (days / 30L).toInt(), days / 30L)
days < 730L -> "Last year" days < 730L -> resources.getString(R.string.home_time_last_year)
else -> "${days / 365L} years ago" else -> resources.getQuantityString(R.plurals.home_time_years_ago, (days / 365L).toInt(), days / 365L)
} }
} }
@@ -1,9 +1,11 @@
package com.subtracks.ui.home package com.subtracks.ui.home
import androidx.annotation.StringRes
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.paging.PagingData import androidx.paging.PagingData
import androidx.paging.cachedIn import androidx.paging.cachedIn
import com.subtracks.R
import com.subtracks.data.model.Album import com.subtracks.data.model.Album
import com.subtracks.data.model.AlbumSongItem import com.subtracks.data.model.AlbumSongItem
import com.subtracks.data.model.Artist import com.subtracks.data.model.Artist
@@ -32,12 +34,12 @@ data class HomeListRequest(
) )
enum class OfflineListKind( enum class OfflineListKind(
val title: String, @param:StringRes val title: Int,
) { ) {
Albums("Downloaded albums"), Albums(R.string.home_downloaded_albums),
Artists("Downloaded artists"), Artists(R.string.home_downloaded_artists),
Songs("Downloaded songs"), Songs(R.string.home_downloaded_songs),
Playlists("Downloaded playlists"), Playlists(R.string.home_downloaded_playlists),
} }
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
@@ -1,5 +1,6 @@
package com.subtracks.ui.home package com.subtracks.ui.home
import androidx.annotation.StringRes
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@@ -54,6 +55,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -81,17 +83,17 @@ import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
enum class HomeSection( enum class HomeSection(
val title: String, @param:StringRes val title: Int,
) { ) {
RecentlyPlayedAlbums("Recently played"), RecentlyPlayedAlbums(R.string.resources_sort_by_recently_played),
RecentlyPlayedArtists("Recently played artists"), RecentlyPlayedArtists(R.string.home_section_recently_played_artists),
MostPlayedAlbums("Most played"), MostPlayedAlbums(R.string.home_section_most_played),
MostPlayedArtists("Most played artists"), MostPlayedArtists(R.string.home_section_most_played_artists),
Genres("Genres"), Genres(R.string.home_section_genres),
Decades("Decades"), Decades(R.string.home_section_decades),
RecentlyStarredSongs("Recently starred"), RecentlyStarredSongs(R.string.home_section_recently_starred),
RecentlyAddedAlbums("Recently added"), RecentlyAddedAlbums(R.string.home_section_recently_added),
Rediscover("Rediscover"), Rediscover(R.string.home_section_rediscover),
; ;
companion object { companion object {
@@ -189,9 +191,9 @@ fun HomeScreen(
} }
if (feed.isEmpty()) { if (feed.isEmpty()) {
EmptyState( EmptyState(
text = "Nothing to show yet.\nSync with your server to fill your library.", text = stringResource(R.string.home_empty),
modifier = modifier, modifier = modifier,
actionLabel = "Sync", actionLabel = stringResource(R.string.sync),
onAction = onSync, onAction = onSync,
) )
return@CompositionLocalProvider return@CompositionLocalProvider
@@ -314,7 +316,7 @@ fun OfflineHomeScreen(
downloadedPlaylistRow(playlists, coverArt, onPlaylistClick, onLongClick) { onMore(OfflineListKind.Playlists) } downloadedPlaylistRow(playlists, coverArt, onPlaylistClick, onLongClick) { onMore(OfflineListKind.Playlists) }
songList( songList(
key = "downloaded-songs", key = "downloaded-songs",
title = "Downloaded songs", title = R.string.home_downloaded_songs,
icon = Icons.Rounded.MusicNote, icon = Icons.Rounded.MusicNote,
songs = songs, songs = songs,
coverArt = coverArt, coverArt = coverArt,
@@ -336,7 +338,7 @@ private fun LazyListScope.downloadedAlbumRow(
) { ) {
if (albums.itemCount == 0) return if (albums.itemCount == 0) return
item(key = "downloaded-albums-header") { item(key = "downloaded-albums-header") {
HomeSectionHeader("Downloaded albums", Icons.Rounded.Album) { onMore() } HomeSectionHeader(R.string.home_downloaded_albums, Icons.Rounded.Album) { onMore() }
} }
item(key = "downloaded-albums-row") { item(key = "downloaded-albums-row") {
LazyRow( LazyRow(
@@ -369,7 +371,7 @@ private fun LazyListScope.downloadedArtistRow(
) { ) {
if (artists.itemCount == 0) return if (artists.itemCount == 0) return
item(key = "downloaded-artists-header") { item(key = "downloaded-artists-header") {
HomeSectionHeader("Downloaded artists", Icons.Rounded.Person) { onMore() } HomeSectionHeader(R.string.home_downloaded_artists, Icons.Rounded.Person) { onMore() }
} }
item(key = "downloaded-artists-row") { item(key = "downloaded-artists-row") {
LazyRow( LazyRow(
@@ -402,7 +404,7 @@ private fun LazyListScope.downloadedPlaylistRow(
) { ) {
if (playlists.itemCount == 0) return if (playlists.itemCount == 0) return
item(key = "downloaded-playlists-header") { item(key = "downloaded-playlists-header") {
HomeSectionHeader("Downloaded playlists", Icons.AutoMirrored.Rounded.PlaylistPlay) { onMore() } HomeSectionHeader(R.string.home_downloaded_playlists, Icons.AutoMirrored.Rounded.PlaylistPlay) { onMore() }
} }
item(key = "downloaded-playlists-row") { item(key = "downloaded-playlists-row") {
LazyRow( LazyRow(
@@ -464,7 +466,7 @@ private fun HomeFeed.isEmpty(): Boolean =
@Composable @Composable
private fun HomeSectionHeader( private fun HomeSectionHeader(
title: String, @StringRes title: Int,
icon: ImageVector, icon: ImageVector,
onMore: (() -> Unit)?, onMore: (() -> Unit)?,
) { ) {
@@ -480,13 +482,13 @@ private fun HomeSectionHeader(
) )
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
Text( Text(
text = title, text = stringResource(title),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
if (onMore != null) { if (onMore != null) {
TextButton(onClick = onMore) { TextButton(onClick = onMore) {
Text("More") Text(stringResource(R.string.controls_more))
} }
} }
} }
@@ -685,7 +687,7 @@ private fun LazyListScope.decadeRow(
modifier = Modifier.padding(top = SECTION_CONTENT_TOP), modifier = Modifier.padding(top = SECTION_CONTENT_TOP),
) { ) {
items(decades, key = { it }) { decade -> items(decades, key = { it }) { decade ->
AssistChip(onClick = { onDecadeClick(decade) }, label = { Text("${decade}s") }) AssistChip(onClick = { onDecadeClick(decade) }, label = { Text(stringResource(R.string.home_decade, decade)) })
} }
} }
} }
@@ -713,7 +715,7 @@ private fun LazyListScope.homeSongs(
private fun LazyListScope.songList( private fun LazyListScope.songList(
key: String, key: String,
title: String, @StringRes title: Int,
icon: ImageVector, icon: ImageVector,
songs: List<AlbumSongItem>, songs: List<AlbumSongItem>,
coverArt: (String?, Boolean) -> CoverArtRef?, coverArt: (String?, Boolean) -> CoverArtRef?,
@@ -20,10 +20,12 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.subtracks.R
import com.subtracks.data.model.Album import com.subtracks.data.model.Album
import com.subtracks.data.model.BulkDownloadAction import com.subtracks.data.model.BulkDownloadAction
import com.subtracks.data.model.CoverArtRef import com.subtracks.data.model.CoverArtRef
@@ -158,8 +160,9 @@ fun AlbumDetailScreen(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val multiDisc = songs.map { it.disc ?: 1L }.distinct().size > 1 val multiDisc = songs.map { it.disc ?: 1L }.distinct().size > 1
val resources = LocalResources.current
val discLabels = val discLabels =
remember(discs) { remember(discs, resources) {
val repeated = val repeated =
discs discs
.map { it.title } .map { it.title }
@@ -171,7 +174,7 @@ fun AlbumDetailScreen(
discs.associate { disc -> discs.associate { disc ->
disc.disc to disc.disc to
disc.title.takeIf { it.isNotBlank() }?.let { title -> disc.title.takeIf { it.isNotBlank() }?.let { title ->
if (title in repeated) "$title: Disc ${disc.disc}" else title if (title in repeated) resources.getString(R.string.disc_track, title, disc.disc) else title
} }
} }
} }
@@ -218,7 +221,9 @@ fun AlbumDetailScreen(
val discLabel = discLabels[disc] val discLabel = discLabels[disc]
if (disc != lastDisc && (multiDisc || discLabel != null)) { if (disc != lastDisc && (multiDisc || discLabel != null)) {
val isFirst = lastDisc == null val isFirst = lastDisc == null
item(key = "disc:$disc") { DiscHeader(discLabel ?: "Disc $disc", first = isFirst) } item(
key = "disc:$disc",
) { DiscHeader(discLabel ?: resources.getString(R.string.disc_header, disc), first = isFirst) }
} }
lastDisc = disc lastDisc = disc
item(key = song.id) { item(key = song.id) {
@@ -20,11 +20,13 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.paging.LoadState import androidx.paging.LoadState
import androidx.paging.compose.LazyPagingItems import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.itemKey import androidx.paging.compose.itemKey
import com.subtracks.R
import com.subtracks.data.model.Album import com.subtracks.data.model.Album
import com.subtracks.data.model.CoverArtRef import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.ListDownloadStatus import com.subtracks.data.model.ListDownloadStatus
@@ -65,9 +67,9 @@ fun AlbumsContent(
FilteredEmptyState(onClearFilters, modifier) FilteredEmptyState(onClearFilters, modifier)
} else { } else {
EmptyState( EmptyState(
text = "No albums yet.\nSync with your server to fill your library.", text = stringResource(R.string.albums_empty),
modifier = modifier, modifier = modifier,
actionLabel = "Sync", actionLabel = stringResource(R.string.sync),
onAction = onSync, onAction = onSync,
) )
} }
@@ -67,6 +67,8 @@ import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextMeasurer import androidx.compose.ui.text.TextMeasurer
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
@@ -75,6 +77,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.subtracks.R
import com.subtracks.data.model.Album import com.subtracks.data.model.Album
import com.subtracks.data.model.Artist import com.subtracks.data.model.Artist
import com.subtracks.data.model.BulkDownloadAction import com.subtracks.data.model.BulkDownloadAction
@@ -346,7 +349,7 @@ fun ArtistDetailScreen(
) { ) {
Icon( Icon(
imageVector = Icons.Rounded.Shuffle, imageVector = Icons.Rounded.Shuffle,
contentDescription = "Shuffle artist", contentDescription = stringResource(R.string.shuffle_artist),
modifier = Modifier.size(SHUFFLE_ICON), modifier = Modifier.size(SHUFFLE_ICON),
) )
} }
@@ -357,7 +360,7 @@ fun ArtistDetailScreen(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Text( Text(
text = "$albumCount ${if (albumCount == 1L) "album" else "albums"}", text = pluralStringResource(R.plurals.resources_album_count, albumCount.toInt(), albumCount),
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
@@ -368,7 +371,10 @@ fun ArtistDetailScreen(
) { ) {
Icon( Icon(
imageVector = if (starred) Icons.Rounded.Star else Icons.Rounded.StarBorder, imageVector = if (starred) Icons.Rounded.Star else Icons.Rounded.StarBorder,
contentDescription = if (starred) "Unstar" else "Star", contentDescription =
stringResource(
if (starred) R.string.actions_unstar else R.string.actions_star,
),
tint = tint =
if (starred) { if (starred) {
MaterialTheme.colorScheme.primary MaterialTheme.colorScheme.primary
@@ -378,7 +384,7 @@ fun ArtistDetailScreen(
) )
} }
IconButton(onClick = onMore) { IconButton(onClick = onMore) {
Icon(Icons.Rounded.MoreHoriz, contentDescription = "More options") Icon(Icons.Rounded.MoreHoriz, contentDescription = stringResource(R.string.more_options))
} }
} }
} }
@@ -453,7 +459,7 @@ fun ArtistDetailScreen(
}, },
navigationIcon = { navigationIcon = {
IconButton(onClick = onBack, modifier = Modifier.graphicsLayer { alpha = barFraction }) { IconButton(onClick = onBack, modifier = Modifier.graphicsLayer { alpha = barFraction }) {
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Back") Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = stringResource(R.string.navigation_back))
} }
}, },
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent), colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent),
@@ -17,12 +17,15 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.paging.LoadState import androidx.paging.LoadState
import androidx.paging.compose.LazyPagingItems import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.itemKey import androidx.paging.compose.itemKey
import com.subtracks.R
import com.subtracks.data.model.Artist import com.subtracks.data.model.Artist
import com.subtracks.data.model.CoverArtRef import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.ListDownloadStatus import com.subtracks.data.model.ListDownloadStatus
@@ -60,7 +63,7 @@ fun ArtistsContent(
if (filtered) { if (filtered) {
FilteredEmptyState(onClearFilters, modifier) FilteredEmptyState(onClearFilters, modifier)
} else { } else {
EmptyState("No artists yet.", modifier, actionLabel = "Sync", onAction = onSync) EmptyState(stringResource(R.string.artists_empty), modifier, actionLabel = stringResource(R.string.sync), onAction = onSync)
} }
} }
@@ -87,7 +90,12 @@ fun ArtistsContent(
}, },
supportingContent = { supportingContent = {
Text( Text(
text = "${artist.albumCount} ${if (artist.albumCount == 1L) "album" else "albums"}", text =
pluralStringResource(
R.plurals.resources_album_count,
artist.albumCount.toInt(),
artist.albumCount,
),
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
@@ -1,5 +1,6 @@
package com.subtracks.ui.library package com.subtracks.ui.library
import androidx.annotation.StringRes
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background import androidx.compose.foundation.background
@@ -94,6 +95,7 @@ import androidx.compose.ui.layout.layout
import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
@@ -103,6 +105,7 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.paging.PagingData import androidx.paging.PagingData
import androidx.paging.compose.collectAsLazyPagingItems import androidx.paging.compose.collectAsLazyPagingItems
import com.subtracks.R
import com.subtracks.data.model.Album import com.subtracks.data.model.Album
import com.subtracks.data.model.Artist import com.subtracks.data.model.Artist
import com.subtracks.data.model.BulkDownloadAction import com.subtracks.data.model.BulkDownloadAction
@@ -140,18 +143,18 @@ import kotlin.math.abs
import kotlin.math.roundToInt import kotlin.math.roundToInt
enum class LibraryTab( enum class LibraryTab(
val label: String, @param:StringRes val label: Int,
val icon: ImageVector, val icon: ImageVector,
) { ) {
Home("Home", Icons.Rounded.Home), Home(R.string.navigation_tabs_home, Icons.Rounded.Home),
Albums("Albums", Icons.Rounded.Album), Albums(R.string.resources_album_name, Icons.Rounded.Album),
Artists("Artists", Icons.Rounded.Person), Artists(R.string.resources_artist_name, Icons.Rounded.Person),
Playlists("Playlists", Icons.AutoMirrored.Rounded.PlaylistPlay), Playlists(R.string.resources_playlist_name, Icons.AutoMirrored.Rounded.PlaylistPlay),
} }
data class SortOption( data class SortOption(
val value: String, val value: String,
val label: String, @param:StringRes val label: Int,
val descendingByDefault: Boolean = false, val descendingByDefault: Boolean = false,
) )
@@ -166,31 +169,47 @@ fun sortOptionsFor(
when (tab) { when (tab) {
LibraryTab.Albums -> { LibraryTab.Albums -> {
buildList { buildList {
add(SortOption(AlbumSort.Name.name, "Name")) add(SortOption(AlbumSort.Name.name, R.string.resources_sort_by_name))
add(SortOption(AlbumSort.Artist.name, "Artist")) add(SortOption(AlbumSort.Artist.name, R.string.resources_sort_by_artist))
add(SortOption(AlbumSort.Year.name, "Year", descendingByDefault = true)) add(SortOption(AlbumSort.Year.name, R.string.resources_sort_by_year, descendingByDefault = true))
add(SortOption(AlbumSort.Added.name, "Added", descendingByDefault = true)) add(SortOption(AlbumSort.Added.name, R.string.resources_sort_by_added, descendingByDefault = true))
add(SortOption(AlbumSort.Starred.name, "Starred", descendingByDefault = true)) add(SortOption(AlbumSort.Starred.name, R.string.resources_filter_starred, descendingByDefault = true))
if (play.frequent) add(SortOption(AlbumSort.Frequent.name, "Frequently played", descendingByDefault = true)) if (play.frequent) {
if (play.recent) add(SortOption(AlbumSort.Recent.name, "Recently played", descendingByDefault = true)) add(
SortOption(AlbumSort.Frequent.name, R.string.resources_sort_by_frequently_played, descendingByDefault = true),
)
}
if (play.recent) {
add(
SortOption(AlbumSort.Recent.name, R.string.resources_sort_by_recently_played, descendingByDefault = true),
)
}
} }
} }
LibraryTab.Artists -> { LibraryTab.Artists -> {
buildList { buildList {
add(SortOption(ArtistSort.Name.name, "Name")) add(SortOption(ArtistSort.Name.name, R.string.resources_sort_by_name))
add(SortOption(ArtistSort.AlbumCount.name, "Albums", descendingByDefault = true)) add(SortOption(ArtistSort.AlbumCount.name, R.string.resources_album_name, descendingByDefault = true))
add(SortOption(ArtistSort.Starred.name, "Starred", descendingByDefault = true)) add(SortOption(ArtistSort.Starred.name, R.string.resources_filter_starred, descendingByDefault = true))
if (play.frequent) add(SortOption(ArtistSort.Frequent.name, "Frequently played", descendingByDefault = true)) if (play.frequent) {
if (play.recent) add(SortOption(ArtistSort.Recent.name, "Recently played", descendingByDefault = true)) add(
SortOption(ArtistSort.Frequent.name, R.string.resources_sort_by_frequently_played, descendingByDefault = true),
)
}
if (play.recent) {
add(
SortOption(ArtistSort.Recent.name, R.string.resources_sort_by_recently_played, descendingByDefault = true),
)
}
} }
} }
LibraryTab.Playlists -> { LibraryTab.Playlists -> {
listOf( listOf(
SortOption(PlaylistSort.Name.name, "Name"), SortOption(PlaylistSort.Name.name, R.string.resources_sort_by_name),
SortOption(PlaylistSort.Added.name, "Added", descendingByDefault = true), SortOption(PlaylistSort.Added.name, R.string.resources_sort_by_added, descendingByDefault = true),
SortOption(PlaylistSort.Updated.name, "Updated", descendingByDefault = true), SortOption(PlaylistSort.Updated.name, R.string.resources_sort_by_updated, descendingByDefault = true),
) )
} }
@@ -428,6 +447,7 @@ fun LibraryScreen(
val listTopInset = statusBarTop val listTopInset = statusBarTop
val listBottomInset = tabBarHeight + BOTTOM_CLEARANCE val listBottomInset = tabBarHeight + BOTTOM_CLEARANCE
val headerColor = artwork?.let(::playerSurfaceColor) ?: MaterialTheme.colorScheme.background val headerColor = artwork?.let(::playerSurfaceColor) ?: MaterialTheme.colorScheme.background
val listOptionsLabel = stringResource(R.string.list_options)
Box( Box(
modifier = modifier =
@@ -581,7 +601,7 @@ fun LibraryScreen(
modifier = modifier =
Modifier Modifier
.size(24.dp) .size(24.dp)
.semantics { contentDescription = "List options" }, .semantics { contentDescription = listOptionsLabel },
) )
if (filtersActive) { if (filtersActive) {
Box( Box(
@@ -654,7 +674,7 @@ private fun ListOptionsSheet(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp), modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
) { ) {
Icon(Icons.Rounded.Search, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant) Icon(Icons.Rounded.Search, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
Text("Search this list", color = MaterialTheme.colorScheme.onSurfaceVariant) Text(stringResource(R.string.search_this_list), color = MaterialTheme.colorScheme.onSurfaceVariant)
} }
} }
@@ -663,14 +683,21 @@ private fun ListOptionsSheet(
modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 8.dp, top = 12.dp), modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 8.dp, top = 12.dp),
) { ) {
Text( Text(
text = "Sort by", text = stringResource(R.string.sort_by),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
IconButton(onClick = onToggleSortDirection) { IconButton(onClick = onToggleSortDirection) {
Icon( Icon(
imageVector = if (descending) Icons.Rounded.ArrowDownward else Icons.Rounded.ArrowUpward, imageVector = if (descending) Icons.Rounded.ArrowDownward else Icons.Rounded.ArrowUpward,
contentDescription = if (descending) "Sort descending" else "Sort ascending", contentDescription =
if (descending) {
stringResource(
R.string.sort_descending,
)
} else {
stringResource(R.string.sort_ascending)
},
) )
} }
} }
@@ -685,7 +712,7 @@ private fun ListOptionsSheet(
onClick = { onClick = {
if (listQuery.sort == option.value) onToggleSortDirection() else onSortChange(option.value) if (listQuery.sort == option.value) onToggleSortDirection() else onSortChange(option.value)
}, },
label = { Text(option.label) }, label = { Text(stringResource(option.label)) },
) )
} }
} }
@@ -695,7 +722,7 @@ private fun ListOptionsSheet(
modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 8.dp, top = 16.dp), modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 8.dp, top = 16.dp),
) { ) {
Text( Text(
text = "Filters", text = stringResource(R.string.filters),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
@@ -703,7 +730,7 @@ private fun ListOptionsSheet(
onClick = onClearFilters, onClick = onClearFilters,
enabled = canClearFilters, enabled = canClearFilters,
) { ) {
Icon(Icons.Rounded.FilterAltOff, contentDescription = "Clear filters") Icon(Icons.Rounded.FilterAltOff, contentDescription = stringResource(R.string.components_clear_filters))
} }
} }
FlowRow( FlowRow(
@@ -713,7 +740,7 @@ private fun ListOptionsSheet(
FilterChip( FilterChip(
selected = listQuery.downloaded, selected = listQuery.downloaded,
onClick = onToggleDownloaded, onClick = onToggleDownloaded,
label = { Text("Downloaded") }, label = { Text(stringResource(R.string.filter_downloaded)) },
leadingIcon = leadingIcon =
if (listQuery.downloaded) { if (listQuery.downloaded) {
{ {
@@ -731,7 +758,7 @@ private fun ListOptionsSheet(
FilterChip( FilterChip(
selected = listQuery.starred != StarredFilter.Any, selected = listQuery.starred != StarredFilter.Any,
onClick = onCycleStarred, onClick = onCycleStarred,
label = { Text("Starred") }, label = { Text(stringResource(R.string.resources_filter_starred)) },
leadingIcon = leadingIcon =
when (listQuery.starred) { when (listQuery.starred) {
StarredFilter.Any -> { StarredFilter.Any -> {
@@ -785,11 +812,11 @@ private fun SearchField(
value = value, value = value,
onValueChange = onValueChange, onValueChange = onValueChange,
singleLine = true, singleLine = true,
placeholder = { Text("Search") }, placeholder = { Text(stringResource(R.string.search_input_placeholder)) },
leadingIcon = { Icon(Icons.Rounded.Search, contentDescription = null) }, leadingIcon = { Icon(Icons.Rounded.Search, contentDescription = null) },
trailingIcon = { trailingIcon = {
IconButton(onClick = onClose) { IconButton(onClick = onClose) {
Icon(Icons.Rounded.Close, contentDescription = "Close search") Icon(Icons.Rounded.Close, contentDescription = stringResource(R.string.close_search))
} }
}, },
shape = RoundedCornerShape(4.dp), shape = RoundedCornerShape(4.dp),
@@ -917,7 +944,7 @@ internal fun LibraryTabs(
IconButton(onClick = onExitOffline) { IconButton(onClick = onExitOffline) {
Icon( Icon(
imageVector = Icons.Rounded.CloudOff, imageVector = Icons.Rounded.CloudOff,
contentDescription = "Offline mode; tap to go online", contentDescription = stringResource(R.string.offline_mode_status),
tint = MaterialTheme.colorScheme.onBackground, tint = MaterialTheme.colorScheme.onBackground,
) )
} }
@@ -925,7 +952,7 @@ internal fun LibraryTabs(
IconButton(onClick = onOpenSettings) { IconButton(onClick = onOpenSettings) {
Icon( Icon(
imageVector = Icons.Rounded.Settings, imageVector = Icons.Rounded.Settings,
contentDescription = "Settings", contentDescription = stringResource(R.string.navigation_tabs_settings),
tint = MaterialTheme.colorScheme.onBackground, tint = MaterialTheme.colorScheme.onBackground,
) )
} }
@@ -1005,7 +1032,7 @@ private fun TabContent(
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Icon( Icon(
imageVector = tab.icon, imageVector = tab.icon,
contentDescription = tab.label, contentDescription = stringResource(tab.label),
tint = color, tint = color,
modifier = modifier =
Modifier.size(TAB_ICON_SIZE).graphicsLayer { Modifier.size(TAB_ICON_SIZE).graphicsLayer {
@@ -1032,7 +1059,7 @@ private fun TabContent(
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
Text( Text(
text = tab.label, text = stringResource(tab.label),
style = MaterialTheme.typography.titleSmall, style = MaterialTheme.typography.titleSmall,
color = color, color = color,
maxLines = 1, maxLines = 1,
@@ -1,5 +1,6 @@
package com.subtracks.ui.library package com.subtracks.ui.library
import android.content.Context
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -9,10 +10,12 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.paging.compose.LazyPagingItems import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.collectAsLazyPagingItems import androidx.paging.compose.collectAsLazyPagingItems
import androidx.paging.compose.itemKey import androidx.paging.compose.itemKey
import com.subtracks.R
import com.subtracks.data.model.BulkDownloadAction import com.subtracks.data.model.BulkDownloadAction
import com.subtracks.data.model.CoverArtRef import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.ListDownloadStatus import com.subtracks.data.model.ListDownloadStatus
@@ -127,6 +130,7 @@ fun PlaylistDetailScreen(
playingSongId: String? = null, playingSongId: String? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val context = LocalContext.current
HeroDetailScaffold( HeroDetailScaffold(
artwork = artwork, artwork = artwork,
title = playlist?.name.orEmpty(), title = playlist?.name.orEmpty(),
@@ -136,7 +140,7 @@ fun PlaylistDetailScreen(
art = coverArt(playlist?.coverArt, false), art = coverArt(playlist?.coverArt, false),
thumbnailRef = coverArt(playlist?.coverArt, true), thumbnailRef = coverArt(playlist?.coverArt, true),
name = playlist?.name.orEmpty(), name = playlist?.name.orEmpty(),
subtitle = playlistSummary(playlist, offline, downloadStatus.downloaded), subtitle = playlistSummary(context, playlist, offline, downloadStatus.downloaded),
comment = playlist?.comment, comment = playlist?.comment,
hasSongs = songs.itemCount > 0, hasSongs = songs.itemCount > 0,
onPlay = onPlay, onPlay = onPlay,
@@ -175,6 +179,7 @@ fun PlaylistDetailScreen(
} }
private fun playlistSummary( private fun playlistSummary(
context: Context,
playlist: Playlist?, playlist: Playlist?,
offline: Boolean, offline: Boolean,
downloadedSongs: Long, downloadedSongs: Long,
@@ -182,19 +187,22 @@ private fun playlistSummary(
playlist ?: return "" playlist ?: return ""
val songs = if (offline) downloadedSongs else playlist.songCount val songs = if (offline) downloadedSongs else playlist.songCount
return listOfNotNull( return listOfNotNull(
"$songs ${if (songs == 1L) "song" else "songs"}", context.resources.getQuantityString(R.plurals.resources_song_count, songs.toInt(), songs),
formatDuration(playlist.duration), formatDuration(context, playlist.duration),
).joinToString(" $DOT ") ).joinToString(" $DOT ")
} }
private fun formatDuration(seconds: Long): String? { private fun formatDuration(
context: Context,
seconds: Long,
): String? {
if (seconds < 60) return null if (seconds < 60) return null
val totalMinutes = seconds / 60 val totalMinutes = seconds / 60
val hours = totalMinutes / 60 val hours = totalMinutes / 60
val minutes = totalMinutes % 60 val minutes = totalMinutes % 60
return when { return when {
hours == 0L -> "$totalMinutes min" hours == 0L -> context.getString(R.string.playlist_duration_minutes, totalMinutes)
minutes == 0L -> "$hours hr" minutes == 0L -> context.getString(R.string.playlist_duration_hours, hours)
else -> "$hours hr $minutes min" else -> context.getString(R.string.playlist_duration_hours_minutes, hours, minutes)
} }
} }
@@ -17,12 +17,15 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.paging.LoadState import androidx.paging.LoadState
import androidx.paging.compose.LazyPagingItems import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.itemKey import androidx.paging.compose.itemKey
import com.subtracks.R
import com.subtracks.data.model.CoverArtRef import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.ListDownloadStatus import com.subtracks.data.model.ListDownloadStatus
import com.subtracks.data.model.Playlist import com.subtracks.data.model.Playlist
@@ -59,7 +62,12 @@ fun PlaylistsContent(
if (filtered) { if (filtered) {
FilteredEmptyState(onClearFilters, modifier) FilteredEmptyState(onClearFilters, modifier)
} else { } else {
EmptyState("No playlists yet.", modifier, actionLabel = "Sync", onAction = onSync) EmptyState(
stringResource(R.string.playlists_empty),
modifier,
actionLabel = stringResource(R.string.sync),
onAction = onSync,
)
} }
} }
@@ -87,7 +95,11 @@ fun PlaylistsContent(
Text( Text(
text = text =
playlist.comment?.takeIf { it.isNotBlank() } playlist.comment?.takeIf { it.isNotBlank() }
?: "${playlist.songCount} ${if (playlist.songCount == 1L) "song" else "songs"}", ?: pluralStringResource(
R.plurals.resources_song_count,
playlist.songCount.toInt(),
playlist.songCount,
),
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
@@ -47,6 +47,7 @@ import androidx.compose.ui.graphics.vector.PathParser
import androidx.compose.ui.graphics.vector.VectorGroup import androidx.compose.ui.graphics.vector.VectorGroup
import androidx.compose.ui.graphics.vector.VectorPath import androidx.compose.ui.graphics.vector.VectorPath
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
@@ -54,6 +55,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.subtracks.R
import com.subtracks.data.model.CoverArtRef import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.DownloadStatus import com.subtracks.data.model.DownloadStatus
import com.subtracks.data.model.Song import com.subtracks.data.model.Song
@@ -224,9 +226,10 @@ private fun PlayIndicator(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val tint = MaterialTheme.colorScheme.primary val tint = MaterialTheme.colorScheme.primary
val playingDescription = stringResource(R.string.status_playing)
if (shadow) { if (shadow) {
val radius = with(LocalDensity.current) { PLAY_SHADOW_RADIUS.toPx() } val radius = with(LocalDensity.current) { PLAY_SHADOW_RADIUS.toPx() }
Canvas(modifier.semantics { contentDescription = "Playing" }) { Canvas(modifier.semantics { contentDescription = playingDescription }) {
val scale = minOf(size.width / PLAY_ARROW.viewportWidth, size.height / PLAY_ARROW.viewportHeight) val scale = minOf(size.width / PLAY_ARROW.viewportWidth, size.height / PLAY_ARROW.viewportHeight)
withTransform({ scale(scale, scale, pivot = Offset.Zero) }) { withTransform({ scale(scale, scale, pivot = Offset.Zero) }) {
drawIntoCanvas { canvas -> drawIntoCanvas { canvas ->
@@ -257,7 +260,7 @@ private fun PlayIndicator(
} else { } else {
Icon( Icon(
imageVector = PLAY_ARROW, imageVector = PLAY_ARROW,
contentDescription = "Playing", contentDescription = playingDescription,
tint = tint, tint = tint,
modifier = modifier, modifier = modifier,
) )
@@ -325,11 +328,13 @@ internal fun formatTrackTime(seconds: Long): String {
@Composable @Composable
private fun DownloadBadge(download: SongDownload?) { private fun DownloadBadge(download: SongDownload?) {
val downloadedDescription = stringResource(R.string.status_downloaded)
val downloadingDescription = stringResource(R.string.status_downloading)
when (download?.status) { when (download?.status) {
DownloadStatus.Completed -> { DownloadStatus.Completed -> {
Icon( Icon(
imageVector = Icons.Rounded.DownloadDone, imageVector = Icons.Rounded.DownloadDone,
contentDescription = "Downloaded", contentDescription = downloadedDescription,
tint = MaterialTheme.colorScheme.onSurfaceVariant, tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(16.dp), modifier = Modifier.size(16.dp),
) )
@@ -339,13 +344,13 @@ private fun DownloadBadge(download: SongDownload?) {
val progress = download.progress val progress = download.progress
if (progress == null) { if (progress == null) {
CircularProgressIndicator( CircularProgressIndicator(
modifier = Modifier.size(14.dp).semantics { contentDescription = "Downloading" }, modifier = Modifier.size(14.dp).semantics { contentDescription = downloadingDescription },
strokeWidth = 2.dp, strokeWidth = 2.dp,
) )
} else { } else {
CircularProgressIndicator( CircularProgressIndicator(
progress = { progress }, progress = { progress },
modifier = Modifier.size(14.dp).semantics { contentDescription = "Downloading" }, modifier = Modifier.size(14.dp).semantics { contentDescription = downloadingDescription },
strokeWidth = 2.dp, strokeWidth = 2.dp,
) )
} }
@@ -38,7 +38,9 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Size import androidx.compose.ui.geometry.Size
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.subtracks.R
import com.subtracks.data.model.CoverArtRef import com.subtracks.data.model.CoverArtRef
import com.subtracks.playback.PlaybackState import com.subtracks.playback.PlaybackState
import com.subtracks.ui.components.CoverArt import com.subtracks.ui.components.CoverArt
@@ -155,7 +157,14 @@ fun MiniPlayer(
} else { } else {
Icon( Icon(
imageVector = if (state.isPlaying) Icons.Rounded.Pause else Icons.Rounded.PlayArrow, imageVector = if (state.isPlaying) Icons.Rounded.Pause else Icons.Rounded.PlayArrow,
contentDescription = if (state.isPlaying) "Pause" else "Play", contentDescription =
if (state.isPlaying) {
stringResource(
R.string.controls_pause,
)
} else {
stringResource(R.string.controls_play)
},
tint = MaterialTheme.colorScheme.onSurface, tint = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.size(36.dp), modifier = Modifier.size(36.dp),
) )
@@ -164,7 +173,7 @@ fun MiniPlayer(
IconButton(onClick = onNext, enabled = state.hasNext, modifier = Modifier.size(48.dp)) { IconButton(onClick = onNext, enabled = state.hasNext, modifier = Modifier.size(48.dp)) {
Icon( Icon(
imageVector = Icons.Rounded.SkipNext, imageVector = Icons.Rounded.SkipNext,
contentDescription = "Next", contentDescription = stringResource(R.string.controls_next),
tint = MaterialTheme.colorScheme.onSurface, tint = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.size(36.dp), modifier = Modifier.size(36.dp),
) )
@@ -1,5 +1,6 @@
package com.subtracks.ui.playback package com.subtracks.ui.playback
import androidx.annotation.StringRes
import androidx.compose.animation.core.animate import androidx.compose.animation.core.animate
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.foundation.background import androidx.compose.foundation.background
@@ -63,6 +64,7 @@ import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.PlatformTextStyle import androidx.compose.ui.text.PlatformTextStyle
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.LineHeightStyle import androidx.compose.ui.text.style.LineHeightStyle
@@ -71,6 +73,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil3.compose.LocalPlatformContext import coil3.compose.LocalPlatformContext
import com.subtracks.R
import com.subtracks.data.model.CoverArtRef import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.QueueKind import com.subtracks.data.model.QueueKind
import com.subtracks.data.model.Song import com.subtracks.data.model.Song
@@ -217,7 +220,7 @@ fun NowPlayingRoute(
val art = controller.coverArt(state.item) val art = controller.coverArt(state.item)
val thumbnail = controller.coverArt(state.item, thumbnail = true) val thumbnail = controller.coverArt(state.item, thumbnail = true)
val queueContext = state.context val queueContext = state.context
val fallbackTitle = state.item?.album?.takeIf { it.isNotBlank() } ?: "Library" val fallbackTitle = state.item?.album?.takeIf { it.isNotBlank() } ?: stringResource(R.string.navigation_tabs_library)
val sourceTitle = remember(queueContext) { mutableStateOf(fallbackTitle) } val sourceTitle = remember(queueContext) { mutableStateOf(fallbackTitle) }
LaunchedEffect(queueContext) { LaunchedEffect(queueContext) {
sourceTitle.value = controller.sourceTitle(queueContext)?.takeIf { it.isNotBlank() } ?: fallbackTitle sourceTitle.value = controller.sourceTitle(queueContext)?.takeIf { it.isNotBlank() } ?: fallbackTitle
@@ -355,6 +358,7 @@ fun NowPlayingScreen(
frozenStrip = null frozenStrip = null
} }
} }
val kindLabel = stringResource(state.context?.kind?.label() ?: R.string.context_kind_library)
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onBackground) { CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onBackground) {
ArtworkTheme(artwork) { ArtworkTheme(artwork) {
@@ -372,7 +376,7 @@ fun NowPlayingScreen(
title = { title = {
Column { Column {
Text( Text(
text = "Now playing: ${state.context?.kind?.label() ?: "library"}".uppercase(), text = stringResource(R.string.now_playing_header, kindLabel).uppercase(),
style = MaterialTheme.typography.labelLarge, style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
letterSpacing = 1.sp, letterSpacing = 1.sp,
@@ -388,7 +392,10 @@ fun NowPlayingScreen(
}, },
navigationIcon = { navigationIcon = {
IconButton(onClick = onBack) { IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Back") Icon(
Icons.AutoMirrored.Rounded.ArrowBack,
contentDescription = stringResource(R.string.navigation_back),
)
} }
}, },
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent), colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent),
@@ -459,7 +466,7 @@ fun NowPlayingScreen(
interactionSource = remember { MutableInteractionSource() }, interactionSource = remember { MutableInteractionSource() },
indication = null, indication = null,
enabled = onAlbumClick != null, enabled = onAlbumClick != null,
onClickLabel = "Open album", onClickLabel = stringResource(R.string.open_album),
) { onAlbumClick?.invoke() } ) { onAlbumClick?.invoke() }
.testTag(NOW_PLAYING_COVER_TAG), .testTag(NOW_PLAYING_COVER_TAG),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
@@ -518,7 +525,7 @@ fun NowPlayingScreen(
interactionSource = remember { MutableInteractionSource() }, interactionSource = remember { MutableInteractionSource() },
indication = null, indication = null,
enabled = onArtistClick != null, enabled = onArtistClick != null,
onClickLabel = "Open artist", onClickLabel = stringResource(R.string.open_artist),
) { onArtistClick?.invoke() }, ) { onArtistClick?.invoke() },
) { ) {
Box(Modifier.height(titleHeight), contentAlignment = Alignment.CenterStart) { Box(Modifier.height(titleHeight), contentAlignment = Alignment.CenterStart) {
@@ -542,7 +549,7 @@ fun NowPlayingScreen(
IconButton(onClick = { onToggleStar?.invoke() }, enabled = onToggleStar != null) { IconButton(onClick = { onToggleStar?.invoke() }, enabled = onToggleStar != null) {
Icon( Icon(
imageVector = if (starred) Icons.Rounded.Star else Icons.Rounded.StarBorder, imageVector = if (starred) Icons.Rounded.Star else Icons.Rounded.StarBorder,
contentDescription = if (starred) "Unstar" else "Star", contentDescription = stringResource(if (starred) R.string.actions_unstar else R.string.actions_star),
tint = tint =
if (starred) { if (starred) {
MaterialTheme.colorScheme.primary MaterialTheme.colorScheme.primary
@@ -590,11 +597,13 @@ fun NowPlayingScreen(
RepeatMode.One -> Icons.Rounded.RepeatOneOn RepeatMode.One -> Icons.Rounded.RepeatOneOn
}, },
contentDescription = contentDescription =
stringResource(
when (state.repeat) { when (state.repeat) {
RepeatMode.Off -> "Repeat off" RepeatMode.Off -> R.string.repeat_off
RepeatMode.All -> "Repeat all" RepeatMode.All -> R.string.repeat_all
RepeatMode.One -> "Repeat one" RepeatMode.One -> R.string.repeat_one
}, },
),
tint = tint =
if (state.repeat == RepeatMode.Off) { if (state.repeat == RepeatMode.Off) {
MaterialTheme.colorScheme.onSurfaceVariant MaterialTheme.colorScheme.onSurfaceVariant
@@ -607,7 +616,7 @@ fun NowPlayingScreen(
IconButton(onClick = onPrevious, modifier = Modifier.size(72.dp)) { IconButton(onClick = onPrevious, modifier = Modifier.size(72.dp)) {
Icon( Icon(
imageVector = Icons.Rounded.SkipPrevious, imageVector = Icons.Rounded.SkipPrevious,
contentDescription = "Previous", contentDescription = stringResource(R.string.controls_previous),
modifier = Modifier.size(60.dp), modifier = Modifier.size(60.dp),
) )
} }
@@ -629,7 +638,14 @@ fun NowPlayingScreen(
} else { } else {
Icon( Icon(
imageVector = if (state.isPlaying) Icons.Rounded.PauseCircle else Icons.Rounded.PlayCircle, imageVector = if (state.isPlaying) Icons.Rounded.PauseCircle else Icons.Rounded.PlayCircle,
contentDescription = if (state.isPlaying) "Pause" else "Play", contentDescription =
if (state.isPlaying) {
stringResource(
R.string.controls_pause,
)
} else {
stringResource(R.string.controls_play)
},
modifier = Modifier.size(playButtonSize), modifier = Modifier.size(playButtonSize),
) )
} }
@@ -637,14 +653,14 @@ fun NowPlayingScreen(
IconButton(onClick = onNext, modifier = Modifier.size(72.dp)) { IconButton(onClick = onNext, modifier = Modifier.size(72.dp)) {
Icon( Icon(
imageVector = Icons.Rounded.SkipNext, imageVector = Icons.Rounded.SkipNext,
contentDescription = "Next", contentDescription = stringResource(R.string.controls_next),
modifier = Modifier.size(60.dp), modifier = Modifier.size(60.dp),
) )
} }
IconButton(onClick = onShuffle, modifier = Modifier.size(48.dp)) { IconButton(onClick = onShuffle, modifier = Modifier.size(48.dp)) {
Icon( Icon(
imageVector = if (state.shuffle) Icons.Rounded.ShuffleOn else Icons.Rounded.Shuffle, imageVector = if (state.shuffle) Icons.Rounded.ShuffleOn else Icons.Rounded.Shuffle,
contentDescription = "Shuffle", contentDescription = stringResource(R.string.controls_shuffle),
tint = tint =
if (state.shuffle) { if (state.shuffle) {
MaterialTheme.colorScheme.onBackground MaterialTheme.colorScheme.onBackground
@@ -663,14 +679,14 @@ fun NowPlayingScreen(
IconButton(onClick = onQueue, modifier = Modifier.size(40.dp)) { IconButton(onClick = onQueue, modifier = Modifier.size(40.dp)) {
Icon( Icon(
imageVector = Icons.AutoMirrored.Rounded.QueueMusic, imageVector = Icons.AutoMirrored.Rounded.QueueMusic,
contentDescription = "Queue", contentDescription = stringResource(R.string.controls_queue),
modifier = Modifier.size(30.dp), modifier = Modifier.size(30.dp),
) )
} }
IconButton(onClick = onMore, modifier = Modifier.size(40.dp)) { IconButton(onClick = onMore, modifier = Modifier.size(40.dp)) {
Icon( Icon(
imageVector = Icons.Rounded.MoreHoriz, imageVector = Icons.Rounded.MoreHoriz,
contentDescription = "More", contentDescription = stringResource(R.string.controls_more),
modifier = Modifier.size(30.dp), modifier = Modifier.size(30.dp),
) )
} }
@@ -682,15 +698,16 @@ fun NowPlayingScreen(
} }
} }
private fun QueueKind.label(): String = @StringRes
private fun QueueKind.label(): Int =
when (this) { when (this) {
QueueKind.Album -> "album" QueueKind.Album -> R.string.context_kind_album
QueueKind.Playlist -> "playlist" QueueKind.Playlist -> R.string.context_kind_playlist
QueueKind.Song -> "song" QueueKind.Song -> R.string.context_kind_song
QueueKind.Artist -> "artist" QueueKind.Artist -> R.string.context_kind_artist
QueueKind.Genre -> "genre" QueueKind.Genre -> R.string.context_kind_genre
QueueKind.Downloaded -> "downloads" QueueKind.Downloaded -> R.string.context_kind_downloads
QueueKind.Starred -> "starred" QueueKind.Starred -> R.string.context_kind_starred
} }
private fun formatTime(milliseconds: Long): String { private fun formatTime(milliseconds: Long): String {
@@ -42,10 +42,12 @@ import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.subtracks.R
import com.subtracks.data.model.CoverArtRef import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.SongItem import com.subtracks.data.model.SongItem
import com.subtracks.data.repo.DownloadRepository import com.subtracks.data.repo.DownloadRepository
@@ -286,7 +288,7 @@ fun QueueRoute(
val queueContext = playback.context val queueContext = playback.context
// The item's album keeps the header stable while the async name lookup runs, so it does not // The item's album keeps the header stable while the async name lookup runs, so it does not
// flash the fallback when tapping between context and manually queued tracks. // flash the fallback when tapping between context and manually queued tracks.
val fallbackTitle = playback.item?.album?.takeIf { it.isNotBlank() } ?: "Next from here" val fallbackTitle = playback.item?.album?.takeIf { it.isNotBlank() } ?: stringResource(R.string.queue_next_from_here)
val sourceTitle = remember(queueContext) { mutableStateOf(fallbackTitle) } val sourceTitle = remember(queueContext) { mutableStateOf(fallbackTitle) }
LaunchedEffect(queueContext) { LaunchedEffect(queueContext) {
sourceTitle.value = controller.sourceTitle(queueContext)?.takeIf { it.isNotBlank() } ?: fallbackTitle sourceTitle.value = controller.sourceTitle(queueContext)?.takeIf { it.isNotBlank() } ?: fallbackTitle
@@ -345,6 +347,9 @@ fun QueueScreen(
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var dragId by remember { mutableStateOf<Long?>(null) } var dragId by remember { mutableStateOf<Long?>(null) }
var dragFrom by remember { mutableStateOf<Long?>(null) } var dragFrom by remember { mutableStateOf<Long?>(null) }
val undoLabel = stringResource(R.string.undo)
val queueReordered = stringResource(R.string.queue_reordered)
val queueRemoved = stringResource(R.string.queue_removed)
// One reorder state per run of rows: sh.calvin.reorderable only targets keys registered with // One reorder state per run of rows: sh.calvin.reorderable only targets keys registered with
// the state that started the drag, so a drag can never reach a row in another run. The context // the state that started the drag, so a drag can never reach a row in another run. The context
@@ -383,7 +388,7 @@ fun QueueScreen(
val showUndo: (String) -> Unit = { message -> val showUndo: (String) -> Unit = { message ->
scope.launch { scope.launch {
snackbarHostState.currentSnackbarData?.dismiss() snackbarHostState.currentSnackbarData?.dismiss()
val result = snackbarHostState.showSnackbar(message, actionLabel = "Undo", duration = SnackbarDuration.Short) val result = snackbarHostState.showSnackbar(message, actionLabel = undoLabel, duration = SnackbarDuration.Short)
if (result == SnackbarResult.ActionPerformed) onUndo() if (result == SnackbarResult.ActionPerformed) onUndo()
} }
} }
@@ -392,10 +397,10 @@ fun QueueScreen(
modifier = modifier, modifier = modifier,
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { Text("Queue") }, title = { Text(stringResource(R.string.queue_title)) },
navigationIcon = { navigationIcon = {
IconButton(onClick = onBack) { IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Back") Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = stringResource(R.string.navigation_back))
} }
}, },
) )
@@ -408,7 +413,7 @@ fun QueueScreen(
} }
rows.isEmpty() -> { rows.isEmpty() -> {
EmptyState("The queue is empty.", Modifier.padding(padding)) EmptyState(stringResource(R.string.queue_empty), Modifier.padding(padding))
} }
else -> { else -> {
@@ -424,7 +429,13 @@ fun QueueScreen(
// block, so a section-based key would duplicate. // block, so a section-based key would duplicate.
item(key = "header-$index") { item(key = "header-$index") {
QueueSectionHeader( QueueSectionHeader(
label = if (row.upNext) "Up next" else contextTitle ?: "Next up", label =
if (row.upNext) {
stringResource(R.string.queue_up_next)
} else {
contextTitle
?: stringResource(R.string.queue_next_up)
},
onClear = if (row.upNext) onClearUpNext else null, onClear = if (row.upNext) onClearUpNext else null,
) )
} }
@@ -475,7 +486,7 @@ fun QueueScreen(
} }
if (to != from) { if (to != from) {
onMove(from, to) onMove(from, to)
showUndo("Queue reordered") showUndo(queueReordered)
} }
} }
dragId = null dragId = null
@@ -486,7 +497,7 @@ fun QueueScreen(
onClick = { onPlay(row.position) }, onClick = { onPlay(row.position) },
onRemove = { onRemove = {
onRemove(row.position) onRemove(row.position)
showUndo("Removed from queue") showUndo(queueRemoved)
}, },
) )
} }
@@ -516,7 +527,11 @@ private fun QueueSectionHeader(
) )
if (onClear != null) { if (onClear != null) {
IconButton(onClick = onClear) { IconButton(onClick = onClear) {
Icon(Icons.Rounded.DeleteSweep, contentDescription = "Clear up next", modifier = Modifier.size(20.dp)) Icon(
Icons.Rounded.DeleteSweep,
contentDescription = stringResource(R.string.queue_clear_up_next),
modifier = Modifier.size(20.dp),
)
} }
} }
} }
@@ -567,13 +582,13 @@ private fun QueueRowItem(
IconButton(onClick = onRemove) { IconButton(onClick = onRemove) {
Icon( Icon(
imageVector = Icons.Rounded.Close, imageVector = Icons.Rounded.Close,
contentDescription = "Remove from queue", contentDescription = stringResource(R.string.queue_remove),
modifier = Modifier.size(20.dp), modifier = Modifier.size(20.dp),
) )
} }
if (dragHandle != null) { if (dragHandle != null) {
Box(modifier = Modifier.size(40.dp).then(dragHandle), contentAlignment = Alignment.Center) { Box(modifier = Modifier.size(40.dp).then(dragHandle), contentAlignment = Alignment.Center) {
Icon(imageVector = Icons.Rounded.DragHandle, contentDescription = "Reorder") Icon(imageVector = Icons.Rounded.DragHandle, contentDescription = stringResource(R.string.queue_reorder))
} }
} }
} }
@@ -41,10 +41,12 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.subtracks.R
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf import org.koin.core.parameter.parametersOf
@@ -98,11 +100,22 @@ fun AddSourceScreen(
modifier = modifier, modifier = modifier,
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { Text(if (state.isEditing) "Edit server" else "Add server") }, title = {
Text(
if (state.isEditing) {
stringResource(R.string.settings_servers_actions_edit)
} else {
stringResource(R.string.settings_servers_actions_add)
},
)
},
navigationIcon = { navigationIcon = {
if (onBack != null) { if (onBack != null) {
IconButton(onClick = onBack) { IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Back") Icon(
Icons.AutoMirrored.Rounded.ArrowBack,
contentDescription = stringResource(R.string.navigation_back),
)
} }
} }
}, },
@@ -113,7 +126,7 @@ fun AddSourceScreen(
enabled = state.canDelete && !state.busy, enabled = state.canDelete && !state.busy,
colors = IconButtonDefaults.iconButtonColors(contentColor = MaterialTheme.colorScheme.error), colors = IconButtonDefaults.iconButtonColors(contentColor = MaterialTheme.colorScheme.error),
) { ) {
Icon(Icons.Rounded.Delete, contentDescription = "Delete server") Icon(Icons.Rounded.Delete, contentDescription = stringResource(R.string.settings_servers_actions_delete))
} }
} }
}, },
@@ -144,10 +157,10 @@ fun AddSourceScreen(
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp) CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
} }
OutlinedButton(onClick = onTest, enabled = !state.busy) { OutlinedButton(onClick = onTest, enabled = !state.busy) {
Text("Test connection") Text(stringResource(R.string.settings_servers_actions_test_connection))
} }
Button(onClick = onSave, enabled = !state.busy) { Button(onClick = onSave, enabled = !state.busy) {
Text("Save and sync") Text(stringResource(R.string.settings_servers_actions_save))
} }
} }
} }
@@ -164,16 +177,16 @@ fun AddSourceScreen(
) { ) {
Text( Text(
if (state.isEditing) { if (state.isEditing) {
"Update this server's connection details." stringResource(R.string.edit_server_intro)
} else { } else {
"Connect a Subsonic-compatible server such as Navidrome, gonic or Airsonic." stringResource(R.string.add_server_intro)
}, },
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
if (state.isEditing && !state.canDelete) { if (state.isEditing && !state.canDelete) {
Text( Text(
"This is the active server. Switch to another server before deleting it.", stringResource(R.string.active_server_hint),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
@@ -181,34 +194,34 @@ fun AddSourceScreen(
OutlinedTextField( OutlinedTextField(
value = state.name, value = state.name,
onValueChange = onNameChange, onValueChange = onNameChange,
label = { Text("Name") }, label = { Text(stringResource(R.string.settings_servers_fields_name)) },
singleLine = true, singleLine = true,
isError = state.nameError, isError = state.nameError,
supportingText = if (state.nameError) ({ Text("Required") }) else null, supportingText = if (state.nameError) ({ Text(stringResource(R.string.required)) }) else null,
modifier = Modifier.fillMaxWidth().focusRequester(nameFocus), modifier = Modifier.fillMaxWidth().focusRequester(nameFocus),
) )
OutlinedTextField( OutlinedTextField(
value = state.address, value = state.address,
onValueChange = onAddressChange, onValueChange = onAddressChange,
label = { Text("Server address") }, label = { Text(stringResource(R.string.settings_servers_fields_address)) },
placeholder = { Text("https://music.example.com") }, placeholder = { Text(stringResource(R.string.server_address_placeholder)) },
singleLine = true, singleLine = true,
isError = state.addressError, isError = state.addressError,
supportingText = if (state.addressError) ({ Text("Required") }) else null, supportingText = if (state.addressError) ({ Text(stringResource(R.string.required)) }) else null,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
modifier = Modifier.fillMaxWidth().focusRequester(addressFocus), modifier = Modifier.fillMaxWidth().focusRequester(addressFocus),
) )
OutlinedTextField( OutlinedTextField(
value = state.username, value = state.username,
onValueChange = onUsernameChange, onValueChange = onUsernameChange,
label = { Text("Username") }, label = { Text(stringResource(R.string.settings_servers_fields_username)) },
singleLine = true, singleLine = true,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
OutlinedTextField( OutlinedTextField(
value = state.password, value = state.password,
onValueChange = onPasswordChange, onValueChange = onPasswordChange,
label = { Text("Password") }, label = { Text(stringResource(R.string.settings_servers_fields_password)) },
singleLine = true, singleLine = true,
visualTransformation = PasswordVisualTransformation(), visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -219,9 +232,9 @@ fun AddSourceScreen(
horizontalArrangement = Arrangement.spacedBy(16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp),
) { ) {
Column(Modifier.weight(1f)) { Column(Modifier.weight(1f)) {
Text("Token authentication") Text(stringResource(R.string.token_authentication))
Text( Text(
"Recommended. Disable only for servers that reject the salted token scheme.", stringResource(R.string.token_authentication_hint),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
@@ -243,18 +256,18 @@ fun AddSourceScreen(
if (confirmingDelete) { if (confirmingDelete) {
AlertDialog( AlertDialog(
onDismissRequest = { confirmingDelete = false }, onDismissRequest = { confirmingDelete = false },
title = { Text("Delete server?") }, title = { Text(stringResource(R.string.delete_server_title)) },
text = { Text("${state.name} and its downloaded songs will be removed from this device.") }, text = { Text(stringResource(R.string.delete_server_body, state.name)) },
confirmButton = { confirmButton = {
TextButton( TextButton(
onClick = { onClick = {
confirmingDelete = false confirmingDelete = false
onDelete() onDelete()
}, },
) { Text("Delete") } ) { Text(stringResource(R.string.actions_delete)) }
}, },
dismissButton = { dismissButton = {
TextButton(onClick = { confirmingDelete = false }) { Text("Cancel") } TextButton(onClick = { confirmingDelete = false }) { Text(stringResource(R.string.actions_cancel)) }
}, },
) )
} }
@@ -1,7 +1,9 @@
package com.subtracks.ui.settings package com.subtracks.ui.settings
import android.content.res.Resources
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.subtracks.R
import com.subtracks.data.repo.DownloadRepository import com.subtracks.data.repo.DownloadRepository
import com.subtracks.data.repo.SourceRepository import com.subtracks.data.repo.SourceRepository
import com.subtracks.data.sync.SyncManager import com.subtracks.data.sync.SyncManager
@@ -38,6 +40,7 @@ class AddSourceViewModel(
private val syncManager: SyncManager, private val syncManager: SyncManager,
private val downloadRepository: DownloadRepository, private val downloadRepository: DownloadRepository,
private val sourceId: Long? = null, private val sourceId: Long? = null,
private val resources: Resources,
) : ViewModel() { ) : ViewModel() {
private val _state = MutableStateFlow(AddSourceState(isEditing = sourceId != null)) private val _state = MutableStateFlow(AddSourceState(isEditing = sourceId != null))
val state: StateFlow<AddSourceState> = _state val state: StateFlow<AddSourceState> = _state
@@ -87,8 +90,16 @@ class AddSourceViewModel(
useTokenAuth = if (fellBack) false else state.useTokenAuth, useTokenAuth = if (fellBack) false else state.useTokenAuth,
message = message =
result.fold( result.fold(
{ if (fellBack) "Server does not support token auth; using the password instead" else "Connection OK" }, {
{ error -> "Failed: ${error.message}" }, if (fellBack) {
resources.getString(
R.string.connection_token_fallback,
)
} else {
resources.getString(R.string.connection_ok)
}
},
{ error -> resources.getString(R.string.connection_failed, error.message) },
), ),
isError = result.isFailure, isError = result.isFailure,
) )
@@ -133,7 +144,7 @@ class AddSourceViewModel(
}, },
onFailure = { error -> onFailure = { error ->
_state.update { _state.update {
it.copy(busy = false, message = "Could not save: ${error.message}", isError = true) it.copy(busy = false, message = resources.getString(R.string.save_failed, error.message), isError = true)
} }
}, },
) )
@@ -146,7 +157,7 @@ class AddSourceViewModel(
viewModelScope.launch { viewModelScope.launch {
if (!sourceRepository.deleteSource(id)) { if (!sourceRepository.deleteSource(id)) {
_state.update { _state.update {
it.copy(busy = false, message = "The active server can't be deleted; switch to another server first", isError = true) it.copy(busy = false, message = resources.getString(R.string.active_server_delete_error), isError = true)
} }
return@launch return@launch
} }
@@ -13,6 +13,7 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import com.mikepenz.aboutlibraries.Libs import com.mikepenz.aboutlibraries.Libs
import com.mikepenz.aboutlibraries.ui.compose.android.produceLibraries import com.mikepenz.aboutlibraries.ui.compose.android.produceLibraries
import com.mikepenz.aboutlibraries.ui.compose.m3.LibrariesContainer import com.mikepenz.aboutlibraries.ui.compose.m3.LibrariesContainer
@@ -38,10 +39,10 @@ fun LicensesScreen(
modifier = modifier, modifier = modifier,
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { Text("Licenses") }, title = { Text(stringResource(R.string.settings_about_actions_licenses)) },
navigationIcon = { navigationIcon = {
IconButton(onClick = onBack) { IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Back") Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = stringResource(R.string.navigation_back))
} }
}, },
) )
@@ -1,5 +1,6 @@
package com.subtracks.ui.settings package com.subtracks.ui.settings
import android.content.res.Resources
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@@ -47,9 +48,12 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.subtracks.R
import com.subtracks.data.model.Source import com.subtracks.data.model.Source
import com.subtracks.data.prefs.StreamQuality import com.subtracks.data.prefs.StreamQuality
import com.subtracks.ui.components.rememberViewportFill import com.subtracks.ui.components.rememberViewportFill
@@ -139,10 +143,10 @@ fun SettingsScreen(
modifier = modifier, modifier = modifier,
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { Text("Settings") }, title = { Text(stringResource(R.string.navigation_tabs_settings)) },
navigationIcon = { navigationIcon = {
IconButton(onClick = onBack) { IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Back") Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = stringResource(R.string.navigation_back))
} }
}, },
) )
@@ -152,7 +156,7 @@ fun SettingsScreen(
state = listState, state = listState,
modifier = Modifier.padding(padding).fillMaxSize(), modifier = Modifier.padding(padding).fillMaxSize(),
) { ) {
item { SectionHeader("Servers") } item { SectionHeader(stringResource(R.string.settings_servers_name)) }
items(sources.size, key = { sources[it].id }) { index -> items(sources.size, key = { sources[it].id }) { index ->
val source = sources[index] val source = sources[index]
ListItem( ListItem(
@@ -168,10 +172,13 @@ fun SettingsScreen(
}, },
trailingContent = { trailingContent = {
IconButton(onClick = { onEditServer(source.id) }) { IconButton(onClick = { onEditServer(source.id) }) {
Icon(Icons.Rounded.Edit, contentDescription = "Edit ${source.name}") Icon(Icons.Rounded.Edit, contentDescription = stringResource(R.string.edit_source_description, source.name))
} }
}, },
modifier = Modifier.clickable(onClickLabel = "Use ${source.name}") { onSelectSource(source.id) }, modifier =
Modifier.clickable(
onClickLabel = stringResource(R.string.use_source_description, source.name),
) { onSelectSource(source.id) },
colors = ListItemDefaults.colors(containerColor = Color.Transparent), colors = ListItemDefaults.colors(containerColor = Color.Transparent),
) )
} }
@@ -183,16 +190,22 @@ fun SettingsScreen(
Button(onClick = onAddServer) { Button(onClick = onAddServer) {
Icon(Icons.Rounded.Add, contentDescription = null, modifier = Modifier.size(18.dp)) Icon(Icons.Rounded.Add, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
Text("Add server") Text(stringResource(R.string.settings_servers_actions_add))
} }
} }
} }
item { SectionHeader("Network") } item { SectionHeader(stringResource(R.string.settings_network_name)) }
item { item {
ListItem( ListItem(
headlineContent = { Text("Offline mode") }, headlineContent = { Text(stringResource(R.string.settings_network_options_offline_mode)) },
supportingContent = { supportingContent = {
Text(if (offline) "Don't use the internet to sync or play music." else "Use the internet to sync music.") Text(
if (offline) {
stringResource(R.string.settings_network_options_offline_mode_on)
} else {
stringResource(R.string.settings_network_options_offline_mode_off)
},
)
}, },
trailingContent = { trailingContent = {
Switch(checked = offline, onCheckedChange = onOfflineChange) Switch(checked = offline, onCheckedChange = onOfflineChange)
@@ -203,7 +216,7 @@ fun SettingsScreen(
} }
item { item {
ListItem( ListItem(
headlineContent = { Text("Stream quality: Wi-Fi") }, headlineContent = { Text(stringResource(R.string.settings_network_options_max_bitrate_wifi_title)) },
supportingContent = { Text(qualityLabel(wifiQuality)) }, supportingContent = { Text(qualityLabel(wifiQuality)) },
modifier = Modifier.clickable { dialog = SettingsDialog.WifiQuality }, modifier = Modifier.clickable { dialog = SettingsDialog.WifiQuality },
colors = ListItemDefaults.colors(containerColor = Color.Transparent), colors = ListItemDefaults.colors(containerColor = Color.Transparent),
@@ -211,7 +224,7 @@ fun SettingsScreen(
} }
item { item {
ListItem( ListItem(
headlineContent = { Text("Stream quality: Mobile") }, headlineContent = { Text(stringResource(R.string.settings_network_options_max_bitrate_mobile_title)) },
supportingContent = { Text(qualityLabel(mobileQuality)) }, supportingContent = { Text(qualityLabel(mobileQuality)) },
modifier = Modifier.clickable { dialog = SettingsDialog.MobileQuality }, modifier = Modifier.clickable { dialog = SettingsDialog.MobileQuality },
colors = ListItemDefaults.colors(containerColor = Color.Transparent), colors = ListItemDefaults.colors(containerColor = Color.Transparent),
@@ -219,17 +232,17 @@ fun SettingsScreen(
} }
item { item {
ListItem( ListItem(
headlineContent = { Text("Sync concurrency") }, headlineContent = { Text(stringResource(R.string.sync_concurrency)) },
supportingContent = { Text(concurrencyLabel(syncConcurrency)) }, supportingContent = { Text(concurrencyLabel(LocalResources.current, syncConcurrency)) },
modifier = Modifier.clickable { dialog = SettingsDialog.SyncConcurrency }, modifier = Modifier.clickable { dialog = SettingsDialog.SyncConcurrency },
colors = ListItemDefaults.colors(containerColor = Color.Transparent), colors = ListItemDefaults.colors(containerColor = Color.Transparent),
) )
} }
item { SectionHeader("Playback") } item { SectionHeader(stringResource(R.string.settings_music_name)) }
item { item {
ListItem( ListItem(
headlineContent = { Text("Scrobble to server") }, headlineContent = { Text(stringResource(R.string.settings_music_options_scrobble_title)) },
supportingContent = { Text("Send now playing and play counts") }, supportingContent = { Text(stringResource(R.string.settings_music_options_scrobble_description_on)) },
trailingContent = { trailingContent = {
Switch( Switch(
checked = scrobbling, checked = scrobbling,
@@ -246,10 +259,10 @@ fun SettingsScreen(
colors = ListItemDefaults.colors(containerColor = Color.Transparent), colors = ListItemDefaults.colors(containerColor = Color.Transparent),
) )
} }
item { SectionHeader("Downloads") } item { SectionHeader(stringResource(R.string.downloads_title)) }
item { item {
ListItem( ListItem(
headlineContent = { Text("Download quality") }, headlineContent = { Text(stringResource(R.string.download_quality)) },
supportingContent = { Text(qualityLabel(downloadQuality)) }, supportingContent = { Text(qualityLabel(downloadQuality)) },
modifier = Modifier.clickable { dialog = SettingsDialog.DownloadQuality }, modifier = Modifier.clickable { dialog = SettingsDialog.DownloadQuality },
colors = ListItemDefaults.colors(containerColor = Color.Transparent), colors = ListItemDefaults.colors(containerColor = Color.Transparent),
@@ -257,8 +270,16 @@ fun SettingsScreen(
} }
item { item {
ListItem( ListItem(
headlineContent = { Text("Download over mobile data") }, headlineContent = { Text(stringResource(R.string.download_over_mobile)) },
supportingContent = { Text(if (downloadOverMetered) "Wi-Fi and mobile" else "Wi-Fi only") }, supportingContent = {
Text(
if (downloadOverMetered) {
stringResource(R.string.download_network_wifi_and_mobile)
} else {
stringResource(R.string.download_network_wifi_only)
},
)
},
trailingContent = { trailingContent = {
Switch( Switch(
checked = downloadOverMetered, checked = downloadOverMetered,
@@ -277,16 +298,16 @@ fun SettingsScreen(
} }
item { item {
ListItem( ListItem(
headlineContent = { Text("Manage downloads") }, headlineContent = { Text(stringResource(R.string.manage_downloads)) },
leadingContent = { Icon(Icons.Rounded.Download, contentDescription = null) }, leadingContent = { Icon(Icons.Rounded.Download, contentDescription = null) },
modifier = Modifier.clickable(onClick = onOpenDownloads), modifier = Modifier.clickable(onClick = onOpenDownloads),
colors = ListItemDefaults.colors(containerColor = Color.Transparent), colors = ListItemDefaults.colors(containerColor = Color.Transparent),
) )
} }
item { SectionHeader("About") } item { SectionHeader(stringResource(R.string.settings_about_name)) }
item { item {
ListItem( ListItem(
headlineContent = { Text("Licenses") }, headlineContent = { Text(stringResource(R.string.settings_about_actions_licenses)) },
leadingContent = { Icon(Icons.Rounded.Description, contentDescription = null) }, leadingContent = { Icon(Icons.Rounded.Description, contentDescription = null) },
modifier = Modifier.clickable(onClick = onOpenLicenses), modifier = Modifier.clickable(onClick = onOpenLicenses),
colors = ListItemDefaults.colors(containerColor = Color.Transparent), colors = ListItemDefaults.colors(containerColor = Color.Transparent),
@@ -299,7 +320,7 @@ fun SettingsScreen(
when (dialog) { when (dialog) {
SettingsDialog.WifiQuality -> { SettingsDialog.WifiQuality -> {
QualityDialog( QualityDialog(
title = "Stream quality: Wi-Fi", title = stringResource(R.string.settings_network_options_max_bitrate_wifi_title),
quality = wifiQuality, quality = wifiQuality,
onSelect = onWifiQualityChange, onSelect = onWifiQualityChange,
onDismiss = { dialog = null }, onDismiss = { dialog = null },
@@ -308,7 +329,7 @@ fun SettingsScreen(
SettingsDialog.MobileQuality -> { SettingsDialog.MobileQuality -> {
QualityDialog( QualityDialog(
title = "Stream quality: Mobile", title = stringResource(R.string.settings_network_options_max_bitrate_mobile_title),
quality = mobileQuality, quality = mobileQuality,
onSelect = onMobileQualityChange, onSelect = onMobileQualityChange,
onDismiss = { dialog = null }, onDismiss = { dialog = null },
@@ -317,7 +338,7 @@ fun SettingsScreen(
SettingsDialog.DownloadQuality -> { SettingsDialog.DownloadQuality -> {
QualityDialog( QualityDialog(
title = "Download quality", title = stringResource(R.string.download_quality),
quality = downloadQuality, quality = downloadQuality,
onSelect = onDownloadQualityChange, onSelect = onDownloadQualityChange,
onDismiss = { dialog = null }, onDismiss = { dialog = null },
@@ -338,13 +359,29 @@ fun SettingsScreen(
} }
} }
private fun qualityLabel(quality: StreamQuality): String = "${quality.format ?: "Server default"} · ${bitrateLabel(quality.maxBitrate)}" @Composable
private fun qualityLabel(quality: StreamQuality): String {
val resources = LocalResources.current
val format = quality.format ?: stringResource(R.string.stream_quality_server_default)
return stringResource(R.string.download_status_summary, format, bitrateLabel(resources, quality.maxBitrate))
}
private fun bitrateLabel(kbps: Int): String = if (kbps == 0) "Unlimited" else "${kbps}kbps" private fun bitrateLabel(
resources: Resources,
kbps: Int,
): String =
if (kbps == 0) {
resources.getString(R.string.settings_network_values_unlimited_kbps)
} else {
resources.getString(R.string.settings_network_values_kbps, kbps.toString())
}
private val syncConcurrencyOptions = listOf(1, 2, 4, 8, 16) private val syncConcurrencyOptions = listOf(1, 2, 4, 8, 16)
private fun concurrencyLabel(value: Int): String = if (value <= 1) "1 (sequential)" else value.toString() private fun concurrencyLabel(
resources: Resources,
value: Int,
): String = if (value <= 1) resources.getString(R.string.sync_concurrency_sequential) else value.toString()
@Composable @Composable
private fun ConcurrencyDialog( private fun ConcurrencyDialog(
@@ -352,14 +389,15 @@ private fun ConcurrencyDialog(
onSelect: (Int) -> Unit, onSelect: (Int) -> Unit,
onDismiss: () -> Unit, onDismiss: () -> Unit,
) { ) {
val resources = LocalResources.current
var draft by remember(selected) { mutableStateOf(selected) } var draft by remember(selected) { mutableStateOf(selected) }
AlertDialog( AlertDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
title = { Text("Sync concurrency") }, title = { Text(stringResource(R.string.sync_concurrency)) },
text = { text = {
ChoiceGroup( ChoiceGroup(
header = "Parallel server requests while syncing", header = stringResource(R.string.sync_concurrency_parallel),
options = syncConcurrencyOptions.map { it to concurrencyLabel(it) }, options = syncConcurrencyOptions.map { it to concurrencyLabel(resources, it) },
selected = draft, selected = draft,
onSelect = { draft = it }, onSelect = { draft = it },
) )
@@ -370,10 +408,10 @@ private fun ConcurrencyDialog(
onSelect(draft) onSelect(draft)
onDismiss() onDismiss()
}, },
) { Text("Done") } ) { Text(stringResource(R.string.action_done)) }
}, },
dismissButton = { dismissButton = {
TextButton(onClick = onDismiss) { Text("Cancel") } TextButton(onClick = onDismiss) { Text(stringResource(R.string.actions_cancel)) }
}, },
) )
} }
@@ -385,6 +423,7 @@ private fun QualityDialog(
onSelect: (StreamQuality) -> Unit, onSelect: (StreamQuality) -> Unit,
onDismiss: () -> Unit, onDismiss: () -> Unit,
) { ) {
val resources = LocalResources.current
var draft by remember(quality) { mutableStateOf(quality) } var draft by remember(quality) { mutableStateOf(quality) }
val scrollState = rememberScrollState() val scrollState = rememberScrollState()
AlertDialog( AlertDialog(
@@ -394,15 +433,19 @@ private fun QualityDialog(
Box { Box {
Column(modifier = Modifier.verticalScroll(scrollState)) { Column(modifier = Modifier.verticalScroll(scrollState)) {
ChoiceGroup( ChoiceGroup(
header = "Maximum bitrate", header = stringResource(R.string.maximum_bitrate),
options = bitrateOptions.map { it to bitrateLabel(it) }, options = bitrateOptions.map { it to bitrateLabel(resources, it) },
selected = draft.maxBitrate, selected = draft.maxBitrate,
onSelect = { draft = draft.copy(maxBitrate = it) }, onSelect = { draft = draft.copy(maxBitrate = it) },
) )
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
ChoiceGroup( ChoiceGroup(
header = "Preferred format", header = stringResource(R.string.settings_network_options_stream_format),
options = streamFormats.map { it to (it ?: "Use server default") }, options =
streamFormats.map {
it to
(it ?: resources.getString(R.string.settings_network_options_stream_format_server_default))
},
selected = draft.format, selected = draft.format,
onSelect = { draft = draft.copy(format = it) }, onSelect = { draft = draft.copy(format = it) },
) )
@@ -429,10 +472,10 @@ private fun QualityDialog(
onSelect(draft) onSelect(draft)
onDismiss() onDismiss()
}, },
) { Text("Done") } ) { Text(stringResource(R.string.action_done)) }
}, },
dismissButton = { dismissButton = {
TextButton(onClick = onDismiss) { Text("Cancel") } TextButton(onClick = onDismiss) { Text(stringResource(R.string.actions_cancel)) }
}, },
) )
} }
+1
View File
@@ -0,0 +1 @@
unqualifiedResLocale=en-US
+24
View File
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="actions_star">مميز</string>
<string name="actions_unstar">ازل التمييز</string>
<string name="navigation_tabs_library">المكتبة</string>
<string name="navigation_tabs_settings">الإعدادات</string>
<string name="resources_filter_genre">حسب النوع</string>
<string name="resources_filter_starred">موسوم</string>
<string name="resources_sort_by_artist">حسب الفنان/ة</string>
<string name="resources_sort_by_frequently_played">مشغل كثيرا</string>
<string name="resources_sort_by_name">حسب الاسم</string>
<string name="resources_sort_by_recently_played">شغل حديثا</string>
<string name="resources_sort_by_year">حسب السنة</string>
<string name="search_input_placeholder">بحث</string>
<string name="settings_about_actions_licenses">الرخص</string>
<string name="settings_about_name">حول</string>
<string name="settings_network_name">الشبكة</string>
<string name="settings_network_values_kbps">%1$s كيلو بايت في الثانية</string>
<string name="settings_network_values_unlimited_kbps">غير محدود</string>
<string name="settings_servers_actions_test_connection">أخبر الأتصال</string>
<string name="settings_servers_fields_password">كلمة المرور</string>
<string name="settings_servers_fields_username">إسم المستخدم</string>
<string name="navigation_tabs_home">الرئيسية</string>
</resources>
@@ -0,0 +1,46 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="actions_cancel">Cancelar</string>
<string name="actions_delete">Deletar</string>
<string name="actions_download">Download</string>
<string name="actions_download_cancel">Cancelar download</string>
<string name="actions_star">Estrela</string>
<string name="actions_unstar">Sem estrela</string>
<string name="controls_shuffle">Misturar</string>
<string name="navigation_tabs_library">Biblioteca</string>
<string name="navigation_tabs_settings">Configurações</string>
<plurals name="resources_album_count">
<item quantity="one">%1$d album</item>
<item quantity="other">%1$d albums</item>
</plurals>
<string name="resources_filter_album">Álbum</string>
<string name="resources_filter_artist">Artista</string>
<string name="resources_filter_genre">Gênero</string>
<string name="resources_filter_starred">Estrelado por</string>
<string name="resources_filter_year">Ano</string>
<plurals name="resources_song_count">
<item quantity="one">%1$d song</item>
<item quantity="other">%1$d songs</item>
</plurals>
<string name="resources_sort_by_artist">Artista</string>
<string name="resources_sort_by_frequently_played">Mais tocados</string>
<string name="resources_sort_by_name">Nome</string>
<string name="resources_sort_by_recently_played">Tocado recentemente</string>
<string name="resources_sort_by_title">Título</string>
<string name="resources_sort_by_year">Ano</string>
<string name="search_input_placeholder">Pesquisar</string>
<string name="settings_about_actions_licenses">Licenças</string>
<string name="settings_about_name">Sobre</string>
<string name="settings_network_name">Rede</string>
<string name="settings_network_options_offline_mode">Modo off-line</string>
<string name="settings_network_options_offline_mode_off">Usar a internet para sincronizar as músicas.</string>
<string name="settings_network_options_offline_mode_on">Não usar a Internet para sincronizar ou tocar músicas.</string>
<string name="settings_network_options_stream_format_server_default">Usar servidor padrão</string>
<string name="settings_network_values_kbps">%1$skbps</string>
<string name="settings_network_values_unlimited_kbps">Ilimitado</string>
<string name="settings_servers_actions_test_connection">Testar conexão</string>
<string name="settings_servers_fields_name">Nome</string>
<string name="settings_servers_fields_password">Senha</string>
<string name="settings_servers_fields_username">Usuário</string>
<string name="navigation_tabs_home">Home</string>
</resources>
@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="actions_cancel">取消</string>
<string name="actions_delete">删除</string>
<string name="actions_download">下载</string>
<string name="actions_download_cancel">取消下载</string>
<string name="actions_star">收藏</string>
<string name="actions_unstar">移除收藏</string>
<string name="controls_shuffle">随机播放</string>
<string name="navigation_tabs_library">所有</string>
<string name="navigation_tabs_settings">设置</string>
<plurals name="resources_album_count">
<item quantity="other">%1$d 张专辑</item>
</plurals>
<string name="resources_filter_album">专辑</string>
<string name="resources_filter_artist">歌手</string>
<string name="resources_filter_genre">根据类型</string>
<string name="resources_filter_starred">已收藏</string>
<string name="resources_filter_year">年份</string>
<plurals name="resources_song_count">
<item quantity="other">%1$d 首歌曲</item>
</plurals>
<string name="resources_sort_by_artist">根据歌手</string>
<string name="resources_sort_by_frequently_played">播放最多</string>
<string name="resources_sort_by_name">根据名称</string>
<string name="resources_sort_by_recently_played">最近播放</string>
<string name="resources_sort_by_title">标题</string>
<string name="resources_sort_by_year">根据年份</string>
<string name="search_input_placeholder">搜索</string>
<string name="settings_about_actions_licenses">许可</string>
<string name="settings_about_name">关于</string>
<string name="settings_network_name">网络</string>
<string name="settings_network_options_offline_mode">离线模式</string>
<string name="settings_network_options_offline_mode_off">使用互联网同步音乐。</string>
<string name="settings_network_options_offline_mode_on">不使用互联网同步或播放音乐。</string>
<string name="settings_network_options_stream_format_server_default">使用服务器默认设置</string>
<string name="settings_network_values_kbps">%1$skbps</string>
<string name="settings_network_values_unlimited_kbps">不限制</string>
<string name="settings_servers_actions_test_connection">测试连接</string>
<string name="settings_servers_fields_name">名称</string>
<string name="settings_servers_fields_password">密码</string>
<string name="settings_servers_fields_username">用户名</string>
<string name="navigation_tabs_home">首页</string>
</resources>
@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="actions_cancel">取消</string>
<string name="actions_delete">刪除</string>
<string name="actions_download">下載</string>
<string name="actions_download_cancel">取消下載</string>
<string name="actions_star">收藏</string>
<string name="actions_unstar">移除收藏</string>
<string name="controls_shuffle">隨機播放</string>
<string name="navigation_tabs_library">所有</string>
<string name="navigation_tabs_settings">設定</string>
<plurals name="resources_album_count">
<item quantity="other">%1$d 張專輯</item>
</plurals>
<string name="resources_filter_album">專輯</string>
<string name="resources_filter_artist">歌手</string>
<string name="resources_filter_genre">根據類型</string>
<string name="resources_filter_starred">已收藏</string>
<string name="resources_filter_year">年份</string>
<plurals name="resources_song_count">
<item quantity="other">%1$d 首歌曲</item>
</plurals>
<string name="resources_sort_by_artist">根據歌手</string>
<string name="resources_sort_by_frequently_played">最常播放</string>
<string name="resources_sort_by_name">根據名稱</string>
<string name="resources_sort_by_recently_played">最近播放</string>
<string name="resources_sort_by_title">標題</string>
<string name="resources_sort_by_year">根據年份</string>
<string name="search_input_placeholder">搜尋</string>
<string name="settings_about_actions_licenses">許可</string>
<string name="settings_about_name">關於</string>
<string name="settings_network_name">網路</string>
<string name="settings_network_options_offline_mode">離線模式</string>
<string name="settings_network_options_offline_mode_off">使用網際網路同步音樂。</string>
<string name="settings_network_options_offline_mode_on">不使用網際網路同步或播放音樂。</string>
<string name="settings_network_options_stream_format_server_default">使用伺服器預設設定</string>
<string name="settings_network_values_kbps">%1$skbps</string>
<string name="settings_network_values_unlimited_kbps">不限制</string>
<string name="settings_servers_actions_test_connection">測試連線</string>
<string name="settings_servers_fields_name">名稱</string>
<string name="settings_servers_fields_password">密碼</string>
<string name="settings_servers_fields_username">用戶名稱</string>
<string name="navigation_tabs_home">首頁</string>
</resources>
+24
View File
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="actions_star">Afegir als favorits</string>
<string name="actions_unstar">Retirar estrella</string>
<string name="navigation_tabs_library">Biblioteca</string>
<string name="navigation_tabs_settings">Paràmetres</string>
<string name="resources_filter_genre">Per gènere</string>
<string name="resources_filter_starred">Favorits</string>
<string name="resources_sort_by_artist">Per artista</string>
<string name="resources_sort_by_frequently_played">Escoltat freqüentment</string>
<string name="resources_sort_by_name">Pel nom</string>
<string name="resources_sort_by_recently_played">Reproduït recentment</string>
<string name="resources_sort_by_year">Per any</string>
<string name="search_input_placeholder">Cercar</string>
<string name="settings_about_actions_licenses">Llicències</string>
<string name="settings_about_name">Quant a</string>
<string name="settings_network_name">Xarxa</string>
<string name="settings_network_values_kbps">%1$s kbps</string>
<string name="settings_network_values_unlimited_kbps">Il·limitat</string>
<string name="settings_servers_actions_test_connection">Comprovar la connexió</string>
<string name="settings_servers_fields_password">Contrasenya</string>
<string name="settings_servers_fields_username">Nom d’usuari</string>
<string name="navigation_tabs_home">Inici</string>
</resources>
+35
View File
@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="actions_cancel">Zrušit</string>
<string name="actions_delete">Smazat</string>
<string name="actions_download">Stáhnout</string>
<string name="actions_download_cancel">Zrušit stahování</string>
<string name="actions_star">Ohodnotit</string>
<string name="actions_unstar">Zrušit hodnocení</string>
<string name="controls_shuffle">Náhodně</string>
<string name="navigation_tabs_library">Knihovna</string>
<string name="navigation_tabs_settings">Nastavení</string>
<string name="resources_filter_album">Album</string>
<string name="resources_filter_artist">Umělec</string>
<string name="resources_filter_genre">Podle žánru</string>
<string name="resources_filter_starred">Ohodnocené</string>
<string name="resources_filter_year">Rok</string>
<string name="resources_sort_by_artist">Umělce</string>
<string name="resources_sort_by_frequently_played">Často přehrávané</string>
<string name="resources_sort_by_name">Názvu</string>
<string name="resources_sort_by_recently_played">Často přehrávané</string>
<string name="resources_sort_by_title">Názvu</string>
<string name="resources_sort_by_year">Roku</string>
<string name="search_input_placeholder">Hledat</string>
<string name="settings_about_actions_licenses">Licence</string>
<string name="settings_about_name">O aplikaci</string>
<string name="settings_network_name">Síť</string>
<string name="settings_network_options_stream_format_server_default">Použít nastavení serveru</string>
<string name="settings_network_values_kbps">%1$skbps</string>
<string name="settings_network_values_unlimited_kbps">Neomezeno</string>
<string name="settings_servers_actions_test_connection">Otestovat spojení</string>
<string name="settings_servers_fields_name">Jméno</string>
<string name="settings_servers_fields_password">Heslo</string>
<string name="settings_servers_fields_username">Uživ. jméno</string>
<string name="navigation_tabs_home">Domů</string>
</resources>
+23
View File
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="actions_cancel">Afbryd</string>
<string name="actions_delete">Slet</string>
<string name="actions_download">Hent</string>
<string name="actions_download_cancel">Afbryd download</string>
<string name="navigation_tabs_library">Bibliotek</string>
<string name="navigation_tabs_settings">Indstillinger</string>
<string name="resources_filter_genre">Efter genre</string>
<string name="resources_sort_by_artist">Efter kunstner</string>
<string name="resources_sort_by_name">Efter navn</string>
<string name="resources_sort_by_year">Efter år</string>
<string name="search_input_placeholder">Søg</string>
<string name="settings_about_actions_licenses">Licenser</string>
<string name="settings_about_name">Omkring</string>
<string name="settings_network_name">Netværk</string>
<string name="settings_network_values_kbps">%1$skbps</string>
<string name="settings_network_values_unlimited_kbps">Ubegrænset</string>
<string name="settings_servers_actions_test_connection">Test forbindelse</string>
<string name="settings_servers_fields_password">Adgangskode</string>
<string name="settings_servers_fields_username">Brugernavn</string>
<string name="navigation_tabs_home">Hjem</string>
</resources>
+46
View File
@@ -0,0 +1,46 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="actions_cancel">Abbrechen</string>
<string name="actions_delete">Löschen</string>
<string name="actions_download">Herunterladen</string>
<string name="actions_download_cancel">Download abbrechen</string>
<string name="actions_star">Markieren</string>
<string name="actions_unstar">Markierung entfernen</string>
<string name="controls_shuffle">Zufall</string>
<string name="navigation_tabs_library">Bibliothek</string>
<string name="navigation_tabs_settings">Einstellungen</string>
<plurals name="resources_album_count">
<item quantity="one">%1$d Album</item>
<item quantity="other">%1$d Alben</item>
</plurals>
<string name="resources_filter_album">Album</string>
<string name="resources_filter_artist">Künstler</string>
<string name="resources_filter_genre">Nach Genre</string>
<string name="resources_filter_starred">Favoriten</string>
<string name="resources_filter_year">Jahr</string>
<plurals name="resources_song_count">
<item quantity="one">%1$d Song</item>
<item quantity="other">%1$d Songs</item>
</plurals>
<string name="resources_sort_by_artist">Nach Interpreten</string>
<string name="resources_sort_by_frequently_played">Häufig abgespielt</string>
<string name="resources_sort_by_name">Nach Name</string>
<string name="resources_sort_by_recently_played">Kürzlich abgespielt</string>
<string name="resources_sort_by_title">Titel</string>
<string name="resources_sort_by_year">Nach Jahr</string>
<string name="search_input_placeholder">Suche</string>
<string name="settings_about_actions_licenses">Lizenzen</string>
<string name="settings_about_name">Über</string>
<string name="settings_network_name">Netzwerk</string>
<string name="settings_network_options_offline_mode">Offline Modus</string>
<string name="settings_network_options_offline_mode_off">Nutze das Internet um Musik zu synchronisieren.</string>
<string name="settings_network_options_offline_mode_on">Nutze nicht das Internet um Musik zu synchronisieren.</string>
<string name="settings_network_options_stream_format_server_default">Standardserver verwenden</string>
<string name="settings_network_values_kbps">%1$skbps</string>
<string name="settings_network_values_unlimited_kbps">Unbegrenzt</string>
<string name="settings_servers_actions_test_connection">Verbindung testen</string>
<string name="settings_servers_fields_name">Name</string>
<string name="settings_servers_fields_password">Passwort</string>
<string name="settings_servers_fields_username">Nutzername</string>
<string name="navigation_tabs_home">Startseite</string>
</resources>
+46
View File
@@ -0,0 +1,46 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="actions_cancel">Cancelar</string>
<string name="actions_delete">Borrar</string>
<string name="actions_download">Descargar</string>
<string name="actions_download_cancel">Anular descarga</string>
<string name="actions_star">Favorito</string>
<string name="actions_unstar">Retirar favorito</string>
<string name="controls_shuffle">Reproducir aleatoriamente</string>
<string name="navigation_tabs_library">Biblioteca</string>
<string name="navigation_tabs_settings">Configuración</string>
<plurals name="resources_album_count">
<item quantity="one">%1$d Álbum</item>
<item quantity="other">%1$d Álbumes</item>
</plurals>
<string name="resources_filter_album">Álbum</string>
<string name="resources_filter_artist">Artista</string>
<string name="resources_filter_genre">Género</string>
<string name="resources_filter_starred">Estrellas</string>
<string name="resources_filter_year">Año</string>
<plurals name="resources_song_count">
<item quantity="one">%1$d Canción</item>
<item quantity="other">%1$d Canciones</item>
</plurals>
<string name="resources_sort_by_artist">Por Artista</string>
<string name="resources_sort_by_frequently_played">Escuchado frecuentemente</string>
<string name="resources_sort_by_name">Por Nombre</string>
<string name="resources_sort_by_recently_played">Escuchado recientemente</string>
<string name="resources_sort_by_title">Título</string>
<string name="resources_sort_by_year">Por Año</string>
<string name="search_input_placeholder">Buscar</string>
<string name="settings_about_actions_licenses">Licencias</string>
<string name="settings_about_name">Información</string>
<string name="settings_network_name">Sitio</string>
<string name="settings_network_options_offline_mode">Modo sin conexión</string>
<string name="settings_network_options_offline_mode_off">Se usa internet para sincronizar música.</string>
<string name="settings_network_options_offline_mode_on">No se usa internet para sincronizar o reproducir música.</string>
<string name="settings_network_options_stream_format_server_default">Utilizar predeterminado del servidor</string>
<string name="settings_network_values_kbps">%1$s kilobytes por segundo</string>
<string name="settings_network_values_unlimited_kbps">Ilimitados</string>
<string name="settings_servers_actions_test_connection">Probar conexión</string>
<string name="settings_servers_fields_name">Nombre</string>
<string name="settings_servers_fields_password">Contraseña</string>
<string name="settings_servers_fields_username">Nombre de usuario</string>
<string name="navigation_tabs_home">Inicio</string>
</resources>
+35
View File
@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="actions_cancel">Utzi</string>
<string name="actions_delete">Ezabatu</string>
<string name="actions_download">Deskargatu</string>
<string name="actions_download_cancel">Utzi deskargatzeari</string>
<string name="actions_star">Gogokoetan sartu</string>
<string name="actions_unstar">Gogokoetatik kendu</string>
<string name="controls_shuffle">Nahastu</string>
<string name="navigation_tabs_library">Liburutegia</string>
<string name="navigation_tabs_settings">Ezarpenak</string>
<plurals name="resources_album_count">
<item quantity="one">Album bat</item>
<item quantity="other">%1$d album</item>
</plurals>
<string name="resources_filter_album">Albuma</string>
<string name="resources_filter_artist">Musikaria</string>
<string name="resources_filter_genre">Generoa</string>
<string name="resources_filter_starred">Gogokoak</string>
<string name="resources_filter_year">Urtea</string>
<plurals name="resources_song_count">
<item quantity="one">Kanta bat</item>
<item quantity="other">%1$d kanta</item>
</plurals>
<string name="resources_sort_by_artist">Musikaria</string>
<string name="resources_sort_by_frequently_played">Askotan jarritakoak</string>
<string name="resources_sort_by_name">Alfabetikoki</string>
<string name="resources_sort_by_recently_played">Duela gutxi jarritakoak</string>
<string name="resources_sort_by_year">Urtearen arabera</string>
<string name="search_input_placeholder">Bilatu</string>
<string name="settings_about_actions_licenses">Lizentziak</string>
<string name="settings_about_name">Honi buruz</string>
<string name="settings_network_name">Sarea</string>
<string name="navigation_tabs_home">Hasiera</string>
</resources>
+46
View File
@@ -0,0 +1,46 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="actions_cancel">Annuler</string>
<string name="actions_delete">Supprimer</string>
<string name="actions_download">Télécharger</string>
<string name="actions_download_cancel">Annuler le téléchargement</string>
<string name="actions_star">Ajouter aux favoris</string>
<string name="actions_unstar">Enlever des favoris</string>
<string name="controls_shuffle">Aléatoire</string>
<string name="navigation_tabs_library">Bibliothèque</string>
<string name="navigation_tabs_settings">Paramètres</string>
<plurals name="resources_album_count">
<item quantity="one">%1$d album</item>
<item quantity="other">%1$d albums</item>
</plurals>
<string name="resources_filter_album">Album</string>
<string name="resources_filter_artist">Artiste</string>
<string name="resources_filter_genre">Par Genre</string>
<string name="resources_filter_starred">Favoris</string>
<string name="resources_filter_year">Année</string>
<plurals name="resources_song_count">
<item quantity="one">%1$d chanson</item>
<item quantity="other">%1$d chansons</item>
</plurals>
<string name="resources_sort_by_artist">Artiste</string>
<string name="resources_sort_by_frequently_played">Fréquemment Joué</string>
<string name="resources_sort_by_name">Nom</string>
<string name="resources_sort_by_recently_played">Récemment Joué</string>
<string name="resources_sort_by_title">Titre</string>
<string name="resources_sort_by_year">Année</string>
<string name="search_input_placeholder">Recherche</string>
<string name="settings_about_actions_licenses">Licences</string>
<string name="settings_about_name">À propos</string>
<string name="settings_network_name">Réseau</string>
<string name="settings_network_options_offline_mode">Mode hors-ligne</string>
<string name="settings_network_options_offline_mode_off">Se connecter à internet pour synchroniser la musique.</string>
<string name="settings_network_options_offline_mode_on">Ne pas se connecter à internet pour synchroniser ou jouer de la musique.</string>
<string name="settings_network_options_stream_format_server_default">Utiliser les paramètres par défaut du serveur</string>
<string name="settings_network_values_kbps">%1$skbit/s</string>
<string name="settings_network_values_unlimited_kbps">Illimité</string>
<string name="settings_servers_actions_test_connection">Tester la connexion</string>
<string name="settings_servers_fields_name">Nom</string>
<string name="settings_servers_fields_password">Mot de passe</string>
<string name="settings_servers_fields_username">Nom d\'utilisateur</string>
<string name="navigation_tabs_home">Accueil</string>
</resources>
+46
View File
@@ -0,0 +1,46 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="actions_cancel">Cancelar</string>
<string name="actions_delete">Eliminar</string>
<string name="actions_download">Descargar</string>
<string name="actions_download_cancel">Cancelar a descarga</string>
<string name="actions_star">Estrela</string>
<string name="actions_unstar">Retirar</string>
<string name="controls_shuffle">Barallar</string>
<string name="navigation_tabs_library">Biblioteca</string>
<string name="navigation_tabs_settings">Axustes</string>
<plurals name="resources_album_count">
<item quantity="one">%1$d álbum</item>
<item quantity="other">%1$d álbums</item>
</plurals>
<string name="resources_filter_album">Álbum</string>
<string name="resources_filter_artist">Artista</string>
<string name="resources_filter_genre">Por xénero</string>
<string name="resources_filter_starred">Favoritas</string>
<string name="resources_filter_year">Ano</string>
<plurals name="resources_song_count">
<item quantity="one">%1$d canción</item>
<item quantity="other">%1$d cancións</item>
</plurals>
<string name="resources_sort_by_artist">Por artista</string>
<string name="resources_sort_by_frequently_played">Reproducidas a miúdo</string>
<string name="resources_sort_by_name">Por nome</string>
<string name="resources_sort_by_recently_played">Reproducidas recente</string>
<string name="resources_sort_by_title">Título</string>
<string name="resources_sort_by_year">Por ano</string>
<string name="search_input_placeholder">Buscar</string>
<string name="settings_about_actions_licenses">Licenzas</string>
<string name="settings_about_name">Acerca de</string>
<string name="settings_network_name">Rede</string>
<string name="settings_network_options_offline_mode">Modo sen conexión</string>
<string name="settings_network_options_offline_mode_off">Usa internet para sincr. música.</string>
<string name="settings_network_options_offline_mode_on">Non usar internet para sincr. ou reproducir música.</string>
<string name="settings_network_options_stream_format_server_default">Usar por defecto do servidor</string>
<string name="settings_network_values_kbps">%1$skbps</string>
<string name="settings_network_values_unlimited_kbps">Sen límite</string>
<string name="settings_servers_actions_test_connection">Comprobar Conexión</string>
<string name="settings_servers_fields_name">Nome</string>
<string name="settings_servers_fields_password">Contrasinal</string>
<string name="settings_servers_fields_username">Identificador</string>
<string name="navigation_tabs_home">Inicio</string>
</resources>
+39
View File
@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="actions_cancel">Mégsem</string>
<string name="actions_delete">Törlés</string>
<string name="actions_download">Letöltés</string>
<string name="actions_download_cancel">Letöltés visszavonása</string>
<string name="actions_star">Kedvencekhez adás</string>
<string name="actions_unstar">Kedvencekből törlés</string>
<string name="controls_shuffle">Véletlen sorrend</string>
<string name="navigation_tabs_settings">Beállítások</string>
<plurals name="resources_album_count">
<item quantity="one">%1$d album</item>
<item quantity="other">%1$d album</item>
</plurals>
<string name="resources_filter_album">Album</string>
<string name="resources_filter_artist">Előadó</string>
<string name="resources_filter_genre">Stílus</string>
<string name="resources_filter_starred">Kedvencek között</string>
<string name="resources_filter_year">Év</string>
<string name="resources_sort_by_artist">Előadó</string>
<string name="resources_sort_by_frequently_played">Gyakran játszott</string>
<string name="resources_sort_by_name">Név</string>
<string name="resources_sort_by_recently_played">Legutóbb játszott</string>
<string name="resources_sort_by_title">Cím</string>
<string name="resources_sort_by_year">Év</string>
<string name="search_input_placeholder">Keresés</string>
<string name="settings_about_actions_licenses">Licenszek</string>
<string name="settings_about_name">Az alkalmazásról</string>
<string name="settings_network_name">Hálózat</string>
<string name="settings_network_options_offline_mode">Offline mód</string>
<string name="settings_network_options_stream_format_server_default">Szerver alapértelmezés használata</string>
<string name="settings_network_values_kbps">%1$skbps</string>
<string name="settings_network_values_unlimited_kbps">Korlátlan</string>
<string name="settings_servers_actions_test_connection">Kapcsolat tesztelése</string>
<string name="settings_servers_fields_name">Név</string>
<string name="settings_servers_fields_password">Jelszó</string>
<string name="settings_servers_fields_username">Felhasználónév</string>
<string name="navigation_tabs_home">Kezdőoldal</string>
</resources>
+46
View File
@@ -0,0 +1,46 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="actions_cancel">Annulla</string>
<string name="actions_delete">Elimina</string>
<string name="actions_download">Scarica</string>
<string name="actions_download_cancel">Annulla download</string>
<string name="actions_star">Aggiungi ai preferiti</string>
<string name="actions_unstar">Rimuovi dai preferiti</string>
<string name="controls_shuffle">Casuale</string>
<string name="navigation_tabs_library">Libreria</string>
<string name="navigation_tabs_settings">Impostazioni</string>
<plurals name="resources_album_count">
<item quantity="one">%1$d album</item>
<item quantity="other">%1$d album</item>
</plurals>
<string name="resources_filter_album">Album</string>
<string name="resources_filter_artist">Artista</string>
<string name="resources_filter_genre">Per genere</string>
<string name="resources_filter_starred">Preferiti</string>
<string name="resources_filter_year">Anno</string>
<plurals name="resources_song_count">
<item quantity="one">%1$d brano</item>
<item quantity="other">%1$d brani</item>
</plurals>
<string name="resources_sort_by_artist">Per artista</string>
<string name="resources_sort_by_frequently_played">Ascoltati frequentemente</string>
<string name="resources_sort_by_name">Per nome</string>
<string name="resources_sort_by_recently_played">Ascoltati di recente</string>
<string name="resources_sort_by_title">Titolo</string>
<string name="resources_sort_by_year">Per anno</string>
<string name="search_input_placeholder">Ricerca</string>
<string name="settings_about_actions_licenses">Licenze</string>
<string name="settings_about_name">Informazioni</string>
<string name="settings_network_name">Rete</string>
<string name="settings_network_options_offline_mode">Modalità Offline</string>
<string name="settings_network_options_offline_mode_off">Usa internet per sincronizzare la musica.</string>
<string name="settings_network_options_offline_mode_on">Non usare internet per sincronizzare o riprodurre la musica.</string>
<string name="settings_network_options_stream_format_server_default">Usa il server predefinito</string>
<string name="settings_network_values_kbps">%1$skbps</string>
<string name="settings_network_values_unlimited_kbps">Illimitato</string>
<string name="settings_servers_actions_test_connection">Prova connessione</string>
<string name="settings_servers_fields_name">Nome</string>
<string name="settings_servers_fields_password">Password</string>
<string name="settings_servers_fields_username">Nome utente</string>
<string name="navigation_tabs_home">Home</string>
</resources>
+20
View File
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="actions_cancel">キャンセル</string>
<string name="actions_delete">削除</string>
<string name="actions_download">ダウンロード</string>
<string name="actions_download_cancel">ダウンロードをキャンセル</string>
<string name="navigation_tabs_library">ライブラリ</string>
<string name="navigation_tabs_settings">設定</string>
<string name="resources_filter_album">アルブム</string>
<string name="resources_filter_starred">星付きアルバム</string>
<string name="resources_filter_year">年</string>
<string name="resources_sort_by_frequently_played">よく聴くアルバム</string>
<string name="resources_sort_by_recently_played">最近再生した</string>
<string name="search_input_placeholder">検索</string>
<string name="settings_network_name">ネット</string>
<string name="settings_servers_fields_name">名前</string>
<string name="settings_servers_fields_password">パスワルド</string>
<string name="settings_servers_fields_username">ウーザ名</string>
<string name="navigation_tabs_home">ホーム</string>
</resources>
+43
View File
@@ -0,0 +1,43 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="actions_cancel">취소</string>
<string name="actions_delete">삭제</string>
<string name="actions_download">다운로드</string>
<string name="actions_download_cancel">다운로드 중단</string>
<string name="actions_star">별점</string>
<string name="actions_unstar">별점 제거</string>
<string name="controls_shuffle">무작위 재생</string>
<string name="navigation_tabs_library">라이브러리</string>
<string name="navigation_tabs_settings">설정</string>
<plurals name="resources_album_count">
<item quantity="other">%1$d개 앨범</item>
</plurals>
<string name="resources_filter_album">앨범</string>
<string name="resources_filter_artist">아티스트</string>
<string name="resources_filter_genre">장르</string>
<string name="resources_filter_starred">평가됨</string>
<string name="resources_filter_year">년</string>
<plurals name="resources_song_count">
<item quantity="other">%1$d 노래</item>
</plurals>
<string name="resources_sort_by_artist">아티스트</string>
<string name="resources_sort_by_frequently_played">자주 듣는 노래</string>
<string name="resources_sort_by_name">이름</string>
<string name="resources_sort_by_recently_played">최근 재생된 순</string>
<string name="resources_sort_by_title">곡명</string>
<string name="resources_sort_by_year">년</string>
<string name="search_input_placeholder">검색</string>
<string name="settings_about_actions_licenses">저작권</string>
<string name="settings_about_name">subtracks에 대하여</string>
<string name="settings_network_name">네트워크</string>
<string name="settings_network_options_offline_mode">오프라인 모드</string>
<string name="settings_network_options_offline_mode_off">노래를 동기화하기 위해 인터넷을 사용합니다.</string>
<string name="settings_network_options_offline_mode_on">노래를 듣거나 동기화하기 위해 인터넷을 사용하지 않습니다.</string>
<string name="settings_network_options_stream_format_server_default">서버를 우선하여 사용합니다</string>
<string name="settings_network_values_unlimited_kbps">제한하지 않음</string>
<string name="settings_servers_actions_test_connection">연결 테스트</string>
<string name="settings_servers_fields_name">이름</string>
<string name="settings_servers_fields_password">비밀번호</string>
<string name="settings_servers_fields_username">사용자 이름</string>
<string name="navigation_tabs_home">홈</string>
</resources>
+46
View File
@@ -0,0 +1,46 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="actions_cancel">Avbryt</string>
<string name="actions_delete">Slett</string>
<string name="actions_download">Last ned</string>
<string name="actions_download_cancel">Avbryt nedlasting</string>
<string name="actions_star">Stjernemerk</string>
<string name="actions_unstar">Fjern stjernemerking</string>
<string name="controls_shuffle">Miks</string>
<string name="navigation_tabs_library">Bibliotek</string>
<string name="navigation_tabs_settings">Innstillinger</string>
<plurals name="resources_album_count">
<item quantity="one">%1$d album</item>
<item quantity="other">%1$d album</item>
</plurals>
<string name="resources_filter_album">Album</string>
<string name="resources_filter_artist">Artist</string>
<string name="resources_filter_genre">Etter sjanger</string>
<string name="resources_filter_starred">Stjernemerket</string>
<string name="resources_filter_year">År</string>
<plurals name="resources_song_count">
<item quantity="one">%1$d sang</item>
<item quantity="other">%1$d sanger</item>
</plurals>
<string name="resources_sort_by_artist">Etter artist</string>
<string name="resources_sort_by_frequently_played">Ofte spilt</string>
<string name="resources_sort_by_name">Etter navn</string>
<string name="resources_sort_by_recently_played">Nylig spilt</string>
<string name="resources_sort_by_title">Tittel</string>
<string name="resources_sort_by_year">Etter år</string>
<string name="search_input_placeholder">Søk</string>
<string name="settings_about_actions_licenses">Lisenser</string>
<string name="settings_about_name">Om</string>
<string name="settings_network_name">Nettverk</string>
<string name="settings_network_options_offline_mode">Offline-modus</string>
<string name="settings_network_options_offline_mode_off">Bruk Internett til å synkronisere musikk.</string>
<string name="settings_network_options_offline_mode_on">Bruk ikke Internett til å synkronisere eller spille av musikk.</string>
<string name="settings_network_options_stream_format_server_default">Bruk serverens standardinnstilling</string>
<string name="settings_network_values_kbps">%1$s kbps</string>
<string name="settings_network_values_unlimited_kbps">Ubegrenset</string>
<string name="settings_servers_actions_test_connection">Test tilkobling</string>
<string name="settings_servers_fields_name">Navn</string>
<string name="settings_servers_fields_password">Passord</string>
<string name="settings_servers_fields_username">Brukernavn</string>
<string name="navigation_tabs_home">Hjem</string>
</resources>
+24
View File
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="actions_star">ਤਾਰਾ</string>
<string name="actions_unstar">ਤਾਰਾ ਹਟਾਓ</string>
<string name="navigation_tabs_library">ਲਾਇਬ੍ਰੇਰੀ</string>
<string name="navigation_tabs_settings">ਸੈਟਿੰਗਾਂ</string>
<string name="resources_filter_genre">ਸ਼ੈਲੀ ਦੁਆਰਾ</string>
<string name="resources_filter_starred">ਸਟਾਰ ਕੀਤੇ ਗਏ</string>
<string name="resources_sort_by_artist">ਕਲਾਕਾਰ ਦੁਆਰਾ</string>
<string name="resources_sort_by_frequently_played">ਅਕਸਰ ਚਲਾਏ ਗਏ</string>
<string name="resources_sort_by_name">ਨਾਮ ਦੁਆਰਾ</string>
<string name="resources_sort_by_recently_played">ਹਾਲ ਹੀ ਵਿੱਚ ਚਲਾਏ ਗਏ</string>
<string name="resources_sort_by_year">ਸਾਲ ਦੁਆਰਾ</string>
<string name="search_input_placeholder">ਖੋਜੋ</string>
<string name="settings_about_actions_licenses">ਲਾਇਸੰਸ</string>
<string name="settings_about_name">ਬਾਰੇ</string>
<string name="settings_network_name">ਨੈੱਟਵਰਕ</string>
<string name="settings_network_values_kbps">%1$skbps</string>
<string name="settings_network_values_unlimited_kbps">ਅਸੀਮਿਤ</string>
<string name="settings_servers_actions_test_connection">ਟੈਸਟ ਕਨੈਕਸ਼ਨ</string>
<string name="settings_servers_fields_password">ਪਾਸਵਰਡ</string>
<string name="settings_servers_fields_username">ਯੂਜ਼ਰਨੇਮ</string>
<string name="navigation_tabs_home">ਘਰ</string>
</resources>
+35
View File
@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="actions_cancel">Anuluj</string>
<string name="actions_delete">Usuń</string>
<string name="actions_download">Pobierz</string>
<string name="actions_download_cancel">Anuluj pobieranie</string>
<string name="actions_star">Ulubione</string>
<string name="actions_unstar">Usuń ulubione</string>
<string name="controls_shuffle">Losowo</string>
<string name="navigation_tabs_library">Kolekcja</string>
<string name="navigation_tabs_settings">Ustawienia</string>
<string name="resources_filter_album">Album</string>
<string name="resources_filter_artist">Artysta</string>
<string name="resources_filter_genre">Wg gatunku</string>
<string name="resources_filter_starred">Ulubione</string>
<string name="resources_filter_year">Rok</string>
<string name="resources_sort_by_artist">Wg wykonawcy</string>
<string name="resources_sort_by_frequently_played">Często odtwarzane</string>
<string name="resources_sort_by_name">Wg nazwy</string>
<string name="resources_sort_by_recently_played">Ostatnio odtwarzane</string>
<string name="resources_sort_by_title">Tytuł</string>
<string name="resources_sort_by_year">Wg roku</string>
<string name="search_input_placeholder">Szukaj</string>
<string name="settings_about_actions_licenses">Licencje</string>
<string name="settings_about_name">Informacje o projekcie</string>
<string name="settings_network_name">Sieć</string>
<string name="settings_network_options_offline_mode">Tryb offline</string>
<string name="settings_network_values_kbps">%1$skbps</string>
<string name="settings_network_values_unlimited_kbps">Bez ograniczeń</string>
<string name="settings_servers_actions_test_connection">Przetestuj połączenie</string>
<string name="settings_servers_fields_name">Nazwa</string>
<string name="settings_servers_fields_password">Hasło</string>
<string name="settings_servers_fields_username">Nazwa użytkownika</string>
<string name="navigation_tabs_home">Strona główna</string>
</resources>
+36
View File
@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="actions_cancel">Cancelar</string>
<string name="actions_delete">Apagar</string>
<string name="actions_download">Descarregar</string>
<string name="actions_download_cancel">Cancelar descarga</string>
<string name="actions_star">Favorito</string>
<string name="actions_unstar">Remover favorito</string>
<string name="controls_shuffle">Aleatório</string>
<string name="navigation_tabs_library">Biblioteca</string>
<string name="navigation_tabs_settings">Definições</string>
<string name="resources_filter_album">Álbum</string>
<string name="resources_filter_artist">Artista</string>
<string name="resources_filter_genre">Por Género</string>
<string name="resources_filter_starred">Favoritos</string>
<string name="resources_filter_year">Ano</string>
<string name="resources_sort_by_artist">Por Artista</string>
<string name="resources_sort_by_frequently_played">Mais Tocado</string>
<string name="resources_sort_by_name">Por Nome</string>
<string name="resources_sort_by_recently_played">Ouviu recentemente</string>
<string name="resources_sort_by_title">Título</string>
<string name="resources_sort_by_year">Por Ano</string>
<string name="search_input_placeholder">Procurar</string>
<string name="settings_about_actions_licenses">Licenças</string>
<string name="settings_about_name">Acerca</string>
<string name="settings_network_name">Rede</string>
<string name="settings_network_options_offline_mode">Modo offline</string>
<string name="settings_network_options_offline_mode_off">Usar a internet para sincronizar música.</string>
<string name="settings_network_options_offline_mode_on">Não usar a internet para sincronizar ou tocar música.</string>
<string name="settings_network_values_kbps">%1$skbps</string>
<string name="settings_network_values_unlimited_kbps">Ilimitado</string>
<string name="settings_servers_actions_test_connection">Testar Ligação</string>
<string name="settings_servers_fields_password">Senha</string>
<string name="settings_servers_fields_username">Nome de utilizador</string>
<string name="navigation_tabs_home">Início</string>
</resources>
+48
View File
@@ -0,0 +1,48 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="actions_cancel">Отменить</string>
<string name="actions_delete">Удалить</string>
<string name="actions_download">Скачать</string>
<string name="actions_download_cancel">Отменить загрузку</string>
<string name="actions_star">Избранное</string>
<string name="actions_unstar">Убрать из избранного</string>
<string name="controls_shuffle">Перемешать</string>
<string name="navigation_tabs_library">Библиотека</string>
<string name="navigation_tabs_settings">Настройки</string>
<plurals name="resources_album_count">
<item quantity="one">%1$d альбом</item>
<item quantity="other">%1$d альбомы</item>
</plurals>
<string name="resources_filter_album">По альбомам</string>
<string name="resources_filter_artist">По исполнителю</string>
<string name="resources_filter_genre">По жанру</string>
<string name="resources_filter_starred">Избранные</string>
<string name="resources_filter_year">По годам</string>
<plurals name="resources_song_count">
<item quantity="one">%1$d трек</item>
<item quantity="few">%1$d треков</item>
<item quantity="many">%1$d треков</item>
<item quantity="other">%1$d треки</item>
</plurals>
<string name="resources_sort_by_artist">По исполнителям</string>
<string name="resources_sort_by_frequently_played">Часто проигрываемые</string>
<string name="resources_sort_by_name">По имени</string>
<string name="resources_sort_by_recently_played">Недавно проигранные</string>
<string name="resources_sort_by_title">По заголовку</string>
<string name="resources_sort_by_year">По году</string>
<string name="search_input_placeholder">Поиск</string>
<string name="settings_about_actions_licenses">Лицензии</string>
<string name="settings_about_name">О Subtracks</string>
<string name="settings_network_name">Сеть</string>
<string name="settings_network_options_offline_mode">Автономный режим</string>
<string name="settings_network_options_offline_mode_off">Использовать интернет для синхронизации музыки.</string>
<string name="settings_network_options_offline_mode_on">Не использовать интернет для синхронизации или воспроизведения музыки.</string>
<string name="settings_network_options_stream_format_server_default">Использовать сервер по умолчанию</string>
<string name="settings_network_values_kbps">%1$s кбит/с</string>
<string name="settings_network_values_unlimited_kbps">Без ограничений</string>
<string name="settings_servers_actions_test_connection">Проверить подключение</string>
<string name="settings_servers_fields_name">Имя</string>
<string name="settings_servers_fields_password">Пароль</string>
<string name="settings_servers_fields_username">Имя пользователя</string>
<string name="navigation_tabs_home">Главная</string>
</resources>
+46
View File
@@ -0,0 +1,46 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="actions_cancel">Avbryt</string>
<string name="actions_delete">Radera</string>
<string name="actions_download">Ladda ned</string>
<string name="actions_download_cancel">Avbryt Nedladdning</string>
<string name="actions_star">Markera</string>
<string name="actions_unstar">Omarkera</string>
<string name="controls_shuffle">Blanda</string>
<string name="navigation_tabs_library">Bibliotek</string>
<string name="navigation_tabs_settings">Inställningar</string>
<plurals name="resources_album_count">
<item quantity="one">%1$d album</item>
<item quantity="other">%1$d album</item>
</plurals>
<string name="resources_filter_album">Album</string>
<string name="resources_filter_artist">Artist</string>
<string name="resources_filter_genre">Genre</string>
<string name="resources_filter_starred">Markerad</string>
<string name="resources_filter_year">År</string>
<plurals name="resources_song_count">
<item quantity="one">%1$d låt</item>
<item quantity="other">%1$d låtar</item>
</plurals>
<string name="resources_sort_by_artist">Konstnär</string>
<string name="resources_sort_by_frequently_played">Spelas oftast</string>
<string name="resources_sort_by_name">Namn</string>
<string name="resources_sort_by_recently_played">Senast spelade</string>
<string name="resources_sort_by_title">Titel</string>
<string name="resources_sort_by_year">Efter år</string>
<string name="search_input_placeholder">Sök</string>
<string name="settings_about_actions_licenses">Licenser</string>
<string name="settings_about_name">Om</string>
<string name="settings_network_name">Nätverk</string>
<string name="settings_network_options_offline_mode">Offline-läge</string>
<string name="settings_network_options_offline_mode_off">Använd internet för att uppdatera musik.</string>
<string name="settings_network_options_offline_mode_on">Använd inte internet för att uppdatera eller spela upp musik.</string>
<string name="settings_network_options_stream_format_server_default">Använd serverns standardinställning</string>
<string name="settings_network_values_kbps">%1$skbps</string>
<string name="settings_network_values_unlimited_kbps">Obegränsad</string>
<string name="settings_servers_actions_test_connection">Testanslutning</string>
<string name="settings_servers_fields_name">Namn</string>
<string name="settings_servers_fields_password">Lösenord</string>
<string name="settings_servers_fields_username">Användarnamn</string>
<string name="navigation_tabs_home">Hem</string>
</resources>
+46
View File
@@ -0,0 +1,46 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="actions_cancel">ரத்துசெய்</string>
<string name="actions_delete">நீக்கு</string>
<string name="actions_download">பதிவிறக்கம்</string>
<string name="actions_download_cancel">பதிவிறக்கத்தை ரத்துசெய்</string>
<string name="actions_star">விண்மீன்</string>
<string name="actions_unstar">ச்டார்</string>
<string name="controls_shuffle">கலக்கு</string>
<string name="navigation_tabs_library">நூலகம்</string>
<string name="navigation_tabs_settings">அமைப்புகள்</string>
<plurals name="resources_album_count">
<item quantity="one">%1$d செருகேடு</item>
<item quantity="other">%1$d செருகேடுகள்</item>
</plurals>
<string name="resources_filter_album">ஆல்பம்</string>
<string name="resources_filter_artist">கலைஞர்</string>
<string name="resources_filter_genre">வகை</string>
<string name="resources_filter_starred">நடித்தார்</string>
<string name="resources_filter_year">ஆண்டு</string>
<plurals name="resources_song_count">
<item quantity="one">%1$d பாடல்</item>
<item quantity="other">%1$d பாடல்கள்</item>
</plurals>
<string name="resources_sort_by_artist">கலைஞர்</string>
<string name="resources_sort_by_frequently_played">அடிக்கடி விளையாடியது</string>
<string name="resources_sort_by_name">பெயர்</string>
<string name="resources_sort_by_recently_played">அண்மைக் காலத்தில் விளையாடியது</string>
<string name="resources_sort_by_title">தலைப்பு</string>
<string name="resources_sort_by_year">ஆண்டு</string>
<string name="search_input_placeholder">தேடல்</string>
<string name="settings_about_actions_licenses">உரிமங்கள்</string>
<string name="settings_about_name">பற்றி</string>
<string name="settings_network_name">பிணையம்</string>
<string name="settings_network_options_offline_mode">இணைப்பில்லாத பயன்முறை</string>
<string name="settings_network_options_offline_mode_off">இசையை ஒத்திசைக்க இணையத்தைப் பயன்படுத்தவும்.</string>
<string name="settings_network_options_offline_mode_on">இசையை ஒத்திசைக்க அல்லது இசைக்க இணையத்தைப் பயன்படுத்த வேண்டாம்.</string>
<string name="settings_network_options_stream_format_server_default">சேவையக இயல்புநிலையைப் பயன்படுத்தவும்</string>
<string name="settings_network_values_kbps">%1$s kbps</string>
<string name="settings_network_values_unlimited_kbps">வரம்பற்றது</string>
<string name="settings_servers_actions_test_connection">சோதனை இணைப்பு</string>
<string name="settings_servers_fields_name">பெயர்</string>
<string name="settings_servers_fields_password">கடவுச்சொல்</string>
<string name="settings_servers_fields_username">பயனர்பெயர்</string>
<string name="navigation_tabs_home">வீடு</string>
</resources>
+45
View File
@@ -0,0 +1,45 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="actions_cancel">ยกเลิก</string>
<string name="actions_delete">ลบ</string>
<string name="actions_download">ดาวน์โหลด</string>
<string name="actions_download_cancel">ยกเลิกการดาวน์โหลด</string>
<string name="actions_star">ดาว</string>
<string name="actions_unstar">ยกเลิกดาว</string>
<string name="controls_shuffle">เล่นแบบสุ่ม</string>
<string name="navigation_tabs_library">ห้องสมุด</string>
<string name="navigation_tabs_settings">การตั้งค่า</string>
<plurals name="resources_album_count">
<item quantity="other">%1$d อัลบั้ม</item>
</plurals>
<string name="resources_filter_album">อัลบั้ม</string>
<string name="resources_filter_artist">ศิลปิน</string>
<string name="resources_filter_genre">ประเภท</string>
<string name="resources_filter_starred">ให้ดาว</string>
<string name="resources_filter_year">ปี</string>
<plurals name="resources_song_count">
<item quantity="one">%1$d เพลง</item>
<item quantity="other">%1$d เพลง</item>
</plurals>
<string name="resources_sort_by_artist">ศิลปิน</string>
<string name="resources_sort_by_frequently_played">เพลงที่เล่นบ่อย</string>
<string name="resources_sort_by_name">ชื่อเพลง</string>
<string name="resources_sort_by_recently_played">เล่นล่าสุด</string>
<string name="resources_sort_by_title">ชื่อเพลง</string>
<string name="resources_sort_by_year">ปี</string>
<string name="search_input_placeholder">ค้นหา</string>
<string name="settings_about_actions_licenses">ใบอนุญาต</string>
<string name="settings_about_name">เกี่ยวกับ</string>
<string name="settings_network_name">เคลือข่าย</string>
<string name="settings_network_options_offline_mode">โหมดอ๊อฟไลน์</string>
<string name="settings_network_options_offline_mode_off">เปิดใช้อินเตอร์เน็ตเพื่อให้ข้อมูลเพลงเหมือนกัน</string>
<string name="settings_network_options_offline_mode_on">ปิดใช้อินเตอร์เน็ตเพื่อให้ข้อมูลเพลงเหมือนกันหรือเล่นเพลง</string>
<string name="settings_network_options_stream_format_server_default">ใช้ค่าเริ่มต้นของเซิร์ฟเวอร์</string>
<string name="settings_network_values_kbps">%1$sกิโลบิตต่อวินาที (kbps)</string>
<string name="settings_network_values_unlimited_kbps">ไม่จำกัด</string>
<string name="settings_servers_actions_test_connection">ทดสอบการเชื่อมต่อ</string>
<string name="settings_servers_fields_name">ชื่อ</string>
<string name="settings_servers_fields_password">รหัสผ่าน</string>
<string name="settings_servers_fields_username">ชื่อผู้ใช้</string>
<string name="navigation_tabs_home">หน้าหลัก</string>
</resources>
+46
View File
@@ -0,0 +1,46 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="actions_cancel">İptal</string>
<string name="actions_delete">Sil</string>
<string name="actions_download">İndir</string>
<string name="actions_download_cancel">İndirmeyi iptal et</string>
<string name="actions_star">Yıldızla</string>
<string name="actions_unstar">Yıldızı Kaldır</string>
<string name="controls_shuffle">Karıştır</string>
<string name="navigation_tabs_library">Kütüphane</string>
<string name="navigation_tabs_settings">Ayarlar</string>
<plurals name="resources_album_count">
<item quantity="one">%1$d album</item>
<item quantity="other">%1$d album</item>
</plurals>
<string name="resources_filter_album">Albüm</string>
<string name="resources_filter_artist">Sanatçı</string>
<string name="resources_filter_genre">Türe göre</string>
<string name="resources_filter_starred">Yıldızlı</string>
<string name="resources_filter_year">Yıl</string>
<plurals name="resources_song_count">
<item quantity="one">%1$d şarkı</item>
<item quantity="other">%1$d şarkı</item>
</plurals>
<string name="resources_sort_by_artist">Sanatçıya Göre</string>
<string name="resources_sort_by_frequently_played">Sık Dinlenenler</string>
<string name="resources_sort_by_name">İsme göre</string>
<string name="resources_sort_by_recently_played">Yeni Dinleneler</string>
<string name="resources_sort_by_title">Başlık</string>
<string name="resources_sort_by_year">Yıla göre</string>
<string name="search_input_placeholder">Ara</string>
<string name="settings_about_actions_licenses">Lisanslar</string>
<string name="settings_about_name">Hakkında</string>
<string name="settings_network_name">Ağ</string>
<string name="settings_network_options_offline_mode">Çevrim dışı mod</string>
<string name="settings_network_options_offline_mode_off">İnterneti kullanarak müzikleri eşzamanla.</string>
<string name="settings_network_options_offline_mode_on">İnterneti müzikleri eşzamanlamak veya çalmak için kullanma.</string>
<string name="settings_network_options_stream_format_server_default">Sunucu öntanımlı değerini kullan</string>
<string name="settings_network_values_kbps">%1$skb/sn</string>
<string name="settings_network_values_unlimited_kbps">Sınırsız</string>
<string name="settings_servers_actions_test_connection">Bağlantıyı Sına</string>
<string name="settings_servers_fields_name">Ad</string>
<string name="settings_servers_fields_password">Şifre</string>
<string name="settings_servers_fields_username">Kullanıcı Adı</string>
<string name="navigation_tabs_home">Giriş</string>
</resources>
+46
View File
@@ -0,0 +1,46 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="actions_cancel">Скасувати</string>
<string name="actions_delete">Видалити</string>
<string name="actions_download">Завантажити</string>
<string name="actions_download_cancel">Скасувати завантаження</string>
<string name="actions_star">Зірка</string>
<string name="actions_unstar">Зняти зірку</string>
<string name="controls_shuffle">Перемішати</string>
<string name="navigation_tabs_library">Бібліотека</string>
<string name="navigation_tabs_settings">Налаштування</string>
<plurals name="resources_album_count">
<item quantity="one">%1$d альбом</item>
<item quantity="other">%1$d альбоми</item>
</plurals>
<string name="resources_filter_album">Альбом</string>
<string name="resources_filter_artist">Виконавець</string>
<string name="resources_filter_genre">Жанр</string>
<string name="resources_filter_starred">Улюблене</string>
<string name="resources_filter_year">Рік</string>
<plurals name="resources_song_count">
<item quantity="one">%1$d пісня</item>
<item quantity="other">%1$d пісні</item>
</plurals>
<string name="resources_sort_by_artist">Виконавець</string>
<string name="resources_sort_by_frequently_played">Часто відтворювані</string>
<string name="resources_sort_by_name">Ім\'я</string>
<string name="resources_sort_by_recently_played">Нещодавно відворені</string>
<string name="resources_sort_by_title">Назва</string>
<string name="resources_sort_by_year">Рік</string>
<string name="search_input_placeholder">Введіть заголовок/опис</string>
<string name="settings_about_actions_licenses">Ліцензії</string>
<string name="settings_about_name">Про застосунок</string>
<string name="settings_network_name">Мережа</string>
<string name="settings_network_options_offline_mode">Автономний режим</string>
<string name="settings_network_options_offline_mode_off">Використовувати інтернет для синхронізації музики.</string>
<string name="settings_network_options_offline_mode_on">Не використовувати інтернет для синхронізації або відтворення музики.</string>
<string name="settings_network_options_stream_format_server_default">Використовувати сервер за замовчуванням</string>
<string name="settings_network_values_kbps">%1$sкбіт/с</string>
<string name="settings_network_values_unlimited_kbps">Необмежено</string>
<string name="settings_servers_actions_test_connection">Тестове підключення</string>
<string name="settings_servers_fields_name">Ім\'я</string>
<string name="settings_servers_fields_password">Пароль</string>
<string name="settings_servers_fields_username">Ім\'я користувача</string>
<string name="navigation_tabs_home">Головна</string>
</resources>
+46
View File
@@ -0,0 +1,46 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="actions_cancel">Hủy</string>
<string name="actions_delete">Xóa</string>
<string name="actions_download">Tải xuống</string>
<string name="actions_download_cancel">Hủy tải xuống</string>
<string name="actions_star">Yêu thích</string>
<string name="actions_unstar">Bỏ yêu thích</string>
<string name="controls_shuffle">Phát ngẫu nhiên</string>
<string name="navigation_tabs_library">Thư viện</string>
<string name="navigation_tabs_settings">Cài đặt</string>
<plurals name="resources_album_count">
<item quantity="one">%1$d album</item>
<item quantity="other">%1$d album</item>
</plurals>
<string name="resources_filter_album">Theo album</string>
<string name="resources_filter_artist">Theo nghệ sĩ</string>
<string name="resources_filter_genre">Theo thể loại</string>
<string name="resources_filter_starred">Theo yêu thích</string>
<string name="resources_filter_year">Theo năm</string>
<plurals name="resources_song_count">
<item quantity="one">%1$d bài hát</item>
<item quantity="other">%1$d bài hát</item>
</plurals>
<string name="resources_sort_by_artist">Nghệ sĩ</string>
<string name="resources_sort_by_frequently_played">Thường nghe</string>
<string name="resources_sort_by_name">Tên</string>
<string name="resources_sort_by_recently_played">Đã phát gần đây</string>
<string name="resources_sort_by_title">Tiêu đề</string>
<string name="resources_sort_by_year">Năm</string>
<string name="search_input_placeholder">Tìm kiếm</string>
<string name="settings_about_actions_licenses">Bản quyền</string>
<string name="settings_about_name">Về ứng dụng</string>
<string name="settings_network_name">Mạng</string>
<string name="settings_network_options_offline_mode">Chế độ ngoại tuyến</string>
<string name="settings_network_options_offline_mode_off">Đồng bộ nhạc qua mạng.</string>
<string name="settings_network_options_offline_mode_on">Không dùng internet để đồng bô hoặc phát nhạc.</string>
<string name="settings_network_options_stream_format_server_default">Theo mặc định máy chủ</string>
<string name="settings_network_values_kbps">%1$skbps</string>
<string name="settings_network_values_unlimited_kbps">Vô giới hạn</string>
<string name="settings_servers_actions_test_connection">Kiểm tra kết nối</string>
<string name="settings_servers_fields_name">Tên</string>
<string name="settings_servers_fields_password">Mật khẩu</string>
<string name="settings_servers_fields_username">Tên đăng nhập</string>
<string name="navigation_tabs_home">Trang chủ</string>
</resources>
+291 -2
View File
@@ -1,7 +1,296 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<string name="app_name">Subtracks</string> <string name="app_name">Subtracks</string>
<string name="download_cancel">Cancel</string>
<string name="download_channel">Downloads</string>
<string name="download_channel_description">Progress while songs download</string> <string name="download_channel_description">Progress while songs download</string>
<string name="actions_cancel">Cancel</string>
<string name="actions_delete">Delete</string>
<string name="actions_download">Download</string>
<string name="actions_download_cancel">Cancel download</string>
<string name="actions_star">Star</string>
<string name="actions_unstar">Unstar</string>
<string name="controls_shuffle">Shuffle</string>
<string name="controls_shuffle_play">Shuffle play</string>
<string name="controls_play">Play</string>
<string name="controls_pause">Pause</string>
<string name="controls_next">Next</string>
<string name="controls_previous">Previous</string>
<string name="controls_queue">Queue</string>
<string name="controls_more">More</string>
<string name="navigation_back">Back</string>
<string name="navigation_tabs_home">Home</string>
<string name="navigation_tabs_library">Library</string>
<string name="navigation_tabs_settings">Settings</string>
<string name="resources_album_name">Albums</string>
<string name="resources_artist_name">Artists</string>
<string name="resources_playlist_name">Playlists</string>
<string name="resources_queue_name">Queue</string>
<string name="resources_filter_album">Album</string>
<string name="resources_filter_artist">Artist</string>
<string name="resources_filter_genre">Genre</string>
<string name="resources_filter_starred">Starred</string>
<string name="resources_filter_year">Year</string>
<string name="resources_song_list_delete_all_content">%1$s\n\nDeleting will free %2$s</string>
<string name="resources_song_list_delete_all_title">Delete downloads</string>
<string name="resources_sort_by_added">Added</string>
<string name="resources_sort_by_artist">Artist</string>
<string name="resources_sort_by_frequently_played">Frequently played</string>
<string name="resources_sort_by_name">Name</string>
<string name="resources_sort_by_recently_played">Recently played</string>
<string name="resources_sort_by_title">Title</string>
<string name="resources_sort_by_updated">Updated</string>
<string name="resources_sort_by_year">Year</string>
<plurals name="resources_album_count">
<item quantity="one">%1$d album</item>
<item quantity="other">%1$d albums</item>
</plurals>
<plurals name="resources_song_count">
<item quantity="one">%1$d song</item>
<item quantity="other">%1$d songs</item>
</plurals>
<string name="search_input_placeholder">Search</string>
<string name="settings_about_actions_licenses">Licenses</string>
<string name="settings_about_name">About</string>
<string name="settings_music_name">Playback</string>
<string name="settings_music_options_scrobble_description_on">Send now playing and play counts</string>
<string name="settings_music_options_scrobble_title">Scrobble to server</string>
<string name="settings_network_name">Network</string>
<string name="settings_network_options_max_bitrate_mobile_title">Stream quality: Mobile</string>
<string name="settings_network_options_max_bitrate_wifi_title">Stream quality: Wi-Fi</string>
<string name="settings_network_options_offline_mode">Offline mode</string>
<string name="settings_network_options_offline_mode_off">Use the internet to sync music.</string>
<string name="settings_network_options_offline_mode_on">Don\'t use the internet to sync or play music.</string>
<string name="settings_network_options_stream_format">Preferred format</string>
<string name="settings_network_options_stream_format_server_default">Use server default</string>
<string name="settings_network_values_kbps">%1$skbps</string>
<string name="settings_network_values_unlimited_kbps">Unlimited</string>
<string name="settings_servers_actions_add">Add server</string>
<string name="settings_servers_actions_delete">Delete server</string>
<string name="settings_servers_actions_edit">Edit server</string>
<string name="settings_servers_actions_save">Save and sync</string>
<string name="settings_servers_actions_test_connection">Test connection</string>
<string name="settings_servers_fields_address">Server address</string>
<string name="settings_servers_fields_name">Name</string>
<string name="settings_servers_fields_password">Password</string>
<string name="settings_servers_fields_username">Username</string>
<string name="settings_servers_name">Servers</string>
<string name="components_filters_hiding">Filters are hiding everything.</string>
<string name="components_clear_filters">Clear filters</string>
<string name="downloads_title">Downloads</string>
<string name="downloads_empty">No downloads yet.</string>
<string name="downloads_all">all downloads</string>
<string name="delete_all">Delete all</string>
<string name="collapse">Collapse</string>
<string name="expand">Expand</string>
<string name="delete_download">Delete download</string>
<string name="cancel_downloads">Cancel downloads</string>
<string name="retry_download">Retry download</string>
<string name="download_cancel_of">Cancel download of %1$s</string>
<string name="download_delete_of">Delete download of %1$s</string>
<string name="download_delete_all_of">Delete downloads of %1$s</string>
<plurals name="downloads_albums_size">
<item quantity="one">%1$d album · %2$s</item>
<item quantity="other">%1$d albums · %2$s</item>
</plurals>
<plurals name="downloads_songs_size">
<item quantity="one">%1$d song · %2$s</item>
<item quantity="other">%1$d songs · %2$s</item>
</plurals>
<string name="download_status_summary">%1$s · %2$s</string>
<string name="bitrate_value">%1$d kbps</string>
<string name="play_next">Play next</string>
<string name="add_to_queue">Add to queue</string>
<string name="info">Info</string>
<string name="more_options">More options</string>
<string name="open_album">Open album</string>
<string name="open_artist">Open artist</string>
<string name="unknown_artist">Unknown artist</string>
<string name="unknown_album">Unknown album</string>
<string name="albums_empty">No albums yet.\nSync with your server to fill your library.</string>
<string name="artists_empty">No artists yet.</string>
<string name="playlists_empty">No playlists yet.</string>
<string name="sync">Sync</string>
<string name="list_options">List options</string>
<string name="search_this_list">Search this list</string>
<string name="sort_by">Sort by</string>
<string name="sort_ascending">Sort ascending</string>
<string name="sort_descending">Sort descending</string>
<string name="filters">Filters</string>
<string name="filter_downloaded">Downloaded</string>
<string name="close_search">Close search</string>
<string name="offline_mode_status">Offline mode; tap to go online</string>
<string name="shuffle_artist">Shuffle artist</string>
<string name="status_playing">Playing</string>
<string name="status_downloaded">Downloaded</string>
<string name="status_queued">Queued</string>
<string name="status_downloading">Downloading</string>
<string name="status_failed">Failed</string>
<string name="info_duration">Duration</string>
<string name="info_track">Track</string>
<string name="info_track_number">%1$d.%2$s</string>
<string name="info_starred">Starred</string>
<string name="info_yes">Yes</string>
<string name="info_size">Size</string>
<string name="info_error">Error</string>
<string name="info_format">Format</string>
<string name="info_bitrate">Bitrate</string>
<string name="info_sample_rate">Sample rate</string>
<string name="info_sample_rate_value">%1$d Hz</string>
<string name="info_channels">Channels</string>
<string name="info_close">Close</string>
<string name="disc_header">Disc %1$d</string>
<string name="disc_track">%1$s: Disc %2$d</string>
<string name="playlist_duration_minutes">%1$d min</string>
<string name="playlist_duration_hours">%1$d hr</string>
<string name="playlist_duration_hours_minutes">%1$d hr %2$d min</string>
<string name="now_playing_header">Now playing: %1$s</string>
<string name="context_kind_library">library</string>
<string name="context_kind_album">album</string>
<string name="context_kind_playlist">playlist</string>
<string name="context_kind_song">song</string>
<string name="context_kind_artist">artist</string>
<string name="context_kind_genre">genre</string>
<string name="context_kind_downloads">downloads</string>
<string name="context_kind_starred">starred</string>
<string name="repeat_off">Repeat off</string>
<string name="repeat_all">Repeat all</string>
<string name="repeat_one">Repeat one</string>
<string name="queue_title">Queue</string>
<string name="queue_empty">The queue is empty.</string>
<string name="queue_up_next">Up next</string>
<string name="queue_next_up">Next up</string>
<string name="queue_next_from_here">Next from here</string>
<string name="queue_reordered">Queue reordered</string>
<string name="queue_removed">Removed from queue</string>
<string name="queue_clear_up_next">Clear up next</string>
<string name="queue_remove">Remove from queue</string>
<string name="queue_reorder">Reorder</string>
<string name="undo">Undo</string>
<string name="action_done">Done</string>
<string name="sync_concurrency">Sync concurrency</string>
<string name="sync_concurrency_parallel">Parallel server requests while syncing</string>
<string name="sync_concurrency_sequential">1 (sequential)</string>
<string name="download_quality">Download quality</string>
<string name="download_over_mobile">Download over mobile data</string>
<string name="download_network_wifi_and_mobile">Wi-Fi and mobile</string>
<string name="download_network_wifi_only">Wi-Fi only</string>
<string name="manage_downloads">Manage downloads</string>
<string name="maximum_bitrate">Maximum bitrate</string>
<string name="stream_quality_server_default">Server default</string>
<string name="required">Required</string>
<string name="server_address_placeholder">https://music.example.com</string>
<string name="token_authentication">Token authentication</string>
<string name="token_authentication_hint">Recommended. Disable only for servers that reject the salted token scheme.</string>
<string name="add_server_intro">Connect a Subsonic-compatible server such as Navidrome, gonic or Airsonic.</string>
<string name="edit_server_intro">Update this server\'s connection details.</string>
<string name="active_server_hint">This is the active server. Switch to another server before deleting it.</string>
<string name="delete_server_title">Delete server?</string>
<string name="delete_server_body">%1$s and its downloaded songs will be removed from this device.</string>
<string name="connection_ok">Connection OK</string>
<string name="connection_token_fallback">Server does not support token auth; using the password instead</string>
<string name="connection_failed">Failed: %1$s</string>
<string name="save_failed">Could not save: %1$s</string>
<string name="active_server_delete_error">The active server can\'t be deleted; switch to another server first</string>
<string name="edit_source_description">Edit %1$s</string>
<string name="use_source_description">Use %1$s</string>
<plurals name="download_notification_title">
<item quantity="one">Downloading %1$d song</item>
<item quantity="other">Downloading %1$d songs</item>
</plurals>
<string name="download_notification_progress">%1$d%%</string>
<string name="download_notification_in_progress">In progress</string>
<string name="playback_error_network">Can\'t reach the server. Check your connection.</string>
<string name="playback_error_refused">The server refused to stream this track.</string>
<string name="star_update_failed">Could not update star</string>
<string name="sync_offline">Offline mode is on</string>
<string name="sync_failed">Sync failed</string>
<string name="sync_failed_detail">Sync failed: %1$s</string>
<string name="error_download_no_space">Not enough space to download</string>
<string name="error_download_storage_unavailable">Storage is unavailable</string>
<string name="error_download_write_failed">Could not write the download</string>
<string name="error_download_server_refused">The server refused to send this track</string>
<string name="error_download_too_many_redirects">The server redirected too many times</string>
<string name="error_download_resume_failed">The download could not be resumed</string>
<string name="error_download_failed">Download failed</string>
<string name="error_download_file_missing">The downloaded file is missing</string>
<string name="error_download_server_error">The server sent an error instead of the track</string>
<string name="error_download_no_address">Can\'t download: the server address is unavailable</string>
<string name="error_download_nothing_left">Nothing left to download from this list</string>
<string name="error_download_stopped">The download stopped unexpectedly</string>
<string name="error_download_inactive_source">Can\'t download from a source that isn\'t active</string>
<string name="error_server_unexpected">Unexpected response from the server</string>
<string name="error_server_unknown">Unknown error</string>
<string name="error_server_malformed">Malformed response from the server</string>
<string name="error_server_truncated">Truncated response from the server</string>
<string name="error_library_too_large">Library is too large to sync: it exceeds %1$d rows</string>
<string name="error_http">HTTP %1$d</string>
<string name="home_section_recently_played_artists">Recently played artists</string>
<string name="home_section_most_played">Most played</string>
<string name="home_section_most_played_artists">Most played artists</string>
<string name="home_section_genres">Genres</string>
<string name="home_section_decades">Decades</string>
<string name="home_section_recently_starred">Recently starred</string>
<string name="home_section_recently_added">Recently added</string>
<string name="home_section_rediscover">Rediscover</string>
<string name="home_downloaded_songs">Downloaded songs</string>
<string name="home_downloaded_albums">Downloaded albums</string>
<string name="home_downloaded_artists">Downloaded artists</string>
<string name="home_downloaded_playlists">Downloaded playlists</string>
<string name="home_empty">Nothing to show yet.\nSync with your server to fill your library.</string>
<string name="home_decade">%1$ds</string>
<string name="home_time_just_now">Just now</string>
<string name="home_time_today">Today</string>
<string name="home_time_yesterday">Yesterday</string>
<string name="home_time_last_week">Last week</string>
<string name="home_time_last_month">Last month</string>
<string name="home_time_last_year">Last year</string>
<plurals name="home_time_days_ago">
<item quantity="one">%1$d day ago</item>
<item quantity="other">%1$d days ago</item>
</plurals>
<plurals name="home_time_weeks_ago">
<item quantity="one">%1$d week ago</item>
<item quantity="other">%1$d weeks ago</item>
</plurals>
<plurals name="home_time_months_ago">
<item quantity="one">%1$d month ago</item>
<item quantity="other">%1$d months ago</item>
</plurals>
<plurals name="home_time_years_ago">
<item quantity="one">%1$d year ago</item>
<item quantity="other">%1$d years ago</item>
</plurals>
<plurals name="home_play_count">
<item quantity="one">%1$d play</item>
<item quantity="other">%1$d plays</item>
</plurals>
<string name="codec_mp3" translatable="false">MP3</string>
<string name="codec_aac" translatable="false">AAC</string>
<string name="codec_alac" translatable="false">ALAC</string>
<string name="codec_flac" translatable="false">FLAC</string>
<string name="codec_ogg" translatable="false">Ogg</string>
<string name="codec_vorbis" translatable="false">Vorbis</string>
<string name="codec_opus" translatable="false">Opus</string>
<string name="codec_webm" translatable="false">WebM</string>
<string name="codec_wav" translatable="false">WAV</string>
</resources> </resources>
@@ -1,5 +1,7 @@
package com.subtracks.data.download package com.subtracks.data.download
import com.subtracks.data.model.DownloadError
class FakeDownloadEngine : DownloadEngine { class FakeDownloadEngine : DownloadEngine {
private val downloads = mutableMapOf<Long, EngineDownload>() private val downloads = mutableMapOf<Long, EngineDownload>()
private var nextId = 1L private var nextId = 1L
@@ -45,7 +47,7 @@ class FakeDownloadEngine : DownloadEngine {
} }
fun fail(id: Long) { fun fail(id: Long) {
downloads[id] = EngineDownload(EngineStatus.Failed, error = "Download failed") downloads[id] = EngineDownload(EngineStatus.Failed, error = DownloadError.Failed)
} }
fun forget(id: Long) { fun forget(id: Long) {
@@ -4,6 +4,7 @@ import android.app.DownloadManager
import android.content.Context import android.content.Context
import androidx.test.core.app.ApplicationProvider import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.data.model.DownloadError
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull import org.junit.Assert.assertNotNull
@@ -40,10 +41,10 @@ class SystemDownloadEngineTest {
} }
@Test @Test
fun failureReasonsReadAsSentences() { fun failureReasonsMapToErrors() {
assertEquals("Not enough space to download", failureMessage(DownloadManager.ERROR_INSUFFICIENT_SPACE)) assertEquals(DownloadError.NoSpace, failureMessage(DownloadManager.ERROR_INSUFFICIENT_SPACE))
assertEquals("Storage is unavailable", failureMessage(DownloadManager.ERROR_DEVICE_NOT_FOUND)) assertEquals(DownloadError.StorageUnavailable, failureMessage(DownloadManager.ERROR_DEVICE_NOT_FOUND))
assertEquals("Download failed", failureMessage(DownloadManager.ERROR_UNKNOWN)) assertEquals(DownloadError.Failed, failureMessage(DownloadManager.ERROR_UNKNOWN))
} }
@Test @Test
@@ -15,6 +15,7 @@ import com.subtracks.data.download.FakeDownloadEngine
import com.subtracks.data.model.Album import com.subtracks.data.model.Album
import com.subtracks.data.model.Artist import com.subtracks.data.model.Artist
import com.subtracks.data.model.BulkDownloadAction import com.subtracks.data.model.BulkDownloadAction
import com.subtracks.data.model.DownloadError
import com.subtracks.data.model.DownloadList import com.subtracks.data.model.DownloadList
import com.subtracks.data.model.DownloadStatus import com.subtracks.data.model.DownloadStatus
import com.subtracks.data.model.ListDownloadStatus import com.subtracks.data.model.ListDownloadStatus
@@ -208,7 +209,7 @@ class DownloadRepositoryTest {
fun aFailedDownloadCanBeStartedAgain() = fun aFailedDownloadCanBeStartedAgain() =
runTest { runTest {
seedLibrary() seedLibrary()
db.downloadDao().upsert(SongDownload(1, "s1", DownloadStatus.Failed, engineId = 3, error = "boom")) db.downloadDao().upsert(SongDownload(1, "s1", DownloadStatus.Failed, engineId = 3, error = DownloadError.Failed))
runBlocking { repository.download(1, "s1") } runBlocking { repository.download(1, "s1") }
@@ -220,14 +221,14 @@ class DownloadRepositoryTest {
fun aFailedDownloadIsNotRestartedByReconcile() = fun aFailedDownloadIsNotRestartedByReconcile() =
runTest { runTest {
seedLibrary() seedLibrary()
db.downloadDao().upsert(SongDownload(1, "s1", DownloadStatus.Failed, engineId = null, error = "boom")) db.downloadDao().upsert(SongDownload(1, "s1", DownloadStatus.Failed, engineId = null, error = DownloadError.Failed))
runBlocking { repository.reconcile() } runBlocking { repository.reconcile() }
runBlocking { repository.reconcile() } runBlocking { repository.reconcile() }
assertTrue(engine.requests.isEmpty()) assertTrue(engine.requests.isEmpty())
assertEquals(DownloadStatus.Failed, row(1, "s1")?.status) assertEquals(DownloadStatus.Failed, row(1, "s1")?.status)
assertEquals("boom", row(1, "s1")?.error) assertEquals(DownloadError.Failed, row(1, "s1")?.error)
} }
@Test @Test
@@ -242,7 +243,7 @@ class DownloadRepositoryTest {
runBlocking { repository.reconcile() } runBlocking { repository.reconcile() }
assertEquals(DownloadStatus.Failed, row(1, "s1")?.status) assertEquals(DownloadStatus.Failed, row(1, "s1")?.status)
assertEquals("Download failed", row(1, "s1")?.error) assertEquals(DownloadError.Failed, row(1, "s1")?.error)
assertFalse(file(1, "s1").exists()) assertFalse(file(1, "s1").exists())
} }
@@ -1000,7 +1001,7 @@ class DownloadRepositoryTest {
runBlocking { repository.reconcile() } runBlocking { repository.reconcile() }
assertEquals(DownloadStatus.Failed, row(1, "s1")?.status) assertEquals(DownloadStatus.Failed, row(1, "s1")?.status)
assertEquals("The server sent an error instead of the track", row(1, "s1")?.error) assertEquals(DownloadError.ServerError, row(1, "s1")?.error)
assertFalse(file(1, "s1").exists()) assertFalse(file(1, "s1").exists())
assertNull(repository.localUri("s1")) assertNull(repository.localUri("s1"))
} }
@@ -0,0 +1,89 @@
package com.subtracks.i18n
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.w3c.dom.Element
import java.io.File
import javax.xml.parsers.DocumentBuilderFactory
class StringResourcesTest {
private val resDir = sequenceOf(File("src/main/res"), File("app/src/main/res")).first(File::isDirectory)
private val defaultFile = File(resDir, "values/strings.xml")
private data class Resource(
val kind: String,
val text: String,
val quantities: Set<String>,
)
private val validQuantities = setOf("zero", "one", "two", "few", "many", "other")
@Test
fun everyLocaleResourceExistsInDefaultWithMatchingTypeAndPlaceholders() {
val default = parse(defaultFile)
assertTrue("default strings.xml should not be empty", default.isNotEmpty())
localeFiles().forEach { file ->
val locale = parse(file)
locale.forEach { (name, resource) ->
val base = default[name]
assertTrue("$name in ${file.parentFile.name} is missing from values/strings.xml", base != null)
assertEquals("$name kind differs in ${file.parentFile.name}", base!!.kind, resource.kind)
if (resource.kind == "plurals") {
assertTrue(
"$name has an invalid quantity in ${file.parentFile.name}",
resource.quantities.all { it in validQuantities },
)
}
val baseArgs = placeholderIndices(base.text)
val localeArgs = placeholderIndices(resource.text)
assertTrue(
"$name in ${file.parentFile.name} uses placeholders $localeArgs not present in the default $baseArgs",
baseArgs.containsAll(localeArgs),
)
}
}
}
private fun localeFiles(): List<File> =
resDir
.listFiles { file -> file.isDirectory && file.name.startsWith("values-") }
.orEmpty()
.map { File(it, "strings.xml") }
.filter(File::isFile)
private fun parse(file: File): Map<String, Resource> {
val document = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(file)
val resources = document.documentElement
val result = mutableMapOf<String, Resource>()
val nodes = resources.childNodes
for (index in 0 until nodes.length) {
val element = nodes.item(index) as? Element ?: continue
val name = element.getAttribute("name")
if (name.isEmpty()) continue
val text =
when (element.tagName) {
"plurals" -> pluralText(element)
else -> element.textContent
}
val quantities =
if (element.tagName == "plurals") {
val items = element.getElementsByTagName("item")
(0 until items.length).mapTo(mutableSetOf()) { (items.item(it) as Element).getAttribute("quantity") }
} else {
emptySet()
}
result[name] = Resource(element.tagName, text, quantities)
}
return result
}
private fun pluralText(element: Element): String {
val items = element.getElementsByTagName("item")
return (0 until items.length).joinToString(" ") { items.item(it).textContent }
}
private fun placeholderIndices(text: String): Set<Int> =
Regex("""%(\d+)\$""").findAll(text).mapTo(mutableSetOf()) { it.groupValues[1].toInt() }
}
@@ -14,6 +14,7 @@ import androidx.compose.ui.test.performClick
import androidx.paging.PagingData import androidx.paging.PagingData
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers
import com.subtracks.R
import com.subtracks.data.model.Album import com.subtracks.data.model.Album
import com.subtracks.data.model.Artist import com.subtracks.data.model.Artist
import com.subtracks.data.model.Playlist import com.subtracks.data.model.Playlist
@@ -100,19 +101,43 @@ class LibraryOptionsTest {
@Test @Test
fun playSortsAreHiddenUntilTheServerReturnsPlayData() { fun playSortsAreHiddenUntilTheServerReturnsPlayData() {
assertEquals( assertEquals(
listOf("Name", "Artist", "Year", "Added", "Starred"), listOf(
R.string.resources_sort_by_name,
R.string.resources_sort_by_artist,
R.string.resources_sort_by_year,
R.string.resources_sort_by_added,
R.string.resources_filter_starred,
),
sortOptionsFor(LibraryTab.Albums).map { it.label }, sortOptionsFor(LibraryTab.Albums).map { it.label },
) )
assertEquals( assertEquals(
listOf("Name", "Artist", "Year", "Added", "Starred", "Frequently played", "Recently played"), listOf(
R.string.resources_sort_by_name,
R.string.resources_sort_by_artist,
R.string.resources_sort_by_year,
R.string.resources_sort_by_added,
R.string.resources_filter_starred,
R.string.resources_sort_by_frequently_played,
R.string.resources_sort_by_recently_played,
),
sortOptionsFor(LibraryTab.Albums, PlaySortAvailability(frequent = true, recent = true)).map { it.label }, sortOptionsFor(LibraryTab.Albums, PlaySortAvailability(frequent = true, recent = true)).map { it.label },
) )
assertEquals( assertEquals(
listOf("Name", "Albums", "Starred"), listOf(
R.string.resources_sort_by_name,
R.string.resources_album_name,
R.string.resources_filter_starred,
),
sortOptionsFor(LibraryTab.Artists).map { it.label }, sortOptionsFor(LibraryTab.Artists).map { it.label },
) )
assertEquals( assertEquals(
listOf("Name", "Albums", "Starred", "Frequently played", "Recently played"), listOf(
R.string.resources_sort_by_name,
R.string.resources_album_name,
R.string.resources_filter_starred,
R.string.resources_sort_by_frequently_played,
R.string.resources_sort_by_recently_played,
),
sortOptionsFor(LibraryTab.Artists, PlaySortAvailability(frequent = true, recent = true)).map { it.label }, sortOptionsFor(LibraryTab.Artists, PlaySortAvailability(frequent = true, recent = true)).map { it.label },
) )
} }
@@ -225,7 +225,7 @@ class LibraryScreenTest {
composeRule.runOnIdle { pagerState.requestScrollToPage(1, step * 0.05f) } composeRule.runOnIdle { pagerState.requestScrollToPage(1, step * 0.05f) }
composeRule.waitForIdle() composeRule.waitForIdle()
composeRule composeRule
.onNodeWithContentDescription(LibraryTab.Playlists.label) .onNodeWithContentDescription(composeRule.activity.getString(LibraryTab.Playlists.label))
.fetchSemanticsNode() .fetchSemanticsNode()
.boundsInRoot.left .boundsInRoot.left
} }
@@ -93,6 +93,8 @@ class DownloadsViewModelTest {
LibraryRepository(db, sourceRepository, NetworkServerActionSink(sourceRepository), scope = repoScope), LibraryRepository(db, sourceRepository, NetworkServerActionSink(sourceRepository), scope = repoScope),
downloadRepository, downloadRepository,
controller, controller,
"Unknown artist",
"Unknown album",
) )
} }
@@ -1,67 +1,77 @@
package com.subtracks.ui.home package com.subtracks.ui.home
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.R
import com.subtracks.data.model.Album import com.subtracks.data.model.Album
import com.subtracks.data.model.Artist import com.subtracks.data.model.Artist
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull import org.junit.Assert.assertNull
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class HomeListInfoTest { class HomeListInfoTest {
private val now = 1_700_000_000_000L private val now = 1_700_000_000_000L
private val resources = ApplicationProvider.getApplicationContext<Context>().resources
@Test @Test
fun relativeTimeUsesFriendlyBuckets() { fun relativeTimeUsesFriendlyBuckets() {
assertNull(relativeTimeLabel(0, now)) assertNull(relativeTimeLabel(0, now, resources))
assertEquals("Just now", relativeTimeLabel(now / 1000 - 10, now)) assertEquals("Just now", relativeTimeLabel(now / 1000 - 10, now, resources))
assertEquals("Today", relativeTimeLabel((now - 3 * 3_600_000L) / 1000L, now)) assertEquals("Today", relativeTimeLabel((now - 3 * 3_600_000L) / 1000L, now, resources))
assertEquals("Yesterday", relativeTimeLabel(secondsAgo(1), now)) assertEquals("Yesterday", relativeTimeLabel(secondsAgo(1), now, resources))
assertEquals("3 days ago", relativeTimeLabel(secondsAgo(3), now)) assertEquals("3 days ago", relativeTimeLabel(secondsAgo(3), now, resources))
assertEquals("Last week", relativeTimeLabel(secondsAgo(10), now)) assertEquals("Last week", relativeTimeLabel(secondsAgo(10), now, resources))
assertEquals("3 weeks ago", relativeTimeLabel(secondsAgo(21), now)) assertEquals("3 weeks ago", relativeTimeLabel(secondsAgo(21), now, resources))
assertEquals("Last month", relativeTimeLabel(secondsAgo(45), now)) assertEquals("Last month", relativeTimeLabel(secondsAgo(45), now, resources))
assertEquals("3 months ago", relativeTimeLabel(secondsAgo(100), now)) assertEquals("3 months ago", relativeTimeLabel(secondsAgo(100), now, resources))
assertEquals("Last year", relativeTimeLabel(secondsAgo(400), now)) assertEquals("Last year", relativeTimeLabel(secondsAgo(400), now, resources))
assertEquals("2 years ago", relativeTimeLabel(secondsAgo(800), now)) assertEquals("2 years ago", relativeTimeLabel(secondsAgo(800), now, resources))
} }
@Test @Test
fun albumInfoMatchesTheSection() { fun albumInfoMatchesTheSection() {
val album = album(created = secondsAgo(1), played = secondsAgo(3), playCount = 5, year = 1999) val album = album(created = secondsAgo(1), played = secondsAgo(3), playCount = 5, year = 1999)
assertEquals("Yesterday", albumListInfo(request(HomeSection.RecentlyAddedAlbums), album, now)) assertEquals("Yesterday", albumListInfo(request(HomeSection.RecentlyAddedAlbums), album, now, resources))
assertEquals("3 days ago", albumListInfo(request(HomeSection.RecentlyPlayedAlbums), album, now)) assertEquals("3 days ago", albumListInfo(request(HomeSection.RecentlyPlayedAlbums), album, now, resources))
assertEquals("5 plays", albumListInfo(request(HomeSection.MostPlayedAlbums), album, now)) assertEquals("5 plays", albumListInfo(request(HomeSection.MostPlayedAlbums), album, now, resources))
assertEquals("3 days ago", albumListInfo(request(HomeSection.Rediscover), album, now)) assertEquals("3 days ago", albumListInfo(request(HomeSection.Rediscover), album, now, resources))
} }
@Test @Test
fun albumInfoIsNullWhenTheSectionHasNothingToShow() { fun albumInfoIsNullWhenTheSectionHasNothingToShow() {
val album = album(created = 0, played = null, playCount = 0, year = 0) val album = album(created = 0, played = null, playCount = 0, year = 0)
assertNull(albumListInfo(request(HomeSection.RecentlyAddedAlbums), album, now)) assertNull(albumListInfo(request(HomeSection.RecentlyAddedAlbums), album, now, resources))
assertNull(albumListInfo(request(HomeSection.RecentlyPlayedAlbums), album, now)) assertNull(albumListInfo(request(HomeSection.RecentlyPlayedAlbums), album, now, resources))
assertNull(albumListInfo(request(HomeSection.MostPlayedAlbums), album, now)) assertNull(albumListInfo(request(HomeSection.MostPlayedAlbums), album, now, resources))
} }
@Test @Test
fun aDecadeListShowsTheYear() { fun aDecadeListShowsTheYear() {
val album = album(created = 0, played = null, playCount = 0, year = 1999) val album = album(created = 0, played = null, playCount = 0, year = 1999)
assertEquals("1999", albumListInfo(HomeListRequest(title = "1990s", decade = 1990), album, now)) assertEquals(
"1999",
albumListInfo(HomeListRequest(title = resources.getString(R.string.home_decade, 1990L), decade = 1990), album, now, resources),
)
} }
@Test @Test
fun artistInfoMatchesTheSection() { fun artistInfoMatchesTheSection() {
val artist = artist(played = secondsAgo(3), playCount = 7) val artist = artist(played = secondsAgo(3), playCount = 7)
assertEquals("3 days ago", artistListInfo(request(HomeSection.RecentlyPlayedArtists), artist, now)) assertEquals("3 days ago", artistListInfo(request(HomeSection.RecentlyPlayedArtists), artist, now, resources))
assertEquals("7 plays", artistListInfo(request(HomeSection.MostPlayedArtists), artist, now)) assertEquals("7 plays", artistListInfo(request(HomeSection.MostPlayedArtists), artist, now, resources))
assertNull(artistListInfo(request(HomeSection.MostPlayedArtists), artist.copy(playCount = 0, played = null), now)) assertNull(artistListInfo(request(HomeSection.MostPlayedArtists), artist.copy(playCount = 0, played = null), now, resources))
} }
private fun secondsAgo(days: Long): Long = (now - days * 86_400_000L) / 1000L private fun secondsAgo(days: Long): Long = (now - days * 86_400_000L) / 1000L
private fun request(section: HomeSection) = HomeListRequest(title = section.title, section = section) private fun request(section: HomeSection) = HomeListRequest(title = resources.getString(section.title), section = section)
private fun album( private fun album(
created: Long, created: Long,
@@ -141,7 +141,8 @@ class AddSourceViewModelTest {
private fun viewModel(sourceId: Long? = null): AddSourceViewModel { private fun viewModel(sourceId: Long? = null): AddSourceViewModel {
val syncManager = SyncManager(db, sourceRepository, QueueRepository(db), scope = repoScope) val syncManager = SyncManager(db, sourceRepository, QueueRepository(db), scope = repoScope)
return AddSourceViewModel(sourceRepository, syncManager, downloadRepository, sourceId).also { createdViewModels += it } val resources = ApplicationProvider.getApplicationContext<Context>().resources
return AddSourceViewModel(sourceRepository, syncManager, downloadRepository, sourceId, resources).also { createdViewModels += it }
} }
private fun ok() = MockResponse().setBody("<subsonic-response status=\"ok\" version=\"1.16.1\"/>") private fun ok() = MockResponse().setBody("<subsonic-response status=\"ok\" version=\"1.16.1\"/>")
+12
View File
@@ -136,6 +136,18 @@ The two branches do not fold case identically: the trigram tokenizer lowercases
`SubsonicClient` speaks the Subsonic REST API over OkHttp. Auth is the token scheme (`t = md5(password + salt)`), salted freshly on every request including media URLs; plaintext `p=` is supported as a fallback for old servers. Responses are parsed as a stream with SAX (`SAXParserFactory`, hardened best-effort — Android's parser rejects some flags, so unsupported ones are ignored), so entities are mapped as elements close rather than through a DOM. The client rejects any document that is not a `<subsonic-response>` and classifies malformed or truncated bodies as a `SubsonicException`, so a captive portal or login page can never be mistaken for an empty library and wipe the local mirror. `SubsonicClient` speaks the Subsonic REST API over OkHttp. Auth is the token scheme (`t = md5(password + salt)`), salted freshly on every request including media URLs; plaintext `p=` is supported as a fallback for old servers. Responses are parsed as a stream with SAX (`SAXParserFactory`, hardened best-effort — Android's parser rejects some flags, so unsupported ones are ignored), so entities are mapped as elements close rather than through a DOM. The client rejects any document that is not a `<subsonic-response>` and classifies malformed or truncated bodies as a `SubsonicException`, so a captive portal or login page can never be mistaken for an empty library and wipe the local mirror.
## Internationalization
All user-facing text lives in Android string resources: `res/values/strings.xml` (the English source, including `<plurals>`) and `res/values-*/strings.xml` for translations. This is the platform-native mechanism, needs no extra dependency, and is a first-class Weblate format ("Android String Resource": monolingual base `res/values/strings.xml`, file mask `res/values-*/strings.xml`, plurals supported). Code reads it through `stringResource`/`pluralStringResource` in Compose and `getString`/`getQuantityString` elsewhere.
Resource names are `snake_case`. They were derived once from the old Flutter ARB keys: resources whose English matched an old string (the "identical" set) reuse the old key's translations, mechanically renamed (`settingsServersFieldsName` -> `settings_servers_fields_name`); everything else is a new key. Strings whose wording changed were deliberately given new keys rather than reusing a translation with stale meaning. The migration is one-time; there is no generator in the build.
27 locales were carried over from the Flutter app: `ar, ca, cs, da, de, es, eu, fr, gl, hu, it, ja, ko, nb, pa, pl, pt, pt-BR, ru, sv, ta, th, tr, uk, vi, zh, zh-Hant` (PR #178 plus `ar/ca/cs/pa/pt` from the frozen Flutter branches). Locale files contain only translated keys; anything missing falls back to English. Chinese uses script qualifiers (`values-b+zh+Hans`, `values-b+zh+Hant`) and Brazilian Portuguese is `values-b+pt+BR`.
Per-app language selection (Android 13+) is generated by AGP: `androidResources.generateLocaleConfig = true` with `res/resources.properties` supplying the default locale. `StringResourcesTest` guards the translations: every locale resource must exist in the default with the same kind and no placeholder the default lacks, which is what caught a duplicated `{value}` in the Thai ARB during migration.
Some text is still English-only: transient toasts and status strings produced below the UI (`showMessage` and `SyncStatus.Failed`), the download progress notification, playback error messages, and Subsonic parser exceptions. Persisted download failures are no longer free text: `SongDownload.error` is a `DownloadError` enum mapped to `@StringRes` and stored by name (unknown legacy values degrade to `DownloadError.Failed`), so `SongInfoDialog` resolves it in the current locale.
## Security and privacy ## Security and privacy
No analytics or third-party telemetry. Credentials are stored locally in app data and are only ever sent to the configured server. Cleartext HTTP is permitted because self-hosted servers often run on a LAN; users can still configure HTTPS. No analytics or third-party telemetry. Credentials are stored locally in app data and are only ever sent to the configured server. Cleartext HTTP is permitted because self-hosted servers often run on a LAN; users can still configure HTTPS.