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
}
androidResources {
generateLocaleConfig = true
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
@@ -39,6 +43,7 @@ android {
lint {
checkReleaseBuilds = false
disable += setOf("MissingTranslation", "MissingQuantity")
}
testOptions {
@@ -9,6 +9,7 @@ import com.subtracks.data.model.Artist
import com.subtracks.data.model.ArtistSearch
import com.subtracks.data.model.ArtworkSeed
import com.subtracks.data.model.Disc
import com.subtracks.data.model.DownloadErrorConverter
import com.subtracks.data.model.DownloadStatusConverter
import com.subtracks.data.model.PlaybackCursor
import com.subtracks.data.model.Playlist
@@ -48,7 +49,7 @@ import com.subtracks.data.model.UpNextEntry
version = 26,
exportSchema = true,
)
@ColumnTypeConverters(QueueKindConverter::class, DownloadStatusConverter::class)
@ColumnTypeConverters(QueueKindConverter::class, DownloadStatusConverter::class, DownloadErrorConverter::class)
abstract class SubtracksDatabase : RoomDatabase() {
abstract fun libraryDao(): LibraryDao
@@ -1,5 +1,7 @@
package com.subtracks.data.download
import com.subtracks.data.model.DownloadError
data class EngineRequest(
val uri: String,
val path: String,
@@ -13,7 +15,7 @@ data class EngineDownload(
val status: EngineStatus,
val bytes: Long = 0,
val total: Long = 0,
val error: String? = null,
val error: DownloadError? = null,
)
interface DownloadEngine {
@@ -53,7 +53,7 @@ class DownloadNotifier(
.setSilent(true)
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
.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())
.build()
@@ -75,7 +75,7 @@ class DownloadNotifier(
val manager = context.getSystemService(NotificationManager::class.java) ?: return
if (manager.getNotificationChannel(CHANNEL_ID) != null) return
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)
setShowBadge(false)
},
@@ -4,6 +4,7 @@ import android.app.DownloadManager
import android.content.Context
import android.net.Uri
import android.os.Environment
import com.subtracks.data.model.DownloadError
class SystemDownloadEngine(
private val context: Context,
@@ -60,13 +61,13 @@ internal fun engineStatus(status: Int): EngineStatus =
else -> EngineStatus.Pending
}
internal fun failureMessage(reason: Int): String =
internal fun failureMessage(reason: Int): DownloadError =
when (reason) {
DownloadManager.ERROR_INSUFFICIENT_SPACE -> "Not enough space to download"
DownloadManager.ERROR_DEVICE_NOT_FOUND -> "Storage is unavailable"
DownloadManager.ERROR_FILE_ERROR, DownloadManager.ERROR_FILE_ALREADY_EXISTS -> "Could not write the download"
DownloadManager.ERROR_HTTP_DATA_ERROR, DownloadManager.ERROR_UNHANDLED_HTTP_CODE -> "The server refused to send this track"
DownloadManager.ERROR_TOO_MANY_REDIRECTS -> "The server redirected too many times"
DownloadManager.ERROR_CANNOT_RESUME -> "The download could not be resumed"
else -> "Download failed"
DownloadManager.ERROR_INSUFFICIENT_SPACE -> DownloadError.NoSpace
DownloadManager.ERROR_DEVICE_NOT_FOUND -> DownloadError.StorageUnavailable
DownloadManager.ERROR_FILE_ERROR, DownloadManager.ERROR_FILE_ALREADY_EXISTS -> DownloadError.WriteFailed
DownloadManager.ERROR_HTTP_DATA_ERROR, DownloadManager.ERROR_UNHANDLED_HTTP_CODE -> DownloadError.ServerRefused
DownloadManager.ERROR_TOO_MANY_REDIRECTS -> DownloadError.TooManyRedirects
DownloadManager.ERROR_CANNOT_RESUME -> DownloadError.ResumeFailed
else -> DownloadError.Failed
}
@@ -1,13 +1,33 @@
package com.subtracks.data.model
import androidx.annotation.StringRes
import androidx.room3.ColumnInfo
import androidx.room3.ColumnTypeConverter
import androidx.room3.Entity
import androidx.room3.ForeignKey
import androidx.room3.Index
import com.subtracks.R
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(
tableName = "song_downloads",
primaryKeys = ["sourceId", "songId"],
@@ -34,7 +54,7 @@ data class SongDownload(
val engineId: Long? = null,
@ColumnInfo(defaultValue = "0") val bytes: Long = 0,
@ColumnInfo(defaultValue = "0") val total: Long = 0,
val error: String? = null,
val error: DownloadError? = null,
@ColumnInfo(defaultValue = "0") val downloadedAt: Long = 0,
) {
val progress: Float?
@@ -104,3 +124,11 @@ class DownloadStatusConverter {
@ColumnTypeConverter
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.looksLikeAudio
import com.subtracks.data.model.BulkDownloadAction
import com.subtracks.data.model.DownloadError
import com.subtracks.data.model.DownloadList
import com.subtracks.data.model.DownloadStatus
import com.subtracks.data.model.DownloadedSong
@@ -174,7 +175,7 @@ class DownloadRepository(
throw e
} catch (_: Exception) {
// Leave it failed rather than waiting, or the next tick would retry it forever.
markFailed(row, STOPPED)
markFailed(row, DownloadError.Stopped)
return@forEach
}
db.downloadDao().upsert(row.copy(engineId = engineId))
@@ -459,8 +460,8 @@ class DownloadRepository(
DownloadStatus.Completed -> {
val file = file(row.sourceId, row.songId)
when {
!file.exists() -> markFailed(row, MISSING_FILE)
!file.looksLikeAudio() -> markFailed(row, SERVER_ERROR)
!file.exists() -> markFailed(row, DownloadError.FileMissing)
!file.looksLikeAudio() -> markFailed(row, DownloadError.ServerError)
}
}
@@ -470,7 +471,7 @@ class DownloadRepository(
}
DownloadStatus.Running -> {
markFailed(row, STOPPED)
markFailed(row, DownloadError.Stopped)
}
DownloadStatus.Failed -> {
@@ -566,8 +567,8 @@ class DownloadRepository(
error =
when {
state.error != null -> state.error
errorPage -> SERVER_ERROR
status == DownloadStatus.Failed -> "Download failed"
errorPage -> DownloadError.ServerError
status == DownloadStatus.Failed -> DownloadError.Failed
else -> null
},
downloadedAt =
@@ -582,11 +583,11 @@ class DownloadRepository(
private suspend fun markFailed(
row: SongDownload,
message: String,
error: DownloadError,
) {
row.engineId?.let { engine.cancel(listOf(it)) }
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() {
@@ -654,8 +655,6 @@ class DownloadRepository(
const val POLL_MS = 1_000L
const val IN_FLIGHT_LIMIT = 8
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 EMPTY_LIST = "Nothing left to download from this list"
const val STOPPED = "The download stopped unexpectedly"
@@ -5,6 +5,7 @@ import android.os.Environment
import android.os.Handler
import android.os.Looper
import android.widget.Toast
import com.subtracks.R
import com.subtracks.data.db.createAndroidDatabase
import com.subtracks.data.download.ArtworkFetcher
import com.subtracks.data.download.ArtworkStore
@@ -115,8 +116,16 @@ fun appModule(
viewModel { HomeViewModel(get(), get(), get(), get()) }
viewModel { params -> HomeListViewModel(get(), get(), get(), params.get()) }
viewModel { SettingsViewModel(get(), get()) }
viewModel { DownloadsViewModel(get(), get(), get()) }
viewModel { params -> AddSourceViewModel(get(), get(), get(), params.getOrNull()) }
viewModel {
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 -> ArtistDetailViewModel(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.positionInRoot
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelStore
@@ -58,6 +59,7 @@ import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.subtracks.R
import com.subtracks.data.model.Song
import com.subtracks.data.repo.DownloadRepository
import com.subtracks.data.repo.LibraryRepository
@@ -474,7 +476,7 @@ private fun MainNavigation() {
) { entry ->
val section = HomeSection.fromRoute(entry.arguments?.getString("section")) ?: return@composable
HomeListRoute(
request = HomeListRequest(title = section.title, section = section),
request = HomeListRequest(title = stringResource(section.title), section = section),
onBack = { navController.popBackStack() },
onAlbumClick = { album ->
navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
@@ -522,7 +524,7 @@ private fun MainNavigation() {
) { entry ->
val decade = entry.arguments?.getLong("decade") ?: return@composable
HomeListRoute(
request = HomeListRequest(title = "${decade}s", decade = decade),
request = HomeListRequest(title = stringResource(R.string.home_decade, decade), decade = decade),
onBack = { navController.popBackStack() },
onAlbumClick = { album ->
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") }
?: return@composable
HomeListRoute(
request = HomeListRequest(title = kind.title, downloaded = kind),
request = HomeListRequest(title = stringResource(kind.title), downloaded = kind),
onBack = { navController.popBackStack() },
onAlbumClick = { album ->
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.layout.ContentScale
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
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.crossfade
import coil3.size.Dimension
import com.subtracks.R
import com.subtracks.data.model.CoverArtRef
import com.subtracks.ui.theme.artworkColorsFromSeed
import com.subtracks.ui.theme.placeholderSeed
@@ -360,13 +362,13 @@ fun FilteredEmptyState(
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(
text = "Filters are hiding everything.",
text = stringResource(R.string.components_filters_hiding),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
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.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import com.subtracks.R
import com.subtracks.data.model.DownloadList
data class PendingDownloadDelete(
@@ -26,17 +28,17 @@ fun DeleteDownloadsDialog(
val freed = formatBytes(LocalContext.current, bytes)
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Delete downloads") },
text = { Text("$name\n\nDeleting will free $freed") },
title = { Text(stringResource(R.string.resources_song_list_delete_all_title)) },
text = { Text(stringResource(R.string.resources_song_list_delete_all_content, name, freed)) },
confirmButton = {
TextButton(
onClick = {
onConfirm()
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.draw.blur
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import com.subtracks.R
import com.subtracks.data.model.ListDownloadStatus
const val DOWNLOADING_INDICATOR_TAG = "Downloading"
@@ -53,6 +55,7 @@ private fun DownloadRing(
progress: () -> Float,
shadow: Boolean,
) {
val downloadingDescription = stringResource(R.string.status_downloading)
Box(Modifier.size(20.dp), contentAlignment = Alignment.Center) {
if (shadow) {
CircularProgressIndicator(
@@ -68,7 +71,7 @@ private fun DownloadRing(
color = MaterialTheme.colorScheme.primary,
trackColor = MaterialTheme.colorScheme.surfaceContainerHighest,
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.positionInParent
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.style.TextAlign
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.CoverArtRef
import com.subtracks.data.model.ListDownloadStatus
@@ -179,7 +181,10 @@ fun HeroDetailScaffold(
},
navigationIcon = {
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),
@@ -223,11 +228,12 @@ fun ListDownloadStatus.actions(): List<BulkDownloadAction> =
fun ListDownloadStatus.action(): BulkDownloadAction = actions().first()
@Composable
private fun downloadActionLabel(action: BulkDownloadAction): String =
when (action) {
BulkDownloadAction.Download -> "Download"
BulkDownloadAction.Cancel -> "Cancel download"
BulkDownloadAction.Delete -> "Delete download"
BulkDownloadAction.Download -> stringResource(R.string.actions_download)
BulkDownloadAction.Cancel -> stringResource(R.string.actions_download_cancel)
BulkDownloadAction.Delete -> stringResource(R.string.delete_download)
}
@Composable
@@ -280,7 +286,7 @@ fun HeroHeader(
modifier =
Modifier
.heightIn(min = 32.dp)
.clickable(onClickLabel = "Open artist") { onSubtitleClick() },
.clickable(onClickLabel = stringResource(R.string.open_artist)) { onSubtitleClick() },
contentAlignment = Alignment.TopCenter,
) {
HeroSubtitle(subtitle, Modifier.padding(top = 4.dp))
@@ -312,7 +318,7 @@ fun HeroHeader(
onClick = { onDownloadAction(action) },
onClickLabel = downloadActionLabel(action),
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,
) {
@@ -320,7 +326,7 @@ fun HeroHeader(
BulkDownloadAction.Delete -> {
Icon(
imageVector = Icons.Rounded.DownloadDone,
contentDescription = "Delete download",
contentDescription = stringResource(R.string.delete_download),
modifier = Modifier.size(28.dp),
)
}
@@ -332,12 +338,20 @@ fun HeroHeader(
modifier = Modifier.size(26.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 -> {
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),
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(
onClick = onShuffle,
@@ -368,7 +386,11 @@ fun HeroHeader(
shape = RoundedCornerShape(topEnd = 24.dp, bottomEnd = 24.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(
@@ -380,7 +402,7 @@ fun HeroHeader(
}
}
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.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.subtracks.R
import com.subtracks.data.model.BulkDownloadAction
import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.DownloadList
@@ -184,10 +186,10 @@ fun ItemContextMenu(
fun queueItems() {
if (queueRef == null) return
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 ->
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 ->
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) }
}
}
}
target.song.artistId?.let { artistId ->
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) }
}
}
}
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 ->
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) }
}
}
@@ -303,25 +314,25 @@ private fun DownloadItem(
when (download?.status) {
DownloadStatus.Completed -> {
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 -> {
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 -> {
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 -> {
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()) {
when (action) {
BulkDownloadAction.Delete -> MenuItem(Icons.Rounded.Delete, "Delete downloads") { dismiss { bulk(target, action) } }
BulkDownloadAction.Cancel -> MenuItem(Icons.Rounded.Cancel, "Cancel downloads") { dismiss { bulk(target, action) } }
BulkDownloadAction.Download -> MenuItem(Icons.Rounded.Download, "Download") { dismiss { bulk(target, action) } }
BulkDownloadAction.Delete -> {
MenuItem(Icons.Rounded.Delete, stringResource(R.string.resources_song_list_delete_all_title)) {
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 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
@@ -21,7 +21,9 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.subtracks.R
import com.subtracks.data.media.readAudioEncoding
import com.subtracks.data.model.AudioEncoding
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() } }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Info") },
title = { Text(stringResource(R.string.info)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
InfoRow("Title", song.title)
InfoRow("Artist", song.artist)
InfoRow("Album", song.album)
InfoRow("Duration", song.duration?.let(DateUtils::formatElapsedTime))
InfoRow("Track", song.track?.let { track -> song.disc?.let { "$it.$track" } ?: track.toString() })
InfoRow("Genre", genres.joinToString(", ").ifBlank { null })
InfoRow("Starred", if (song.starred != null) "Yes" else null)
InfoRow(stringResource(R.string.resources_sort_by_title), song.title)
InfoRow(stringResource(R.string.resources_filter_artist), song.artist)
InfoRow(stringResource(R.string.resources_filter_album), song.album)
InfoRow(stringResource(R.string.info_duration), song.duration?.let(DateUtils::formatElapsedTime))
InfoRow(
stringResource(R.string.info_track),
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) {
InfoRow("Download", downloadStatus(download.status))
InfoRow("Size", sizeBytes?.let { Formatter.formatFileSize(context, it) })
download.error?.let { InfoRow("Error", it) }
InfoRow(stringResource(R.string.actions_download), downloadStatus(download.status))
InfoRow(stringResource(R.string.info_size), sizeBytes?.let { Formatter.formatFileSize(context, 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.
InfoRow("Bitrate", (shown?.bitrate?.takeIf { it > 0 } ?: averageBps)?.let { "${it / 1000} kbps" })
InfoRow("Sample rate", shown?.sampleRate?.takeIf { it > 0 }?.let { "$it Hz" })
InfoRow("Channels", shown?.channels?.takeIf { it > 0 }?.toString())
InfoRow(
stringResource(R.string.info_bitrate),
(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
@Composable
private fun downloadStatus(status: DownloadStatus): String =
when (status) {
DownloadStatus.Completed -> "Downloaded"
DownloadStatus.Queued -> "Queued"
DownloadStatus.Running -> "Downloading"
DownloadStatus.Failed -> "Failed"
DownloadStatus.Completed -> stringResource(R.string.status_downloaded)
DownloadStatus.Queued -> stringResource(R.string.status_queued)
DownloadStatus.Running -> stringResource(R.string.status_downloading)
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.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.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.subtracks.R
import com.subtracks.data.model.AudioEncoding
import com.subtracks.data.model.DownloadList
import com.subtracks.data.model.DownloadStatus
@@ -86,7 +89,7 @@ fun DownloadsRoute(
when (val dialog = pending) {
is DownloadDeleteTarget.All -> {
DeleteDownloadsDialog(
name = "all downloads",
name = stringResource(R.string.downloads_all),
bytes = tree.bytes,
onConfirm = viewModel::deleteAll,
onDismiss = { pending = null },
@@ -123,17 +126,17 @@ fun DownloadsScreen(
modifier = modifier,
topBar = {
TopAppBar(
title = { Text("Downloads") },
title = { Text(stringResource(R.string.downloads_title)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Back")
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = stringResource(R.string.navigation_back))
}
},
)
},
) { padding ->
if (tree.songs == 0) {
EmptyState("No downloads yet.", Modifier.padding(padding))
EmptyState(stringResource(R.string.downloads_empty), Modifier.padding(padding))
return@Scaffold
}
LazyColumn(
@@ -150,7 +153,13 @@ fun DownloadsScreen(
val active = activeDownloads(songs)
NodeRow(
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,
indent = 0,
progress = nodeProgress(songs),
@@ -174,7 +183,13 @@ fun DownloadsScreen(
val active = activeDownloads(album.songs)
NodeRow(
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,
indent = 1,
progress = nodeProgress(album.songs),
@@ -219,11 +234,11 @@ private fun SummaryRow(
horizontalArrangement = Arrangement.SpaceBetween,
) {
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,
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) {
Icon(
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 = {
if (progress != null) {
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 {
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 = {
if (active) {
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 {
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?,
): String {
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) {
DownloadStatus.Completed -> {
listOfNotNull(
encoding?.format,
encoding?.bitrate?.takeIf { it > 0 }?.let { "${it / 1000} kbps" },
bitrateText,
size,
).joinToString(" · ")
}
DownloadStatus.Queued -> {
"Queued · $size"
stringResource(R.string.download_status_summary, stringResource(R.string.status_queued), size)
}
DownloadStatus.Running -> {
"Downloading · $size"
stringResource(R.string.download_status_summary, stringResource(R.string.status_downloading), size)
}
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 downloadRepository: DownloadRepository,
private val playbackController: PlaybackController,
private val unknownArtist: String,
private val unknownAlbum: String,
) : ViewModel() {
private val sourceId = libraryRepository.activeSourceId.filterNotNull()
val tree: StateFlow<DownloadTree> =
sourceId
.flatMapLatest { downloadRepository.downloadedSongs(it) }
.map(::buildTree)
.map { buildTree(it, unknownArtist, unknownAlbum) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), DownloadTree())
fun deleteAll() {
@@ -89,7 +91,11 @@ class DownloadsViewModel(
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(
artists =
songs
@@ -97,7 +103,7 @@ internal fun buildTree(songs: List<DownloadedSong>): DownloadTree =
.map { (artistKey, artistSongs) ->
DownloadArtistNode(
id = artistKey.first,
name = artistKey.second.ifBlank { "Unknown artist" },
name = artistKey.second.ifBlank { unknownArtist },
bytes = artistSongs.sumOf { it.size },
albums =
artistSongs
@@ -105,7 +111,7 @@ internal fun buildTree(songs: List<DownloadedSong>): DownloadTree =
.map { (albumKey, albumSongs) ->
DownloadAlbumNode(
id = albumKey.first,
name = albumKey.second.ifBlank { "Unknown album" },
name = albumKey.second.ifBlank { unknownAlbum },
bytes = albumSongs.sumOf { it.size },
songs = albumSongs.sortedBy { it.title.lowercase() },
)
@@ -1,5 +1,6 @@
package com.subtracks.ui.home
import android.content.res.Resources
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
@@ -34,6 +35,9 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
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.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -41,6 +45,7 @@ import androidx.paging.LoadState
import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.collectAsLazyPagingItems
import androidx.paging.compose.itemKey
import com.subtracks.R
import com.subtracks.data.model.Album
import com.subtracks.data.model.AlbumSongItem
import com.subtracks.data.model.Artist
@@ -133,6 +138,7 @@ fun HomeListScreen(
modifier: Modifier = Modifier,
) {
val now = remember { System.currentTimeMillis() }
val resources = LocalContext.current.resources
Scaffold(
modifier = modifier,
topBar = {
@@ -140,7 +146,7 @@ fun HomeListScreen(
title = { Text(request.title) },
navigationIcon = {
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(
albums = albums,
coverArt = coverArt,
info = { albumListInfo(request, it, now) },
info = { albumListInfo(request, it, now, resources) },
onAlbumClick = onAlbumClick,
onLongClick = onLongClick,
modifier = Modifier.padding(padding),
@@ -164,7 +170,7 @@ fun HomeListScreen(
ArtistsList(
artists = artists,
coverArt = coverArt,
info = { artistListInfo(request, it, now) },
info = { artistListInfo(request, it, now, resources) },
onArtistClick = onArtistClick,
onLongClick = onLongClick,
modifier = Modifier.padding(padding),
@@ -207,7 +213,7 @@ fun HomeListScreen(
HomeSection.Decades -> {
PlainList(
items = decades,
label = { "${it}s" },
label = { stringResource(R.string.home_decade, it) },
onClick = onDecadeClick,
modifier = Modifier.padding(padding),
)
@@ -217,7 +223,7 @@ fun HomeListScreen(
ArtistsList(
artists = artists,
coverArt = coverArt,
info = { artistListInfo(request, it, now) },
info = { artistListInfo(request, it, now, resources) },
onArtistClick = onArtistClick,
onLongClick = onLongClick,
modifier = Modifier.padding(padding),
@@ -254,7 +260,7 @@ fun HomeListScreen(
AlbumsList(
albums = albums,
coverArt = coverArt,
info = { albumListInfo(request, it, now) },
info = { albumListInfo(request, it, now, resources) },
onAlbumClick = onAlbumClick,
onLongClick = onLongClick,
modifier = Modifier.padding(padding),
@@ -269,7 +275,7 @@ fun HomeListScreen(
@Composable
private fun <T> PlainList(
items: List<T>,
label: (T) -> String,
label: @Composable (T) -> String,
onClick: (T) -> Unit,
modifier: Modifier = Modifier,
) {
@@ -470,7 +476,7 @@ private fun ArtistListRow(
)
Row(verticalAlignment = Alignment.CenterVertically) {
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,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
@@ -494,10 +500,11 @@ internal fun artistListInfo(
request: HomeListRequest,
artist: Artist,
now: Long,
resources: Resources,
): String? =
when (request.section) {
HomeSection.RecentlyPlayedArtists -> artist.played?.let { relativeTimeLabel(it, now) }
HomeSection.MostPlayedArtists -> playCountLabel(artist.playCount)
HomeSection.RecentlyPlayedArtists -> artist.played?.let { relativeTimeLabel(it, now, resources) }
HomeSection.MostPlayedArtists -> playCountLabel(resources, artist.playCount)
else -> null
}
@@ -505,18 +512,19 @@ internal fun albumListInfo(
request: HomeListRequest,
album: Album,
now: Long,
resources: Resources,
): String? =
when {
request.section == HomeSection.RecentlyAddedAlbums -> {
relativeTimeLabel(album.created, now)
relativeTimeLabel(album.created, now, resources)
}
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 -> {
playCountLabel(album.playCount)
playCountLabel(resources, album.playCount)
}
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(
epochSeconds: Long,
nowMillis: Long,
resources: Resources,
): String? {
if (epochSeconds <= 0L) return null
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
return when {
elapsedMillis < 60_000L -> "Just now"
days == 0L -> "Today"
days == 1L -> "Yesterday"
days < 7L -> "$days days ago"
days < 14L -> "Last week"
days < 30L -> "${days / 7L} weeks ago"
days < 60L -> "Last month"
days < 365L -> "${days / 30L} months ago"
days < 730L -> "Last year"
else -> "${days / 365L} years ago"
elapsedMillis < 60_000L -> resources.getString(R.string.home_time_just_now)
days == 0L -> resources.getString(R.string.home_time_today)
days == 1L -> resources.getString(R.string.home_time_yesterday)
days < 7L -> resources.getQuantityString(R.plurals.home_time_days_ago, days.toInt(), days)
days < 14L -> resources.getString(R.string.home_time_last_week)
days < 30L -> resources.getQuantityString(R.plurals.home_time_weeks_ago, (days / 7L).toInt(), days / 7L)
days < 60L -> resources.getString(R.string.home_time_last_month)
days < 365L -> resources.getQuantityString(R.plurals.home_time_months_ago, (days / 30L).toInt(), days / 30L)
days < 730L -> resources.getString(R.string.home_time_last_year)
else -> resources.getQuantityString(R.plurals.home_time_years_ago, (days / 365L).toInt(), days / 365L)
}
}
@@ -1,9 +1,11 @@
package com.subtracks.ui.home
import androidx.annotation.StringRes
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.PagingData
import androidx.paging.cachedIn
import com.subtracks.R
import com.subtracks.data.model.Album
import com.subtracks.data.model.AlbumSongItem
import com.subtracks.data.model.Artist
@@ -32,12 +34,12 @@ data class HomeListRequest(
)
enum class OfflineListKind(
val title: String,
@param:StringRes val title: Int,
) {
Albums("Downloaded albums"),
Artists("Downloaded artists"),
Songs("Downloaded songs"),
Playlists("Downloaded playlists"),
Albums(R.string.home_downloaded_albums),
Artists(R.string.home_downloaded_artists),
Songs(R.string.home_downloaded_songs),
Playlists(R.string.home_downloaded_playlists),
}
@OptIn(ExperimentalCoroutinesApi::class)
@@ -1,5 +1,6 @@
package com.subtracks.ui.home
import androidx.annotation.StringRes
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.horizontalScroll
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.graphics.vector.ImageVector
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
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
enum class HomeSection(
val title: String,
@param:StringRes val title: Int,
) {
RecentlyPlayedAlbums("Recently played"),
RecentlyPlayedArtists("Recently played artists"),
MostPlayedAlbums("Most played"),
MostPlayedArtists("Most played artists"),
Genres("Genres"),
Decades("Decades"),
RecentlyStarredSongs("Recently starred"),
RecentlyAddedAlbums("Recently added"),
Rediscover("Rediscover"),
RecentlyPlayedAlbums(R.string.resources_sort_by_recently_played),
RecentlyPlayedArtists(R.string.home_section_recently_played_artists),
MostPlayedAlbums(R.string.home_section_most_played),
MostPlayedArtists(R.string.home_section_most_played_artists),
Genres(R.string.home_section_genres),
Decades(R.string.home_section_decades),
RecentlyStarredSongs(R.string.home_section_recently_starred),
RecentlyAddedAlbums(R.string.home_section_recently_added),
Rediscover(R.string.home_section_rediscover),
;
companion object {
@@ -189,9 +191,9 @@ fun HomeScreen(
}
if (feed.isEmpty()) {
EmptyState(
text = "Nothing to show yet.\nSync with your server to fill your library.",
text = stringResource(R.string.home_empty),
modifier = modifier,
actionLabel = "Sync",
actionLabel = stringResource(R.string.sync),
onAction = onSync,
)
return@CompositionLocalProvider
@@ -314,7 +316,7 @@ fun OfflineHomeScreen(
downloadedPlaylistRow(playlists, coverArt, onPlaylistClick, onLongClick) { onMore(OfflineListKind.Playlists) }
songList(
key = "downloaded-songs",
title = "Downloaded songs",
title = R.string.home_downloaded_songs,
icon = Icons.Rounded.MusicNote,
songs = songs,
coverArt = coverArt,
@@ -336,7 +338,7 @@ private fun LazyListScope.downloadedAlbumRow(
) {
if (albums.itemCount == 0) return
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") {
LazyRow(
@@ -369,7 +371,7 @@ private fun LazyListScope.downloadedArtistRow(
) {
if (artists.itemCount == 0) return
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") {
LazyRow(
@@ -402,7 +404,7 @@ private fun LazyListScope.downloadedPlaylistRow(
) {
if (playlists.itemCount == 0) return
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") {
LazyRow(
@@ -464,7 +466,7 @@ private fun HomeFeed.isEmpty(): Boolean =
@Composable
private fun HomeSectionHeader(
title: String,
@StringRes title: Int,
icon: ImageVector,
onMore: (() -> Unit)?,
) {
@@ -480,13 +482,13 @@ private fun HomeSectionHeader(
)
Spacer(Modifier.width(8.dp))
Text(
text = title,
text = stringResource(title),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f),
)
if (onMore != null) {
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),
) {
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(
key: String,
title: String,
@StringRes title: Int,
icon: ImageVector,
songs: List<AlbumSongItem>,
coverArt: (String?, Boolean) -> CoverArtRef?,
@@ -20,10 +20,12 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.subtracks.R
import com.subtracks.data.model.Album
import com.subtracks.data.model.BulkDownloadAction
import com.subtracks.data.model.CoverArtRef
@@ -158,8 +160,9 @@ fun AlbumDetailScreen(
modifier: Modifier = Modifier,
) {
val multiDisc = songs.map { it.disc ?: 1L }.distinct().size > 1
val resources = LocalResources.current
val discLabels =
remember(discs) {
remember(discs, resources) {
val repeated =
discs
.map { it.title }
@@ -171,7 +174,7 @@ fun AlbumDetailScreen(
discs.associate { disc ->
disc.disc to
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]
if (disc != lastDisc && (multiDisc || discLabel != 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
item(key = song.id) {
@@ -20,11 +20,13 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
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.paging.LoadState
import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.itemKey
import com.subtracks.R
import com.subtracks.data.model.Album
import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.ListDownloadStatus
@@ -65,9 +67,9 @@ fun AlbumsContent(
FilteredEmptyState(onClearFilters, modifier)
} else {
EmptyState(
text = "No albums yet.\nSync with your server to fill your library.",
text = stringResource(R.string.albums_empty),
modifier = modifier,
actionLabel = "Sync",
actionLabel = stringResource(R.string.sync),
onAction = onSync,
)
}
@@ -67,6 +67,8 @@ import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
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.TextMeasurer
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.lifecycle.compose.collectAsStateWithLifecycle
import com.subtracks.R
import com.subtracks.data.model.Album
import com.subtracks.data.model.Artist
import com.subtracks.data.model.BulkDownloadAction
@@ -346,7 +349,7 @@ fun ArtistDetailScreen(
) {
Icon(
imageVector = Icons.Rounded.Shuffle,
contentDescription = "Shuffle artist",
contentDescription = stringResource(R.string.shuffle_artist),
modifier = Modifier.size(SHUFFLE_ICON),
)
}
@@ -357,7 +360,7 @@ fun ArtistDetailScreen(
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "$albumCount ${if (albumCount == 1L) "album" else "albums"}",
text = pluralStringResource(R.plurals.resources_album_count, albumCount.toInt(), albumCount),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
@@ -368,7 +371,10 @@ fun ArtistDetailScreen(
) {
Icon(
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 =
if (starred) {
MaterialTheme.colorScheme.primary
@@ -378,7 +384,7 @@ fun ArtistDetailScreen(
)
}
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 = {
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),
@@ -17,12 +17,15 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
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.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.paging.LoadState
import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.itemKey
import com.subtracks.R
import com.subtracks.data.model.Artist
import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.ListDownloadStatus
@@ -60,7 +63,7 @@ fun ArtistsContent(
if (filtered) {
FilteredEmptyState(onClearFilters, modifier)
} 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 = {
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,
overflow = TextOverflow.Ellipsis,
)
@@ -1,5 +1,6 @@
package com.subtracks.ui.library
import androidx.annotation.StringRes
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Canvas
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.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
@@ -103,6 +105,7 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.paging.PagingData
import androidx.paging.compose.collectAsLazyPagingItems
import com.subtracks.R
import com.subtracks.data.model.Album
import com.subtracks.data.model.Artist
import com.subtracks.data.model.BulkDownloadAction
@@ -140,18 +143,18 @@ import kotlin.math.abs
import kotlin.math.roundToInt
enum class LibraryTab(
val label: String,
@param:StringRes val label: Int,
val icon: ImageVector,
) {
Home("Home", Icons.Rounded.Home),
Albums("Albums", Icons.Rounded.Album),
Artists("Artists", Icons.Rounded.Person),
Playlists("Playlists", Icons.AutoMirrored.Rounded.PlaylistPlay),
Home(R.string.navigation_tabs_home, Icons.Rounded.Home),
Albums(R.string.resources_album_name, Icons.Rounded.Album),
Artists(R.string.resources_artist_name, Icons.Rounded.Person),
Playlists(R.string.resources_playlist_name, Icons.AutoMirrored.Rounded.PlaylistPlay),
}
data class SortOption(
val value: String,
val label: String,
@param:StringRes val label: Int,
val descendingByDefault: Boolean = false,
)
@@ -166,31 +169,47 @@ fun sortOptionsFor(
when (tab) {
LibraryTab.Albums -> {
buildList {
add(SortOption(AlbumSort.Name.name, "Name"))
add(SortOption(AlbumSort.Artist.name, "Artist"))
add(SortOption(AlbumSort.Year.name, "Year", descendingByDefault = true))
add(SortOption(AlbumSort.Added.name, "Added", descendingByDefault = true))
add(SortOption(AlbumSort.Starred.name, "Starred", descendingByDefault = true))
if (play.frequent) add(SortOption(AlbumSort.Frequent.name, "Frequently played", descendingByDefault = true))
if (play.recent) add(SortOption(AlbumSort.Recent.name, "Recently played", descendingByDefault = true))
add(SortOption(AlbumSort.Name.name, R.string.resources_sort_by_name))
add(SortOption(AlbumSort.Artist.name, R.string.resources_sort_by_artist))
add(SortOption(AlbumSort.Year.name, R.string.resources_sort_by_year, descendingByDefault = true))
add(SortOption(AlbumSort.Added.name, R.string.resources_sort_by_added, descendingByDefault = true))
add(SortOption(AlbumSort.Starred.name, R.string.resources_filter_starred, descendingByDefault = true))
if (play.frequent) {
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 -> {
buildList {
add(SortOption(ArtistSort.Name.name, "Name"))
add(SortOption(ArtistSort.AlbumCount.name, "Albums", descendingByDefault = true))
add(SortOption(ArtistSort.Starred.name, "Starred", descendingByDefault = true))
if (play.frequent) add(SortOption(ArtistSort.Frequent.name, "Frequently played", descendingByDefault = true))
if (play.recent) add(SortOption(ArtistSort.Recent.name, "Recently played", descendingByDefault = true))
add(SortOption(ArtistSort.Name.name, R.string.resources_sort_by_name))
add(SortOption(ArtistSort.AlbumCount.name, R.string.resources_album_name, descendingByDefault = true))
add(SortOption(ArtistSort.Starred.name, R.string.resources_filter_starred, descendingByDefault = true))
if (play.frequent) {
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 -> {
listOf(
SortOption(PlaylistSort.Name.name, "Name"),
SortOption(PlaylistSort.Added.name, "Added", descendingByDefault = true),
SortOption(PlaylistSort.Updated.name, "Updated", descendingByDefault = true),
SortOption(PlaylistSort.Name.name, R.string.resources_sort_by_name),
SortOption(PlaylistSort.Added.name, R.string.resources_sort_by_added, descendingByDefault = true),
SortOption(PlaylistSort.Updated.name, R.string.resources_sort_by_updated, descendingByDefault = true),
)
}
@@ -428,6 +447,7 @@ fun LibraryScreen(
val listTopInset = statusBarTop
val listBottomInset = tabBarHeight + BOTTOM_CLEARANCE
val headerColor = artwork?.let(::playerSurfaceColor) ?: MaterialTheme.colorScheme.background
val listOptionsLabel = stringResource(R.string.list_options)
Box(
modifier =
@@ -581,7 +601,7 @@ fun LibraryScreen(
modifier =
Modifier
.size(24.dp)
.semantics { contentDescription = "List options" },
.semantics { contentDescription = listOptionsLabel },
)
if (filtersActive) {
Box(
@@ -654,7 +674,7 @@ private fun ListOptionsSheet(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
) {
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),
) {
Text(
text = "Sort by",
text = stringResource(R.string.sort_by),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f),
)
IconButton(onClick = onToggleSortDirection) {
Icon(
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 = {
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),
) {
Text(
text = "Filters",
text = stringResource(R.string.filters),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f),
)
@@ -703,7 +730,7 @@ private fun ListOptionsSheet(
onClick = onClearFilters,
enabled = canClearFilters,
) {
Icon(Icons.Rounded.FilterAltOff, contentDescription = "Clear filters")
Icon(Icons.Rounded.FilterAltOff, contentDescription = stringResource(R.string.components_clear_filters))
}
}
FlowRow(
@@ -713,7 +740,7 @@ private fun ListOptionsSheet(
FilterChip(
selected = listQuery.downloaded,
onClick = onToggleDownloaded,
label = { Text("Downloaded") },
label = { Text(stringResource(R.string.filter_downloaded)) },
leadingIcon =
if (listQuery.downloaded) {
{
@@ -731,7 +758,7 @@ private fun ListOptionsSheet(
FilterChip(
selected = listQuery.starred != StarredFilter.Any,
onClick = onCycleStarred,
label = { Text("Starred") },
label = { Text(stringResource(R.string.resources_filter_starred)) },
leadingIcon =
when (listQuery.starred) {
StarredFilter.Any -> {
@@ -785,11 +812,11 @@ private fun SearchField(
value = value,
onValueChange = onValueChange,
singleLine = true,
placeholder = { Text("Search") },
placeholder = { Text(stringResource(R.string.search_input_placeholder)) },
leadingIcon = { Icon(Icons.Rounded.Search, contentDescription = null) },
trailingIcon = {
IconButton(onClick = onClose) {
Icon(Icons.Rounded.Close, contentDescription = "Close search")
Icon(Icons.Rounded.Close, contentDescription = stringResource(R.string.close_search))
}
},
shape = RoundedCornerShape(4.dp),
@@ -917,7 +944,7 @@ internal fun LibraryTabs(
IconButton(onClick = onExitOffline) {
Icon(
imageVector = Icons.Rounded.CloudOff,
contentDescription = "Offline mode; tap to go online",
contentDescription = stringResource(R.string.offline_mode_status),
tint = MaterialTheme.colorScheme.onBackground,
)
}
@@ -925,7 +952,7 @@ internal fun LibraryTabs(
IconButton(onClick = onOpenSettings) {
Icon(
imageVector = Icons.Rounded.Settings,
contentDescription = "Settings",
contentDescription = stringResource(R.string.navigation_tabs_settings),
tint = MaterialTheme.colorScheme.onBackground,
)
}
@@ -1005,7 +1032,7 @@ private fun TabContent(
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = tab.icon,
contentDescription = tab.label,
contentDescription = stringResource(tab.label),
tint = color,
modifier =
Modifier.size(TAB_ICON_SIZE).graphicsLayer {
@@ -1032,7 +1059,7 @@ private fun TabContent(
Row(verticalAlignment = Alignment.CenterVertically) {
Spacer(Modifier.width(8.dp))
Text(
text = tab.label,
text = stringResource(tab.label),
style = MaterialTheme.typography.titleSmall,
color = color,
maxLines = 1,
@@ -1,5 +1,6 @@
package com.subtracks.ui.library
import android.content.Context
import androidx.compose.foundation.combinedClickable
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -9,10 +10,12 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.collectAsLazyPagingItems
import androidx.paging.compose.itemKey
import com.subtracks.R
import com.subtracks.data.model.BulkDownloadAction
import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.ListDownloadStatus
@@ -127,6 +130,7 @@ fun PlaylistDetailScreen(
playingSongId: String? = null,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
HeroDetailScaffold(
artwork = artwork,
title = playlist?.name.orEmpty(),
@@ -136,7 +140,7 @@ fun PlaylistDetailScreen(
art = coverArt(playlist?.coverArt, false),
thumbnailRef = coverArt(playlist?.coverArt, true),
name = playlist?.name.orEmpty(),
subtitle = playlistSummary(playlist, offline, downloadStatus.downloaded),
subtitle = playlistSummary(context, playlist, offline, downloadStatus.downloaded),
comment = playlist?.comment,
hasSongs = songs.itemCount > 0,
onPlay = onPlay,
@@ -175,6 +179,7 @@ fun PlaylistDetailScreen(
}
private fun playlistSummary(
context: Context,
playlist: Playlist?,
offline: Boolean,
downloadedSongs: Long,
@@ -182,19 +187,22 @@ private fun playlistSummary(
playlist ?: return ""
val songs = if (offline) downloadedSongs else playlist.songCount
return listOfNotNull(
"$songs ${if (songs == 1L) "song" else "songs"}",
formatDuration(playlist.duration),
context.resources.getQuantityString(R.plurals.resources_song_count, songs.toInt(), songs),
formatDuration(context, playlist.duration),
).joinToString(" $DOT ")
}
private fun formatDuration(seconds: Long): String? {
private fun formatDuration(
context: Context,
seconds: Long,
): String? {
if (seconds < 60) return null
val totalMinutes = seconds / 60
val hours = totalMinutes / 60
val minutes = totalMinutes % 60
return when {
hours == 0L -> "$totalMinutes min"
minutes == 0L -> "$hours hr"
else -> "$hours hr $minutes min"
hours == 0L -> context.getString(R.string.playlist_duration_minutes, totalMinutes)
minutes == 0L -> context.getString(R.string.playlist_duration_hours, hours)
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.draw.clip
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.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.paging.LoadState
import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.itemKey
import com.subtracks.R
import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.ListDownloadStatus
import com.subtracks.data.model.Playlist
@@ -59,7 +62,12 @@ fun PlaylistsContent(
if (filtered) {
FilteredEmptyState(onClearFilters, modifier)
} 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 =
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,
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.VectorPath
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
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.unit.dp
import androidx.compose.ui.unit.sp
import com.subtracks.R
import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.DownloadStatus
import com.subtracks.data.model.Song
@@ -224,9 +226,10 @@ private fun PlayIndicator(
modifier: Modifier = Modifier,
) {
val tint = MaterialTheme.colorScheme.primary
val playingDescription = stringResource(R.string.status_playing)
if (shadow) {
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)
withTransform({ scale(scale, scale, pivot = Offset.Zero) }) {
drawIntoCanvas { canvas ->
@@ -257,7 +260,7 @@ private fun PlayIndicator(
} else {
Icon(
imageVector = PLAY_ARROW,
contentDescription = "Playing",
contentDescription = playingDescription,
tint = tint,
modifier = modifier,
)
@@ -325,11 +328,13 @@ internal fun formatTrackTime(seconds: Long): String {
@Composable
private fun DownloadBadge(download: SongDownload?) {
val downloadedDescription = stringResource(R.string.status_downloaded)
val downloadingDescription = stringResource(R.string.status_downloading)
when (download?.status) {
DownloadStatus.Completed -> {
Icon(
imageVector = Icons.Rounded.DownloadDone,
contentDescription = "Downloaded",
contentDescription = downloadedDescription,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(16.dp),
)
@@ -339,13 +344,13 @@ private fun DownloadBadge(download: SongDownload?) {
val progress = download.progress
if (progress == null) {
CircularProgressIndicator(
modifier = Modifier.size(14.dp).semantics { contentDescription = "Downloading" },
modifier = Modifier.size(14.dp).semantics { contentDescription = downloadingDescription },
strokeWidth = 2.dp,
)
} else {
CircularProgressIndicator(
progress = { progress },
modifier = Modifier.size(14.dp).semantics { contentDescription = "Downloading" },
modifier = Modifier.size(14.dp).semantics { contentDescription = downloadingDescription },
strokeWidth = 2.dp,
)
}
@@ -38,7 +38,9 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.subtracks.R
import com.subtracks.data.model.CoverArtRef
import com.subtracks.playback.PlaybackState
import com.subtracks.ui.components.CoverArt
@@ -155,7 +157,14 @@ fun MiniPlayer(
} else {
Icon(
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,
modifier = Modifier.size(36.dp),
)
@@ -164,7 +173,7 @@ fun MiniPlayer(
IconButton(onClick = onNext, enabled = state.hasNext, modifier = Modifier.size(48.dp)) {
Icon(
imageVector = Icons.Rounded.SkipNext,
contentDescription = "Next",
contentDescription = stringResource(R.string.controls_next),
tint = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.size(36.dp),
)
@@ -1,5 +1,6 @@
package com.subtracks.ui.playback
import androidx.annotation.StringRes
import androidx.compose.animation.core.animate
import androidx.compose.animation.core.tween
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.platform.LocalDensity
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.PlatformTextStyle
import androidx.compose.ui.text.font.FontWeight
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.lifecycle.compose.collectAsStateWithLifecycle
import coil3.compose.LocalPlatformContext
import com.subtracks.R
import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.QueueKind
import com.subtracks.data.model.Song
@@ -217,7 +220,7 @@ fun NowPlayingRoute(
val art = controller.coverArt(state.item)
val thumbnail = controller.coverArt(state.item, thumbnail = true)
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) }
LaunchedEffect(queueContext) {
sourceTitle.value = controller.sourceTitle(queueContext)?.takeIf { it.isNotBlank() } ?: fallbackTitle
@@ -355,6 +358,7 @@ fun NowPlayingScreen(
frozenStrip = null
}
}
val kindLabel = stringResource(state.context?.kind?.label() ?: R.string.context_kind_library)
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onBackground) {
ArtworkTheme(artwork) {
@@ -372,7 +376,7 @@ fun NowPlayingScreen(
title = {
Column {
Text(
text = "Now playing: ${state.context?.kind?.label() ?: "library"}".uppercase(),
text = stringResource(R.string.now_playing_header, kindLabel).uppercase(),
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold,
letterSpacing = 1.sp,
@@ -388,7 +392,10 @@ fun NowPlayingScreen(
},
navigationIcon = {
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),
@@ -459,7 +466,7 @@ fun NowPlayingScreen(
interactionSource = remember { MutableInteractionSource() },
indication = null,
enabled = onAlbumClick != null,
onClickLabel = "Open album",
onClickLabel = stringResource(R.string.open_album),
) { onAlbumClick?.invoke() }
.testTag(NOW_PLAYING_COVER_TAG),
contentAlignment = Alignment.Center,
@@ -518,7 +525,7 @@ fun NowPlayingScreen(
interactionSource = remember { MutableInteractionSource() },
indication = null,
enabled = onArtistClick != null,
onClickLabel = "Open artist",
onClickLabel = stringResource(R.string.open_artist),
) { onArtistClick?.invoke() },
) {
Box(Modifier.height(titleHeight), contentAlignment = Alignment.CenterStart) {
@@ -542,7 +549,7 @@ fun NowPlayingScreen(
IconButton(onClick = { onToggleStar?.invoke() }, enabled = onToggleStar != null) {
Icon(
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 =
if (starred) {
MaterialTheme.colorScheme.primary
@@ -590,11 +597,13 @@ fun NowPlayingScreen(
RepeatMode.One -> Icons.Rounded.RepeatOneOn
},
contentDescription =
when (state.repeat) {
RepeatMode.Off -> "Repeat off"
RepeatMode.All -> "Repeat all"
RepeatMode.One -> "Repeat one"
},
stringResource(
when (state.repeat) {
RepeatMode.Off -> R.string.repeat_off
RepeatMode.All -> R.string.repeat_all
RepeatMode.One -> R.string.repeat_one
},
),
tint =
if (state.repeat == RepeatMode.Off) {
MaterialTheme.colorScheme.onSurfaceVariant
@@ -607,7 +616,7 @@ fun NowPlayingScreen(
IconButton(onClick = onPrevious, modifier = Modifier.size(72.dp)) {
Icon(
imageVector = Icons.Rounded.SkipPrevious,
contentDescription = "Previous",
contentDescription = stringResource(R.string.controls_previous),
modifier = Modifier.size(60.dp),
)
}
@@ -629,7 +638,14 @@ fun NowPlayingScreen(
} else {
Icon(
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),
)
}
@@ -637,14 +653,14 @@ fun NowPlayingScreen(
IconButton(onClick = onNext, modifier = Modifier.size(72.dp)) {
Icon(
imageVector = Icons.Rounded.SkipNext,
contentDescription = "Next",
contentDescription = stringResource(R.string.controls_next),
modifier = Modifier.size(60.dp),
)
}
IconButton(onClick = onShuffle, modifier = Modifier.size(48.dp)) {
Icon(
imageVector = if (state.shuffle) Icons.Rounded.ShuffleOn else Icons.Rounded.Shuffle,
contentDescription = "Shuffle",
contentDescription = stringResource(R.string.controls_shuffle),
tint =
if (state.shuffle) {
MaterialTheme.colorScheme.onBackground
@@ -663,14 +679,14 @@ fun NowPlayingScreen(
IconButton(onClick = onQueue, modifier = Modifier.size(40.dp)) {
Icon(
imageVector = Icons.AutoMirrored.Rounded.QueueMusic,
contentDescription = "Queue",
contentDescription = stringResource(R.string.controls_queue),
modifier = Modifier.size(30.dp),
)
}
IconButton(onClick = onMore, modifier = Modifier.size(40.dp)) {
Icon(
imageVector = Icons.Rounded.MoreHoriz,
contentDescription = "More",
contentDescription = stringResource(R.string.controls_more),
modifier = Modifier.size(30.dp),
)
}
@@ -682,15 +698,16 @@ fun NowPlayingScreen(
}
}
private fun QueueKind.label(): String =
@StringRes
private fun QueueKind.label(): Int =
when (this) {
QueueKind.Album -> "album"
QueueKind.Playlist -> "playlist"
QueueKind.Song -> "song"
QueueKind.Artist -> "artist"
QueueKind.Genre -> "genre"
QueueKind.Downloaded -> "downloads"
QueueKind.Starred -> "starred"
QueueKind.Album -> R.string.context_kind_album
QueueKind.Playlist -> R.string.context_kind_playlist
QueueKind.Song -> R.string.context_kind_song
QueueKind.Artist -> R.string.context_kind_artist
QueueKind.Genre -> R.string.context_kind_genre
QueueKind.Downloaded -> R.string.context_kind_downloads
QueueKind.Starred -> R.string.context_kind_starred
}
private fun formatTime(milliseconds: Long): String {
@@ -42,10 +42,12 @@ import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope
import com.subtracks.R
import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.SongItem
import com.subtracks.data.repo.DownloadRepository
@@ -286,7 +288,7 @@ fun QueueRoute(
val queueContext = playback.context
// 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.
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) }
LaunchedEffect(queueContext) {
sourceTitle.value = controller.sourceTitle(queueContext)?.takeIf { it.isNotBlank() } ?: fallbackTitle
@@ -345,6 +347,9 @@ fun QueueScreen(
val scope = rememberCoroutineScope()
var dragId 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
// 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 ->
scope.launch {
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()
}
}
@@ -392,10 +397,10 @@ fun QueueScreen(
modifier = modifier,
topBar = {
TopAppBar(
title = { Text("Queue") },
title = { Text(stringResource(R.string.queue_title)) },
navigationIcon = {
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() -> {
EmptyState("The queue is empty.", Modifier.padding(padding))
EmptyState(stringResource(R.string.queue_empty), Modifier.padding(padding))
}
else -> {
@@ -424,7 +429,13 @@ fun QueueScreen(
// block, so a section-based key would duplicate.
item(key = "header-$index") {
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,
)
}
@@ -475,7 +486,7 @@ fun QueueScreen(
}
if (to != from) {
onMove(from, to)
showUndo("Queue reordered")
showUndo(queueReordered)
}
}
dragId = null
@@ -486,7 +497,7 @@ fun QueueScreen(
onClick = { onPlay(row.position) },
onRemove = {
onRemove(row.position)
showUndo("Removed from queue")
showUndo(queueRemoved)
},
)
}
@@ -516,7 +527,11 @@ private fun QueueSectionHeader(
)
if (onClear != null) {
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) {
Icon(
imageVector = Icons.Rounded.Close,
contentDescription = "Remove from queue",
contentDescription = stringResource(R.string.queue_remove),
modifier = Modifier.size(20.dp),
)
}
if (dragHandle != null) {
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.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.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.subtracks.R
import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf
@@ -98,11 +100,22 @@ fun AddSourceScreen(
modifier = modifier,
topBar = {
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 = {
if (onBack != null) {
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,
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)
}
OutlinedButton(onClick = onTest, enabled = !state.busy) {
Text("Test connection")
Text(stringResource(R.string.settings_servers_actions_test_connection))
}
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(
if (state.isEditing) {
"Update this server's connection details."
stringResource(R.string.edit_server_intro)
} else {
"Connect a Subsonic-compatible server such as Navidrome, gonic or Airsonic."
stringResource(R.string.add_server_intro)
},
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (state.isEditing && !state.canDelete) {
Text(
"This is the active server. Switch to another server before deleting it.",
stringResource(R.string.active_server_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -181,34 +194,34 @@ fun AddSourceScreen(
OutlinedTextField(
value = state.name,
onValueChange = onNameChange,
label = { Text("Name") },
label = { Text(stringResource(R.string.settings_servers_fields_name)) },
singleLine = true,
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),
)
OutlinedTextField(
value = state.address,
onValueChange = onAddressChange,
label = { Text("Server address") },
placeholder = { Text("https://music.example.com") },
label = { Text(stringResource(R.string.settings_servers_fields_address)) },
placeholder = { Text(stringResource(R.string.server_address_placeholder)) },
singleLine = true,
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),
modifier = Modifier.fillMaxWidth().focusRequester(addressFocus),
)
OutlinedTextField(
value = state.username,
onValueChange = onUsernameChange,
label = { Text("Username") },
label = { Text(stringResource(R.string.settings_servers_fields_username)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = state.password,
onValueChange = onPasswordChange,
label = { Text("Password") },
label = { Text(stringResource(R.string.settings_servers_fields_password)) },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth(),
@@ -219,9 +232,9 @@ fun AddSourceScreen(
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
Column(Modifier.weight(1f)) {
Text("Token authentication")
Text(stringResource(R.string.token_authentication))
Text(
"Recommended. Disable only for servers that reject the salted token scheme.",
stringResource(R.string.token_authentication_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -243,18 +256,18 @@ fun AddSourceScreen(
if (confirmingDelete) {
AlertDialog(
onDismissRequest = { confirmingDelete = false },
title = { Text("Delete server?") },
text = { Text("${state.name} and its downloaded songs will be removed from this device.") },
title = { Text(stringResource(R.string.delete_server_title)) },
text = { Text(stringResource(R.string.delete_server_body, state.name)) },
confirmButton = {
TextButton(
onClick = {
confirmingDelete = false
onDelete()
},
) { Text("Delete") }
) { Text(stringResource(R.string.actions_delete)) }
},
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
import android.content.res.Resources
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.subtracks.R
import com.subtracks.data.repo.DownloadRepository
import com.subtracks.data.repo.SourceRepository
import com.subtracks.data.sync.SyncManager
@@ -38,6 +40,7 @@ class AddSourceViewModel(
private val syncManager: SyncManager,
private val downloadRepository: DownloadRepository,
private val sourceId: Long? = null,
private val resources: Resources,
) : ViewModel() {
private val _state = MutableStateFlow(AddSourceState(isEditing = sourceId != null))
val state: StateFlow<AddSourceState> = _state
@@ -87,8 +90,16 @@ class AddSourceViewModel(
useTokenAuth = if (fellBack) false else state.useTokenAuth,
message =
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,
)
@@ -133,7 +144,7 @@ class AddSourceViewModel(
},
onFailure = { error ->
_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 {
if (!sourceRepository.deleteSource(id)) {
_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
}
@@ -13,6 +13,7 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import com.mikepenz.aboutlibraries.Libs
import com.mikepenz.aboutlibraries.ui.compose.android.produceLibraries
import com.mikepenz.aboutlibraries.ui.compose.m3.LibrariesContainer
@@ -38,10 +39,10 @@ fun LicensesScreen(
modifier = modifier,
topBar = {
TopAppBar(
title = { Text("Licenses") },
title = { Text(stringResource(R.string.settings_about_actions_licenses)) },
navigationIcon = {
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
import android.content.res.Resources
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@@ -47,9 +48,12 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
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.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.subtracks.R
import com.subtracks.data.model.Source
import com.subtracks.data.prefs.StreamQuality
import com.subtracks.ui.components.rememberViewportFill
@@ -139,10 +143,10 @@ fun SettingsScreen(
modifier = modifier,
topBar = {
TopAppBar(
title = { Text("Settings") },
title = { Text(stringResource(R.string.navigation_tabs_settings)) },
navigationIcon = {
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,
modifier = Modifier.padding(padding).fillMaxSize(),
) {
item { SectionHeader("Servers") }
item { SectionHeader(stringResource(R.string.settings_servers_name)) }
items(sources.size, key = { sources[it].id }) { index ->
val source = sources[index]
ListItem(
@@ -168,10 +172,13 @@ fun SettingsScreen(
},
trailingContent = {
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),
)
}
@@ -183,16 +190,22 @@ fun SettingsScreen(
Button(onClick = onAddServer) {
Icon(Icons.Rounded.Add, contentDescription = null, modifier = Modifier.size(18.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 {
ListItem(
headlineContent = { Text("Offline mode") },
headlineContent = { Text(stringResource(R.string.settings_network_options_offline_mode)) },
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 = {
Switch(checked = offline, onCheckedChange = onOfflineChange)
@@ -203,7 +216,7 @@ fun SettingsScreen(
}
item {
ListItem(
headlineContent = { Text("Stream quality: Wi-Fi") },
headlineContent = { Text(stringResource(R.string.settings_network_options_max_bitrate_wifi_title)) },
supportingContent = { Text(qualityLabel(wifiQuality)) },
modifier = Modifier.clickable { dialog = SettingsDialog.WifiQuality },
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
@@ -211,7 +224,7 @@ fun SettingsScreen(
}
item {
ListItem(
headlineContent = { Text("Stream quality: Mobile") },
headlineContent = { Text(stringResource(R.string.settings_network_options_max_bitrate_mobile_title)) },
supportingContent = { Text(qualityLabel(mobileQuality)) },
modifier = Modifier.clickable { dialog = SettingsDialog.MobileQuality },
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
@@ -219,17 +232,17 @@ fun SettingsScreen(
}
item {
ListItem(
headlineContent = { Text("Sync concurrency") },
supportingContent = { Text(concurrencyLabel(syncConcurrency)) },
headlineContent = { Text(stringResource(R.string.sync_concurrency)) },
supportingContent = { Text(concurrencyLabel(LocalResources.current, syncConcurrency)) },
modifier = Modifier.clickable { dialog = SettingsDialog.SyncConcurrency },
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
item { SectionHeader("Playback") }
item { SectionHeader(stringResource(R.string.settings_music_name)) }
item {
ListItem(
headlineContent = { Text("Scrobble to server") },
supportingContent = { Text("Send now playing and play counts") },
headlineContent = { Text(stringResource(R.string.settings_music_options_scrobble_title)) },
supportingContent = { Text(stringResource(R.string.settings_music_options_scrobble_description_on)) },
trailingContent = {
Switch(
checked = scrobbling,
@@ -246,10 +259,10 @@ fun SettingsScreen(
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
item { SectionHeader("Downloads") }
item { SectionHeader(stringResource(R.string.downloads_title)) }
item {
ListItem(
headlineContent = { Text("Download quality") },
headlineContent = { Text(stringResource(R.string.download_quality)) },
supportingContent = { Text(qualityLabel(downloadQuality)) },
modifier = Modifier.clickable { dialog = SettingsDialog.DownloadQuality },
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
@@ -257,8 +270,16 @@ fun SettingsScreen(
}
item {
ListItem(
headlineContent = { Text("Download over mobile data") },
supportingContent = { Text(if (downloadOverMetered) "Wi-Fi and mobile" else "Wi-Fi only") },
headlineContent = { Text(stringResource(R.string.download_over_mobile)) },
supportingContent = {
Text(
if (downloadOverMetered) {
stringResource(R.string.download_network_wifi_and_mobile)
} else {
stringResource(R.string.download_network_wifi_only)
},
)
},
trailingContent = {
Switch(
checked = downloadOverMetered,
@@ -277,16 +298,16 @@ fun SettingsScreen(
}
item {
ListItem(
headlineContent = { Text("Manage downloads") },
headlineContent = { Text(stringResource(R.string.manage_downloads)) },
leadingContent = { Icon(Icons.Rounded.Download, contentDescription = null) },
modifier = Modifier.clickable(onClick = onOpenDownloads),
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
item { SectionHeader("About") }
item { SectionHeader(stringResource(R.string.settings_about_name)) }
item {
ListItem(
headlineContent = { Text("Licenses") },
headlineContent = { Text(stringResource(R.string.settings_about_actions_licenses)) },
leadingContent = { Icon(Icons.Rounded.Description, contentDescription = null) },
modifier = Modifier.clickable(onClick = onOpenLicenses),
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
@@ -299,7 +320,7 @@ fun SettingsScreen(
when (dialog) {
SettingsDialog.WifiQuality -> {
QualityDialog(
title = "Stream quality: Wi-Fi",
title = stringResource(R.string.settings_network_options_max_bitrate_wifi_title),
quality = wifiQuality,
onSelect = onWifiQualityChange,
onDismiss = { dialog = null },
@@ -308,7 +329,7 @@ fun SettingsScreen(
SettingsDialog.MobileQuality -> {
QualityDialog(
title = "Stream quality: Mobile",
title = stringResource(R.string.settings_network_options_max_bitrate_mobile_title),
quality = mobileQuality,
onSelect = onMobileQualityChange,
onDismiss = { dialog = null },
@@ -317,7 +338,7 @@ fun SettingsScreen(
SettingsDialog.DownloadQuality -> {
QualityDialog(
title = "Download quality",
title = stringResource(R.string.download_quality),
quality = downloadQuality,
onSelect = onDownloadQualityChange,
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 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
private fun ConcurrencyDialog(
@@ -352,14 +389,15 @@ private fun ConcurrencyDialog(
onSelect: (Int) -> Unit,
onDismiss: () -> Unit,
) {
val resources = LocalResources.current
var draft by remember(selected) { mutableStateOf(selected) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Sync concurrency") },
title = { Text(stringResource(R.string.sync_concurrency)) },
text = {
ChoiceGroup(
header = "Parallel server requests while syncing",
options = syncConcurrencyOptions.map { it to concurrencyLabel(it) },
header = stringResource(R.string.sync_concurrency_parallel),
options = syncConcurrencyOptions.map { it to concurrencyLabel(resources, it) },
selected = draft,
onSelect = { draft = it },
)
@@ -370,10 +408,10 @@ private fun ConcurrencyDialog(
onSelect(draft)
onDismiss()
},
) { Text("Done") }
) { Text(stringResource(R.string.action_done)) }
},
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,
onDismiss: () -> Unit,
) {
val resources = LocalResources.current
var draft by remember(quality) { mutableStateOf(quality) }
val scrollState = rememberScrollState()
AlertDialog(
@@ -394,15 +433,19 @@ private fun QualityDialog(
Box {
Column(modifier = Modifier.verticalScroll(scrollState)) {
ChoiceGroup(
header = "Maximum bitrate",
options = bitrateOptions.map { it to bitrateLabel(it) },
header = stringResource(R.string.maximum_bitrate),
options = bitrateOptions.map { it to bitrateLabel(resources, it) },
selected = draft.maxBitrate,
onSelect = { draft = draft.copy(maxBitrate = it) },
)
Spacer(Modifier.height(16.dp))
ChoiceGroup(
header = "Preferred format",
options = streamFormats.map { it to (it ?: "Use server default") },
header = stringResource(R.string.settings_network_options_stream_format),
options =
streamFormats.map {
it to
(it ?: resources.getString(R.string.settings_network_options_stream_format_server_default))
},
selected = draft.format,
onSelect = { draft = draft.copy(format = it) },
)
@@ -429,10 +472,10 @@ private fun QualityDialog(
onSelect(draft)
onDismiss()
},
) { Text("Done") }
) { Text(stringResource(R.string.action_done)) }
},
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"?>
<resources>
<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="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>
@@ -1,5 +1,7 @@
package com.subtracks.data.download
import com.subtracks.data.model.DownloadError
class FakeDownloadEngine : DownloadEngine {
private val downloads = mutableMapOf<Long, EngineDownload>()
private var nextId = 1L
@@ -45,7 +47,7 @@ class FakeDownloadEngine : DownloadEngine {
}
fun fail(id: Long) {
downloads[id] = EngineDownload(EngineStatus.Failed, error = "Download failed")
downloads[id] = EngineDownload(EngineStatus.Failed, error = DownloadError.Failed)
}
fun forget(id: Long) {
@@ -4,6 +4,7 @@ import android.app.DownloadManager
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.data.model.DownloadError
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
@@ -40,10 +41,10 @@ class SystemDownloadEngineTest {
}
@Test
fun failureReasonsReadAsSentences() {
assertEquals("Not enough space to download", failureMessage(DownloadManager.ERROR_INSUFFICIENT_SPACE))
assertEquals("Storage is unavailable", failureMessage(DownloadManager.ERROR_DEVICE_NOT_FOUND))
assertEquals("Download failed", failureMessage(DownloadManager.ERROR_UNKNOWN))
fun failureReasonsMapToErrors() {
assertEquals(DownloadError.NoSpace, failureMessage(DownloadManager.ERROR_INSUFFICIENT_SPACE))
assertEquals(DownloadError.StorageUnavailable, failureMessage(DownloadManager.ERROR_DEVICE_NOT_FOUND))
assertEquals(DownloadError.Failed, failureMessage(DownloadManager.ERROR_UNKNOWN))
}
@Test
@@ -15,6 +15,7 @@ import com.subtracks.data.download.FakeDownloadEngine
import com.subtracks.data.model.Album
import com.subtracks.data.model.Artist
import com.subtracks.data.model.BulkDownloadAction
import com.subtracks.data.model.DownloadError
import com.subtracks.data.model.DownloadList
import com.subtracks.data.model.DownloadStatus
import com.subtracks.data.model.ListDownloadStatus
@@ -208,7 +209,7 @@ class DownloadRepositoryTest {
fun aFailedDownloadCanBeStartedAgain() =
runTest {
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") }
@@ -220,14 +221,14 @@ class DownloadRepositoryTest {
fun aFailedDownloadIsNotRestartedByReconcile() =
runTest {
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() }
assertTrue(engine.requests.isEmpty())
assertEquals(DownloadStatus.Failed, row(1, "s1")?.status)
assertEquals("boom", row(1, "s1")?.error)
assertEquals(DownloadError.Failed, row(1, "s1")?.error)
}
@Test
@@ -242,7 +243,7 @@ class DownloadRepositoryTest {
runBlocking { repository.reconcile() }
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())
}
@@ -1000,7 +1001,7 @@ class DownloadRepositoryTest {
runBlocking { repository.reconcile() }
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())
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.test.ext.junit.runners.AndroidJUnit4
import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers
import com.subtracks.R
import com.subtracks.data.model.Album
import com.subtracks.data.model.Artist
import com.subtracks.data.model.Playlist
@@ -100,19 +101,43 @@ class LibraryOptionsTest {
@Test
fun playSortsAreHiddenUntilTheServerReturnsPlayData() {
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 },
)
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 },
)
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 },
)
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 },
)
}
@@ -225,7 +225,7 @@ class LibraryScreenTest {
composeRule.runOnIdle { pagerState.requestScrollToPage(1, step * 0.05f) }
composeRule.waitForIdle()
composeRule
.onNodeWithContentDescription(LibraryTab.Playlists.label)
.onNodeWithContentDescription(composeRule.activity.getString(LibraryTab.Playlists.label))
.fetchSemanticsNode()
.boundsInRoot.left
}
@@ -93,6 +93,8 @@ class DownloadsViewModelTest {
LibraryRepository(db, sourceRepository, NetworkServerActionSink(sourceRepository), scope = repoScope),
downloadRepository,
controller,
"Unknown artist",
"Unknown album",
)
}
@@ -1,67 +1,77 @@
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.Artist
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class HomeListInfoTest {
private val now = 1_700_000_000_000L
private val resources = ApplicationProvider.getApplicationContext<Context>().resources
@Test
fun relativeTimeUsesFriendlyBuckets() {
assertNull(relativeTimeLabel(0, now))
assertEquals("Just now", relativeTimeLabel(now / 1000 - 10, now))
assertEquals("Today", relativeTimeLabel((now - 3 * 3_600_000L) / 1000L, now))
assertEquals("Yesterday", relativeTimeLabel(secondsAgo(1), now))
assertEquals("3 days ago", relativeTimeLabel(secondsAgo(3), now))
assertEquals("Last week", relativeTimeLabel(secondsAgo(10), now))
assertEquals("3 weeks ago", relativeTimeLabel(secondsAgo(21), now))
assertEquals("Last month", relativeTimeLabel(secondsAgo(45), now))
assertEquals("3 months ago", relativeTimeLabel(secondsAgo(100), now))
assertEquals("Last year", relativeTimeLabel(secondsAgo(400), now))
assertEquals("2 years ago", relativeTimeLabel(secondsAgo(800), now))
assertNull(relativeTimeLabel(0, now, resources))
assertEquals("Just now", relativeTimeLabel(now / 1000 - 10, now, resources))
assertEquals("Today", relativeTimeLabel((now - 3 * 3_600_000L) / 1000L, now, resources))
assertEquals("Yesterday", relativeTimeLabel(secondsAgo(1), now, resources))
assertEquals("3 days ago", relativeTimeLabel(secondsAgo(3), now, resources))
assertEquals("Last week", relativeTimeLabel(secondsAgo(10), now, resources))
assertEquals("3 weeks ago", relativeTimeLabel(secondsAgo(21), now, resources))
assertEquals("Last month", relativeTimeLabel(secondsAgo(45), now, resources))
assertEquals("3 months ago", relativeTimeLabel(secondsAgo(100), now, resources))
assertEquals("Last year", relativeTimeLabel(secondsAgo(400), now, resources))
assertEquals("2 years ago", relativeTimeLabel(secondsAgo(800), now, resources))
}
@Test
fun albumInfoMatchesTheSection() {
val album = album(created = secondsAgo(1), played = secondsAgo(3), playCount = 5, year = 1999)
assertEquals("Yesterday", albumListInfo(request(HomeSection.RecentlyAddedAlbums), album, now))
assertEquals("3 days ago", albumListInfo(request(HomeSection.RecentlyPlayedAlbums), album, now))
assertEquals("5 plays", albumListInfo(request(HomeSection.MostPlayedAlbums), album, now))
assertEquals("3 days ago", albumListInfo(request(HomeSection.Rediscover), album, now))
assertEquals("Yesterday", albumListInfo(request(HomeSection.RecentlyAddedAlbums), album, now, resources))
assertEquals("3 days ago", albumListInfo(request(HomeSection.RecentlyPlayedAlbums), album, now, resources))
assertEquals("5 plays", albumListInfo(request(HomeSection.MostPlayedAlbums), album, now, resources))
assertEquals("3 days ago", albumListInfo(request(HomeSection.Rediscover), album, now, resources))
}
@Test
fun albumInfoIsNullWhenTheSectionHasNothingToShow() {
val album = album(created = 0, played = null, playCount = 0, year = 0)
assertNull(albumListInfo(request(HomeSection.RecentlyAddedAlbums), album, now))
assertNull(albumListInfo(request(HomeSection.RecentlyPlayedAlbums), album, now))
assertNull(albumListInfo(request(HomeSection.MostPlayedAlbums), album, now))
assertNull(albumListInfo(request(HomeSection.RecentlyAddedAlbums), album, now, resources))
assertNull(albumListInfo(request(HomeSection.RecentlyPlayedAlbums), album, now, resources))
assertNull(albumListInfo(request(HomeSection.MostPlayedAlbums), album, now, resources))
}
@Test
fun aDecadeListShowsTheYear() {
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
fun artistInfoMatchesTheSection() {
val artist = artist(played = secondsAgo(3), playCount = 7)
assertEquals("3 days ago", artistListInfo(request(HomeSection.RecentlyPlayedArtists), artist, now))
assertEquals("7 plays", artistListInfo(request(HomeSection.MostPlayedArtists), artist, now))
assertNull(artistListInfo(request(HomeSection.MostPlayedArtists), artist.copy(playCount = 0, played = null), now))
assertEquals("3 days ago", artistListInfo(request(HomeSection.RecentlyPlayedArtists), artist, now, resources))
assertEquals("7 plays", artistListInfo(request(HomeSection.MostPlayedArtists), artist, now, resources))
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 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(
created: Long,
@@ -141,7 +141,8 @@ class AddSourceViewModelTest {
private fun viewModel(sourceId: Long? = null): AddSourceViewModel {
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\"/>")
+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.
## 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
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.