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