localize user-facing text into string resources

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

Persisted download failures become a DownloadError enum mapped to string
resources (stored by name, unknown legacy values degrade to Failed), the
per-app locale config is generated by AGP, and StringResourcesTest guards
locale keys and placeholders.
This commit is contained in:
austinried
2026-10-04 00:30:07 +00:00
parent 88cd3764bf
commit 24757bda3f
74 changed files with 2206 additions and 361 deletions
@@ -9,6 +9,7 @@ import com.subtracks.data.model.Artist
import com.subtracks.data.model.ArtistSearch
import com.subtracks.data.model.ArtworkSeed
import com.subtracks.data.model.Disc
import com.subtracks.data.model.DownloadErrorConverter
import com.subtracks.data.model.DownloadStatusConverter
import com.subtracks.data.model.PlaybackCursor
import com.subtracks.data.model.Playlist
@@ -48,7 +49,7 @@ import com.subtracks.data.model.UpNextEntry
version = 26,
exportSchema = true,
)
@ColumnTypeConverters(QueueKindConverter::class, DownloadStatusConverter::class)
@ColumnTypeConverters(QueueKindConverter::class, DownloadStatusConverter::class, DownloadErrorConverter::class)
abstract class SubtracksDatabase : RoomDatabase() {
abstract fun libraryDao(): LibraryDao
@@ -1,5 +1,7 @@
package com.subtracks.data.download
import com.subtracks.data.model.DownloadError
data class EngineRequest(
val uri: String,
val path: String,
@@ -13,7 +15,7 @@ data class EngineDownload(
val status: EngineStatus,
val bytes: Long = 0,
val total: Long = 0,
val error: String? = null,
val error: DownloadError? = null,
)
interface DownloadEngine {
@@ -53,7 +53,7 @@ class DownloadNotifier(
.setSilent(true)
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
.setProgress(PROGRESS_MAX, ((content.progress ?: 0f) * PROGRESS_MAX).toInt(), content.progress == null)
.addAction(0, context.getString(R.string.download_cancel), cancelIntent())
.addAction(0, context.getString(R.string.actions_cancel), cancelIntent())
.setContentIntent(launchIntent())
.build()
@@ -75,7 +75,7 @@ class DownloadNotifier(
val manager = context.getSystemService(NotificationManager::class.java) ?: return
if (manager.getNotificationChannel(CHANNEL_ID) != null) return
manager.createNotificationChannel(
NotificationChannel(CHANNEL_ID, context.getString(R.string.download_channel), NotificationManager.IMPORTANCE_LOW).apply {
NotificationChannel(CHANNEL_ID, context.getString(R.string.downloads_title), NotificationManager.IMPORTANCE_LOW).apply {
description = context.getString(R.string.download_channel_description)
setShowBadge(false)
},
@@ -4,6 +4,7 @@ import android.app.DownloadManager
import android.content.Context
import android.net.Uri
import android.os.Environment
import com.subtracks.data.model.DownloadError
class SystemDownloadEngine(
private val context: Context,
@@ -60,13 +61,13 @@ internal fun engineStatus(status: Int): EngineStatus =
else -> EngineStatus.Pending
}
internal fun failureMessage(reason: Int): String =
internal fun failureMessage(reason: Int): DownloadError =
when (reason) {
DownloadManager.ERROR_INSUFFICIENT_SPACE -> "Not enough space to download"
DownloadManager.ERROR_DEVICE_NOT_FOUND -> "Storage is unavailable"
DownloadManager.ERROR_FILE_ERROR, DownloadManager.ERROR_FILE_ALREADY_EXISTS -> "Could not write the download"
DownloadManager.ERROR_HTTP_DATA_ERROR, DownloadManager.ERROR_UNHANDLED_HTTP_CODE -> "The server refused to send this track"
DownloadManager.ERROR_TOO_MANY_REDIRECTS -> "The server redirected too many times"
DownloadManager.ERROR_CANNOT_RESUME -> "The download could not be resumed"
else -> "Download failed"
DownloadManager.ERROR_INSUFFICIENT_SPACE -> DownloadError.NoSpace
DownloadManager.ERROR_DEVICE_NOT_FOUND -> DownloadError.StorageUnavailable
DownloadManager.ERROR_FILE_ERROR, DownloadManager.ERROR_FILE_ALREADY_EXISTS -> DownloadError.WriteFailed
DownloadManager.ERROR_HTTP_DATA_ERROR, DownloadManager.ERROR_UNHANDLED_HTTP_CODE -> DownloadError.ServerRefused
DownloadManager.ERROR_TOO_MANY_REDIRECTS -> DownloadError.TooManyRedirects
DownloadManager.ERROR_CANNOT_RESUME -> DownloadError.ResumeFailed
else -> DownloadError.Failed
}
@@ -1,13 +1,33 @@
package com.subtracks.data.model
import androidx.annotation.StringRes
import androidx.room3.ColumnInfo
import androidx.room3.ColumnTypeConverter
import androidx.room3.Entity
import androidx.room3.ForeignKey
import androidx.room3.Index
import com.subtracks.R
enum class DownloadStatus { Queued, Running, Completed, Failed }
enum class DownloadError(
@param:StringRes val messageRes: Int,
) {
NoSpace(R.string.error_download_no_space),
StorageUnavailable(R.string.error_download_storage_unavailable),
WriteFailed(R.string.error_download_write_failed),
ServerRefused(R.string.error_download_server_refused),
TooManyRedirects(R.string.error_download_too_many_redirects),
ResumeFailed(R.string.error_download_resume_failed),
Failed(R.string.error_download_failed),
FileMissing(R.string.error_download_file_missing),
ServerError(R.string.error_download_server_error),
NoAddress(R.string.error_download_no_address),
NothingLeft(R.string.error_download_nothing_left),
Stopped(R.string.error_download_stopped),
InactiveSource(R.string.error_download_inactive_source),
}
@Entity(
tableName = "song_downloads",
primaryKeys = ["sourceId", "songId"],
@@ -34,7 +54,7 @@ data class SongDownload(
val engineId: Long? = null,
@ColumnInfo(defaultValue = "0") val bytes: Long = 0,
@ColumnInfo(defaultValue = "0") val total: Long = 0,
val error: String? = null,
val error: DownloadError? = null,
@ColumnInfo(defaultValue = "0") val downloadedAt: Long = 0,
) {
val progress: Float?
@@ -104,3 +124,11 @@ class DownloadStatusConverter {
@ColumnTypeConverter
fun toDownloadStatus(value: String): DownloadStatus = DownloadStatus.entries.firstOrNull { it.name == value } ?: DownloadStatus.Failed
}
class DownloadErrorConverter {
@ColumnTypeConverter
fun fromDownloadError(error: DownloadError): String = error.name
@ColumnTypeConverter
fun toDownloadError(value: String): DownloadError = DownloadError.entries.firstOrNull { it.name == value } ?: DownloadError.Failed
}
@@ -10,6 +10,7 @@ import com.subtracks.data.download.EngineRequest
import com.subtracks.data.download.EngineStatus
import com.subtracks.data.download.looksLikeAudio
import com.subtracks.data.model.BulkDownloadAction
import com.subtracks.data.model.DownloadError
import com.subtracks.data.model.DownloadList
import com.subtracks.data.model.DownloadStatus
import com.subtracks.data.model.DownloadedSong
@@ -174,7 +175,7 @@ class DownloadRepository(
throw e
} catch (_: Exception) {
// Leave it failed rather than waiting, or the next tick would retry it forever.
markFailed(row, STOPPED)
markFailed(row, DownloadError.Stopped)
return@forEach
}
db.downloadDao().upsert(row.copy(engineId = engineId))
@@ -459,8 +460,8 @@ class DownloadRepository(
DownloadStatus.Completed -> {
val file = file(row.sourceId, row.songId)
when {
!file.exists() -> markFailed(row, MISSING_FILE)
!file.looksLikeAudio() -> markFailed(row, SERVER_ERROR)
!file.exists() -> markFailed(row, DownloadError.FileMissing)
!file.looksLikeAudio() -> markFailed(row, DownloadError.ServerError)
}
}
@@ -470,7 +471,7 @@ class DownloadRepository(
}
DownloadStatus.Running -> {
markFailed(row, STOPPED)
markFailed(row, DownloadError.Stopped)
}
DownloadStatus.Failed -> {
@@ -566,8 +567,8 @@ class DownloadRepository(
error =
when {
state.error != null -> state.error
errorPage -> SERVER_ERROR
status == DownloadStatus.Failed -> "Download failed"
errorPage -> DownloadError.ServerError
status == DownloadStatus.Failed -> DownloadError.Failed
else -> null
},
downloadedAt =
@@ -582,11 +583,11 @@ class DownloadRepository(
private suspend fun markFailed(
row: SongDownload,
message: String,
error: DownloadError,
) {
row.engineId?.let { engine.cancel(listOf(it)) }
file(row.sourceId, row.songId).delete()
db.downloadDao().upsert(row.copy(status = DownloadStatus.Failed, engineId = null, error = message))
db.downloadDao().upsert(row.copy(status = DownloadStatus.Failed, engineId = null, error = error))
}
private fun ensurePolling() {
@@ -654,8 +655,6 @@ class DownloadRepository(
const val POLL_MS = 1_000L
const val IN_FLIGHT_LIMIT = 8
const val DELETE_CHUNK = 900
const val MISSING_FILE = "The downloaded file is missing"
const val SERVER_ERROR = "The server sent an error instead of the track"
const val NO_ADDRESS = "Can't download: the server address is unavailable"
const val EMPTY_LIST = "Nothing left to download from this list"
const val STOPPED = "The download stopped unexpectedly"
@@ -5,6 +5,7 @@ import android.os.Environment
import android.os.Handler
import android.os.Looper
import android.widget.Toast
import com.subtracks.R
import com.subtracks.data.db.createAndroidDatabase
import com.subtracks.data.download.ArtworkFetcher
import com.subtracks.data.download.ArtworkStore
@@ -115,8 +116,16 @@ fun appModule(
viewModel { HomeViewModel(get(), get(), get(), get()) }
viewModel { params -> HomeListViewModel(get(), get(), get(), params.get()) }
viewModel { SettingsViewModel(get(), get()) }
viewModel { DownloadsViewModel(get(), get(), get()) }
viewModel { params -> AddSourceViewModel(get(), get(), get(), params.getOrNull()) }
viewModel {
DownloadsViewModel(
get(),
get(),
get(),
context.getString(R.string.unknown_artist),
context.getString(R.string.unknown_album),
)
}
viewModel { params -> AddSourceViewModel(get(), get(), get(), params.getOrNull(), context.resources) }
viewModel { params -> AlbumDetailViewModel(get(), get(), get(), get(), params.get()) }
viewModel { params -> ArtistDetailViewModel(get(), get(), get(), get(), params.get()) }
viewModel { params -> PlaylistDetailViewModel(get(), get(), get(), get(), params.get()) }
@@ -45,6 +45,7 @@ import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelStore
@@ -58,6 +59,7 @@ import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.subtracks.R
import com.subtracks.data.model.Song
import com.subtracks.data.repo.DownloadRepository
import com.subtracks.data.repo.LibraryRepository
@@ -474,7 +476,7 @@ private fun MainNavigation() {
) { entry ->
val section = HomeSection.fromRoute(entry.arguments?.getString("section")) ?: return@composable
HomeListRoute(
request = HomeListRequest(title = section.title, section = section),
request = HomeListRequest(title = stringResource(section.title), section = section),
onBack = { navController.popBackStack() },
onAlbumClick = { album ->
navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
@@ -522,7 +524,7 @@ private fun MainNavigation() {
) { entry ->
val decade = entry.arguments?.getLong("decade") ?: return@composable
HomeListRoute(
request = HomeListRequest(title = "${decade}s", decade = decade),
request = HomeListRequest(title = stringResource(R.string.home_decade, decade), decade = decade),
onBack = { navController.popBackStack() },
onAlbumClick = { album ->
navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
@@ -548,7 +550,7 @@ private fun MainNavigation() {
OfflineListKind.entries.firstOrNull { it.name == entry.arguments?.getString("kind") }
?: return@composable
HomeListRoute(
request = HomeListRequest(title = kind.title, downloaded = kind),
request = HomeListRequest(title = stringResource(kind.title), downloaded = kind),
onBack = { navController.popBackStack() },
onAlbumClick = { album ->
navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
@@ -38,6 +38,7 @@ import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.FilterQuality
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@@ -49,6 +50,7 @@ import coil3.request.ImageRequest
import coil3.request.SuccessResult
import coil3.request.crossfade
import coil3.size.Dimension
import com.subtracks.R
import com.subtracks.data.model.CoverArtRef
import com.subtracks.ui.theme.artworkColorsFromSeed
import com.subtracks.ui.theme.placeholderSeed
@@ -360,13 +362,13 @@ fun FilteredEmptyState(
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(
text = "Filters are hiding everything.",
text = stringResource(R.string.components_filters_hiding),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
Button(onClick = onClearFilters) {
Text("Clear filters")
Text(stringResource(R.string.components_clear_filters))
}
}
}
@@ -7,6 +7,8 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import com.subtracks.R
import com.subtracks.data.model.DownloadList
data class PendingDownloadDelete(
@@ -26,17 +28,17 @@ fun DeleteDownloadsDialog(
val freed = formatBytes(LocalContext.current, bytes)
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Delete downloads") },
text = { Text("$name\n\nDeleting will free $freed") },
title = { Text(stringResource(R.string.resources_song_list_delete_all_title)) },
text = { Text(stringResource(R.string.resources_song_list_delete_all_content, name, freed)) },
confirmButton = {
TextButton(
onClick = {
onConfirm()
onDismiss()
},
) { Text("Delete") }
) { Text(stringResource(R.string.actions_delete)) }
},
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.actions_cancel)) } },
)
}
@@ -13,9 +13,11 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.blur
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import com.subtracks.R
import com.subtracks.data.model.ListDownloadStatus
const val DOWNLOADING_INDICATOR_TAG = "Downloading"
@@ -53,6 +55,7 @@ private fun DownloadRing(
progress: () -> Float,
shadow: Boolean,
) {
val downloadingDescription = stringResource(R.string.status_downloading)
Box(Modifier.size(20.dp), contentAlignment = Alignment.Center) {
if (shadow) {
CircularProgressIndicator(
@@ -68,7 +71,7 @@ private fun DownloadRing(
color = MaterialTheme.colorScheme.primary,
trackColor = MaterialTheme.colorScheme.surfaceContainerHighest,
strokeWidth = 2.5.dp,
modifier = Modifier.fillMaxSize().semantics { contentDescription = DOWNLOADING_INDICATOR_TAG },
modifier = Modifier.fillMaxSize().semantics { contentDescription = downloadingDescription },
)
}
}
@@ -62,11 +62,13 @@ import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.layout.positionInParent
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.subtracks.R
import com.subtracks.data.model.BulkDownloadAction
import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.ListDownloadStatus
@@ -179,7 +181,10 @@ fun HeroDetailScaffold(
},
navigationIcon = {
IconButton(onClick = onBack, modifier = Modifier.graphicsLayer { alpha = barFraction }) {
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Back")
Icon(
Icons.AutoMirrored.Rounded.ArrowBack,
contentDescription = stringResource(R.string.navigation_back),
)
}
},
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent),
@@ -223,11 +228,12 @@ fun ListDownloadStatus.actions(): List<BulkDownloadAction> =
fun ListDownloadStatus.action(): BulkDownloadAction = actions().first()
@Composable
private fun downloadActionLabel(action: BulkDownloadAction): String =
when (action) {
BulkDownloadAction.Download -> "Download"
BulkDownloadAction.Cancel -> "Cancel download"
BulkDownloadAction.Delete -> "Delete download"
BulkDownloadAction.Download -> stringResource(R.string.actions_download)
BulkDownloadAction.Cancel -> stringResource(R.string.actions_download_cancel)
BulkDownloadAction.Delete -> stringResource(R.string.delete_download)
}
@Composable
@@ -280,7 +286,7 @@ fun HeroHeader(
modifier =
Modifier
.heightIn(min = 32.dp)
.clickable(onClickLabel = "Open artist") { onSubtitleClick() },
.clickable(onClickLabel = stringResource(R.string.open_artist)) { onSubtitleClick() },
contentAlignment = Alignment.TopCenter,
) {
HeroSubtitle(subtitle, Modifier.padding(top = 4.dp))
@@ -312,7 +318,7 @@ fun HeroHeader(
onClick = { onDownloadAction(action) },
onClickLabel = downloadActionLabel(action),
onLongClick = if (canDelete) ({ onDownloadAction(BulkDownloadAction.Delete) }) else null,
onLongClickLabel = if (canDelete) "Delete download" else null,
onLongClickLabel = if (canDelete) stringResource(R.string.delete_download) else null,
),
contentAlignment = Alignment.Center,
) {
@@ -320,7 +326,7 @@ fun HeroHeader(
BulkDownloadAction.Delete -> {
Icon(
imageVector = Icons.Rounded.DownloadDone,
contentDescription = "Delete download",
contentDescription = stringResource(R.string.delete_download),
modifier = Modifier.size(28.dp),
)
}
@@ -332,12 +338,20 @@ fun HeroHeader(
modifier = Modifier.size(26.dp),
strokeWidth = 2.dp,
)
Icon(Icons.Rounded.Close, contentDescription = "Cancel download", modifier = Modifier.size(16.dp))
Icon(
Icons.Rounded.Close,
contentDescription = stringResource(R.string.actions_download_cancel),
modifier = Modifier.size(16.dp),
)
}
}
BulkDownloadAction.Download -> {
Icon(Icons.Rounded.Download, contentDescription = "Download", modifier = Modifier.size(24.dp))
Icon(
Icons.Rounded.Download,
contentDescription = stringResource(R.string.actions_download),
modifier = Modifier.size(24.dp),
)
}
}
}
@@ -360,7 +374,11 @@ fun HeroHeader(
shape = RoundedCornerShape(topStart = 24.dp, bottomStart = 24.dp),
modifier = Modifier.width(68.dp).height(48.dp),
) {
Icon(Icons.Rounded.PlayArrow, contentDescription = "Play", modifier = Modifier.size(30.dp))
Icon(
Icons.Rounded.PlayArrow,
contentDescription = stringResource(R.string.controls_play),
modifier = Modifier.size(30.dp),
)
}
FilledIconButton(
onClick = onShuffle,
@@ -368,7 +386,11 @@ fun HeroHeader(
shape = RoundedCornerShape(topEnd = 24.dp, bottomEnd = 24.dp),
modifier = Modifier.width(68.dp).height(48.dp),
) {
Icon(Icons.Rounded.Shuffle, contentDescription = "Shuffle play", modifier = Modifier.size(30.dp))
Icon(
Icons.Rounded.Shuffle,
contentDescription = stringResource(R.string.controls_shuffle_play),
modifier = Modifier.size(30.dp),
)
}
}
Box(
@@ -380,7 +402,7 @@ fun HeroHeader(
}
}
IconButton(onClick = onMore, modifier = Modifier.padding(end = 8.dp)) {
Icon(Icons.Rounded.MoreHoriz, contentDescription = "More options")
Icon(Icons.Rounded.MoreHoriz, contentDescription = stringResource(R.string.more_options))
}
}
}
@@ -42,8 +42,10 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.subtracks.R
import com.subtracks.data.model.BulkDownloadAction
import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.DownloadList
@@ -184,10 +186,10 @@ fun ItemContextMenu(
fun queueItems() {
if (queueRef == null) return
actions.playNext?.let { next ->
MenuItem(Icons.Rounded.SkipNext, "Play next") { dismiss { next(queueRef) } }
MenuItem(Icons.Rounded.SkipNext, stringResource(R.string.play_next)) { dismiss { next(queueRef) } }
}
actions.addToQueue?.let { add ->
MenuItem(Icons.AutoMirrored.Rounded.QueueMusic, "Add to queue") { dismiss { add(queueRef) } }
MenuItem(Icons.AutoMirrored.Rounded.QueueMusic, stringResource(R.string.add_to_queue)) { dismiss { add(queueRef) } }
}
}
@@ -209,20 +211,26 @@ fun ItemContextMenu(
)
target.song.albumId?.let { albumId ->
actions.viewAlbum?.let { view ->
MenuItem(Icons.Rounded.Album, target.song.album?.takeIf { it.isNotBlank() } ?: "Album") {
MenuItem(
Icons.Rounded.Album,
target.song.album?.takeIf { it.isNotBlank() } ?: stringResource(R.string.resources_filter_album),
) {
dismiss { view(albumId) }
}
}
}
target.song.artistId?.let { artistId ->
actions.viewArtist?.let { view ->
MenuItem(Icons.Rounded.Person, target.song.artist?.takeIf { it.isNotBlank() } ?: "Artist") {
MenuItem(
Icons.Rounded.Person,
target.song.artist?.takeIf { it.isNotBlank() } ?: stringResource(R.string.resources_filter_artist),
) {
dismiss { view(artistId) }
}
}
}
onInfo?.let { info ->
MenuItem(Icons.Rounded.Info, "Info") { dismiss { info(target.song) } }
MenuItem(Icons.Rounded.Info, stringResource(R.string.info)) { dismiss { info(target.song) } }
}
}
@@ -236,7 +244,10 @@ fun ItemContextMenu(
)
target.album.artistId?.let { artistId ->
actions.viewArtist?.let { view ->
MenuItem(Icons.Rounded.Person, target.album.albumArtist?.takeIf { it.isNotBlank() } ?: "Artist") {
MenuItem(
Icons.Rounded.Person,
target.album.albumArtist?.takeIf { it.isNotBlank() } ?: stringResource(R.string.resources_filter_artist),
) {
dismiss { view(artistId) }
}
}
@@ -303,25 +314,25 @@ private fun DownloadItem(
when (download?.status) {
DownloadStatus.Completed -> {
actions.deleteDownload?.let { delete ->
MenuItem(Icons.Rounded.Delete, "Delete download") { dismiss { delete(song) } }
MenuItem(Icons.Rounded.Delete, stringResource(R.string.delete_download)) { dismiss { delete(song) } }
}
}
DownloadStatus.Queued, DownloadStatus.Running -> {
actions.cancelDownload?.let { cancel ->
MenuItem(Icons.Rounded.Cancel, "Cancel download") { dismiss { cancel(song) } }
MenuItem(Icons.Rounded.Cancel, stringResource(R.string.actions_download_cancel)) { dismiss { cancel(song) } }
}
}
DownloadStatus.Failed -> {
actions.download?.let { start ->
MenuItem(Icons.Rounded.Download, "Retry download") { dismiss { start(song) } }
MenuItem(Icons.Rounded.Download, stringResource(R.string.retry_download)) { dismiss { start(song) } }
}
}
null -> {
actions.download?.let { start ->
MenuItem(Icons.Rounded.Download, "Download") { dismiss { start(song) } }
MenuItem(Icons.Rounded.Download, stringResource(R.string.actions_download)) { dismiss { start(song) } }
}
}
}
@@ -343,9 +354,21 @@ private fun BulkDownloadItem(
}
for (action in (status ?: ListDownloadStatus()).actions()) {
when (action) {
BulkDownloadAction.Delete -> MenuItem(Icons.Rounded.Delete, "Delete downloads") { dismiss { bulk(target, action) } }
BulkDownloadAction.Cancel -> MenuItem(Icons.Rounded.Cancel, "Cancel downloads") { dismiss { bulk(target, action) } }
BulkDownloadAction.Download -> MenuItem(Icons.Rounded.Download, "Download") { dismiss { bulk(target, action) } }
BulkDownloadAction.Delete -> {
MenuItem(Icons.Rounded.Delete, stringResource(R.string.resources_song_list_delete_all_title)) {
dismiss { bulk(target, action) }
}
}
BulkDownloadAction.Cancel -> {
MenuItem(Icons.Rounded.Cancel, stringResource(R.string.cancel_downloads)) { dismiss { bulk(target, action) } }
}
BulkDownloadAction.Download -> {
MenuItem(Icons.Rounded.Download, stringResource(R.string.actions_download)) {
dismiss { bulk(target, action) }
}
}
}
}
}
@@ -390,6 +413,8 @@ private fun MenuItem(
private fun starIcon(starred: Long?): ImageVector = if (starred == null) Icons.Rounded.StarBorder else Icons.Rounded.Star
private fun starLabel(starred: Long?): String = if (starred == null) "Star" else "Unstar"
@Composable
private fun starLabel(starred: Long?): String =
if (starred == null) stringResource(R.string.actions_star) else stringResource(R.string.actions_unstar)
private const val STAR_DISMISS_DELAY_MS = 200L
@@ -21,7 +21,9 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.subtracks.R
import com.subtracks.data.media.readAudioEncoding
import com.subtracks.data.model.AudioEncoding
import com.subtracks.data.model.DownloadStatus
@@ -48,29 +50,45 @@ fun SongInfoDialog(
val averageBps = sizeBytes?.let { bytes -> song.duration?.takeIf { it > 0 }?.let { seconds -> (bytes * 8 / seconds).toInt() } }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Info") },
title = { Text(stringResource(R.string.info)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
InfoRow("Title", song.title)
InfoRow("Artist", song.artist)
InfoRow("Album", song.album)
InfoRow("Duration", song.duration?.let(DateUtils::formatElapsedTime))
InfoRow("Track", song.track?.let { track -> song.disc?.let { "$it.$track" } ?: track.toString() })
InfoRow("Genre", genres.joinToString(", ").ifBlank { null })
InfoRow("Starred", if (song.starred != null) "Yes" else null)
InfoRow(stringResource(R.string.resources_sort_by_title), song.title)
InfoRow(stringResource(R.string.resources_filter_artist), song.artist)
InfoRow(stringResource(R.string.resources_filter_album), song.album)
InfoRow(stringResource(R.string.info_duration), song.duration?.let(DateUtils::formatElapsedTime))
InfoRow(
stringResource(R.string.info_track),
song.track?.let { track ->
song.disc?.let { disc -> stringResource(R.string.info_track_number, disc, track) }
?: track.toString()
},
)
InfoRow(stringResource(R.string.resources_filter_genre), genres.joinToString(", ").ifBlank { null })
InfoRow(stringResource(R.string.info_starred), if (song.starred != null) stringResource(R.string.info_yes) else null)
if (download != null) {
InfoRow("Download", downloadStatus(download.status))
InfoRow("Size", sizeBytes?.let { Formatter.formatFileSize(context, it) })
download.error?.let { InfoRow("Error", it) }
InfoRow(stringResource(R.string.actions_download), downloadStatus(download.status))
InfoRow(stringResource(R.string.info_size), sizeBytes?.let { Formatter.formatFileSize(context, it) })
download.error?.let { InfoRow(stringResource(R.string.info_error), stringResource(it.messageRes)) }
}
InfoRow("Format", shown?.format)
InfoRow(stringResource(R.string.info_format), shown?.format)
// A decoder often reports no bitrate; the file's own size over the track length does.
InfoRow("Bitrate", (shown?.bitrate?.takeIf { it > 0 } ?: averageBps)?.let { "${it / 1000} kbps" })
InfoRow("Sample rate", shown?.sampleRate?.takeIf { it > 0 }?.let { "$it Hz" })
InfoRow("Channels", shown?.channels?.takeIf { it > 0 }?.toString())
InfoRow(
stringResource(R.string.info_bitrate),
(shown?.bitrate?.takeIf { it > 0 } ?: averageBps)?.let { stringResource(R.string.bitrate_value, it / 1000) },
)
InfoRow(
stringResource(R.string.info_sample_rate),
shown
?.sampleRate
?.takeIf {
it > 0
}?.let { stringResource(R.string.info_sample_rate_value, it) },
)
InfoRow(stringResource(R.string.info_channels), shown?.channels?.takeIf { it > 0 }?.toString())
}
},
confirmButton = { TextButton(onClick = onDismiss) { Text("Close") } },
confirmButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.info_close)) } },
)
}
@@ -100,10 +118,11 @@ private fun InfoRow(
private val INFO_LABEL_WIDTH = 80.dp
@Composable
private fun downloadStatus(status: DownloadStatus): String =
when (status) {
DownloadStatus.Completed -> "Downloaded"
DownloadStatus.Queued -> "Queued"
DownloadStatus.Running -> "Downloading"
DownloadStatus.Failed -> "Failed"
DownloadStatus.Completed -> stringResource(R.string.status_downloaded)
DownloadStatus.Queued -> stringResource(R.string.status_queued)
DownloadStatus.Running -> stringResource(R.string.status_downloading)
DownloadStatus.Failed -> stringResource(R.string.status_failed)
}
@@ -38,9 +38,12 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.subtracks.R
import com.subtracks.data.model.AudioEncoding
import com.subtracks.data.model.DownloadList
import com.subtracks.data.model.DownloadStatus
@@ -86,7 +89,7 @@ fun DownloadsRoute(
when (val dialog = pending) {
is DownloadDeleteTarget.All -> {
DeleteDownloadsDialog(
name = "all downloads",
name = stringResource(R.string.downloads_all),
bytes = tree.bytes,
onConfirm = viewModel::deleteAll,
onDismiss = { pending = null },
@@ -123,17 +126,17 @@ fun DownloadsScreen(
modifier = modifier,
topBar = {
TopAppBar(
title = { Text("Downloads") },
title = { Text(stringResource(R.string.downloads_title)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Back")
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = stringResource(R.string.navigation_back))
}
},
)
},
) { padding ->
if (tree.songs == 0) {
EmptyState("No downloads yet.", Modifier.padding(padding))
EmptyState(stringResource(R.string.downloads_empty), Modifier.padding(padding))
return@Scaffold
}
LazyColumn(
@@ -150,7 +153,13 @@ fun DownloadsScreen(
val active = activeDownloads(songs)
NodeRow(
name = artist.name,
subtitle = "${artist.albums.size} albums · ${size(artist.bytes)}",
subtitle =
pluralStringResource(
R.plurals.downloads_albums_size,
artist.albums.size,
artist.albums.size,
size(artist.bytes),
),
expanded = artistKey in expanded,
indent = 0,
progress = nodeProgress(songs),
@@ -174,7 +183,13 @@ fun DownloadsScreen(
val active = activeDownloads(album.songs)
NodeRow(
name = album.name,
subtitle = "${album.songs.size} songs · ${size(album.bytes)}",
subtitle =
pluralStringResource(
R.plurals.downloads_songs_size,
album.songs.size,
album.songs.size,
size(album.bytes),
),
expanded = albumKey in expanded,
indent = 1,
progress = nodeProgress(album.songs),
@@ -219,11 +234,11 @@ private fun SummaryRow(
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = "${tree.songs} songs · ${size(tree.bytes)}",
text = pluralStringResource(R.plurals.downloads_songs_size, tree.songs, tree.songs, size(tree.bytes)),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
TextButton(onClick = { onDelete(DownloadDeleteTarget.All) }) { Text("Delete all") }
TextButton(onClick = { onDelete(DownloadDeleteTarget.All) }) { Text(stringResource(R.string.delete_all)) }
}
}
@@ -255,18 +270,18 @@ private fun NodeRow(
IconButton(onClick = onToggle) {
Icon(
imageVector = if (expanded) Icons.Rounded.ExpandLess else Icons.Rounded.ExpandMore,
contentDescription = if (expanded) "Collapse" else "Expand",
contentDescription = if (expanded) stringResource(R.string.collapse) else stringResource(R.string.expand),
)
}
},
trailingContent = {
if (progress != null) {
IconButton(onClick = onCancel) {
Icon(Icons.Rounded.Close, contentDescription = "Cancel download of $name")
Icon(Icons.Rounded.Close, contentDescription = stringResource(R.string.download_cancel_of, name))
}
} else {
IconButton(onClick = onDelete) {
Icon(Icons.Rounded.Delete, contentDescription = "Delete downloads of $name")
Icon(Icons.Rounded.Delete, contentDescription = stringResource(R.string.download_delete_all_of, name))
}
}
},
@@ -317,11 +332,11 @@ private fun SongRow(
trailingContent = {
if (active) {
IconButton(onClick = onCancel) {
Icon(Icons.Rounded.Close, contentDescription = "Cancel download of ${song.title}")
Icon(Icons.Rounded.Close, contentDescription = stringResource(R.string.download_cancel_of, song.title))
}
} else {
IconButton(onClick = onDelete) {
Icon(Icons.Rounded.Delete, contentDescription = "Delete download of ${song.title}")
Icon(Icons.Rounded.Delete, contentDescription = stringResource(R.string.download_delete_of, song.title))
}
}
},
@@ -336,25 +351,27 @@ private fun songSubtitle(
encoding: AudioEncoding?,
): String {
val size = size(song.size)
val bitrate = encoding?.bitrate?.takeIf { it > 0 }
val bitrateText = if (bitrate != null) stringResource(R.string.bitrate_value, bitrate / 1000) else null
return when (song.status) {
DownloadStatus.Completed -> {
listOfNotNull(
encoding?.format,
encoding?.bitrate?.takeIf { it > 0 }?.let { "${it / 1000} kbps" },
bitrateText,
size,
).joinToString(" · ")
}
DownloadStatus.Queued -> {
"Queued · $size"
stringResource(R.string.download_status_summary, stringResource(R.string.status_queued), size)
}
DownloadStatus.Running -> {
"Downloading · $size"
stringResource(R.string.download_status_summary, stringResource(R.string.status_downloading), size)
}
DownloadStatus.Failed -> {
"Failed · $size"
stringResource(R.string.download_status_summary, stringResource(R.string.status_failed), size)
}
}
}
@@ -45,13 +45,15 @@ class DownloadsViewModel(
private val libraryRepository: LibraryRepository,
private val downloadRepository: DownloadRepository,
private val playbackController: PlaybackController,
private val unknownArtist: String,
private val unknownAlbum: String,
) : ViewModel() {
private val sourceId = libraryRepository.activeSourceId.filterNotNull()
val tree: StateFlow<DownloadTree> =
sourceId
.flatMapLatest { downloadRepository.downloadedSongs(it) }
.map(::buildTree)
.map { buildTree(it, unknownArtist, unknownAlbum) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), DownloadTree())
fun deleteAll() {
@@ -89,7 +91,11 @@ class DownloadsViewModel(
downloadRepository.localFile(song.sourceId, song.songId)?.let { readAudioEncoding(it) }
}
internal fun buildTree(songs: List<DownloadedSong>): DownloadTree =
internal fun buildTree(
songs: List<DownloadedSong>,
unknownArtist: String = "Unknown artist",
unknownAlbum: String = "Unknown album",
): DownloadTree =
DownloadTree(
artists =
songs
@@ -97,7 +103,7 @@ internal fun buildTree(songs: List<DownloadedSong>): DownloadTree =
.map { (artistKey, artistSongs) ->
DownloadArtistNode(
id = artistKey.first,
name = artistKey.second.ifBlank { "Unknown artist" },
name = artistKey.second.ifBlank { unknownArtist },
bytes = artistSongs.sumOf { it.size },
albums =
artistSongs
@@ -105,7 +111,7 @@ internal fun buildTree(songs: List<DownloadedSong>): DownloadTree =
.map { (albumKey, albumSongs) ->
DownloadAlbumNode(
id = albumKey.first,
name = albumKey.second.ifBlank { "Unknown album" },
name = albumKey.second.ifBlank { unknownAlbum },
bytes = albumSongs.sumOf { it.size },
songs = albumSongs.sortedBy { it.title.lowercase() },
)
@@ -1,5 +1,6 @@
package com.subtracks.ui.home
import android.content.res.Resources
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
@@ -34,6 +35,9 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -41,6 +45,7 @@ import androidx.paging.LoadState
import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.collectAsLazyPagingItems
import androidx.paging.compose.itemKey
import com.subtracks.R
import com.subtracks.data.model.Album
import com.subtracks.data.model.AlbumSongItem
import com.subtracks.data.model.Artist
@@ -133,6 +138,7 @@ fun HomeListScreen(
modifier: Modifier = Modifier,
) {
val now = remember { System.currentTimeMillis() }
val resources = LocalContext.current.resources
Scaffold(
modifier = modifier,
topBar = {
@@ -140,7 +146,7 @@ fun HomeListScreen(
title = { Text(request.title) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Back")
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = stringResource(R.string.navigation_back))
}
},
)
@@ -153,7 +159,7 @@ fun HomeListScreen(
AlbumsList(
albums = albums,
coverArt = coverArt,
info = { albumListInfo(request, it, now) },
info = { albumListInfo(request, it, now, resources) },
onAlbumClick = onAlbumClick,
onLongClick = onLongClick,
modifier = Modifier.padding(padding),
@@ -164,7 +170,7 @@ fun HomeListScreen(
ArtistsList(
artists = artists,
coverArt = coverArt,
info = { artistListInfo(request, it, now) },
info = { artistListInfo(request, it, now, resources) },
onArtistClick = onArtistClick,
onLongClick = onLongClick,
modifier = Modifier.padding(padding),
@@ -207,7 +213,7 @@ fun HomeListScreen(
HomeSection.Decades -> {
PlainList(
items = decades,
label = { "${it}s" },
label = { stringResource(R.string.home_decade, it) },
onClick = onDecadeClick,
modifier = Modifier.padding(padding),
)
@@ -217,7 +223,7 @@ fun HomeListScreen(
ArtistsList(
artists = artists,
coverArt = coverArt,
info = { artistListInfo(request, it, now) },
info = { artistListInfo(request, it, now, resources) },
onArtistClick = onArtistClick,
onLongClick = onLongClick,
modifier = Modifier.padding(padding),
@@ -254,7 +260,7 @@ fun HomeListScreen(
AlbumsList(
albums = albums,
coverArt = coverArt,
info = { albumListInfo(request, it, now) },
info = { albumListInfo(request, it, now, resources) },
onAlbumClick = onAlbumClick,
onLongClick = onLongClick,
modifier = Modifier.padding(padding),
@@ -269,7 +275,7 @@ fun HomeListScreen(
@Composable
private fun <T> PlainList(
items: List<T>,
label: (T) -> String,
label: @Composable (T) -> String,
onClick: (T) -> Unit,
modifier: Modifier = Modifier,
) {
@@ -470,7 +476,7 @@ private fun ArtistListRow(
)
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "${artist.albumCount} ${if (artist.albumCount == 1L) "album" else "albums"}",
text = pluralStringResource(R.plurals.resources_album_count, artist.albumCount.toInt(), artist.albumCount),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
@@ -494,10 +500,11 @@ internal fun artistListInfo(
request: HomeListRequest,
artist: Artist,
now: Long,
resources: Resources,
): String? =
when (request.section) {
HomeSection.RecentlyPlayedArtists -> artist.played?.let { relativeTimeLabel(it, now) }
HomeSection.MostPlayedArtists -> playCountLabel(artist.playCount)
HomeSection.RecentlyPlayedArtists -> artist.played?.let { relativeTimeLabel(it, now, resources) }
HomeSection.MostPlayedArtists -> playCountLabel(resources, artist.playCount)
else -> null
}
@@ -505,18 +512,19 @@ internal fun albumListInfo(
request: HomeListRequest,
album: Album,
now: Long,
resources: Resources,
): String? =
when {
request.section == HomeSection.RecentlyAddedAlbums -> {
relativeTimeLabel(album.created, now)
relativeTimeLabel(album.created, now, resources)
}
request.section == HomeSection.RecentlyPlayedAlbums || request.section == HomeSection.Rediscover -> {
album.played?.let { relativeTimeLabel(it, now) }
album.played?.let { relativeTimeLabel(it, now, resources) }
}
request.section == HomeSection.MostPlayedAlbums -> {
playCountLabel(album.playCount)
playCountLabel(resources, album.playCount)
}
request.decade != null -> {
@@ -528,26 +536,30 @@ internal fun albumListInfo(
}
}
private fun playCountLabel(count: Long): String? = if (count <= 0L) null else "$count ${if (count == 1L) "play" else "plays"}"
private fun playCountLabel(
resources: Resources,
count: Long,
): String? = if (count <= 0L) null else resources.getQuantityString(R.plurals.home_play_count, count.toInt(), count)
internal fun relativeTimeLabel(
epochSeconds: Long,
nowMillis: Long,
resources: Resources,
): String? {
if (epochSeconds <= 0L) return null
val elapsedMillis = nowMillis - epochSeconds * 1000L
if (elapsedMillis < 0L) return "Just now"
if (elapsedMillis < 0L) return resources.getString(R.string.home_time_just_now)
val days = elapsedMillis / 86_400_000L
return when {
elapsedMillis < 60_000L -> "Just now"
days == 0L -> "Today"
days == 1L -> "Yesterday"
days < 7L -> "$days days ago"
days < 14L -> "Last week"
days < 30L -> "${days / 7L} weeks ago"
days < 60L -> "Last month"
days < 365L -> "${days / 30L} months ago"
days < 730L -> "Last year"
else -> "${days / 365L} years ago"
elapsedMillis < 60_000L -> resources.getString(R.string.home_time_just_now)
days == 0L -> resources.getString(R.string.home_time_today)
days == 1L -> resources.getString(R.string.home_time_yesterday)
days < 7L -> resources.getQuantityString(R.plurals.home_time_days_ago, days.toInt(), days)
days < 14L -> resources.getString(R.string.home_time_last_week)
days < 30L -> resources.getQuantityString(R.plurals.home_time_weeks_ago, (days / 7L).toInt(), days / 7L)
days < 60L -> resources.getString(R.string.home_time_last_month)
days < 365L -> resources.getQuantityString(R.plurals.home_time_months_ago, (days / 30L).toInt(), days / 30L)
days < 730L -> resources.getString(R.string.home_time_last_year)
else -> resources.getQuantityString(R.plurals.home_time_years_ago, (days / 365L).toInt(), days / 365L)
}
}
@@ -1,9 +1,11 @@
package com.subtracks.ui.home
import androidx.annotation.StringRes
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.PagingData
import androidx.paging.cachedIn
import com.subtracks.R
import com.subtracks.data.model.Album
import com.subtracks.data.model.AlbumSongItem
import com.subtracks.data.model.Artist
@@ -32,12 +34,12 @@ data class HomeListRequest(
)
enum class OfflineListKind(
val title: String,
@param:StringRes val title: Int,
) {
Albums("Downloaded albums"),
Artists("Downloaded artists"),
Songs("Downloaded songs"),
Playlists("Downloaded playlists"),
Albums(R.string.home_downloaded_albums),
Artists(R.string.home_downloaded_artists),
Songs(R.string.home_downloaded_songs),
Playlists(R.string.home_downloaded_playlists),
}
@OptIn(ExperimentalCoroutinesApi::class)
@@ -1,5 +1,6 @@
package com.subtracks.ui.home
import androidx.annotation.StringRes
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
@@ -54,6 +55,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@@ -81,17 +83,17 @@ import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel
enum class HomeSection(
val title: String,
@param:StringRes val title: Int,
) {
RecentlyPlayedAlbums("Recently played"),
RecentlyPlayedArtists("Recently played artists"),
MostPlayedAlbums("Most played"),
MostPlayedArtists("Most played artists"),
Genres("Genres"),
Decades("Decades"),
RecentlyStarredSongs("Recently starred"),
RecentlyAddedAlbums("Recently added"),
Rediscover("Rediscover"),
RecentlyPlayedAlbums(R.string.resources_sort_by_recently_played),
RecentlyPlayedArtists(R.string.home_section_recently_played_artists),
MostPlayedAlbums(R.string.home_section_most_played),
MostPlayedArtists(R.string.home_section_most_played_artists),
Genres(R.string.home_section_genres),
Decades(R.string.home_section_decades),
RecentlyStarredSongs(R.string.home_section_recently_starred),
RecentlyAddedAlbums(R.string.home_section_recently_added),
Rediscover(R.string.home_section_rediscover),
;
companion object {
@@ -189,9 +191,9 @@ fun HomeScreen(
}
if (feed.isEmpty()) {
EmptyState(
text = "Nothing to show yet.\nSync with your server to fill your library.",
text = stringResource(R.string.home_empty),
modifier = modifier,
actionLabel = "Sync",
actionLabel = stringResource(R.string.sync),
onAction = onSync,
)
return@CompositionLocalProvider
@@ -314,7 +316,7 @@ fun OfflineHomeScreen(
downloadedPlaylistRow(playlists, coverArt, onPlaylistClick, onLongClick) { onMore(OfflineListKind.Playlists) }
songList(
key = "downloaded-songs",
title = "Downloaded songs",
title = R.string.home_downloaded_songs,
icon = Icons.Rounded.MusicNote,
songs = songs,
coverArt = coverArt,
@@ -336,7 +338,7 @@ private fun LazyListScope.downloadedAlbumRow(
) {
if (albums.itemCount == 0) return
item(key = "downloaded-albums-header") {
HomeSectionHeader("Downloaded albums", Icons.Rounded.Album) { onMore() }
HomeSectionHeader(R.string.home_downloaded_albums, Icons.Rounded.Album) { onMore() }
}
item(key = "downloaded-albums-row") {
LazyRow(
@@ -369,7 +371,7 @@ private fun LazyListScope.downloadedArtistRow(
) {
if (artists.itemCount == 0) return
item(key = "downloaded-artists-header") {
HomeSectionHeader("Downloaded artists", Icons.Rounded.Person) { onMore() }
HomeSectionHeader(R.string.home_downloaded_artists, Icons.Rounded.Person) { onMore() }
}
item(key = "downloaded-artists-row") {
LazyRow(
@@ -402,7 +404,7 @@ private fun LazyListScope.downloadedPlaylistRow(
) {
if (playlists.itemCount == 0) return
item(key = "downloaded-playlists-header") {
HomeSectionHeader("Downloaded playlists", Icons.AutoMirrored.Rounded.PlaylistPlay) { onMore() }
HomeSectionHeader(R.string.home_downloaded_playlists, Icons.AutoMirrored.Rounded.PlaylistPlay) { onMore() }
}
item(key = "downloaded-playlists-row") {
LazyRow(
@@ -464,7 +466,7 @@ private fun HomeFeed.isEmpty(): Boolean =
@Composable
private fun HomeSectionHeader(
title: String,
@StringRes title: Int,
icon: ImageVector,
onMore: (() -> Unit)?,
) {
@@ -480,13 +482,13 @@ private fun HomeSectionHeader(
)
Spacer(Modifier.width(8.dp))
Text(
text = title,
text = stringResource(title),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f),
)
if (onMore != null) {
TextButton(onClick = onMore) {
Text("More")
Text(stringResource(R.string.controls_more))
}
}
}
@@ -685,7 +687,7 @@ private fun LazyListScope.decadeRow(
modifier = Modifier.padding(top = SECTION_CONTENT_TOP),
) {
items(decades, key = { it }) { decade ->
AssistChip(onClick = { onDecadeClick(decade) }, label = { Text("${decade}s") })
AssistChip(onClick = { onDecadeClick(decade) }, label = { Text(stringResource(R.string.home_decade, decade)) })
}
}
}
@@ -713,7 +715,7 @@ private fun LazyListScope.homeSongs(
private fun LazyListScope.songList(
key: String,
title: String,
@StringRes title: Int,
icon: ImageVector,
songs: List<AlbumSongItem>,
coverArt: (String?, Boolean) -> CoverArtRef?,
@@ -20,10 +20,12 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.subtracks.R
import com.subtracks.data.model.Album
import com.subtracks.data.model.BulkDownloadAction
import com.subtracks.data.model.CoverArtRef
@@ -158,8 +160,9 @@ fun AlbumDetailScreen(
modifier: Modifier = Modifier,
) {
val multiDisc = songs.map { it.disc ?: 1L }.distinct().size > 1
val resources = LocalResources.current
val discLabels =
remember(discs) {
remember(discs, resources) {
val repeated =
discs
.map { it.title }
@@ -171,7 +174,7 @@ fun AlbumDetailScreen(
discs.associate { disc ->
disc.disc to
disc.title.takeIf { it.isNotBlank() }?.let { title ->
if (title in repeated) "$title: Disc ${disc.disc}" else title
if (title in repeated) resources.getString(R.string.disc_track, title, disc.disc) else title
}
}
}
@@ -218,7 +221,9 @@ fun AlbumDetailScreen(
val discLabel = discLabels[disc]
if (disc != lastDisc && (multiDisc || discLabel != null)) {
val isFirst = lastDisc == null
item(key = "disc:$disc") { DiscHeader(discLabel ?: "Disc $disc", first = isFirst) }
item(
key = "disc:$disc",
) { DiscHeader(discLabel ?: resources.getString(R.string.disc_header, disc), first = isFirst) }
}
lastDisc = disc
item(key = song.id) {
@@ -20,11 +20,13 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.paging.LoadState
import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.itemKey
import com.subtracks.R
import com.subtracks.data.model.Album
import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.ListDownloadStatus
@@ -65,9 +67,9 @@ fun AlbumsContent(
FilteredEmptyState(onClearFilters, modifier)
} else {
EmptyState(
text = "No albums yet.\nSync with your server to fill your library.",
text = stringResource(R.string.albums_empty),
modifier = modifier,
actionLabel = "Sync",
actionLabel = stringResource(R.string.sync),
onAction = onSync,
)
}
@@ -67,6 +67,8 @@ import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextMeasurer
import androidx.compose.ui.text.TextStyle
@@ -75,6 +77,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.subtracks.R
import com.subtracks.data.model.Album
import com.subtracks.data.model.Artist
import com.subtracks.data.model.BulkDownloadAction
@@ -346,7 +349,7 @@ fun ArtistDetailScreen(
) {
Icon(
imageVector = Icons.Rounded.Shuffle,
contentDescription = "Shuffle artist",
contentDescription = stringResource(R.string.shuffle_artist),
modifier = Modifier.size(SHUFFLE_ICON),
)
}
@@ -357,7 +360,7 @@ fun ArtistDetailScreen(
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "$albumCount ${if (albumCount == 1L) "album" else "albums"}",
text = pluralStringResource(R.plurals.resources_album_count, albumCount.toInt(), albumCount),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
@@ -368,7 +371,10 @@ fun ArtistDetailScreen(
) {
Icon(
imageVector = if (starred) Icons.Rounded.Star else Icons.Rounded.StarBorder,
contentDescription = if (starred) "Unstar" else "Star",
contentDescription =
stringResource(
if (starred) R.string.actions_unstar else R.string.actions_star,
),
tint =
if (starred) {
MaterialTheme.colorScheme.primary
@@ -378,7 +384,7 @@ fun ArtistDetailScreen(
)
}
IconButton(onClick = onMore) {
Icon(Icons.Rounded.MoreHoriz, contentDescription = "More options")
Icon(Icons.Rounded.MoreHoriz, contentDescription = stringResource(R.string.more_options))
}
}
}
@@ -453,7 +459,7 @@ fun ArtistDetailScreen(
},
navigationIcon = {
IconButton(onClick = onBack, modifier = Modifier.graphicsLayer { alpha = barFraction }) {
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Back")
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = stringResource(R.string.navigation_back))
}
},
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent),
@@ -17,12 +17,15 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.paging.LoadState
import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.itemKey
import com.subtracks.R
import com.subtracks.data.model.Artist
import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.ListDownloadStatus
@@ -60,7 +63,7 @@ fun ArtistsContent(
if (filtered) {
FilteredEmptyState(onClearFilters, modifier)
} else {
EmptyState("No artists yet.", modifier, actionLabel = "Sync", onAction = onSync)
EmptyState(stringResource(R.string.artists_empty), modifier, actionLabel = stringResource(R.string.sync), onAction = onSync)
}
}
@@ -87,7 +90,12 @@ fun ArtistsContent(
},
supportingContent = {
Text(
text = "${artist.albumCount} ${if (artist.albumCount == 1L) "album" else "albums"}",
text =
pluralStringResource(
R.plurals.resources_album_count,
artist.albumCount.toInt(),
artist.albumCount,
),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
@@ -1,5 +1,6 @@
package com.subtracks.ui.library
import androidx.annotation.StringRes
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
@@ -94,6 +95,7 @@ import androidx.compose.ui.layout.layout
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
@@ -103,6 +105,7 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.paging.PagingData
import androidx.paging.compose.collectAsLazyPagingItems
import com.subtracks.R
import com.subtracks.data.model.Album
import com.subtracks.data.model.Artist
import com.subtracks.data.model.BulkDownloadAction
@@ -140,18 +143,18 @@ import kotlin.math.abs
import kotlin.math.roundToInt
enum class LibraryTab(
val label: String,
@param:StringRes val label: Int,
val icon: ImageVector,
) {
Home("Home", Icons.Rounded.Home),
Albums("Albums", Icons.Rounded.Album),
Artists("Artists", Icons.Rounded.Person),
Playlists("Playlists", Icons.AutoMirrored.Rounded.PlaylistPlay),
Home(R.string.navigation_tabs_home, Icons.Rounded.Home),
Albums(R.string.resources_album_name, Icons.Rounded.Album),
Artists(R.string.resources_artist_name, Icons.Rounded.Person),
Playlists(R.string.resources_playlist_name, Icons.AutoMirrored.Rounded.PlaylistPlay),
}
data class SortOption(
val value: String,
val label: String,
@param:StringRes val label: Int,
val descendingByDefault: Boolean = false,
)
@@ -166,31 +169,47 @@ fun sortOptionsFor(
when (tab) {
LibraryTab.Albums -> {
buildList {
add(SortOption(AlbumSort.Name.name, "Name"))
add(SortOption(AlbumSort.Artist.name, "Artist"))
add(SortOption(AlbumSort.Year.name, "Year", descendingByDefault = true))
add(SortOption(AlbumSort.Added.name, "Added", descendingByDefault = true))
add(SortOption(AlbumSort.Starred.name, "Starred", descendingByDefault = true))
if (play.frequent) add(SortOption(AlbumSort.Frequent.name, "Frequently played", descendingByDefault = true))
if (play.recent) add(SortOption(AlbumSort.Recent.name, "Recently played", descendingByDefault = true))
add(SortOption(AlbumSort.Name.name, R.string.resources_sort_by_name))
add(SortOption(AlbumSort.Artist.name, R.string.resources_sort_by_artist))
add(SortOption(AlbumSort.Year.name, R.string.resources_sort_by_year, descendingByDefault = true))
add(SortOption(AlbumSort.Added.name, R.string.resources_sort_by_added, descendingByDefault = true))
add(SortOption(AlbumSort.Starred.name, R.string.resources_filter_starred, descendingByDefault = true))
if (play.frequent) {
add(
SortOption(AlbumSort.Frequent.name, R.string.resources_sort_by_frequently_played, descendingByDefault = true),
)
}
if (play.recent) {
add(
SortOption(AlbumSort.Recent.name, R.string.resources_sort_by_recently_played, descendingByDefault = true),
)
}
}
}
LibraryTab.Artists -> {
buildList {
add(SortOption(ArtistSort.Name.name, "Name"))
add(SortOption(ArtistSort.AlbumCount.name, "Albums", descendingByDefault = true))
add(SortOption(ArtistSort.Starred.name, "Starred", descendingByDefault = true))
if (play.frequent) add(SortOption(ArtistSort.Frequent.name, "Frequently played", descendingByDefault = true))
if (play.recent) add(SortOption(ArtistSort.Recent.name, "Recently played", descendingByDefault = true))
add(SortOption(ArtistSort.Name.name, R.string.resources_sort_by_name))
add(SortOption(ArtistSort.AlbumCount.name, R.string.resources_album_name, descendingByDefault = true))
add(SortOption(ArtistSort.Starred.name, R.string.resources_filter_starred, descendingByDefault = true))
if (play.frequent) {
add(
SortOption(ArtistSort.Frequent.name, R.string.resources_sort_by_frequently_played, descendingByDefault = true),
)
}
if (play.recent) {
add(
SortOption(ArtistSort.Recent.name, R.string.resources_sort_by_recently_played, descendingByDefault = true),
)
}
}
}
LibraryTab.Playlists -> {
listOf(
SortOption(PlaylistSort.Name.name, "Name"),
SortOption(PlaylistSort.Added.name, "Added", descendingByDefault = true),
SortOption(PlaylistSort.Updated.name, "Updated", descendingByDefault = true),
SortOption(PlaylistSort.Name.name, R.string.resources_sort_by_name),
SortOption(PlaylistSort.Added.name, R.string.resources_sort_by_added, descendingByDefault = true),
SortOption(PlaylistSort.Updated.name, R.string.resources_sort_by_updated, descendingByDefault = true),
)
}
@@ -428,6 +447,7 @@ fun LibraryScreen(
val listTopInset = statusBarTop
val listBottomInset = tabBarHeight + BOTTOM_CLEARANCE
val headerColor = artwork?.let(::playerSurfaceColor) ?: MaterialTheme.colorScheme.background
val listOptionsLabel = stringResource(R.string.list_options)
Box(
modifier =
@@ -581,7 +601,7 @@ fun LibraryScreen(
modifier =
Modifier
.size(24.dp)
.semantics { contentDescription = "List options" },
.semantics { contentDescription = listOptionsLabel },
)
if (filtersActive) {
Box(
@@ -654,7 +674,7 @@ private fun ListOptionsSheet(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
) {
Icon(Icons.Rounded.Search, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
Text("Search this list", color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(stringResource(R.string.search_this_list), color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
@@ -663,14 +683,21 @@ private fun ListOptionsSheet(
modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 8.dp, top = 12.dp),
) {
Text(
text = "Sort by",
text = stringResource(R.string.sort_by),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f),
)
IconButton(onClick = onToggleSortDirection) {
Icon(
imageVector = if (descending) Icons.Rounded.ArrowDownward else Icons.Rounded.ArrowUpward,
contentDescription = if (descending) "Sort descending" else "Sort ascending",
contentDescription =
if (descending) {
stringResource(
R.string.sort_descending,
)
} else {
stringResource(R.string.sort_ascending)
},
)
}
}
@@ -685,7 +712,7 @@ private fun ListOptionsSheet(
onClick = {
if (listQuery.sort == option.value) onToggleSortDirection() else onSortChange(option.value)
},
label = { Text(option.label) },
label = { Text(stringResource(option.label)) },
)
}
}
@@ -695,7 +722,7 @@ private fun ListOptionsSheet(
modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 8.dp, top = 16.dp),
) {
Text(
text = "Filters",
text = stringResource(R.string.filters),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f),
)
@@ -703,7 +730,7 @@ private fun ListOptionsSheet(
onClick = onClearFilters,
enabled = canClearFilters,
) {
Icon(Icons.Rounded.FilterAltOff, contentDescription = "Clear filters")
Icon(Icons.Rounded.FilterAltOff, contentDescription = stringResource(R.string.components_clear_filters))
}
}
FlowRow(
@@ -713,7 +740,7 @@ private fun ListOptionsSheet(
FilterChip(
selected = listQuery.downloaded,
onClick = onToggleDownloaded,
label = { Text("Downloaded") },
label = { Text(stringResource(R.string.filter_downloaded)) },
leadingIcon =
if (listQuery.downloaded) {
{
@@ -731,7 +758,7 @@ private fun ListOptionsSheet(
FilterChip(
selected = listQuery.starred != StarredFilter.Any,
onClick = onCycleStarred,
label = { Text("Starred") },
label = { Text(stringResource(R.string.resources_filter_starred)) },
leadingIcon =
when (listQuery.starred) {
StarredFilter.Any -> {
@@ -785,11 +812,11 @@ private fun SearchField(
value = value,
onValueChange = onValueChange,
singleLine = true,
placeholder = { Text("Search") },
placeholder = { Text(stringResource(R.string.search_input_placeholder)) },
leadingIcon = { Icon(Icons.Rounded.Search, contentDescription = null) },
trailingIcon = {
IconButton(onClick = onClose) {
Icon(Icons.Rounded.Close, contentDescription = "Close search")
Icon(Icons.Rounded.Close, contentDescription = stringResource(R.string.close_search))
}
},
shape = RoundedCornerShape(4.dp),
@@ -917,7 +944,7 @@ internal fun LibraryTabs(
IconButton(onClick = onExitOffline) {
Icon(
imageVector = Icons.Rounded.CloudOff,
contentDescription = "Offline mode; tap to go online",
contentDescription = stringResource(R.string.offline_mode_status),
tint = MaterialTheme.colorScheme.onBackground,
)
}
@@ -925,7 +952,7 @@ internal fun LibraryTabs(
IconButton(onClick = onOpenSettings) {
Icon(
imageVector = Icons.Rounded.Settings,
contentDescription = "Settings",
contentDescription = stringResource(R.string.navigation_tabs_settings),
tint = MaterialTheme.colorScheme.onBackground,
)
}
@@ -1005,7 +1032,7 @@ private fun TabContent(
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = tab.icon,
contentDescription = tab.label,
contentDescription = stringResource(tab.label),
tint = color,
modifier =
Modifier.size(TAB_ICON_SIZE).graphicsLayer {
@@ -1032,7 +1059,7 @@ private fun TabContent(
Row(verticalAlignment = Alignment.CenterVertically) {
Spacer(Modifier.width(8.dp))
Text(
text = tab.label,
text = stringResource(tab.label),
style = MaterialTheme.typography.titleSmall,
color = color,
maxLines = 1,
@@ -1,5 +1,6 @@
package com.subtracks.ui.library
import android.content.Context
import androidx.compose.foundation.combinedClickable
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -9,10 +10,12 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.collectAsLazyPagingItems
import androidx.paging.compose.itemKey
import com.subtracks.R
import com.subtracks.data.model.BulkDownloadAction
import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.ListDownloadStatus
@@ -127,6 +130,7 @@ fun PlaylistDetailScreen(
playingSongId: String? = null,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
HeroDetailScaffold(
artwork = artwork,
title = playlist?.name.orEmpty(),
@@ -136,7 +140,7 @@ fun PlaylistDetailScreen(
art = coverArt(playlist?.coverArt, false),
thumbnailRef = coverArt(playlist?.coverArt, true),
name = playlist?.name.orEmpty(),
subtitle = playlistSummary(playlist, offline, downloadStatus.downloaded),
subtitle = playlistSummary(context, playlist, offline, downloadStatus.downloaded),
comment = playlist?.comment,
hasSongs = songs.itemCount > 0,
onPlay = onPlay,
@@ -175,6 +179,7 @@ fun PlaylistDetailScreen(
}
private fun playlistSummary(
context: Context,
playlist: Playlist?,
offline: Boolean,
downloadedSongs: Long,
@@ -182,19 +187,22 @@ private fun playlistSummary(
playlist ?: return ""
val songs = if (offline) downloadedSongs else playlist.songCount
return listOfNotNull(
"$songs ${if (songs == 1L) "song" else "songs"}",
formatDuration(playlist.duration),
context.resources.getQuantityString(R.plurals.resources_song_count, songs.toInt(), songs),
formatDuration(context, playlist.duration),
).joinToString(" $DOT ")
}
private fun formatDuration(seconds: Long): String? {
private fun formatDuration(
context: Context,
seconds: Long,
): String? {
if (seconds < 60) return null
val totalMinutes = seconds / 60
val hours = totalMinutes / 60
val minutes = totalMinutes % 60
return when {
hours == 0L -> "$totalMinutes min"
minutes == 0L -> "$hours hr"
else -> "$hours hr $minutes min"
hours == 0L -> context.getString(R.string.playlist_duration_minutes, totalMinutes)
minutes == 0L -> context.getString(R.string.playlist_duration_hours, hours)
else -> context.getString(R.string.playlist_duration_hours_minutes, hours, minutes)
}
}
@@ -17,12 +17,15 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.paging.LoadState
import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.itemKey
import com.subtracks.R
import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.ListDownloadStatus
import com.subtracks.data.model.Playlist
@@ -59,7 +62,12 @@ fun PlaylistsContent(
if (filtered) {
FilteredEmptyState(onClearFilters, modifier)
} else {
EmptyState("No playlists yet.", modifier, actionLabel = "Sync", onAction = onSync)
EmptyState(
stringResource(R.string.playlists_empty),
modifier,
actionLabel = stringResource(R.string.sync),
onAction = onSync,
)
}
}
@@ -87,7 +95,11 @@ fun PlaylistsContent(
Text(
text =
playlist.comment?.takeIf { it.isNotBlank() }
?: "${playlist.songCount} ${if (playlist.songCount == 1L) "song" else "songs"}",
?: pluralStringResource(
R.plurals.resources_song_count,
playlist.songCount.toInt(),
playlist.songCount,
),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
@@ -47,6 +47,7 @@ import androidx.compose.ui.graphics.vector.PathParser
import androidx.compose.ui.graphics.vector.VectorGroup
import androidx.compose.ui.graphics.vector.VectorPath
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
@@ -54,6 +55,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.subtracks.R
import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.DownloadStatus
import com.subtracks.data.model.Song
@@ -224,9 +226,10 @@ private fun PlayIndicator(
modifier: Modifier = Modifier,
) {
val tint = MaterialTheme.colorScheme.primary
val playingDescription = stringResource(R.string.status_playing)
if (shadow) {
val radius = with(LocalDensity.current) { PLAY_SHADOW_RADIUS.toPx() }
Canvas(modifier.semantics { contentDescription = "Playing" }) {
Canvas(modifier.semantics { contentDescription = playingDescription }) {
val scale = minOf(size.width / PLAY_ARROW.viewportWidth, size.height / PLAY_ARROW.viewportHeight)
withTransform({ scale(scale, scale, pivot = Offset.Zero) }) {
drawIntoCanvas { canvas ->
@@ -257,7 +260,7 @@ private fun PlayIndicator(
} else {
Icon(
imageVector = PLAY_ARROW,
contentDescription = "Playing",
contentDescription = playingDescription,
tint = tint,
modifier = modifier,
)
@@ -325,11 +328,13 @@ internal fun formatTrackTime(seconds: Long): String {
@Composable
private fun DownloadBadge(download: SongDownload?) {
val downloadedDescription = stringResource(R.string.status_downloaded)
val downloadingDescription = stringResource(R.string.status_downloading)
when (download?.status) {
DownloadStatus.Completed -> {
Icon(
imageVector = Icons.Rounded.DownloadDone,
contentDescription = "Downloaded",
contentDescription = downloadedDescription,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(16.dp),
)
@@ -339,13 +344,13 @@ private fun DownloadBadge(download: SongDownload?) {
val progress = download.progress
if (progress == null) {
CircularProgressIndicator(
modifier = Modifier.size(14.dp).semantics { contentDescription = "Downloading" },
modifier = Modifier.size(14.dp).semantics { contentDescription = downloadingDescription },
strokeWidth = 2.dp,
)
} else {
CircularProgressIndicator(
progress = { progress },
modifier = Modifier.size(14.dp).semantics { contentDescription = "Downloading" },
modifier = Modifier.size(14.dp).semantics { contentDescription = downloadingDescription },
strokeWidth = 2.dp,
)
}
@@ -38,7 +38,9 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.subtracks.R
import com.subtracks.data.model.CoverArtRef
import com.subtracks.playback.PlaybackState
import com.subtracks.ui.components.CoverArt
@@ -155,7 +157,14 @@ fun MiniPlayer(
} else {
Icon(
imageVector = if (state.isPlaying) Icons.Rounded.Pause else Icons.Rounded.PlayArrow,
contentDescription = if (state.isPlaying) "Pause" else "Play",
contentDescription =
if (state.isPlaying) {
stringResource(
R.string.controls_pause,
)
} else {
stringResource(R.string.controls_play)
},
tint = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.size(36.dp),
)
@@ -164,7 +173,7 @@ fun MiniPlayer(
IconButton(onClick = onNext, enabled = state.hasNext, modifier = Modifier.size(48.dp)) {
Icon(
imageVector = Icons.Rounded.SkipNext,
contentDescription = "Next",
contentDescription = stringResource(R.string.controls_next),
tint = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.size(36.dp),
)
@@ -1,5 +1,6 @@
package com.subtracks.ui.playback
import androidx.annotation.StringRes
import androidx.compose.animation.core.animate
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
@@ -63,6 +64,7 @@ import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.PlatformTextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.LineHeightStyle
@@ -71,6 +73,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil3.compose.LocalPlatformContext
import com.subtracks.R
import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.QueueKind
import com.subtracks.data.model.Song
@@ -217,7 +220,7 @@ fun NowPlayingRoute(
val art = controller.coverArt(state.item)
val thumbnail = controller.coverArt(state.item, thumbnail = true)
val queueContext = state.context
val fallbackTitle = state.item?.album?.takeIf { it.isNotBlank() } ?: "Library"
val fallbackTitle = state.item?.album?.takeIf { it.isNotBlank() } ?: stringResource(R.string.navigation_tabs_library)
val sourceTitle = remember(queueContext) { mutableStateOf(fallbackTitle) }
LaunchedEffect(queueContext) {
sourceTitle.value = controller.sourceTitle(queueContext)?.takeIf { it.isNotBlank() } ?: fallbackTitle
@@ -355,6 +358,7 @@ fun NowPlayingScreen(
frozenStrip = null
}
}
val kindLabel = stringResource(state.context?.kind?.label() ?: R.string.context_kind_library)
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onBackground) {
ArtworkTheme(artwork) {
@@ -372,7 +376,7 @@ fun NowPlayingScreen(
title = {
Column {
Text(
text = "Now playing: ${state.context?.kind?.label() ?: "library"}".uppercase(),
text = stringResource(R.string.now_playing_header, kindLabel).uppercase(),
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold,
letterSpacing = 1.sp,
@@ -388,7 +392,10 @@ fun NowPlayingScreen(
},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Back")
Icon(
Icons.AutoMirrored.Rounded.ArrowBack,
contentDescription = stringResource(R.string.navigation_back),
)
}
},
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent),
@@ -459,7 +466,7 @@ fun NowPlayingScreen(
interactionSource = remember { MutableInteractionSource() },
indication = null,
enabled = onAlbumClick != null,
onClickLabel = "Open album",
onClickLabel = stringResource(R.string.open_album),
) { onAlbumClick?.invoke() }
.testTag(NOW_PLAYING_COVER_TAG),
contentAlignment = Alignment.Center,
@@ -518,7 +525,7 @@ fun NowPlayingScreen(
interactionSource = remember { MutableInteractionSource() },
indication = null,
enabled = onArtistClick != null,
onClickLabel = "Open artist",
onClickLabel = stringResource(R.string.open_artist),
) { onArtistClick?.invoke() },
) {
Box(Modifier.height(titleHeight), contentAlignment = Alignment.CenterStart) {
@@ -542,7 +549,7 @@ fun NowPlayingScreen(
IconButton(onClick = { onToggleStar?.invoke() }, enabled = onToggleStar != null) {
Icon(
imageVector = if (starred) Icons.Rounded.Star else Icons.Rounded.StarBorder,
contentDescription = if (starred) "Unstar" else "Star",
contentDescription = stringResource(if (starred) R.string.actions_unstar else R.string.actions_star),
tint =
if (starred) {
MaterialTheme.colorScheme.primary
@@ -590,11 +597,13 @@ fun NowPlayingScreen(
RepeatMode.One -> Icons.Rounded.RepeatOneOn
},
contentDescription =
when (state.repeat) {
RepeatMode.Off -> "Repeat off"
RepeatMode.All -> "Repeat all"
RepeatMode.One -> "Repeat one"
},
stringResource(
when (state.repeat) {
RepeatMode.Off -> R.string.repeat_off
RepeatMode.All -> R.string.repeat_all
RepeatMode.One -> R.string.repeat_one
},
),
tint =
if (state.repeat == RepeatMode.Off) {
MaterialTheme.colorScheme.onSurfaceVariant
@@ -607,7 +616,7 @@ fun NowPlayingScreen(
IconButton(onClick = onPrevious, modifier = Modifier.size(72.dp)) {
Icon(
imageVector = Icons.Rounded.SkipPrevious,
contentDescription = "Previous",
contentDescription = stringResource(R.string.controls_previous),
modifier = Modifier.size(60.dp),
)
}
@@ -629,7 +638,14 @@ fun NowPlayingScreen(
} else {
Icon(
imageVector = if (state.isPlaying) Icons.Rounded.PauseCircle else Icons.Rounded.PlayCircle,
contentDescription = if (state.isPlaying) "Pause" else "Play",
contentDescription =
if (state.isPlaying) {
stringResource(
R.string.controls_pause,
)
} else {
stringResource(R.string.controls_play)
},
modifier = Modifier.size(playButtonSize),
)
}
@@ -637,14 +653,14 @@ fun NowPlayingScreen(
IconButton(onClick = onNext, modifier = Modifier.size(72.dp)) {
Icon(
imageVector = Icons.Rounded.SkipNext,
contentDescription = "Next",
contentDescription = stringResource(R.string.controls_next),
modifier = Modifier.size(60.dp),
)
}
IconButton(onClick = onShuffle, modifier = Modifier.size(48.dp)) {
Icon(
imageVector = if (state.shuffle) Icons.Rounded.ShuffleOn else Icons.Rounded.Shuffle,
contentDescription = "Shuffle",
contentDescription = stringResource(R.string.controls_shuffle),
tint =
if (state.shuffle) {
MaterialTheme.colorScheme.onBackground
@@ -663,14 +679,14 @@ fun NowPlayingScreen(
IconButton(onClick = onQueue, modifier = Modifier.size(40.dp)) {
Icon(
imageVector = Icons.AutoMirrored.Rounded.QueueMusic,
contentDescription = "Queue",
contentDescription = stringResource(R.string.controls_queue),
modifier = Modifier.size(30.dp),
)
}
IconButton(onClick = onMore, modifier = Modifier.size(40.dp)) {
Icon(
imageVector = Icons.Rounded.MoreHoriz,
contentDescription = "More",
contentDescription = stringResource(R.string.controls_more),
modifier = Modifier.size(30.dp),
)
}
@@ -682,15 +698,16 @@ fun NowPlayingScreen(
}
}
private fun QueueKind.label(): String =
@StringRes
private fun QueueKind.label(): Int =
when (this) {
QueueKind.Album -> "album"
QueueKind.Playlist -> "playlist"
QueueKind.Song -> "song"
QueueKind.Artist -> "artist"
QueueKind.Genre -> "genre"
QueueKind.Downloaded -> "downloads"
QueueKind.Starred -> "starred"
QueueKind.Album -> R.string.context_kind_album
QueueKind.Playlist -> R.string.context_kind_playlist
QueueKind.Song -> R.string.context_kind_song
QueueKind.Artist -> R.string.context_kind_artist
QueueKind.Genre -> R.string.context_kind_genre
QueueKind.Downloaded -> R.string.context_kind_downloads
QueueKind.Starred -> R.string.context_kind_starred
}
private fun formatTime(milliseconds: Long): String {
@@ -42,10 +42,12 @@ import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope
import com.subtracks.R
import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.SongItem
import com.subtracks.data.repo.DownloadRepository
@@ -286,7 +288,7 @@ fun QueueRoute(
val queueContext = playback.context
// The item's album keeps the header stable while the async name lookup runs, so it does not
// flash the fallback when tapping between context and manually queued tracks.
val fallbackTitle = playback.item?.album?.takeIf { it.isNotBlank() } ?: "Next from here"
val fallbackTitle = playback.item?.album?.takeIf { it.isNotBlank() } ?: stringResource(R.string.queue_next_from_here)
val sourceTitle = remember(queueContext) { mutableStateOf(fallbackTitle) }
LaunchedEffect(queueContext) {
sourceTitle.value = controller.sourceTitle(queueContext)?.takeIf { it.isNotBlank() } ?: fallbackTitle
@@ -345,6 +347,9 @@ fun QueueScreen(
val scope = rememberCoroutineScope()
var dragId by remember { mutableStateOf<Long?>(null) }
var dragFrom by remember { mutableStateOf<Long?>(null) }
val undoLabel = stringResource(R.string.undo)
val queueReordered = stringResource(R.string.queue_reordered)
val queueRemoved = stringResource(R.string.queue_removed)
// One reorder state per run of rows: sh.calvin.reorderable only targets keys registered with
// the state that started the drag, so a drag can never reach a row in another run. The context
@@ -383,7 +388,7 @@ fun QueueScreen(
val showUndo: (String) -> Unit = { message ->
scope.launch {
snackbarHostState.currentSnackbarData?.dismiss()
val result = snackbarHostState.showSnackbar(message, actionLabel = "Undo", duration = SnackbarDuration.Short)
val result = snackbarHostState.showSnackbar(message, actionLabel = undoLabel, duration = SnackbarDuration.Short)
if (result == SnackbarResult.ActionPerformed) onUndo()
}
}
@@ -392,10 +397,10 @@ fun QueueScreen(
modifier = modifier,
topBar = {
TopAppBar(
title = { Text("Queue") },
title = { Text(stringResource(R.string.queue_title)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Back")
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = stringResource(R.string.navigation_back))
}
},
)
@@ -408,7 +413,7 @@ fun QueueScreen(
}
rows.isEmpty() -> {
EmptyState("The queue is empty.", Modifier.padding(padding))
EmptyState(stringResource(R.string.queue_empty), Modifier.padding(padding))
}
else -> {
@@ -424,7 +429,13 @@ fun QueueScreen(
// block, so a section-based key would duplicate.
item(key = "header-$index") {
QueueSectionHeader(
label = if (row.upNext) "Up next" else contextTitle ?: "Next up",
label =
if (row.upNext) {
stringResource(R.string.queue_up_next)
} else {
contextTitle
?: stringResource(R.string.queue_next_up)
},
onClear = if (row.upNext) onClearUpNext else null,
)
}
@@ -475,7 +486,7 @@ fun QueueScreen(
}
if (to != from) {
onMove(from, to)
showUndo("Queue reordered")
showUndo(queueReordered)
}
}
dragId = null
@@ -486,7 +497,7 @@ fun QueueScreen(
onClick = { onPlay(row.position) },
onRemove = {
onRemove(row.position)
showUndo("Removed from queue")
showUndo(queueRemoved)
},
)
}
@@ -516,7 +527,11 @@ private fun QueueSectionHeader(
)
if (onClear != null) {
IconButton(onClick = onClear) {
Icon(Icons.Rounded.DeleteSweep, contentDescription = "Clear up next", modifier = Modifier.size(20.dp))
Icon(
Icons.Rounded.DeleteSweep,
contentDescription = stringResource(R.string.queue_clear_up_next),
modifier = Modifier.size(20.dp),
)
}
}
}
@@ -567,13 +582,13 @@ private fun QueueRowItem(
IconButton(onClick = onRemove) {
Icon(
imageVector = Icons.Rounded.Close,
contentDescription = "Remove from queue",
contentDescription = stringResource(R.string.queue_remove),
modifier = Modifier.size(20.dp),
)
}
if (dragHandle != null) {
Box(modifier = Modifier.size(40.dp).then(dragHandle), contentAlignment = Alignment.Center) {
Icon(imageVector = Icons.Rounded.DragHandle, contentDescription = "Reorder")
Icon(imageVector = Icons.Rounded.DragHandle, contentDescription = stringResource(R.string.queue_reorder))
}
}
}
@@ -41,10 +41,12 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.subtracks.R
import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf
@@ -98,11 +100,22 @@ fun AddSourceScreen(
modifier = modifier,
topBar = {
TopAppBar(
title = { Text(if (state.isEditing) "Edit server" else "Add server") },
title = {
Text(
if (state.isEditing) {
stringResource(R.string.settings_servers_actions_edit)
} else {
stringResource(R.string.settings_servers_actions_add)
},
)
},
navigationIcon = {
if (onBack != null) {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Back")
Icon(
Icons.AutoMirrored.Rounded.ArrowBack,
contentDescription = stringResource(R.string.navigation_back),
)
}
}
},
@@ -113,7 +126,7 @@ fun AddSourceScreen(
enabled = state.canDelete && !state.busy,
colors = IconButtonDefaults.iconButtonColors(contentColor = MaterialTheme.colorScheme.error),
) {
Icon(Icons.Rounded.Delete, contentDescription = "Delete server")
Icon(Icons.Rounded.Delete, contentDescription = stringResource(R.string.settings_servers_actions_delete))
}
}
},
@@ -144,10 +157,10 @@ fun AddSourceScreen(
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
}
OutlinedButton(onClick = onTest, enabled = !state.busy) {
Text("Test connection")
Text(stringResource(R.string.settings_servers_actions_test_connection))
}
Button(onClick = onSave, enabled = !state.busy) {
Text("Save and sync")
Text(stringResource(R.string.settings_servers_actions_save))
}
}
}
@@ -164,16 +177,16 @@ fun AddSourceScreen(
) {
Text(
if (state.isEditing) {
"Update this server's connection details."
stringResource(R.string.edit_server_intro)
} else {
"Connect a Subsonic-compatible server such as Navidrome, gonic or Airsonic."
stringResource(R.string.add_server_intro)
},
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (state.isEditing && !state.canDelete) {
Text(
"This is the active server. Switch to another server before deleting it.",
stringResource(R.string.active_server_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -181,34 +194,34 @@ fun AddSourceScreen(
OutlinedTextField(
value = state.name,
onValueChange = onNameChange,
label = { Text("Name") },
label = { Text(stringResource(R.string.settings_servers_fields_name)) },
singleLine = true,
isError = state.nameError,
supportingText = if (state.nameError) ({ Text("Required") }) else null,
supportingText = if (state.nameError) ({ Text(stringResource(R.string.required)) }) else null,
modifier = Modifier.fillMaxWidth().focusRequester(nameFocus),
)
OutlinedTextField(
value = state.address,
onValueChange = onAddressChange,
label = { Text("Server address") },
placeholder = { Text("https://music.example.com") },
label = { Text(stringResource(R.string.settings_servers_fields_address)) },
placeholder = { Text(stringResource(R.string.server_address_placeholder)) },
singleLine = true,
isError = state.addressError,
supportingText = if (state.addressError) ({ Text("Required") }) else null,
supportingText = if (state.addressError) ({ Text(stringResource(R.string.required)) }) else null,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
modifier = Modifier.fillMaxWidth().focusRequester(addressFocus),
)
OutlinedTextField(
value = state.username,
onValueChange = onUsernameChange,
label = { Text("Username") },
label = { Text(stringResource(R.string.settings_servers_fields_username)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = state.password,
onValueChange = onPasswordChange,
label = { Text("Password") },
label = { Text(stringResource(R.string.settings_servers_fields_password)) },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth(),
@@ -219,9 +232,9 @@ fun AddSourceScreen(
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
Column(Modifier.weight(1f)) {
Text("Token authentication")
Text(stringResource(R.string.token_authentication))
Text(
"Recommended. Disable only for servers that reject the salted token scheme.",
stringResource(R.string.token_authentication_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -243,18 +256,18 @@ fun AddSourceScreen(
if (confirmingDelete) {
AlertDialog(
onDismissRequest = { confirmingDelete = false },
title = { Text("Delete server?") },
text = { Text("${state.name} and its downloaded songs will be removed from this device.") },
title = { Text(stringResource(R.string.delete_server_title)) },
text = { Text(stringResource(R.string.delete_server_body, state.name)) },
confirmButton = {
TextButton(
onClick = {
confirmingDelete = false
onDelete()
},
) { Text("Delete") }
) { Text(stringResource(R.string.actions_delete)) }
},
dismissButton = {
TextButton(onClick = { confirmingDelete = false }) { Text("Cancel") }
TextButton(onClick = { confirmingDelete = false }) { Text(stringResource(R.string.actions_cancel)) }
},
)
}
@@ -1,7 +1,9 @@
package com.subtracks.ui.settings
import android.content.res.Resources
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.subtracks.R
import com.subtracks.data.repo.DownloadRepository
import com.subtracks.data.repo.SourceRepository
import com.subtracks.data.sync.SyncManager
@@ -38,6 +40,7 @@ class AddSourceViewModel(
private val syncManager: SyncManager,
private val downloadRepository: DownloadRepository,
private val sourceId: Long? = null,
private val resources: Resources,
) : ViewModel() {
private val _state = MutableStateFlow(AddSourceState(isEditing = sourceId != null))
val state: StateFlow<AddSourceState> = _state
@@ -87,8 +90,16 @@ class AddSourceViewModel(
useTokenAuth = if (fellBack) false else state.useTokenAuth,
message =
result.fold(
{ if (fellBack) "Server does not support token auth; using the password instead" else "Connection OK" },
{ error -> "Failed: ${error.message}" },
{
if (fellBack) {
resources.getString(
R.string.connection_token_fallback,
)
} else {
resources.getString(R.string.connection_ok)
}
},
{ error -> resources.getString(R.string.connection_failed, error.message) },
),
isError = result.isFailure,
)
@@ -133,7 +144,7 @@ class AddSourceViewModel(
},
onFailure = { error ->
_state.update {
it.copy(busy = false, message = "Could not save: ${error.message}", isError = true)
it.copy(busy = false, message = resources.getString(R.string.save_failed, error.message), isError = true)
}
},
)
@@ -146,7 +157,7 @@ class AddSourceViewModel(
viewModelScope.launch {
if (!sourceRepository.deleteSource(id)) {
_state.update {
it.copy(busy = false, message = "The active server can't be deleted; switch to another server first", isError = true)
it.copy(busy = false, message = resources.getString(R.string.active_server_delete_error), isError = true)
}
return@launch
}
@@ -13,6 +13,7 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import com.mikepenz.aboutlibraries.Libs
import com.mikepenz.aboutlibraries.ui.compose.android.produceLibraries
import com.mikepenz.aboutlibraries.ui.compose.m3.LibrariesContainer
@@ -38,10 +39,10 @@ fun LicensesScreen(
modifier = modifier,
topBar = {
TopAppBar(
title = { Text("Licenses") },
title = { Text(stringResource(R.string.settings_about_actions_licenses)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Back")
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = stringResource(R.string.navigation_back))
}
},
)
@@ -1,5 +1,6 @@
package com.subtracks.ui.settings
import android.content.res.Resources
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@@ -47,9 +48,12 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.subtracks.R
import com.subtracks.data.model.Source
import com.subtracks.data.prefs.StreamQuality
import com.subtracks.ui.components.rememberViewportFill
@@ -139,10 +143,10 @@ fun SettingsScreen(
modifier = modifier,
topBar = {
TopAppBar(
title = { Text("Settings") },
title = { Text(stringResource(R.string.navigation_tabs_settings)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Back")
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = stringResource(R.string.navigation_back))
}
},
)
@@ -152,7 +156,7 @@ fun SettingsScreen(
state = listState,
modifier = Modifier.padding(padding).fillMaxSize(),
) {
item { SectionHeader("Servers") }
item { SectionHeader(stringResource(R.string.settings_servers_name)) }
items(sources.size, key = { sources[it].id }) { index ->
val source = sources[index]
ListItem(
@@ -168,10 +172,13 @@ fun SettingsScreen(
},
trailingContent = {
IconButton(onClick = { onEditServer(source.id) }) {
Icon(Icons.Rounded.Edit, contentDescription = "Edit ${source.name}")
Icon(Icons.Rounded.Edit, contentDescription = stringResource(R.string.edit_source_description, source.name))
}
},
modifier = Modifier.clickable(onClickLabel = "Use ${source.name}") { onSelectSource(source.id) },
modifier =
Modifier.clickable(
onClickLabel = stringResource(R.string.use_source_description, source.name),
) { onSelectSource(source.id) },
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
@@ -183,16 +190,22 @@ fun SettingsScreen(
Button(onClick = onAddServer) {
Icon(Icons.Rounded.Add, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text("Add server")
Text(stringResource(R.string.settings_servers_actions_add))
}
}
}
item { SectionHeader("Network") }
item { SectionHeader(stringResource(R.string.settings_network_name)) }
item {
ListItem(
headlineContent = { Text("Offline mode") },
headlineContent = { Text(stringResource(R.string.settings_network_options_offline_mode)) },
supportingContent = {
Text(if (offline) "Don't use the internet to sync or play music." else "Use the internet to sync music.")
Text(
if (offline) {
stringResource(R.string.settings_network_options_offline_mode_on)
} else {
stringResource(R.string.settings_network_options_offline_mode_off)
},
)
},
trailingContent = {
Switch(checked = offline, onCheckedChange = onOfflineChange)
@@ -203,7 +216,7 @@ fun SettingsScreen(
}
item {
ListItem(
headlineContent = { Text("Stream quality: Wi-Fi") },
headlineContent = { Text(stringResource(R.string.settings_network_options_max_bitrate_wifi_title)) },
supportingContent = { Text(qualityLabel(wifiQuality)) },
modifier = Modifier.clickable { dialog = SettingsDialog.WifiQuality },
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
@@ -211,7 +224,7 @@ fun SettingsScreen(
}
item {
ListItem(
headlineContent = { Text("Stream quality: Mobile") },
headlineContent = { Text(stringResource(R.string.settings_network_options_max_bitrate_mobile_title)) },
supportingContent = { Text(qualityLabel(mobileQuality)) },
modifier = Modifier.clickable { dialog = SettingsDialog.MobileQuality },
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
@@ -219,17 +232,17 @@ fun SettingsScreen(
}
item {
ListItem(
headlineContent = { Text("Sync concurrency") },
supportingContent = { Text(concurrencyLabel(syncConcurrency)) },
headlineContent = { Text(stringResource(R.string.sync_concurrency)) },
supportingContent = { Text(concurrencyLabel(LocalResources.current, syncConcurrency)) },
modifier = Modifier.clickable { dialog = SettingsDialog.SyncConcurrency },
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
item { SectionHeader("Playback") }
item { SectionHeader(stringResource(R.string.settings_music_name)) }
item {
ListItem(
headlineContent = { Text("Scrobble to server") },
supportingContent = { Text("Send now playing and play counts") },
headlineContent = { Text(stringResource(R.string.settings_music_options_scrobble_title)) },
supportingContent = { Text(stringResource(R.string.settings_music_options_scrobble_description_on)) },
trailingContent = {
Switch(
checked = scrobbling,
@@ -246,10 +259,10 @@ fun SettingsScreen(
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
item { SectionHeader("Downloads") }
item { SectionHeader(stringResource(R.string.downloads_title)) }
item {
ListItem(
headlineContent = { Text("Download quality") },
headlineContent = { Text(stringResource(R.string.download_quality)) },
supportingContent = { Text(qualityLabel(downloadQuality)) },
modifier = Modifier.clickable { dialog = SettingsDialog.DownloadQuality },
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
@@ -257,8 +270,16 @@ fun SettingsScreen(
}
item {
ListItem(
headlineContent = { Text("Download over mobile data") },
supportingContent = { Text(if (downloadOverMetered) "Wi-Fi and mobile" else "Wi-Fi only") },
headlineContent = { Text(stringResource(R.string.download_over_mobile)) },
supportingContent = {
Text(
if (downloadOverMetered) {
stringResource(R.string.download_network_wifi_and_mobile)
} else {
stringResource(R.string.download_network_wifi_only)
},
)
},
trailingContent = {
Switch(
checked = downloadOverMetered,
@@ -277,16 +298,16 @@ fun SettingsScreen(
}
item {
ListItem(
headlineContent = { Text("Manage downloads") },
headlineContent = { Text(stringResource(R.string.manage_downloads)) },
leadingContent = { Icon(Icons.Rounded.Download, contentDescription = null) },
modifier = Modifier.clickable(onClick = onOpenDownloads),
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
item { SectionHeader("About") }
item { SectionHeader(stringResource(R.string.settings_about_name)) }
item {
ListItem(
headlineContent = { Text("Licenses") },
headlineContent = { Text(stringResource(R.string.settings_about_actions_licenses)) },
leadingContent = { Icon(Icons.Rounded.Description, contentDescription = null) },
modifier = Modifier.clickable(onClick = onOpenLicenses),
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
@@ -299,7 +320,7 @@ fun SettingsScreen(
when (dialog) {
SettingsDialog.WifiQuality -> {
QualityDialog(
title = "Stream quality: Wi-Fi",
title = stringResource(R.string.settings_network_options_max_bitrate_wifi_title),
quality = wifiQuality,
onSelect = onWifiQualityChange,
onDismiss = { dialog = null },
@@ -308,7 +329,7 @@ fun SettingsScreen(
SettingsDialog.MobileQuality -> {
QualityDialog(
title = "Stream quality: Mobile",
title = stringResource(R.string.settings_network_options_max_bitrate_mobile_title),
quality = mobileQuality,
onSelect = onMobileQualityChange,
onDismiss = { dialog = null },
@@ -317,7 +338,7 @@ fun SettingsScreen(
SettingsDialog.DownloadQuality -> {
QualityDialog(
title = "Download quality",
title = stringResource(R.string.download_quality),
quality = downloadQuality,
onSelect = onDownloadQualityChange,
onDismiss = { dialog = null },
@@ -338,13 +359,29 @@ fun SettingsScreen(
}
}
private fun qualityLabel(quality: StreamQuality): String = "${quality.format ?: "Server default"} · ${bitrateLabel(quality.maxBitrate)}"
@Composable
private fun qualityLabel(quality: StreamQuality): String {
val resources = LocalResources.current
val format = quality.format ?: stringResource(R.string.stream_quality_server_default)
return stringResource(R.string.download_status_summary, format, bitrateLabel(resources, quality.maxBitrate))
}
private fun bitrateLabel(kbps: Int): String = if (kbps == 0) "Unlimited" else "${kbps}kbps"
private fun bitrateLabel(
resources: Resources,
kbps: Int,
): String =
if (kbps == 0) {
resources.getString(R.string.settings_network_values_unlimited_kbps)
} else {
resources.getString(R.string.settings_network_values_kbps, kbps.toString())
}
private val syncConcurrencyOptions = listOf(1, 2, 4, 8, 16)
private fun concurrencyLabel(value: Int): String = if (value <= 1) "1 (sequential)" else value.toString()
private fun concurrencyLabel(
resources: Resources,
value: Int,
): String = if (value <= 1) resources.getString(R.string.sync_concurrency_sequential) else value.toString()
@Composable
private fun ConcurrencyDialog(
@@ -352,14 +389,15 @@ private fun ConcurrencyDialog(
onSelect: (Int) -> Unit,
onDismiss: () -> Unit,
) {
val resources = LocalResources.current
var draft by remember(selected) { mutableStateOf(selected) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Sync concurrency") },
title = { Text(stringResource(R.string.sync_concurrency)) },
text = {
ChoiceGroup(
header = "Parallel server requests while syncing",
options = syncConcurrencyOptions.map { it to concurrencyLabel(it) },
header = stringResource(R.string.sync_concurrency_parallel),
options = syncConcurrencyOptions.map { it to concurrencyLabel(resources, it) },
selected = draft,
onSelect = { draft = it },
)
@@ -370,10 +408,10 @@ private fun ConcurrencyDialog(
onSelect(draft)
onDismiss()
},
) { Text("Done") }
) { Text(stringResource(R.string.action_done)) }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Cancel") }
TextButton(onClick = onDismiss) { Text(stringResource(R.string.actions_cancel)) }
},
)
}
@@ -385,6 +423,7 @@ private fun QualityDialog(
onSelect: (StreamQuality) -> Unit,
onDismiss: () -> Unit,
) {
val resources = LocalResources.current
var draft by remember(quality) { mutableStateOf(quality) }
val scrollState = rememberScrollState()
AlertDialog(
@@ -394,15 +433,19 @@ private fun QualityDialog(
Box {
Column(modifier = Modifier.verticalScroll(scrollState)) {
ChoiceGroup(
header = "Maximum bitrate",
options = bitrateOptions.map { it to bitrateLabel(it) },
header = stringResource(R.string.maximum_bitrate),
options = bitrateOptions.map { it to bitrateLabel(resources, it) },
selected = draft.maxBitrate,
onSelect = { draft = draft.copy(maxBitrate = it) },
)
Spacer(Modifier.height(16.dp))
ChoiceGroup(
header = "Preferred format",
options = streamFormats.map { it to (it ?: "Use server default") },
header = stringResource(R.string.settings_network_options_stream_format),
options =
streamFormats.map {
it to
(it ?: resources.getString(R.string.settings_network_options_stream_format_server_default))
},
selected = draft.format,
onSelect = { draft = draft.copy(format = it) },
)
@@ -429,10 +472,10 @@ private fun QualityDialog(
onSelect(draft)
onDismiss()
},
) { Text("Done") }
) { Text(stringResource(R.string.action_done)) }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Cancel") }
TextButton(onClick = onDismiss) { Text(stringResource(R.string.actions_cancel)) }
},
)
}