diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 214ff6c8..3e5a6dae 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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 { diff --git a/app/src/main/kotlin/com/subtracks/data/db/SubtracksDatabase.kt b/app/src/main/kotlin/com/subtracks/data/db/SubtracksDatabase.kt index 7ad8251d..a33a8561 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/SubtracksDatabase.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/SubtracksDatabase.kt @@ -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 diff --git a/app/src/main/kotlin/com/subtracks/data/download/DownloadEngine.kt b/app/src/main/kotlin/com/subtracks/data/download/DownloadEngine.kt index c8c602de..512968ee 100644 --- a/app/src/main/kotlin/com/subtracks/data/download/DownloadEngine.kt +++ b/app/src/main/kotlin/com/subtracks/data/download/DownloadEngine.kt @@ -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 { diff --git a/app/src/main/kotlin/com/subtracks/data/download/DownloadNotifier.kt b/app/src/main/kotlin/com/subtracks/data/download/DownloadNotifier.kt index 1b83a451..6db323a2 100644 --- a/app/src/main/kotlin/com/subtracks/data/download/DownloadNotifier.kt +++ b/app/src/main/kotlin/com/subtracks/data/download/DownloadNotifier.kt @@ -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) }, diff --git a/app/src/main/kotlin/com/subtracks/data/download/SystemDownloadEngine.kt b/app/src/main/kotlin/com/subtracks/data/download/SystemDownloadEngine.kt index dea352e4..ebaf84f0 100644 --- a/app/src/main/kotlin/com/subtracks/data/download/SystemDownloadEngine.kt +++ b/app/src/main/kotlin/com/subtracks/data/download/SystemDownloadEngine.kt @@ -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 } diff --git a/app/src/main/kotlin/com/subtracks/data/model/Download.kt b/app/src/main/kotlin/com/subtracks/data/model/Download.kt index 0bf21e35..7498a2e6 100644 --- a/app/src/main/kotlin/com/subtracks/data/model/Download.kt +++ b/app/src/main/kotlin/com/subtracks/data/model/Download.kt @@ -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 +} diff --git a/app/src/main/kotlin/com/subtracks/data/repo/DownloadRepository.kt b/app/src/main/kotlin/com/subtracks/data/repo/DownloadRepository.kt index e4f3e123..6d31d928 100644 --- a/app/src/main/kotlin/com/subtracks/data/repo/DownloadRepository.kt +++ b/app/src/main/kotlin/com/subtracks/data/repo/DownloadRepository.kt @@ -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" diff --git a/app/src/main/kotlin/com/subtracks/di/AppModule.kt b/app/src/main/kotlin/com/subtracks/di/AppModule.kt index f2c9d21d..f7dd0127 100644 --- a/app/src/main/kotlin/com/subtracks/di/AppModule.kt +++ b/app/src/main/kotlin/com/subtracks/di/AppModule.kt @@ -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()) } diff --git a/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt b/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt index a90a153a..5ce489e6 100644 --- a/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt +++ b/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt @@ -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)) diff --git a/app/src/main/kotlin/com/subtracks/ui/components/Components.kt b/app/src/main/kotlin/com/subtracks/ui/components/Components.kt index ab00bff0..b9026c94 100644 --- a/app/src/main/kotlin/com/subtracks/ui/components/Components.kt +++ b/app/src/main/kotlin/com/subtracks/ui/components/Components.kt @@ -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)) } } } diff --git a/app/src/main/kotlin/com/subtracks/ui/components/DeleteDownloadsDialog.kt b/app/src/main/kotlin/com/subtracks/ui/components/DeleteDownloadsDialog.kt index 1009935b..b7fc7c74 100644 --- a/app/src/main/kotlin/com/subtracks/ui/components/DeleteDownloadsDialog.kt +++ b/app/src/main/kotlin/com/subtracks/ui/components/DeleteDownloadsDialog.kt @@ -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)) } }, ) } diff --git a/app/src/main/kotlin/com/subtracks/ui/components/DownloadIndicator.kt b/app/src/main/kotlin/com/subtracks/ui/components/DownloadIndicator.kt index 366ea481..1a43a329 100644 --- a/app/src/main/kotlin/com/subtracks/ui/components/DownloadIndicator.kt +++ b/app/src/main/kotlin/com/subtracks/ui/components/DownloadIndicator.kt @@ -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 }, ) } } diff --git a/app/src/main/kotlin/com/subtracks/ui/components/HeroDetailScaffold.kt b/app/src/main/kotlin/com/subtracks/ui/components/HeroDetailScaffold.kt index 7c489f46..12d4b86b 100644 --- a/app/src/main/kotlin/com/subtracks/ui/components/HeroDetailScaffold.kt +++ b/app/src/main/kotlin/com/subtracks/ui/components/HeroDetailScaffold.kt @@ -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 = 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)) } } } diff --git a/app/src/main/kotlin/com/subtracks/ui/components/ItemContextMenu.kt b/app/src/main/kotlin/com/subtracks/ui/components/ItemContextMenu.kt index 02838c6d..3a3beff6 100644 --- a/app/src/main/kotlin/com/subtracks/ui/components/ItemContextMenu.kt +++ b/app/src/main/kotlin/com/subtracks/ui/components/ItemContextMenu.kt @@ -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 diff --git a/app/src/main/kotlin/com/subtracks/ui/components/SongInfoDialog.kt b/app/src/main/kotlin/com/subtracks/ui/components/SongInfoDialog.kt index ad6c6bde..1b99012a 100644 --- a/app/src/main/kotlin/com/subtracks/ui/components/SongInfoDialog.kt +++ b/app/src/main/kotlin/com/subtracks/ui/components/SongInfoDialog.kt @@ -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) } diff --git a/app/src/main/kotlin/com/subtracks/ui/downloads/DownloadsScreen.kt b/app/src/main/kotlin/com/subtracks/ui/downloads/DownloadsScreen.kt index d636805b..f946155c 100644 --- a/app/src/main/kotlin/com/subtracks/ui/downloads/DownloadsScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/downloads/DownloadsScreen.kt @@ -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) } } } diff --git a/app/src/main/kotlin/com/subtracks/ui/downloads/DownloadsViewModel.kt b/app/src/main/kotlin/com/subtracks/ui/downloads/DownloadsViewModel.kt index 9711dbd2..35d6e38a 100644 --- a/app/src/main/kotlin/com/subtracks/ui/downloads/DownloadsViewModel.kt +++ b/app/src/main/kotlin/com/subtracks/ui/downloads/DownloadsViewModel.kt @@ -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 = 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): DownloadTree = +internal fun buildTree( + songs: List, + unknownArtist: String = "Unknown artist", + unknownAlbum: String = "Unknown album", +): DownloadTree = DownloadTree( artists = songs @@ -97,7 +103,7 @@ internal fun buildTree(songs: List): 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): 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() }, ) diff --git a/app/src/main/kotlin/com/subtracks/ui/home/HomeListScreen.kt b/app/src/main/kotlin/com/subtracks/ui/home/HomeListScreen.kt index e752e581..9de9cbc7 100644 --- a/app/src/main/kotlin/com/subtracks/ui/home/HomeListScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/home/HomeListScreen.kt @@ -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 PlainList( items: List, - 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) } } diff --git a/app/src/main/kotlin/com/subtracks/ui/home/HomeListViewModel.kt b/app/src/main/kotlin/com/subtracks/ui/home/HomeListViewModel.kt index 6c5b4cae..6172fc59 100644 --- a/app/src/main/kotlin/com/subtracks/ui/home/HomeListViewModel.kt +++ b/app/src/main/kotlin/com/subtracks/ui/home/HomeListViewModel.kt @@ -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) diff --git a/app/src/main/kotlin/com/subtracks/ui/home/HomeScreen.kt b/app/src/main/kotlin/com/subtracks/ui/home/HomeScreen.kt index 2cca5d6e..88965cad 100644 --- a/app/src/main/kotlin/com/subtracks/ui/home/HomeScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/home/HomeScreen.kt @@ -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, coverArt: (String?, Boolean) -> CoverArtRef?, diff --git a/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailScreen.kt b/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailScreen.kt index 809be17f..9593d434 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailScreen.kt @@ -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) { diff --git a/app/src/main/kotlin/com/subtracks/ui/library/AlbumsContent.kt b/app/src/main/kotlin/com/subtracks/ui/library/AlbumsContent.kt index 52c0b9f7..18712500 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/AlbumsContent.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/AlbumsContent.kt @@ -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, ) } diff --git a/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailScreen.kt b/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailScreen.kt index 58c4ab73..098a8a8f 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailScreen.kt @@ -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), diff --git a/app/src/main/kotlin/com/subtracks/ui/library/ArtistsContent.kt b/app/src/main/kotlin/com/subtracks/ui/library/ArtistsContent.kt index 2cab8c7c..9334fe81 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/ArtistsContent.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/ArtistsContent.kt @@ -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, ) diff --git a/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt b/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt index 6e07c316..bd08f0ba 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt @@ -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, diff --git a/app/src/main/kotlin/com/subtracks/ui/library/PlaylistDetailScreen.kt b/app/src/main/kotlin/com/subtracks/ui/library/PlaylistDetailScreen.kt index ca32d763..ddc8dae3 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/PlaylistDetailScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/PlaylistDetailScreen.kt @@ -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) } } diff --git a/app/src/main/kotlin/com/subtracks/ui/library/PlaylistsContent.kt b/app/src/main/kotlin/com/subtracks/ui/library/PlaylistsContent.kt index 36eee1e9..36b8f3be 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/PlaylistsContent.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/PlaylistsContent.kt @@ -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, ) diff --git a/app/src/main/kotlin/com/subtracks/ui/library/SongsContent.kt b/app/src/main/kotlin/com/subtracks/ui/library/SongsContent.kt index 620410d3..eb7e3535 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/SongsContent.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/SongsContent.kt @@ -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, ) } diff --git a/app/src/main/kotlin/com/subtracks/ui/playback/MiniPlayer.kt b/app/src/main/kotlin/com/subtracks/ui/playback/MiniPlayer.kt index d444776a..3890a1f6 100644 --- a/app/src/main/kotlin/com/subtracks/ui/playback/MiniPlayer.kt +++ b/app/src/main/kotlin/com/subtracks/ui/playback/MiniPlayer.kt @@ -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), ) diff --git a/app/src/main/kotlin/com/subtracks/ui/playback/NowPlayingScreen.kt b/app/src/main/kotlin/com/subtracks/ui/playback/NowPlayingScreen.kt index 124d85e2..1ead9d8e 100644 --- a/app/src/main/kotlin/com/subtracks/ui/playback/NowPlayingScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/playback/NowPlayingScreen.kt @@ -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 { diff --git a/app/src/main/kotlin/com/subtracks/ui/playback/QueueScreen.kt b/app/src/main/kotlin/com/subtracks/ui/playback/QueueScreen.kt index 3fc276a5..9619f3ba 100644 --- a/app/src/main/kotlin/com/subtracks/ui/playback/QueueScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/playback/QueueScreen.kt @@ -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(null) } var dragFrom by remember { mutableStateOf(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)) } } } diff --git a/app/src/main/kotlin/com/subtracks/ui/settings/AddSourceScreen.kt b/app/src/main/kotlin/com/subtracks/ui/settings/AddSourceScreen.kt index e58cecd3..ba1c19d1 100644 --- a/app/src/main/kotlin/com/subtracks/ui/settings/AddSourceScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/settings/AddSourceScreen.kt @@ -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)) } }, ) } diff --git a/app/src/main/kotlin/com/subtracks/ui/settings/AddSourceViewModel.kt b/app/src/main/kotlin/com/subtracks/ui/settings/AddSourceViewModel.kt index 85d08511..c39024cc 100644 --- a/app/src/main/kotlin/com/subtracks/ui/settings/AddSourceViewModel.kt +++ b/app/src/main/kotlin/com/subtracks/ui/settings/AddSourceViewModel.kt @@ -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 = _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 } diff --git a/app/src/main/kotlin/com/subtracks/ui/settings/LicensesScreen.kt b/app/src/main/kotlin/com/subtracks/ui/settings/LicensesScreen.kt index 81044c4a..cb8fff1a 100644 --- a/app/src/main/kotlin/com/subtracks/ui/settings/LicensesScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/settings/LicensesScreen.kt @@ -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)) } }, ) diff --git a/app/src/main/kotlin/com/subtracks/ui/settings/SettingsScreen.kt b/app/src/main/kotlin/com/subtracks/ui/settings/SettingsScreen.kt index 38be3e17..09a74a14 100644 --- a/app/src/main/kotlin/com/subtracks/ui/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/settings/SettingsScreen.kt @@ -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)) } }, ) } diff --git a/app/src/main/res/resources.properties b/app/src/main/res/resources.properties new file mode 100644 index 00000000..467b3efe --- /dev/null +++ b/app/src/main/res/resources.properties @@ -0,0 +1 @@ +unqualifiedResLocale=en-US diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml new file mode 100644 index 00000000..0f09e807 --- /dev/null +++ b/app/src/main/res/values-ar/strings.xml @@ -0,0 +1,24 @@ + + + مميز + ازل التمييز + المكتبة + الإعدادات + حسب النوع + موسوم + حسب الفنان/ة + مشغل كثيرا + حسب الاسم + شغل حديثا + حسب السنة + بحث + الرخص + حول + الشبكة + %1$s كيلو بايت في الثانية + غير محدود + أخبر الأتصال + كلمة المرور + إسم المستخدم + الرئيسية + diff --git a/app/src/main/res/values-b+pt+BR/strings.xml b/app/src/main/res/values-b+pt+BR/strings.xml new file mode 100644 index 00000000..29d86580 --- /dev/null +++ b/app/src/main/res/values-b+pt+BR/strings.xml @@ -0,0 +1,46 @@ + + + Cancelar + Deletar + Download + Cancelar download + Estrela + Sem estrela + Misturar + Biblioteca + Configurações + + %1$d album + %1$d albums + + Álbum + Artista + Gênero + Estrelado por + Ano + + %1$d song + %1$d songs + + Artista + Mais tocados + Nome + Tocado recentemente + Título + Ano + Pesquisar + Licenças + Sobre + Rede + Modo off-line + Usar a internet para sincronizar as músicas. + Não usar a Internet para sincronizar ou tocar músicas. + Usar servidor padrão + %1$skbps + Ilimitado + Testar conexão + Nome + Senha + Usuário + Home + diff --git a/app/src/main/res/values-b+zh+Hans/strings.xml b/app/src/main/res/values-b+zh+Hans/strings.xml new file mode 100644 index 00000000..366cee1a --- /dev/null +++ b/app/src/main/res/values-b+zh+Hans/strings.xml @@ -0,0 +1,44 @@ + + + 取消 + 删除 + 下载 + 取消下载 + 收藏 + 移除收藏 + 随机播放 + 所有 + 设置 + + %1$d 张专辑 + + 专辑 + 歌手 + 根据类型 + 已收藏 + 年份 + + %1$d 首歌曲 + + 根据歌手 + 播放最多 + 根据名称 + 最近播放 + 标题 + 根据年份 + 搜索 + 许可 + 关于 + 网络 + 离线模式 + 使用互联网同步音乐。 + 不使用互联网同步或播放音乐。 + 使用服务器默认设置 + %1$skbps + 不限制 + 测试连接 + 名称 + 密码 + 用户名 + 首页 + diff --git a/app/src/main/res/values-b+zh+Hant/strings.xml b/app/src/main/res/values-b+zh+Hant/strings.xml new file mode 100644 index 00000000..64575407 --- /dev/null +++ b/app/src/main/res/values-b+zh+Hant/strings.xml @@ -0,0 +1,44 @@ + + + 取消 + 刪除 + 下載 + 取消下載 + 收藏 + 移除收藏 + 隨機播放 + 所有 + 設定 + + %1$d 張專輯 + + 專輯 + 歌手 + 根據類型 + 已收藏 + 年份 + + %1$d 首歌曲 + + 根據歌手 + 最常播放 + 根據名稱 + 最近播放 + 標題 + 根據年份 + 搜尋 + 許可 + 關於 + 網路 + 離線模式 + 使用網際網路同步音樂。 + 不使用網際網路同步或播放音樂。 + 使用伺服器預設設定 + %1$skbps + 不限制 + 測試連線 + 名稱 + 密碼 + 用戶名稱 + 首頁 + diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml new file mode 100644 index 00000000..c52955f1 --- /dev/null +++ b/app/src/main/res/values-ca/strings.xml @@ -0,0 +1,24 @@ + + + Afegir als favorits + Retirar estrella + Biblioteca + Paràmetres + Per gènere + Favorits + Per artista + Escoltat freqüentment + Pel nom + Reproduït recentment + Per any + Cercar + Llicències + Quant a + Xarxa + %1$s kbps + Il·limitat + Comprovar la connexió + Contrasenya + Nom d’usuari + Inici + diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml new file mode 100644 index 00000000..48fc8b3e --- /dev/null +++ b/app/src/main/res/values-cs/strings.xml @@ -0,0 +1,35 @@ + + + Zrušit + Smazat + Stáhnout + Zrušit stahování + Ohodnotit + Zrušit hodnocení + Náhodně + Knihovna + Nastavení + Album + Umělec + Podle žánru + Ohodnocené + Rok + Umělce + Často přehrávané + Názvu + Často přehrávané + Názvu + Roku + Hledat + Licence + O aplikaci + Síť + Použít nastavení serveru + %1$skbps + Neomezeno + Otestovat spojení + Jméno + Heslo + Uživ. jméno + Domů + diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml new file mode 100644 index 00000000..c3450bc2 --- /dev/null +++ b/app/src/main/res/values-da/strings.xml @@ -0,0 +1,23 @@ + + + Afbryd + Slet + Hent + Afbryd download + Bibliotek + Indstillinger + Efter genre + Efter kunstner + Efter navn + Efter år + Søg + Licenser + Omkring + Netværk + %1$skbps + Ubegrænset + Test forbindelse + Adgangskode + Brugernavn + Hjem + diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml new file mode 100644 index 00000000..37188d53 --- /dev/null +++ b/app/src/main/res/values-de/strings.xml @@ -0,0 +1,46 @@ + + + Abbrechen + Löschen + Herunterladen + Download abbrechen + Markieren + Markierung entfernen + Zufall + Bibliothek + Einstellungen + + %1$d Album + %1$d Alben + + Album + Künstler + Nach Genre + Favoriten + Jahr + + %1$d Song + %1$d Songs + + Nach Interpreten + Häufig abgespielt + Nach Name + Kürzlich abgespielt + Titel + Nach Jahr + Suche + Lizenzen + Über + Netzwerk + Offline Modus + Nutze das Internet um Musik zu synchronisieren. + Nutze nicht das Internet um Musik zu synchronisieren. + Standardserver verwenden + %1$skbps + Unbegrenzt + Verbindung testen + Name + Passwort + Nutzername + Startseite + diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml new file mode 100644 index 00000000..32207deb --- /dev/null +++ b/app/src/main/res/values-es/strings.xml @@ -0,0 +1,46 @@ + + + Cancelar + Borrar + Descargar + Anular descarga + Favorito + Retirar favorito + Reproducir aleatoriamente + Biblioteca + Configuración + + %1$d Álbum + %1$d Álbumes + + Álbum + Artista + Género + Estrellas + Año + + %1$d Canción + %1$d Canciones + + Por Artista + Escuchado frecuentemente + Por Nombre + Escuchado recientemente + Título + Por Año + Buscar + Licencias + Información + Sitio + Modo sin conexión + Se usa internet para sincronizar música. + No se usa internet para sincronizar o reproducir música. + Utilizar predeterminado del servidor + %1$s kilobytes por segundo + Ilimitados + Probar conexión + Nombre + Contraseña + Nombre de usuario + Inicio + diff --git a/app/src/main/res/values-eu/strings.xml b/app/src/main/res/values-eu/strings.xml new file mode 100644 index 00000000..0a35e374 --- /dev/null +++ b/app/src/main/res/values-eu/strings.xml @@ -0,0 +1,35 @@ + + + Utzi + Ezabatu + Deskargatu + Utzi deskargatzeari + Gogokoetan sartu + Gogokoetatik kendu + Nahastu + Liburutegia + Ezarpenak + + Album bat + %1$d album + + Albuma + Musikaria + Generoa + Gogokoak + Urtea + + Kanta bat + %1$d kanta + + Musikaria + Askotan jarritakoak + Alfabetikoki + Duela gutxi jarritakoak + Urtearen arabera + Bilatu + Lizentziak + Honi buruz + Sarea + Hasiera + diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml new file mode 100644 index 00000000..68c5c7a6 --- /dev/null +++ b/app/src/main/res/values-fr/strings.xml @@ -0,0 +1,46 @@ + + + Annuler + Supprimer + Télécharger + Annuler le téléchargement + Ajouter aux favoris + Enlever des favoris + Aléatoire + Bibliothèque + Paramètres + + %1$d album + %1$d albums + + Album + Artiste + Par Genre + Favoris + Année + + %1$d chanson + %1$d chansons + + Artiste + Fréquemment Joué + Nom + Récemment Joué + Titre + Année + Recherche + Licences + À propos + Réseau + Mode hors-ligne + Se connecter à internet pour synchroniser la musique. + Ne pas se connecter à internet pour synchroniser ou jouer de la musique. + Utiliser les paramètres par défaut du serveur + %1$skbit/s + Illimité + Tester la connexion + Nom + Mot de passe + Nom d\'utilisateur + Accueil + diff --git a/app/src/main/res/values-gl/strings.xml b/app/src/main/res/values-gl/strings.xml new file mode 100644 index 00000000..0e3ad800 --- /dev/null +++ b/app/src/main/res/values-gl/strings.xml @@ -0,0 +1,46 @@ + + + Cancelar + Eliminar + Descargar + Cancelar a descarga + Estrela + Retirar + Barallar + Biblioteca + Axustes + + %1$d álbum + %1$d álbums + + Álbum + Artista + Por xénero + Favoritas + Ano + + %1$d canción + %1$d cancións + + Por artista + Reproducidas a miúdo + Por nome + Reproducidas recente + Título + Por ano + Buscar + Licenzas + Acerca de + Rede + Modo sen conexión + Usa internet para sincr. música. + Non usar internet para sincr. ou reproducir música. + Usar por defecto do servidor + %1$skbps + Sen límite + Comprobar Conexión + Nome + Contrasinal + Identificador + Inicio + diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml new file mode 100644 index 00000000..899e6ce5 --- /dev/null +++ b/app/src/main/res/values-hu/strings.xml @@ -0,0 +1,39 @@ + + + Mégsem + Törlés + Letöltés + Letöltés visszavonása + Kedvencekhez adás + Kedvencekből törlés + Véletlen sorrend + Beállítások + + %1$d album + %1$d album + + Album + Előadó + Stílus + Kedvencek között + Év + Előadó + Gyakran játszott + Név + Legutóbb játszott + Cím + Év + Keresés + Licenszek + Az alkalmazásról + Hálózat + Offline mód + Szerver alapértelmezés használata + %1$skbps + Korlátlan + Kapcsolat tesztelése + Név + Jelszó + Felhasználónév + Kezdőoldal + diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml new file mode 100644 index 00000000..99e10ea6 --- /dev/null +++ b/app/src/main/res/values-it/strings.xml @@ -0,0 +1,46 @@ + + + Annulla + Elimina + Scarica + Annulla download + Aggiungi ai preferiti + Rimuovi dai preferiti + Casuale + Libreria + Impostazioni + + %1$d album + %1$d album + + Album + Artista + Per genere + Preferiti + Anno + + %1$d brano + %1$d brani + + Per artista + Ascoltati frequentemente + Per nome + Ascoltati di recente + Titolo + Per anno + Ricerca + Licenze + Informazioni + Rete + Modalità Offline + Usa internet per sincronizzare la musica. + Non usare internet per sincronizzare o riprodurre la musica. + Usa il server predefinito + %1$skbps + Illimitato + Prova connessione + Nome + Password + Nome utente + Home + diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml new file mode 100644 index 00000000..6fecd0a6 --- /dev/null +++ b/app/src/main/res/values-ja/strings.xml @@ -0,0 +1,20 @@ + + + キャンセル + 削除 + ダウンロード + ダウンロードをキャンセル + ライブラリ + 設定 + アルブム + 星付きアルバム + 年 + よく聴くアルバム + 最近再生した + 検索 + ネット + 名前 + パスワルド + ウーザ名 + ホーム + diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml new file mode 100644 index 00000000..7b09f9f7 --- /dev/null +++ b/app/src/main/res/values-ko/strings.xml @@ -0,0 +1,43 @@ + + + 취소 + 삭제 + 다운로드 + 다운로드 중단 + 별점 + 별점 제거 + 무작위 재생 + 라이브러리 + 설정 + + %1$d개 앨범 + + 앨범 + 아티스트 + 장르 + 평가됨 + 년 + + %1$d 노래 + + 아티스트 + 자주 듣는 노래 + 이름 + 최근 재생된 순 + 곡명 + 년 + 검색 + 저작권 + subtracks에 대하여 + 네트워크 + 오프라인 모드 + 노래를 동기화하기 위해 인터넷을 사용합니다. + 노래를 듣거나 동기화하기 위해 인터넷을 사용하지 않습니다. + 서버를 우선하여 사용합니다 + 제한하지 않음 + 연결 테스트 + 이름 + 비밀번호 + 사용자 이름 + 홈 + diff --git a/app/src/main/res/values-nb/strings.xml b/app/src/main/res/values-nb/strings.xml new file mode 100644 index 00000000..6d2e4490 --- /dev/null +++ b/app/src/main/res/values-nb/strings.xml @@ -0,0 +1,46 @@ + + + Avbryt + Slett + Last ned + Avbryt nedlasting + Stjernemerk + Fjern stjernemerking + Miks + Bibliotek + Innstillinger + + %1$d album + %1$d album + + Album + Artist + Etter sjanger + Stjernemerket + År + + %1$d sang + %1$d sanger + + Etter artist + Ofte spilt + Etter navn + Nylig spilt + Tittel + Etter år + Søk + Lisenser + Om + Nettverk + Offline-modus + Bruk Internett til å synkronisere musikk. + Bruk ikke Internett til å synkronisere eller spille av musikk. + Bruk serverens standardinnstilling + %1$s kbps + Ubegrenset + Test tilkobling + Navn + Passord + Brukernavn + Hjem + diff --git a/app/src/main/res/values-pa/strings.xml b/app/src/main/res/values-pa/strings.xml new file mode 100644 index 00000000..f4c4342e --- /dev/null +++ b/app/src/main/res/values-pa/strings.xml @@ -0,0 +1,24 @@ + + + ਤਾਰਾ + ਤਾਰਾ ਹਟਾਓ + ਲਾਇਬ੍ਰੇਰੀ + ਸੈਟਿੰਗਾਂ + ਸ਼ੈਲੀ ਦੁਆਰਾ + ਸਟਾਰ ਕੀਤੇ ਗਏ + ਕਲਾਕਾਰ ਦੁਆਰਾ + ਅਕਸਰ ਚਲਾਏ ਗਏ + ਨਾਮ ਦੁਆਰਾ + ਹਾਲ ਹੀ ਵਿੱਚ ਚਲਾਏ ਗਏ + ਸਾਲ ਦੁਆਰਾ + ਖੋਜੋ + ਲਾਇਸੰਸ + ਬਾਰੇ + ਨੈੱਟਵਰਕ + %1$skbps + ਅਸੀਮਿਤ + ਟੈਸਟ ਕਨੈਕਸ਼ਨ + ਪਾਸਵਰਡ + ਯੂਜ਼ਰਨੇਮ + ਘਰ + diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml new file mode 100644 index 00000000..5a260428 --- /dev/null +++ b/app/src/main/res/values-pl/strings.xml @@ -0,0 +1,35 @@ + + + Anuluj + Usuń + Pobierz + Anuluj pobieranie + Ulubione + Usuń ulubione + Losowo + Kolekcja + Ustawienia + Album + Artysta + Wg gatunku + Ulubione + Rok + Wg wykonawcy + Często odtwarzane + Wg nazwy + Ostatnio odtwarzane + Tytuł + Wg roku + Szukaj + Licencje + Informacje o projekcie + Sieć + Tryb offline + %1$skbps + Bez ograniczeń + Przetestuj połączenie + Nazwa + Hasło + Nazwa użytkownika + Strona główna + diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml new file mode 100644 index 00000000..bc8424d7 --- /dev/null +++ b/app/src/main/res/values-pt/strings.xml @@ -0,0 +1,36 @@ + + + Cancelar + Apagar + Descarregar + Cancelar descarga + Favorito + Remover favorito + Aleatório + Biblioteca + Definições + Álbum + Artista + Por Género + Favoritos + Ano + Por Artista + Mais Tocado + Por Nome + Ouviu recentemente + Título + Por Ano + Procurar + Licenças + Acerca + Rede + Modo offline + Usar a internet para sincronizar música. + Não usar a internet para sincronizar ou tocar música. + %1$skbps + Ilimitado + Testar Ligação + Senha + Nome de utilizador + Início + diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml new file mode 100644 index 00000000..292a4a40 --- /dev/null +++ b/app/src/main/res/values-ru/strings.xml @@ -0,0 +1,48 @@ + + + Отменить + Удалить + Скачать + Отменить загрузку + Избранное + Убрать из избранного + Перемешать + Библиотека + Настройки + + %1$d альбом + %1$d альбомы + + По альбомам + По исполнителю + По жанру + Избранные + По годам + + %1$d трек + %1$d треков + %1$d треков + %1$d треки + + По исполнителям + Часто проигрываемые + По имени + Недавно проигранные + По заголовку + По году + Поиск + Лицензии + О Subtracks + Сеть + Автономный режим + Использовать интернет для синхронизации музыки. + Не использовать интернет для синхронизации или воспроизведения музыки. + Использовать сервер по умолчанию + %1$s кбит/с + Без ограничений + Проверить подключение + Имя + Пароль + Имя пользователя + Главная + diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml new file mode 100644 index 00000000..84155e75 --- /dev/null +++ b/app/src/main/res/values-sv/strings.xml @@ -0,0 +1,46 @@ + + + Avbryt + Radera + Ladda ned + Avbryt Nedladdning + Markera + Omarkera + Blanda + Bibliotek + Inställningar + + %1$d album + %1$d album + + Album + Artist + Genre + Markerad + År + + %1$d låt + %1$d låtar + + Konstnär + Spelas oftast + Namn + Senast spelade + Titel + Efter år + Sök + Licenser + Om + Nätverk + Offline-läge + Använd internet för att uppdatera musik. + Använd inte internet för att uppdatera eller spela upp musik. + Använd serverns standardinställning + %1$skbps + Obegränsad + Testanslutning + Namn + Lösenord + Användarnamn + Hem + diff --git a/app/src/main/res/values-ta/strings.xml b/app/src/main/res/values-ta/strings.xml new file mode 100644 index 00000000..d1a01b2a --- /dev/null +++ b/app/src/main/res/values-ta/strings.xml @@ -0,0 +1,46 @@ + + + ரத்துசெய் + நீக்கு + பதிவிறக்கம் + பதிவிறக்கத்தை ரத்துசெய் + விண்மீன் + ச்டார் + கலக்கு + நூலகம் + அமைப்புகள் + + %1$d செருகேடு + %1$d செருகேடுகள் + + ஆல்பம் + கலைஞர் + வகை + நடித்தார் + ஆண்டு + + %1$d பாடல் + %1$d பாடல்கள் + + கலைஞர் + அடிக்கடி விளையாடியது + பெயர் + அண்மைக் காலத்தில் விளையாடியது + தலைப்பு + ஆண்டு + தேடல் + உரிமங்கள் + பற்றி + பிணையம் + இணைப்பில்லாத பயன்முறை + இசையை ஒத்திசைக்க இணையத்தைப் பயன்படுத்தவும். + இசையை ஒத்திசைக்க அல்லது இசைக்க இணையத்தைப் பயன்படுத்த வேண்டாம். + சேவையக இயல்புநிலையைப் பயன்படுத்தவும் + %1$s kbps + வரம்பற்றது + சோதனை இணைப்பு + பெயர் + கடவுச்சொல் + பயனர்பெயர் + வீடு + diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml new file mode 100644 index 00000000..7b45b7a3 --- /dev/null +++ b/app/src/main/res/values-th/strings.xml @@ -0,0 +1,45 @@ + + + ยกเลิก + ลบ + ดาวน์โหลด + ยกเลิกการดาวน์โหลด + ดาว + ยกเลิกดาว + เล่นแบบสุ่ม + ห้องสมุด + การตั้งค่า + + %1$d อัลบั้ม + + อัลบั้ม + ศิลปิน + ประเภท + ให้ดาว + ปี + + %1$d เพลง + %1$d เพลง + + ศิลปิน + เพลงที่เล่นบ่อย + ชื่อเพลง + เล่นล่าสุด + ชื่อเพลง + ปี + ค้นหา + ใบอนุญาต + เกี่ยวกับ + เคลือข่าย + โหมดอ๊อฟไลน์ + เปิดใช้อินเตอร์เน็ตเพื่อให้ข้อมูลเพลงเหมือนกัน + ปิดใช้อินเตอร์เน็ตเพื่อให้ข้อมูลเพลงเหมือนกันหรือเล่นเพลง + ใช้ค่าเริ่มต้นของเซิร์ฟเวอร์ + %1$sกิโลบิตต่อวินาที (kbps) + ไม่จำกัด + ทดสอบการเชื่อมต่อ + ชื่อ + รหัสผ่าน + ชื่อผู้ใช้ + หน้าหลัก + diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml new file mode 100644 index 00000000..7a198e54 --- /dev/null +++ b/app/src/main/res/values-tr/strings.xml @@ -0,0 +1,46 @@ + + + İptal + Sil + İndir + İndirmeyi iptal et + Yıldızla + Yıldızı Kaldır + Karıştır + Kütüphane + Ayarlar + + %1$d album + %1$d album + + Albüm + Sanatçı + Türe göre + Yıldızlı + Yıl + + %1$d şarkı + %1$d şarkı + + Sanatçıya Göre + Sık Dinlenenler + İsme göre + Yeni Dinleneler + Başlık + Yıla göre + Ara + Lisanslar + Hakkında + Ağ + Çevrim dışı mod + İnterneti kullanarak müzikleri eşzamanla. + İnterneti müzikleri eşzamanlamak veya çalmak için kullanma. + Sunucu öntanımlı değerini kullan + %1$skb/sn + Sınırsız + Bağlantıyı Sına + Ad + Şifre + Kullanıcı Adı + Giriş + diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml new file mode 100644 index 00000000..c397c902 --- /dev/null +++ b/app/src/main/res/values-uk/strings.xml @@ -0,0 +1,46 @@ + + + Скасувати + Видалити + Завантажити + Скасувати завантаження + Зірка + Зняти зірку + Перемішати + Бібліотека + Налаштування + + %1$d альбом + %1$d альбоми + + Альбом + Виконавець + Жанр + Улюблене + Рік + + %1$d пісня + %1$d пісні + + Виконавець + Часто відтворювані + Ім\'я + Нещодавно відворені + Назва + Рік + Введіть заголовок/опис + Ліцензії + Про застосунок + Мережа + Автономний режим + Використовувати інтернет для синхронізації музики. + Не використовувати інтернет для синхронізації або відтворення музики. + Використовувати сервер за замовчуванням + %1$sкбіт/с + Необмежено + Тестове підключення + Ім\'я + Пароль + Ім\'я користувача + Головна + diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml new file mode 100644 index 00000000..18827642 --- /dev/null +++ b/app/src/main/res/values-vi/strings.xml @@ -0,0 +1,46 @@ + + + Hủy + Xóa + Tải xuống + Hủy tải xuống + Yêu thích + Bỏ yêu thích + Phát ngẫu nhiên + Thư viện + Cài đặt + + %1$d album + %1$d album + + Theo album + Theo nghệ sĩ + Theo thể loại + Theo yêu thích + Theo năm + + %1$d bài hát + %1$d bài hát + + Nghệ sĩ + Thường nghe + Tên + Đã phát gần đây + Tiêu đề + Năm + Tìm kiếm + Bản quyền + Về ứng dụng + Mạng + Chế độ ngoại tuyến + Đồng bộ nhạc qua mạng. + Không dùng internet để đồng bô hoặc phát nhạc. + Theo mặc định máy chủ + %1$skbps + Vô giới hạn + Kiểm tra kết nối + Tên + Mật khẩu + Tên đăng nhập + Trang chủ + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 130ac239..4c2a6357 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,7 +1,296 @@ Subtracks - Cancel - Downloads Progress while songs download + + Cancel + Delete + Download + Cancel download + Star + Unstar + + Shuffle + Shuffle play + Play + Pause + Next + Previous + Queue + More + + Back + Home + Library + Settings + + Albums + Artists + Playlists + Queue + Album + Artist + Genre + Starred + Year + %1$s\n\nDeleting will free %2$s + Delete downloads + Added + Artist + Frequently played + Name + Recently played + Title + Updated + Year + + + %1$d album + %1$d albums + + + %1$d song + %1$d songs + + + Search + + Licenses + About + Playback + Send now playing and play counts + Scrobble to server + Network + Stream quality: Mobile + Stream quality: Wi-Fi + Offline mode + Use the internet to sync music. + Don\'t use the internet to sync or play music. + Preferred format + Use server default + %1$skbps + Unlimited + Add server + Delete server + Edit server + Save and sync + Test connection + Server address + Name + Password + Username + Servers + + Filters are hiding everything. + Clear filters + + Downloads + No downloads yet. + all downloads + Delete all + Collapse + Expand + Delete download + Cancel downloads + Retry download + Cancel download of %1$s + Delete download of %1$s + Delete downloads of %1$s + + %1$d album · %2$s + %1$d albums · %2$s + + + %1$d song · %2$s + %1$d songs · %2$s + + %1$s · %2$s + %1$d kbps + + Play next + Add to queue + Info + More options + Open album + Open artist + Unknown artist + Unknown album + + No albums yet.\nSync with your server to fill your library. + No artists yet. + No playlists yet. + Sync + List options + Search this list + Sort by + Sort ascending + Sort descending + Filters + Downloaded + Close search + Offline mode; tap to go online + Shuffle artist + + Playing + Downloaded + Queued + Downloading + Failed + + Duration + Track + %1$d.%2$s + Starred + Yes + Size + Error + Format + Bitrate + Sample rate + %1$d Hz + Channels + Close + + Disc %1$d + %1$s: Disc %2$d + %1$d min + %1$d hr + %1$d hr %2$d min + + Now playing: %1$s + library + album + playlist + song + artist + genre + downloads + starred + Repeat off + Repeat all + Repeat one + + Queue + The queue is empty. + Up next + Next up + Next from here + Queue reordered + Removed from queue + Clear up next + Remove from queue + Reorder + Undo + + Done + Sync concurrency + Parallel server requests while syncing + 1 (sequential) + Download quality + Download over mobile data + Wi-Fi and mobile + Wi-Fi only + Manage downloads + Maximum bitrate + Server default + Required + https://music.example.com + Token authentication + Recommended. Disable only for servers that reject the salted token scheme. + Connect a Subsonic-compatible server such as Navidrome, gonic or Airsonic. + Update this server\'s connection details. + This is the active server. Switch to another server before deleting it. + Delete server? + %1$s and its downloaded songs will be removed from this device. + Connection OK + Server does not support token auth; using the password instead + Failed: %1$s + Could not save: %1$s + The active server can\'t be deleted; switch to another server first + Edit %1$s + Use %1$s + + + Downloading %1$d song + Downloading %1$d songs + + %1$d%% + In progress + Can\'t reach the server. Check your connection. + The server refused to stream this track. + Could not update star + Offline mode is on + Sync failed + Sync failed: %1$s + + Not enough space to download + Storage is unavailable + Could not write the download + The server refused to send this track + The server redirected too many times + The download could not be resumed + Download failed + The downloaded file is missing + The server sent an error instead of the track + Can\'t download: the server address is unavailable + Nothing left to download from this list + The download stopped unexpectedly + Can\'t download from a source that isn\'t active + Unexpected response from the server + Unknown error + Malformed response from the server + Truncated response from the server + Library is too large to sync: it exceeds %1$d rows + HTTP %1$d + + Recently played artists + Most played + Most played artists + Genres + Decades + Recently starred + Recently added + Rediscover + Downloaded songs + Downloaded albums + Downloaded artists + Downloaded playlists + Nothing to show yet.\nSync with your server to fill your library. + %1$ds + Just now + Today + Yesterday + Last week + Last month + Last year + + %1$d day ago + %1$d days ago + + + %1$d week ago + %1$d weeks ago + + + %1$d month ago + %1$d months ago + + + %1$d year ago + %1$d years ago + + + %1$d play + %1$d plays + + + MP3 + AAC + ALAC + FLAC + Ogg + Vorbis + Opus + WebM + WAV diff --git a/app/src/test/kotlin/com/subtracks/data/download/FakeDownloadEngine.kt b/app/src/test/kotlin/com/subtracks/data/download/FakeDownloadEngine.kt index 0c115f24..894e2248 100644 --- a/app/src/test/kotlin/com/subtracks/data/download/FakeDownloadEngine.kt +++ b/app/src/test/kotlin/com/subtracks/data/download/FakeDownloadEngine.kt @@ -1,5 +1,7 @@ package com.subtracks.data.download +import com.subtracks.data.model.DownloadError + class FakeDownloadEngine : DownloadEngine { private val downloads = mutableMapOf() 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) { diff --git a/app/src/test/kotlin/com/subtracks/data/download/SystemDownloadEngineTest.kt b/app/src/test/kotlin/com/subtracks/data/download/SystemDownloadEngineTest.kt index 61c05327..ddcf0728 100644 --- a/app/src/test/kotlin/com/subtracks/data/download/SystemDownloadEngineTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/download/SystemDownloadEngineTest.kt @@ -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 diff --git a/app/src/test/kotlin/com/subtracks/data/repo/DownloadRepositoryTest.kt b/app/src/test/kotlin/com/subtracks/data/repo/DownloadRepositoryTest.kt index f5f8e6af..22066b22 100644 --- a/app/src/test/kotlin/com/subtracks/data/repo/DownloadRepositoryTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/repo/DownloadRepositoryTest.kt @@ -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")) } diff --git a/app/src/test/kotlin/com/subtracks/i18n/StringResourcesTest.kt b/app/src/test/kotlin/com/subtracks/i18n/StringResourcesTest.kt new file mode 100644 index 00000000..374d7b48 --- /dev/null +++ b/app/src/test/kotlin/com/subtracks/i18n/StringResourcesTest.kt @@ -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, + ) + + 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 = + resDir + .listFiles { file -> file.isDirectory && file.name.startsWith("values-") } + .orEmpty() + .map { File(it, "strings.xml") } + .filter(File::isFile) + + private fun parse(file: File): Map { + val document = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(file) + val resources = document.documentElement + val result = mutableMapOf() + 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 = + Regex("""%(\d+)\$""").findAll(text).mapTo(mutableSetOf()) { it.groupValues[1].toInt() } +} diff --git a/app/src/test/kotlin/com/subtracks/ui/LibraryOptionsTest.kt b/app/src/test/kotlin/com/subtracks/ui/LibraryOptionsTest.kt index 81d62e30..cc28a19a 100644 --- a/app/src/test/kotlin/com/subtracks/ui/LibraryOptionsTest.kt +++ b/app/src/test/kotlin/com/subtracks/ui/LibraryOptionsTest.kt @@ -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 }, ) } diff --git a/app/src/test/kotlin/com/subtracks/ui/LibraryScreenTest.kt b/app/src/test/kotlin/com/subtracks/ui/LibraryScreenTest.kt index 1fa99357..addbdd54 100644 --- a/app/src/test/kotlin/com/subtracks/ui/LibraryScreenTest.kt +++ b/app/src/test/kotlin/com/subtracks/ui/LibraryScreenTest.kt @@ -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 } diff --git a/app/src/test/kotlin/com/subtracks/ui/downloads/DownloadsViewModelTest.kt b/app/src/test/kotlin/com/subtracks/ui/downloads/DownloadsViewModelTest.kt index fb31ee45..522ec460 100644 --- a/app/src/test/kotlin/com/subtracks/ui/downloads/DownloadsViewModelTest.kt +++ b/app/src/test/kotlin/com/subtracks/ui/downloads/DownloadsViewModelTest.kt @@ -93,6 +93,8 @@ class DownloadsViewModelTest { LibraryRepository(db, sourceRepository, NetworkServerActionSink(sourceRepository), scope = repoScope), downloadRepository, controller, + "Unknown artist", + "Unknown album", ) } diff --git a/app/src/test/kotlin/com/subtracks/ui/home/HomeListInfoTest.kt b/app/src/test/kotlin/com/subtracks/ui/home/HomeListInfoTest.kt index 4d5f0c5c..ca9b2028 100644 --- a/app/src/test/kotlin/com/subtracks/ui/home/HomeListInfoTest.kt +++ b/app/src/test/kotlin/com/subtracks/ui/home/HomeListInfoTest.kt @@ -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().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, diff --git a/app/src/test/kotlin/com/subtracks/ui/settings/AddSourceViewModelTest.kt b/app/src/test/kotlin/com/subtracks/ui/settings/AddSourceViewModelTest.kt index 220579f4..1a27dc4e 100644 --- a/app/src/test/kotlin/com/subtracks/ui/settings/AddSourceViewModelTest.kt +++ b/app/src/test/kotlin/com/subtracks/ui/settings/AddSourceViewModelTest.kt @@ -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().resources + return AddSourceViewModel(sourceRepository, syncManager, downloadRepository, sourceId, resources).also { createdViewModels += it } } private fun ok() = MockResponse().setBody("") diff --git a/docs/architecture.md b/docs/architecture.md index cef5a406..59d0dc39 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 `` 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 ``) 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.