Added settings screen
This commit is contained in:
parent
cae8f99830
commit
35b77c75fe
13 changed files with 596 additions and 39 deletions
|
|
@ -93,4 +93,5 @@ dependencies {
|
|||
|
||||
implementation(libs.androidx.compose.material.icons.core)
|
||||
implementation("androidx.compose.material:material-icons-extended")
|
||||
implementation("androidx.navigation:navigation-compose:2.9.0")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,14 +7,43 @@ import androidx.activity.ComponentActivity
|
|||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.viewModels
|
||||
import androidx.compose.animation.AnimatedContentTransitionScope
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import com.keylightpiano.pianotimeline.ui.RecordingListScreen
|
||||
import com.keylightpiano.pianotimeline.ui.RecordingListViewModel
|
||||
import com.keylightpiano.pianotimeline.ui.SettingsScreen
|
||||
import com.keylightpiano.pianotimeline.ui.theme.PianoTimelineTheme
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Type-safe navigation routes. Kotlin-serialization-backed destinations
|
||||
* (Navigation-Compose 2.8+), so adding arguments later is just adding fields.
|
||||
*/
|
||||
@Serializable
|
||||
object ListRoute
|
||||
|
||||
@Serializable
|
||||
object SettingsRoute
|
||||
|
||||
// Standard Android forward/back slide feel: the incoming screen slides in from
|
||||
// the right while the outgoing one slides partway out to the left, both with a
|
||||
// short fade. On back (pop), the directions reverse. 300ms matches the platform
|
||||
// default duration closely enough to feel native.
|
||||
private const val NAV_ANIM_MS = 300
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
// Hoist the ViewModel to the Activity so deep-link intents (which arrive here)
|
||||
// can be forwarded to it. The Compose screen uses this same instance.
|
||||
// can be forwarded to it. Both Compose screens share this same instance.
|
||||
private val vm: RecordingListViewModel by viewModels()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
|
|
@ -27,7 +56,62 @@ class MainActivity : ComponentActivity() {
|
|||
setContent {
|
||||
// Force dark mode regardless of system setting
|
||||
PianoTimelineTheme(darkTheme = true) {
|
||||
RecordingListScreen(vm = vm)
|
||||
val navController = rememberNavController()
|
||||
|
||||
// Opaque themed backdrop BEHIND the NavHost. During a screen
|
||||
// transition Compose does not stack the two destinations -- it
|
||||
// plays exit then enter -- so mid-animation there's a gap where
|
||||
// neither fully covers the host. Without this, the window's
|
||||
// default (white) background shows through as a flash. Filling
|
||||
// the host with the theme background makes that gap dark instead.
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = ListRoute,
|
||||
// Forward navigation (A -> B): new screen enters from the right.
|
||||
enterTransition = {
|
||||
slideIntoContainer(
|
||||
AnimatedContentTransitionScope.SlideDirection.Left,
|
||||
animationSpec = tween(NAV_ANIM_MS)
|
||||
) + fadeIn(tween(NAV_ANIM_MS))
|
||||
},
|
||||
exitTransition = {
|
||||
slideOutOfContainer(
|
||||
AnimatedContentTransitionScope.SlideDirection.Left,
|
||||
animationSpec = tween(NAV_ANIM_MS)
|
||||
) + fadeOut(tween(NAV_ANIM_MS))
|
||||
},
|
||||
// Back navigation (pop B -> A): directions reverse.
|
||||
popEnterTransition = {
|
||||
slideIntoContainer(
|
||||
AnimatedContentTransitionScope.SlideDirection.Right,
|
||||
animationSpec = tween(NAV_ANIM_MS)
|
||||
) + fadeIn(tween(NAV_ANIM_MS))
|
||||
},
|
||||
popExitTransition = {
|
||||
slideOutOfContainer(
|
||||
AnimatedContentTransitionScope.SlideDirection.Right,
|
||||
animationSpec = tween(NAV_ANIM_MS)
|
||||
) + fadeOut(tween(NAV_ANIM_MS))
|
||||
}
|
||||
) {
|
||||
composable<ListRoute> {
|
||||
RecordingListScreen(
|
||||
vm = vm,
|
||||
onOpenSettings = { navController.navigate(SettingsRoute) }
|
||||
)
|
||||
}
|
||||
composable<SettingsRoute> {
|
||||
SettingsScreen(
|
||||
vm = vm,
|
||||
onBack = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import com.keylightpiano.pianotimeline.data.local.RecordingDao
|
|||
import com.keylightpiano.pianotimeline.data.local.toDomainOrNull
|
||||
import com.keylightpiano.pianotimeline.data.remote.MusicNetwork
|
||||
import com.keylightpiano.pianotimeline.domain.Recording
|
||||
import com.keylightpiano.pianotimeline.player.MediaCache
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
|
@ -69,12 +70,31 @@ class MusicRepository(context: Context) {
|
|||
dao.setNew(id, true)
|
||||
}
|
||||
|
||||
/** Clear the "new" badge from every recording (Settings action). */
|
||||
suspend fun clearAllNew() = withContext(Dispatchers.IO) {
|
||||
dao.clearAllNew()
|
||||
}
|
||||
|
||||
/**
|
||||
* Total bytes currently held in the ExoPlayer disk cache. Backed by the real
|
||||
* SimpleCache (MediaCache), same source of truth as `isFullyCached`.
|
||||
*/
|
||||
suspend fun cacheSizeBytes(context: Context): Long = withContext(Dispatchers.IO) {
|
||||
MediaCache.cacheSizeBytes(context)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all cached audio/midi files from disk and reset paths in the database.
|
||||
* Used by the "clear cache" setting.
|
||||
*
|
||||
* Clears BOTH the ExoPlayer SimpleCache (where streamed audio actually lands)
|
||||
* and the legacy on-disk cache dirs, then resets the Room path columns.
|
||||
*/
|
||||
suspend fun clearAllCache(context: Context) = withContext(Dispatchers.IO) {
|
||||
// Delete physical files
|
||||
// Clear the real media cache (ExoPlayer SimpleCache).
|
||||
MediaCache.clear(context)
|
||||
|
||||
// Delete legacy physical files, if any remain from earlier caching schemes.
|
||||
val cacheDir = audioCacheDir(context)
|
||||
if (cacheDir.exists()) {
|
||||
cacheDir.deleteRecursively()
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ class SettingsStore(private val context: Context) {
|
|||
val KEY_REPEAT = stringPreferencesKey("repeat_mode")
|
||||
val KEY_LAST_TRACK_ID = stringPreferencesKey("last_track_id")
|
||||
val KEY_LAST_POSITION_MS = longPreferencesKey("last_position_ms")
|
||||
val KEY_NOTIFICATIONS = booleanPreferencesKey("notifications_enabled")
|
||||
}
|
||||
|
||||
/** Read the stored shuffle flag (default false). */
|
||||
|
|
@ -85,4 +86,18 @@ class SettingsStore(private val context: Context) {
|
|||
it.remove(KEY_LAST_POSITION_MS)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* "Notify me about new recordings" toggle. Currently just a stored flag —
|
||||
* the actual notification delivery (WorkManager/FCM) isn't wired up yet, so
|
||||
* reading/writing this is a no-op beyond persistence. Default off.
|
||||
*/
|
||||
suspend fun getNotificationsEnabled(): Boolean {
|
||||
val prefs = context.userPrefsStore.data.first()
|
||||
return prefs[KEY_NOTIFICATIONS] ?: false
|
||||
}
|
||||
|
||||
suspend fun setNotificationsEnabled(enabled: Boolean) {
|
||||
context.userPrefsStore.edit { it[KEY_NOTIFICATIONS] = enabled }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,6 +63,10 @@ interface RecordingDao {
|
|||
/** Clear the "new" flag for a recording (called when its play button is first pressed). */
|
||||
@Query("UPDATE recordings SET isNew = 0 WHERE id = :id")
|
||||
suspend fun clearNew(id: String)
|
||||
|
||||
/** Clear the "new" flag on every recording (from the Settings screen). */
|
||||
@Query("UPDATE recordings SET isNew = 0")
|
||||
suspend fun clearAllNew()
|
||||
}
|
||||
|
||||
data class IdAndSize(val id: String, val fileSize: Long)
|
||||
|
|
|
|||
|
|
@ -34,7 +34,12 @@ import kotlinx.coroutines.launch
|
|||
class PlayerManager(
|
||||
private val context: Context,
|
||||
private val scope: CoroutineScope,
|
||||
private val onTrackEnded: () -> Unit = {}
|
||||
private val onTrackEnded: () -> Unit = {},
|
||||
// Fired when a track finishes playing all the way through, which means it has
|
||||
// now been streamed-and-cached end to end. The ViewModel uses this to refresh
|
||||
// the "downloaded" glyphs without waiting for an app restart. Best-effort: it
|
||||
// signals "the cache probably changed, re-check", not a guarantee.
|
||||
private val onCacheMayHaveChanged: () -> Unit = {}
|
||||
) {
|
||||
private val _state = MutableStateFlow(PlaybackState())
|
||||
val state: StateFlow<PlaybackState> = _state.asStateFlow()
|
||||
|
|
@ -65,6 +70,10 @@ class PlayerManager(
|
|||
if (playbackState == Player.STATE_READY) startPositionLoop()
|
||||
if (playbackState == Player.STATE_ENDED) {
|
||||
stopPositionLoop()
|
||||
// The track played to the end, so its bytes are now fully in the
|
||||
// disk cache. Let the ViewModel know before advancing, so the
|
||||
// just-finished recording can show its "downloaded" glyph.
|
||||
onCacheMayHaveChanged()
|
||||
onTrackEnded()
|
||||
}
|
||||
}
|
||||
|
|
@ -81,7 +90,7 @@ class PlayerManager(
|
|||
*/
|
||||
fun playOrToggle(recordingId: String, url: String) {
|
||||
if (recordingId == currentId) {
|
||||
// Same track — toggle play/pause.
|
||||
// Same track -- toggle play/pause.
|
||||
if (player.isPlaying) player.pause() else player.play()
|
||||
return
|
||||
}
|
||||
|
|
@ -98,7 +107,7 @@ class PlayerManager(
|
|||
}
|
||||
|
||||
/**
|
||||
* Load a track and seek to [positionMs] but do NOT start playing — used to
|
||||
* Load a track and seek to [positionMs] but do NOT start playing -- used to
|
||||
* restore the last session on launch, Spotify-style (comes back paused).
|
||||
* ExoPlayer defers the seek until the item is prepared enough to honor it,
|
||||
* so calling prepare() then seekTo() before playback is safe.
|
||||
|
|
@ -149,7 +158,7 @@ class PlayerManager(
|
|||
positionJob = scope.launch {
|
||||
while (true) {
|
||||
updateState()
|
||||
delay(200) // 5x/sec — smooth enough for a progress bar
|
||||
delay(200) // 5x/sec -- smooth enough for a progress bar
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ import androidx.compose.foundation.layout.width
|
|||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Block
|
||||
import androidx.compose.material.icons.filled.DownloadDone
|
||||
import androidx.compose.material.icons.filled.DownloadForOffline
|
||||
import androidx.compose.material.icons.filled.MusicNote
|
||||
import androidx.compose.material.icons.filled.Star
|
||||
import androidx.compose.material.icons.outlined.StarBorder
|
||||
|
|
@ -206,7 +206,7 @@ fun RecordingItem(
|
|||
// full audio is cached (playable offline).
|
||||
if (recording.isCached) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.DownloadDone,
|
||||
imageVector = Icons.Filled.DownloadForOffline,
|
||||
contentDescription = stringResource(R.string.cd_downloaded),
|
||||
tint = NotesGreen,
|
||||
modifier = Modifier.size(14.dp)
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import androidx.compose.foundation.lazy.items
|
|||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Clear
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
|
|
@ -65,15 +66,15 @@ import androidx.compose.ui.text.style.TextAlign
|
|||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.keylightpiano.pianotimeline.R
|
||||
import com.keylightpiano.pianotimeline.domain.Recording
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun RecordingListScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
vm: RecordingListViewModel = viewModel()
|
||||
vm: RecordingListViewModel,
|
||||
onOpenSettings: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val syncState by vm.syncState.collectAsStateWithLifecycle()
|
||||
val recordings by vm.recordings.collectAsStateWithLifecycle()
|
||||
|
|
@ -94,6 +95,7 @@ fun RecordingListScreen(
|
|||
var showSortMenu by remember { mutableStateOf(false) }
|
||||
var showArtistFilter by rememberSaveable { mutableStateOf(false) }
|
||||
var showYearFilter by rememberSaveable { mutableStateOf(false) }
|
||||
var showOverflowMenu by remember { mutableStateOf(false) }
|
||||
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
|
|
@ -213,6 +215,29 @@ fun RecordingListScreen(
|
|||
else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
// Overflow (three dots) → Settings
|
||||
Box {
|
||||
IconButton(onClick = { showOverflowMenu = true }) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.MoreVert,
|
||||
contentDescription = stringResource(R.string.cd_more_options),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = showOverflowMenu,
|
||||
onDismissRequest = { showOverflowMenu = false }
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.settings_title)) },
|
||||
onClick = {
|
||||
showOverflowMenu = false
|
||||
onOpenSettings()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
scrollBehavior = scrollBehavior
|
||||
)
|
||||
|
|
|
|||
|
|
@ -37,12 +37,17 @@ class RecordingListViewModel(app: Application) : AndroidViewModel(app) {
|
|||
private val repo = MusicRepository(app)
|
||||
private val syncManager = SyncManager(app)
|
||||
private val settings = SettingsStore(app)
|
||||
private val playerManager = PlayerManager(app, viewModelScope, onTrackEnded = { advanceToNext() })
|
||||
private val playerManager = PlayerManager(
|
||||
app,
|
||||
viewModelScope,
|
||||
onTrackEnded = { advanceToNext() },
|
||||
onCacheMayHaveChanged = { bumpCacheGeneration() }
|
||||
)
|
||||
|
||||
/** Playback state the UI observes (which card is playing, position, etc). */
|
||||
val playbackState: StateFlow<PlaybackState> = playerManager.state
|
||||
|
||||
// ── Connectivity ───────────────────────────────────────────────────
|
||||
// -- Connectivity --
|
||||
|
||||
private val networkMonitor = NetworkMonitor(app)
|
||||
|
||||
|
|
@ -60,7 +65,7 @@ class RecordingListViewModel(app: Application) : AndroidViewModel(app) {
|
|||
.debounce(800L)
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), networkMonitor.isOnlineNow())
|
||||
|
||||
// ── Continuous play: repeat + shuffle ──────────────────────────────
|
||||
// -- Continuous play: repeat + shuffle --
|
||||
|
||||
private val _repeatMode = MutableStateFlow(RepeatMode.OFF)
|
||||
val repeatMode: StateFlow<RepeatMode> = _repeatMode.asStateFlow()
|
||||
|
|
@ -76,7 +81,42 @@ class RecordingListViewModel(app: Application) : AndroidViewModel(app) {
|
|||
*/
|
||||
private var shuffleOrder: List<String> = emptyList()
|
||||
|
||||
// ── Deep-link signals the UI observes ──────────────────────────────
|
||||
// -- Cache freshness signal --
|
||||
|
||||
/**
|
||||
* Bumped whenever something changes the on-disk cache in a way the recording
|
||||
* list needs to reflect: "clear cache" (via clearCache) and a track finishing
|
||||
* playback (via the player's onCacheMayHaveChanged, since a fully-played track
|
||||
* is now fully cached). The `recordings` flow folds this in, so incrementing
|
||||
* it forces `isCached` to be recomputed and the "downloaded" glyphs to update
|
||||
* immediately -- no app restart.
|
||||
*/
|
||||
private val _cacheGeneration = MutableStateFlow(0)
|
||||
|
||||
/** Nudge the recordings flow to re-check on-disk cache state. */
|
||||
private fun bumpCacheGeneration() {
|
||||
_cacheGeneration.value += 1
|
||||
}
|
||||
|
||||
// -- Settings state --
|
||||
|
||||
/**
|
||||
* "Notify me about new recordings" toggle. Persisted, but currently a no-op
|
||||
* beyond storage -- notification delivery isn't wired up yet. The Settings UI
|
||||
* observes this to render the switch state.
|
||||
*/
|
||||
private val _notificationsEnabled = MutableStateFlow(false)
|
||||
val notificationsEnabled: StateFlow<Boolean> = _notificationsEnabled.asStateFlow()
|
||||
|
||||
/**
|
||||
* Cached-media size in bytes, shown on the Settings screen. Null = "not yet
|
||||
* computed" (Settings hasn't asked for it, or a refresh is in flight). The
|
||||
* Settings screen calls refreshCacheSize() when it opens.
|
||||
*/
|
||||
private val _cacheSizeBytes = MutableStateFlow<Long?>(null)
|
||||
val cacheSizeBytes: StateFlow<Long?> = _cacheSizeBytes.asStateFlow()
|
||||
|
||||
// -- Deep-link signals the UI observes --
|
||||
|
||||
/**
|
||||
* Set to a recording id when an incoming link should make the list scroll
|
||||
|
|
@ -99,13 +139,13 @@ class RecordingListViewModel(app: Application) : AndroidViewModel(app) {
|
|||
|
||||
fun consumeVisualizationRequest() { _openVisualizationFor.value = null }
|
||||
|
||||
// ── Share URLs (used by the long-press menu) ───────────────────────
|
||||
// -- Share URLs (used by the long-press menu) --
|
||||
|
||||
fun audioShareUrl(recording: Recording): String = repo.audioUrl(recording)
|
||||
|
||||
fun midiShareUrl(recording: Recording): String? = repo.midiUrl(recording)
|
||||
|
||||
// ── Sync state ─────────────────────────────────────────────────────
|
||||
// -- Sync state --
|
||||
|
||||
private val _syncState = MutableStateFlow<SyncState>(SyncState.Syncing)
|
||||
val syncState: StateFlow<SyncState> = _syncState.asStateFlow()
|
||||
|
|
@ -114,7 +154,7 @@ class RecordingListViewModel(app: Application) : AndroidViewModel(app) {
|
|||
private val _isRefreshing = MutableStateFlow(false)
|
||||
val isRefreshing: StateFlow<Boolean> = _isRefreshing.asStateFlow()
|
||||
|
||||
// ── User-controlled filters ────────────────────────────────────────
|
||||
// -- User-controlled filters --
|
||||
|
||||
private val _searchQuery = MutableStateFlow("")
|
||||
val searchQuery: StateFlow<String> = _searchQuery.asStateFlow()
|
||||
|
|
@ -136,7 +176,7 @@ class RecordingListViewModel(app: Application) : AndroidViewModel(app) {
|
|||
private val _hiddenYears = MutableStateFlow<Set<Int>>(emptySet())
|
||||
val hiddenYears: StateFlow<Set<Int>> = _hiddenYears.asStateFlow()
|
||||
|
||||
// ── Derived: all unique grouping artists, for the filter UI ────────
|
||||
// -- Derived: all unique grouping artists, for the filter UI --
|
||||
|
||||
/** All recordings straight from Room (unfiltered). */
|
||||
private val allRecordings = repo.observeRecordings()
|
||||
|
|
@ -163,7 +203,7 @@ class RecordingListViewModel(app: Application) : AndroidViewModel(app) {
|
|||
.sortedDescending()
|
||||
}
|
||||
|
||||
// ── The final filtered + sorted list the UI renders ────────────────
|
||||
// -- The final filtered + sorted list the UI renders --
|
||||
|
||||
private val filteredRecordings: kotlinx.coroutines.flow.Flow<List<Recording>> = combine(
|
||||
allRecordings,
|
||||
|
|
@ -177,7 +217,7 @@ class RecordingListViewModel(app: Application) : AndroidViewModel(app) {
|
|||
.filterBySearch(query)
|
||||
.sortedWith(sort.comparator())
|
||||
// Split into favorites and the rest. Favorites sit at the top, ordered by
|
||||
// WHEN they were favorited (oldest first → newest favorite at the bottom
|
||||
// WHEN they were favorited (oldest first -> newest favorite at the bottom
|
||||
// of the group), which reads as insertion order. Non-favorites keep the
|
||||
// user's chosen sort.
|
||||
val (favorites, others) = filteredSorted.partition { it.isFavorite }
|
||||
|
|
@ -186,13 +226,14 @@ class RecordingListViewModel(app: Application) : AndroidViewModel(app) {
|
|||
|
||||
/**
|
||||
* The list the UI renders. On top of the filtered/sorted list we stamp each
|
||||
* recording's live `isCached` flag — derived from the real ExoPlayer disk
|
||||
* recording's live `isCached` flag -- derived from the real ExoPlayer disk
|
||||
* cache (MediaCache), not the unused Room column. Recomputed whenever
|
||||
* connectivity flips (isOnline) so "playable offline" is fresh when it
|
||||
* matters. The map runs on IO because it touches cache metadata on disk.
|
||||
* connectivity flips (isOnline) OR the cache generation is bumped (e.g. after
|
||||
* "clear cache"), so "playable offline" stays fresh. The map runs on IO
|
||||
* because it touches cache metadata on disk.
|
||||
*/
|
||||
val recordings: StateFlow<List<Recording>> =
|
||||
combine(filteredRecordings, isOnline) { list, _ ->
|
||||
combine(filteredRecordings, isOnline, _cacheGeneration) { list, _, _ ->
|
||||
list.map { rec ->
|
||||
val cached = MediaCache.isFullyCached(app, repo.audioUrl(rec))
|
||||
if (cached == rec.isCached) rec else rec.copy(isCached = cached)
|
||||
|
|
@ -201,7 +242,7 @@ class RecordingListViewModel(app: Application) : AndroidViewModel(app) {
|
|||
.flowOn(Dispatchers.IO)
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
|
||||
|
||||
// ── Init: run sync on creation ─────────────────────────────────────
|
||||
// -- Init: run sync on creation --
|
||||
|
||||
init {
|
||||
// Restore persisted playback preferences (shuffle / repeat) before sync.
|
||||
|
|
@ -210,10 +251,11 @@ class RecordingListViewModel(app: Application) : AndroidViewModel(app) {
|
|||
_repeatMode.value = runCatching {
|
||||
RepeatMode.valueOf(settings.getRepeatModeName())
|
||||
}.getOrDefault(RepeatMode.OFF)
|
||||
_notificationsEnabled.value = settings.getNotificationsEnabled()
|
||||
}
|
||||
|
||||
// Restore the last-played track (PAUSED, Spotify-style) once the list is
|
||||
// available so we can resolve the id → URL. Fires only once.
|
||||
// available so we can resolve the id -> URL. Fires only once.
|
||||
viewModelScope.launch {
|
||||
val savedId = settings.getLastTrackId() ?: return@launch
|
||||
val savedPos = settings.getLastPositionMs()
|
||||
|
|
@ -224,7 +266,7 @@ class RecordingListViewModel(app: Application) : AndroidViewModel(app) {
|
|||
playerManager.preparePaused(match.id, repo.audioUrl(match), savedPos)
|
||||
} else {
|
||||
// Track no longer exists (removed on the server, or storage cleared)
|
||||
// — drop the stale pointer so we don't keep trying.
|
||||
// -- drop the stale pointer so we don't keep trying.
|
||||
settings.clearLastPlayed()
|
||||
}
|
||||
}
|
||||
|
|
@ -253,7 +295,7 @@ class RecordingListViewModel(app: Application) : AndroidViewModel(app) {
|
|||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
// No connection → don't even attempt a network sync. Show whatever is
|
||||
// No connection -> don't even attempt a network sync. Show whatever is
|
||||
// cached in Room if we have it; otherwise surface an offline error.
|
||||
if (!isOnline.value) {
|
||||
_syncState.value =
|
||||
|
|
@ -276,7 +318,7 @@ class RecordingListViewModel(app: Application) : AndroidViewModel(app) {
|
|||
}
|
||||
}
|
||||
|
||||
// ── Public actions the UI calls ────────────────────────────────────
|
||||
// -- Public actions the UI calls --
|
||||
|
||||
fun setSearchQuery(query: String) {
|
||||
_searchQuery.value = query
|
||||
|
|
@ -328,6 +370,51 @@ class RecordingListViewModel(app: Application) : AndroidViewModel(app) {
|
|||
}
|
||||
}
|
||||
|
||||
// -- Settings actions --
|
||||
|
||||
/**
|
||||
* Toggle the "notify on new recordings" preference. Persists the flag only --
|
||||
* no delivery mechanism is wired up yet. When notifications are actually
|
||||
* implemented, the permission-request flow (POST_NOTIFICATIONS on Android 13+)
|
||||
* will be triggered from the UI layer, not here, since it needs an Activity.
|
||||
*/
|
||||
fun setNotificationsEnabled(enabled: Boolean) {
|
||||
_notificationsEnabled.value = enabled
|
||||
viewModelScope.launch { settings.setNotificationsEnabled(enabled) }
|
||||
}
|
||||
|
||||
/** Recompute the cached-media size shown on the Settings screen. */
|
||||
fun refreshCacheSize() {
|
||||
viewModelScope.launch {
|
||||
_cacheSizeBytes.value = repo.cacheSizeBytes(getApplication())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all cached recordings from disk, then refresh the displayed size and
|
||||
* bump the cache generation so the list drops its "downloaded" glyphs
|
||||
* immediately. Playback is stopped first because SimpleCache can't be cleared
|
||||
* while a player is actively reading from it.
|
||||
*/
|
||||
fun clearCache() {
|
||||
viewModelScope.launch {
|
||||
// Stop any active playback so the cache isn't being read during clear.
|
||||
playerManager.stop()
|
||||
settings.clearLastPlayed()
|
||||
repo.clearAllCache(getApplication())
|
||||
_cacheSizeBytes.value = repo.cacheSizeBytes(getApplication())
|
||||
// Force the recordings flow to recompute isCached now (not on restart).
|
||||
bumpCacheGeneration()
|
||||
}
|
||||
}
|
||||
|
||||
/** Clear the "new" badge from every recording (Settings action). */
|
||||
fun clearAllNewBadges() {
|
||||
viewModelScope.launch {
|
||||
repo.clearAllNew()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when a recording's play button is pressed. Clears its "new" badge,
|
||||
* then plays (or toggles pause/resume if it's already the active track).
|
||||
|
|
@ -355,7 +442,7 @@ class RecordingListViewModel(app: Application) : AndroidViewModel(app) {
|
|||
viewModelScope.launch { settings.clearLastPlayed() }
|
||||
}
|
||||
|
||||
// ── Deep-link handling ─────────────────────────────────────────────
|
||||
// -- Deep-link handling --
|
||||
|
||||
/** A link that arrived before the recording list was ready, retried later. */
|
||||
private var pendingLink: DeepLinkParser.ParsedLink? = null
|
||||
|
|
@ -408,7 +495,7 @@ class RecordingListViewModel(app: Application) : AndroidViewModel(app) {
|
|||
}
|
||||
|
||||
/**
|
||||
* Manual "next" button — behaves like a natural track-end advance,
|
||||
* Manual "next" button -- behaves like a natural track-end advance,
|
||||
* honoring shuffle and repeat-all, but a manual skip should always move on
|
||||
* even under Repeat One (you don't want the button to just replay).
|
||||
*/
|
||||
|
|
@ -468,7 +555,7 @@ class RecordingListViewModel(app: Application) : AndroidViewModel(app) {
|
|||
return prevId?.let { id -> visible.firstOrNull { it.id == id } }
|
||||
}
|
||||
|
||||
// ── Repeat / shuffle controls ──────────────────────────────────────
|
||||
// -- Repeat / shuffle controls --
|
||||
|
||||
fun cycleRepeatMode() {
|
||||
val next = when (_repeatMode.value) {
|
||||
|
|
@ -535,7 +622,7 @@ class RecordingListViewModel(app: Application) : AndroidViewModel(app) {
|
|||
val url = repo.audioUrl(nextRecording)
|
||||
playerManager.playOrToggle(nextRecording.id, url)
|
||||
} else {
|
||||
// End of queue with Repeat Off → stop.
|
||||
// End of queue with Repeat Off -> stop.
|
||||
playerManager.stop()
|
||||
}
|
||||
}
|
||||
|
|
@ -561,7 +648,7 @@ class RecordingListViewModel(app: Application) : AndroidViewModel(app) {
|
|||
val nextId = when {
|
||||
idx != -1 && idx + 1 < shuffleOrder.size -> shuffleOrder[idx + 1]
|
||||
_repeatMode.value == RepeatMode.ALL -> {
|
||||
// Completed a pass — reshuffle for the next one.
|
||||
// Completed a pass -- reshuffle for the next one.
|
||||
rebuildShuffleOrder(startId = null)
|
||||
shuffleOrder.firstOrNull()
|
||||
}
|
||||
|
|
@ -582,7 +669,7 @@ class RecordingListViewModel(app: Application) : AndroidViewModel(app) {
|
|||
*/
|
||||
fun refresh() {
|
||||
if (_isRefreshing.value) return // ignore overlapping pulls
|
||||
// Offline → there's nothing to fetch. Bail immediately so the pull
|
||||
// Offline -> there's nothing to fetch. Bail immediately so the pull
|
||||
// indicator doesn't spin against a network call that can only fail.
|
||||
if (!isOnline.value) return
|
||||
_isRefreshing.value = true
|
||||
|
|
@ -614,7 +701,7 @@ class RecordingListViewModel(app: Application) : AndroidViewModel(app) {
|
|||
}
|
||||
}
|
||||
|
||||
// ── State types ────────────────────────────────────────────────────────
|
||||
// -- State types --
|
||||
|
||||
sealed interface SyncState {
|
||||
data object Syncing : SyncState
|
||||
|
|
@ -625,7 +712,7 @@ sealed interface SyncState {
|
|||
/** Three-state repeat, like a normal audio player. */
|
||||
enum class RepeatMode { OFF, ALL, ONE }
|
||||
|
||||
// ── Private helpers ────────────────────────────────────────────────────
|
||||
// -- Private helpers --
|
||||
|
||||
/**
|
||||
* Case-insensitive, token-based search.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,261 @@
|
|||
package com.keylightpiano.pianotimeline.ui
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.SwitchDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
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.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.keylightpiano.pianotimeline.R
|
||||
import java.util.Locale
|
||||
|
||||
private val AccentGreen = Color(0xFF70D66B)
|
||||
|
||||
/**
|
||||
* Settings screen. Utility surface — deliberately quiet, matching the app's
|
||||
* existing Material 3 dark theme and green accent rather than introducing a new
|
||||
* visual language.
|
||||
*
|
||||
* Shares the same RecordingListViewModel as the list screen so preferences and
|
||||
* cache actions operate on the one source of truth.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SettingsScreen(
|
||||
vm: RecordingListViewModel,
|
||||
onBack: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val notificationsEnabled by vm.notificationsEnabled.collectAsStateWithLifecycle()
|
||||
val cacheSizeBytes by vm.cacheSizeBytes.collectAsStateWithLifecycle()
|
||||
|
||||
// Compute the cache size once when the screen first appears.
|
||||
LaunchedEffect(Unit) { vm.refreshCacheSize() }
|
||||
|
||||
var showClearCacheDialog by remember { mutableStateOf(false) }
|
||||
var showClearBadgesDialog by remember { mutableStateOf(false) }
|
||||
|
||||
Scaffold(
|
||||
modifier = modifier,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.settings_title), fontWeight = FontWeight.Bold) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.cd_back)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
// ── Notifications ──────────────────────────────────────────
|
||||
SettingsSection(title = stringResource(R.string.settings_section_notifications)) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = stringResource(R.string.settings_notifications_title),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.settings_notifications_subtitle),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Switch(
|
||||
checked = notificationsEnabled,
|
||||
onCheckedChange = { vm.setNotificationsEnabled(it) },
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedThumbColor = Color.White,
|
||||
checkedTrackColor = AccentGreen
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Storage ────────────────────────────────────────────────
|
||||
SettingsSection(title = stringResource(R.string.settings_section_storage)) {
|
||||
SettingsClickableRow(
|
||||
title = stringResource(R.string.settings_clear_cache_title),
|
||||
subtitle = stringResource(
|
||||
R.string.settings_clear_cache_subtitle,
|
||||
formatBytes(cacheSizeBytes)
|
||||
),
|
||||
onClick = { showClearCacheDialog = true }
|
||||
)
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f))
|
||||
SettingsClickableRow(
|
||||
title = stringResource(R.string.settings_clear_badges_title),
|
||||
subtitle = stringResource(R.string.settings_clear_badges_subtitle),
|
||||
onClick = { showClearBadgesDialog = true }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Confirmation dialogs ───────────────────────────────────────────
|
||||
|
||||
if (showClearCacheDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showClearCacheDialog = false },
|
||||
title = { Text(stringResource(R.string.settings_clear_cache_title)) },
|
||||
text = { Text(stringResource(R.string.settings_clear_cache_confirm)) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
vm.clearCache()
|
||||
showClearCacheDialog = false
|
||||
}) {
|
||||
Text(stringResource(R.string.settings_clear), color = AccentGreen)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showClearCacheDialog = false }) {
|
||||
Text(stringResource(R.string.settings_cancel))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (showClearBadgesDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showClearBadgesDialog = false },
|
||||
title = { Text(stringResource(R.string.settings_clear_badges_title)) },
|
||||
text = { Text(stringResource(R.string.settings_clear_badges_confirm)) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
vm.clearAllNewBadges()
|
||||
showClearBadgesDialog = false
|
||||
}) {
|
||||
Text(stringResource(R.string.settings_clear), color = AccentGreen)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showClearBadgesDialog = false }) {
|
||||
Text(stringResource(R.string.settings_cancel))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** A titled group of settings rows, wrapped in a card. */
|
||||
@Composable
|
||||
private fun SettingsSection(
|
||||
title: String,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = AccentGreen,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(start = 4.dp)
|
||||
)
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A tappable row with a title + subtitle. */
|
||||
@Composable
|
||||
private fun SettingsClickableRow(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp)
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-readable byte size. Null (not-yet-computed) shows an em dash.
|
||||
* Uses binary units (KB/MB/GB) matching how Android's own storage screens read.
|
||||
*/
|
||||
private fun formatBytes(bytes: Long?): String {
|
||||
if (bytes == null) return "—"
|
||||
if (bytes <= 0L) return "0 MB"
|
||||
val kb = 1024.0
|
||||
val mb = kb * 1024
|
||||
val gb = mb * 1024
|
||||
return when {
|
||||
bytes >= gb -> String.format(Locale.getDefault(), "%.1f GB", bytes / gb)
|
||||
bytes >= mb -> String.format(Locale.getDefault(), "%.1f MB", bytes / mb)
|
||||
bytes >= kb -> String.format(Locale.getDefault(), "%.0f KB", bytes / kb)
|
||||
else -> "$bytes B"
|
||||
}
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@
|
|||
<string name="cd_shuffle">Aleatorio</string>
|
||||
<string name="cd_repeat">Repetir</string>
|
||||
<string name="cd_search_filter">Buscar y filtrar</string>
|
||||
<string name="cd_more_options">M\u00E1s opciones</string>
|
||||
|
||||
<!-- Sync states -->
|
||||
<string name="syncing">Sincronizando\u2026</string>
|
||||
|
|
@ -56,4 +57,20 @@
|
|||
<string name="sort_title_az">T\u00EDtulo A \u2192 Z</string>
|
||||
<string name="sort_longest">M\u00E1s largas</string>
|
||||
<string name="sort_shortest">M\u00E1s cortas</string>
|
||||
|
||||
<!-- Settings -->
|
||||
<string name="settings_title">Ajustes</string>
|
||||
<string name="cd_back">Atr\u00E1s</string>
|
||||
<string name="settings_section_notifications">Notificaciones</string>
|
||||
<string name="settings_notifications_title">Avisarme de nuevas grabaciones</string>
|
||||
<string name="settings_notifications_subtitle">Recibe una notificaci\u00F3n cuando se a\u00F1adan nuevas grabaciones</string>
|
||||
<string name="settings_section_storage">Almacenamiento</string>
|
||||
<string name="settings_clear_cache_title">Borrar grabaciones en cach\u00E9</string>
|
||||
<string name="settings_clear_cache_subtitle">Usando %1$s</string>
|
||||
<string name="settings_clear_cache_confirm">Las grabaciones descargadas se eliminar\u00E1n de este dispositivo. Se volver\u00E1n a descargar la pr\u00F3xima vez que las reproduzcas.</string>
|
||||
<string name="settings_clear_badges_title">Borrar todas las etiquetas \u201CNUEVO\u201D</string>
|
||||
<string name="settings_clear_badges_subtitle">Quita la etiqueta NUEVO de todas las grabaciones</string>
|
||||
<string name="settings_clear_badges_confirm">Se quitar\u00E1 la etiqueta NUEVO de todas las grabaciones.</string>
|
||||
<string name="settings_clear">Borrar</string>
|
||||
<string name="settings_cancel">Cancelar</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
<string name="cd_shuffle">Casuale</string>
|
||||
<string name="cd_repeat">Ripeti</string>
|
||||
<string name="cd_search_filter">Cerca e filtra</string>
|
||||
<string name="cd_more_options">Altre opzioni</string>
|
||||
|
||||
<!-- Sync states -->
|
||||
<string name="syncing">Sincronizzazione\u2026</string>
|
||||
|
|
@ -56,4 +57,20 @@
|
|||
<string name="sort_title_az">Titolo A \u2192 Z</string>
|
||||
<string name="sort_longest">Pi\u00F9 lunghi</string>
|
||||
<string name="sort_shortest">Pi\u00F9 brevi</string>
|
||||
|
||||
<!-- Settings -->
|
||||
<string name="settings_title">Impostazioni</string>
|
||||
<string name="cd_back">Indietro</string>
|
||||
<string name="settings_section_notifications">Notifiche</string>
|
||||
<string name="settings_notifications_title">Avvisami delle nuove registrazioni</string>
|
||||
<string name="settings_notifications_subtitle">Ricevi una notifica quando vengono aggiunte nuove registrazioni</string>
|
||||
<string name="settings_section_storage">Archiviazione</string>
|
||||
<string name="settings_clear_cache_title">Cancella registrazioni in cache</string>
|
||||
<string name="settings_clear_cache_subtitle">In uso %1$s</string>
|
||||
<string name="settings_clear_cache_confirm">Le registrazioni scaricate verranno rimosse da questo dispositivo. Verranno scaricate di nuovo la prossima volta che le riproduci.</string>
|
||||
<string name="settings_clear_badges_title">Cancella tutte le etichette \u201CNUOVO\u201D</string>
|
||||
<string name="settings_clear_badges_subtitle">Rimuovi l\u2019etichetta NUOVO da tutte le registrazioni</string>
|
||||
<string name="settings_clear_badges_confirm">L\u2019etichetta NUOVO verr\u00E0 rimossa da tutte le registrazioni.</string>
|
||||
<string name="settings_clear">Cancella</string>
|
||||
<string name="settings_cancel">Annulla</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
<string name="cd_shuffle">Shuffle</string>
|
||||
<string name="cd_repeat">Repeat</string>
|
||||
<string name="cd_search_filter">Search & Filter</string>
|
||||
<string name="cd_more_options">More options</string>
|
||||
|
||||
<!-- Sync states -->
|
||||
<string name="syncing">Syncing\u2026</string>
|
||||
|
|
@ -56,4 +57,20 @@
|
|||
<string name="sort_title_az">Title A \u2192 Z</string>
|
||||
<string name="sort_longest">Longest first</string>
|
||||
<string name="sort_shortest">Shortest first</string>
|
||||
|
||||
<!-- Settings -->
|
||||
<string name="settings_title">Settings</string>
|
||||
<string name="cd_back">Back</string>
|
||||
<string name="settings_section_notifications">Notifications</string>
|
||||
<string name="settings_notifications_title">Notify me about new recordings</string>
|
||||
<string name="settings_notifications_subtitle">Get a notification when new recordings are added</string>
|
||||
<string name="settings_section_storage">Storage</string>
|
||||
<string name="settings_clear_cache_title">Clear cached recordings</string>
|
||||
<string name="settings_clear_cache_subtitle">Using %1$s</string>
|
||||
<string name="settings_clear_cache_confirm">Downloaded recordings will be removed from this device. They\'ll download again next time you play them.</string>
|
||||
<string name="settings_clear_badges_title">Clear all \u201CNEW\u201D badges</string>
|
||||
<string name="settings_clear_badges_subtitle">Remove the NEW label from every recording</string>
|
||||
<string name="settings_clear_badges_confirm">The NEW badge will be removed from all recordings.</string>
|
||||
<string name="settings_clear">Clear</string>
|
||||
<string name="settings_cancel">Cancel</string>
|
||||
</resources>
|
||||
|
|
|
|||
Loading…
Reference in a new issue