Added settings screen

This commit is contained in:
OmniaX-Dev 2026-07-06 06:09:29 +02:00
parent cae8f99830
commit 35b77c75fe
13 changed files with 596 additions and 39 deletions

View file

@ -93,4 +93,5 @@ dependencies {
implementation(libs.androidx.compose.material.icons.core) implementation(libs.androidx.compose.material.icons.core)
implementation("androidx.compose.material:material-icons-extended") implementation("androidx.compose.material:material-icons-extended")
implementation("androidx.navigation:navigation-compose:2.9.0")
} }

View file

@ -7,14 +7,43 @@ import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels 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.RecordingListScreen
import com.keylightpiano.pianotimeline.ui.RecordingListViewModel import com.keylightpiano.pianotimeline.ui.RecordingListViewModel
import com.keylightpiano.pianotimeline.ui.SettingsScreen
import com.keylightpiano.pianotimeline.ui.theme.PianoTimelineTheme 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() { class MainActivity : ComponentActivity() {
// Hoist the ViewModel to the Activity so deep-link intents (which arrive here) // 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() private val vm: RecordingListViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
@ -27,7 +56,62 @@ class MainActivity : ComponentActivity() {
setContent { setContent {
// Force dark mode regardless of system setting // Force dark mode regardless of system setting
PianoTimelineTheme(darkTheme = true) { 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() }
)
}
}
}
} }
} }
} }

View file

@ -6,6 +6,7 @@ import com.keylightpiano.pianotimeline.data.local.RecordingDao
import com.keylightpiano.pianotimeline.data.local.toDomainOrNull import com.keylightpiano.pianotimeline.data.local.toDomainOrNull
import com.keylightpiano.pianotimeline.data.remote.MusicNetwork import com.keylightpiano.pianotimeline.data.remote.MusicNetwork
import com.keylightpiano.pianotimeline.domain.Recording import com.keylightpiano.pianotimeline.domain.Recording
import com.keylightpiano.pianotimeline.player.MediaCache
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
@ -69,12 +70,31 @@ class MusicRepository(context: Context) {
dao.setNew(id, true) 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. * Clear all cached audio/midi files from disk and reset paths in the database.
* Used by the "clear cache" setting. * 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) { 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) val cacheDir = audioCacheDir(context)
if (cacheDir.exists()) { if (cacheDir.exists()) {
cacheDir.deleteRecursively() cacheDir.deleteRecursively()

View file

@ -29,6 +29,7 @@ class SettingsStore(private val context: Context) {
val KEY_REPEAT = stringPreferencesKey("repeat_mode") val KEY_REPEAT = stringPreferencesKey("repeat_mode")
val KEY_LAST_TRACK_ID = stringPreferencesKey("last_track_id") val KEY_LAST_TRACK_ID = stringPreferencesKey("last_track_id")
val KEY_LAST_POSITION_MS = longPreferencesKey("last_position_ms") val KEY_LAST_POSITION_MS = longPreferencesKey("last_position_ms")
val KEY_NOTIFICATIONS = booleanPreferencesKey("notifications_enabled")
} }
/** Read the stored shuffle flag (default false). */ /** Read the stored shuffle flag (default false). */
@ -85,4 +86,18 @@ class SettingsStore(private val context: Context) {
it.remove(KEY_LAST_POSITION_MS) 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 }
}
} }

View file

@ -63,6 +63,10 @@ interface RecordingDao {
/** Clear the "new" flag for a recording (called when its play button is first pressed). */ /** Clear the "new" flag for a recording (called when its play button is first pressed). */
@Query("UPDATE recordings SET isNew = 0 WHERE id = :id") @Query("UPDATE recordings SET isNew = 0 WHERE id = :id")
suspend fun clearNew(id: String) 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) data class IdAndSize(val id: String, val fileSize: Long)

View file

@ -34,7 +34,12 @@ import kotlinx.coroutines.launch
class PlayerManager( class PlayerManager(
private val context: Context, private val context: Context,
private val scope: CoroutineScope, 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()) private val _state = MutableStateFlow(PlaybackState())
val state: StateFlow<PlaybackState> = _state.asStateFlow() val state: StateFlow<PlaybackState> = _state.asStateFlow()
@ -65,6 +70,10 @@ class PlayerManager(
if (playbackState == Player.STATE_READY) startPositionLoop() if (playbackState == Player.STATE_READY) startPositionLoop()
if (playbackState == Player.STATE_ENDED) { if (playbackState == Player.STATE_ENDED) {
stopPositionLoop() 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() onTrackEnded()
} }
} }
@ -81,7 +90,7 @@ class PlayerManager(
*/ */
fun playOrToggle(recordingId: String, url: String) { fun playOrToggle(recordingId: String, url: String) {
if (recordingId == currentId) { if (recordingId == currentId) {
// Same track toggle play/pause. // Same track -- toggle play/pause.
if (player.isPlaying) player.pause() else player.play() if (player.isPlaying) player.pause() else player.play()
return 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). * restore the last session on launch, Spotify-style (comes back paused).
* ExoPlayer defers the seek until the item is prepared enough to honor it, * ExoPlayer defers the seek until the item is prepared enough to honor it,
* so calling prepare() then seekTo() before playback is safe. * so calling prepare() then seekTo() before playback is safe.
@ -149,7 +158,7 @@ class PlayerManager(
positionJob = scope.launch { positionJob = scope.launch {
while (true) { while (true) {
updateState() updateState()
delay(200) // 5x/sec smooth enough for a progress bar delay(200) // 5x/sec -- smooth enough for a progress bar
} }
} }
} }

View file

@ -19,7 +19,7 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Block 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.MusicNote
import androidx.compose.material.icons.filled.Star import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.outlined.StarBorder import androidx.compose.material.icons.outlined.StarBorder
@ -206,7 +206,7 @@ fun RecordingItem(
// full audio is cached (playable offline). // full audio is cached (playable offline).
if (recording.isCached) { if (recording.isCached) {
Icon( Icon(
imageVector = Icons.Filled.DownloadDone, imageVector = Icons.Filled.DownloadForOffline,
contentDescription = stringResource(R.string.cd_downloaded), contentDescription = stringResource(R.string.cd_downloaded),
tint = NotesGreen, tint = NotesGreen,
modifier = Modifier.size(14.dp) modifier = Modifier.size(14.dp)

View file

@ -28,6 +28,7 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Clear import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator 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.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.keylightpiano.pianotimeline.R import com.keylightpiano.pianotimeline.R
import com.keylightpiano.pianotimeline.domain.Recording import com.keylightpiano.pianotimeline.domain.Recording
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable @Composable
fun RecordingListScreen( fun RecordingListScreen(
modifier: Modifier = Modifier, vm: RecordingListViewModel,
vm: RecordingListViewModel = viewModel() onOpenSettings: () -> Unit,
modifier: Modifier = Modifier
) { ) {
val syncState by vm.syncState.collectAsStateWithLifecycle() val syncState by vm.syncState.collectAsStateWithLifecycle()
val recordings by vm.recordings.collectAsStateWithLifecycle() val recordings by vm.recordings.collectAsStateWithLifecycle()
@ -94,6 +95,7 @@ fun RecordingListScreen(
var showSortMenu by remember { mutableStateOf(false) } var showSortMenu by remember { mutableStateOf(false) }
var showArtistFilter by rememberSaveable { mutableStateOf(false) } var showArtistFilter by rememberSaveable { mutableStateOf(false) }
var showYearFilter by rememberSaveable { mutableStateOf(false) } var showYearFilter by rememberSaveable { mutableStateOf(false) }
var showOverflowMenu by remember { mutableStateOf(false) }
val listState = rememberLazyListState() val listState = rememberLazyListState()
@ -213,6 +215,29 @@ fun RecordingListScreen(
else MaterialTheme.colorScheme.onSurfaceVariant 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 scrollBehavior = scrollBehavior
) )

View file

@ -37,12 +37,17 @@ class RecordingListViewModel(app: Application) : AndroidViewModel(app) {
private val repo = MusicRepository(app) private val repo = MusicRepository(app)
private val syncManager = SyncManager(app) private val syncManager = SyncManager(app)
private val settings = SettingsStore(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). */ /** Playback state the UI observes (which card is playing, position, etc). */
val playbackState: StateFlow<PlaybackState> = playerManager.state val playbackState: StateFlow<PlaybackState> = playerManager.state
// ── Connectivity ─────────────────────────────────────────────────── // -- Connectivity --
private val networkMonitor = NetworkMonitor(app) private val networkMonitor = NetworkMonitor(app)
@ -60,7 +65,7 @@ class RecordingListViewModel(app: Application) : AndroidViewModel(app) {
.debounce(800L) .debounce(800L)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), networkMonitor.isOnlineNow()) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), networkMonitor.isOnlineNow())
// ── Continuous play: repeat + shuffle ────────────────────────────── // -- Continuous play: repeat + shuffle --
private val _repeatMode = MutableStateFlow(RepeatMode.OFF) private val _repeatMode = MutableStateFlow(RepeatMode.OFF)
val repeatMode: StateFlow<RepeatMode> = _repeatMode.asStateFlow() val repeatMode: StateFlow<RepeatMode> = _repeatMode.asStateFlow()
@ -76,7 +81,42 @@ class RecordingListViewModel(app: Application) : AndroidViewModel(app) {
*/ */
private var shuffleOrder: List<String> = emptyList() 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 * 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 } 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 audioShareUrl(recording: Recording): String = repo.audioUrl(recording)
fun midiShareUrl(recording: Recording): String? = repo.midiUrl(recording) fun midiShareUrl(recording: Recording): String? = repo.midiUrl(recording)
// ── Sync state ───────────────────────────────────────────────────── // -- Sync state --
private val _syncState = MutableStateFlow<SyncState>(SyncState.Syncing) private val _syncState = MutableStateFlow<SyncState>(SyncState.Syncing)
val syncState: StateFlow<SyncState> = _syncState.asStateFlow() val syncState: StateFlow<SyncState> = _syncState.asStateFlow()
@ -114,7 +154,7 @@ class RecordingListViewModel(app: Application) : AndroidViewModel(app) {
private val _isRefreshing = MutableStateFlow(false) private val _isRefreshing = MutableStateFlow(false)
val isRefreshing: StateFlow<Boolean> = _isRefreshing.asStateFlow() val isRefreshing: StateFlow<Boolean> = _isRefreshing.asStateFlow()
// ── User-controlled filters ──────────────────────────────────────── // -- User-controlled filters --
private val _searchQuery = MutableStateFlow("") private val _searchQuery = MutableStateFlow("")
val searchQuery: StateFlow<String> = _searchQuery.asStateFlow() val searchQuery: StateFlow<String> = _searchQuery.asStateFlow()
@ -136,7 +176,7 @@ class RecordingListViewModel(app: Application) : AndroidViewModel(app) {
private val _hiddenYears = MutableStateFlow<Set<Int>>(emptySet()) private val _hiddenYears = MutableStateFlow<Set<Int>>(emptySet())
val hiddenYears: StateFlow<Set<Int>> = _hiddenYears.asStateFlow() 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). */ /** All recordings straight from Room (unfiltered). */
private val allRecordings = repo.observeRecordings() private val allRecordings = repo.observeRecordings()
@ -163,7 +203,7 @@ class RecordingListViewModel(app: Application) : AndroidViewModel(app) {
.sortedDescending() .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( private val filteredRecordings: kotlinx.coroutines.flow.Flow<List<Recording>> = combine(
allRecordings, allRecordings,
@ -177,7 +217,7 @@ class RecordingListViewModel(app: Application) : AndroidViewModel(app) {
.filterBySearch(query) .filterBySearch(query)
.sortedWith(sort.comparator()) .sortedWith(sort.comparator())
// Split into favorites and the rest. Favorites sit at the top, ordered by // 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 // of the group), which reads as insertion order. Non-favorites keep the
// user's chosen sort. // user's chosen sort.
val (favorites, others) = filteredSorted.partition { it.isFavorite } 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 * 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 * cache (MediaCache), not the unused Room column. Recomputed whenever
* connectivity flips (isOnline) so "playable offline" is fresh when it * connectivity flips (isOnline) OR the cache generation is bumped (e.g. after
* matters. The map runs on IO because it touches cache metadata on disk. * "clear cache"), so "playable offline" stays fresh. The map runs on IO
* because it touches cache metadata on disk.
*/ */
val recordings: StateFlow<List<Recording>> = val recordings: StateFlow<List<Recording>> =
combine(filteredRecordings, isOnline) { list, _ -> combine(filteredRecordings, isOnline, _cacheGeneration) { list, _, _ ->
list.map { rec -> list.map { rec ->
val cached = MediaCache.isFullyCached(app, repo.audioUrl(rec)) val cached = MediaCache.isFullyCached(app, repo.audioUrl(rec))
if (cached == rec.isCached) rec else rec.copy(isCached = cached) if (cached == rec.isCached) rec else rec.copy(isCached = cached)
@ -201,7 +242,7 @@ class RecordingListViewModel(app: Application) : AndroidViewModel(app) {
.flowOn(Dispatchers.IO) .flowOn(Dispatchers.IO)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
// ── Init: run sync on creation ───────────────────────────────────── // -- Init: run sync on creation --
init { init {
// Restore persisted playback preferences (shuffle / repeat) before sync. // Restore persisted playback preferences (shuffle / repeat) before sync.
@ -210,10 +251,11 @@ class RecordingListViewModel(app: Application) : AndroidViewModel(app) {
_repeatMode.value = runCatching { _repeatMode.value = runCatching {
RepeatMode.valueOf(settings.getRepeatModeName()) RepeatMode.valueOf(settings.getRepeatModeName())
}.getOrDefault(RepeatMode.OFF) }.getOrDefault(RepeatMode.OFF)
_notificationsEnabled.value = settings.getNotificationsEnabled()
} }
// Restore the last-played track (PAUSED, Spotify-style) once the list is // 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 { viewModelScope.launch {
val savedId = settings.getLastTrackId() ?: return@launch val savedId = settings.getLastTrackId() ?: return@launch
val savedPos = settings.getLastPositionMs() val savedPos = settings.getLastPositionMs()
@ -224,7 +266,7 @@ class RecordingListViewModel(app: Application) : AndroidViewModel(app) {
playerManager.preparePaused(match.id, repo.audioUrl(match), savedPos) playerManager.preparePaused(match.id, repo.audioUrl(match), savedPos)
} else { } else {
// Track no longer exists (removed on the server, or storage cleared) // 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() settings.clearLastPlayed()
} }
} }
@ -253,7 +295,7 @@ class RecordingListViewModel(app: Application) : AndroidViewModel(app) {
} }
viewModelScope.launch { 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. // cached in Room if we have it; otherwise surface an offline error.
if (!isOnline.value) { if (!isOnline.value) {
_syncState.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) { fun setSearchQuery(query: String) {
_searchQuery.value = query _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, * 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). * 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() } viewModelScope.launch { settings.clearLastPlayed() }
} }
// ── Deep-link handling ───────────────────────────────────────────── // -- Deep-link handling --
/** A link that arrived before the recording list was ready, retried later. */ /** A link that arrived before the recording list was ready, retried later. */
private var pendingLink: DeepLinkParser.ParsedLink? = null 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 * 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). * 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 } } return prevId?.let { id -> visible.firstOrNull { it.id == id } }
} }
// ── Repeat / shuffle controls ────────────────────────────────────── // -- Repeat / shuffle controls --
fun cycleRepeatMode() { fun cycleRepeatMode() {
val next = when (_repeatMode.value) { val next = when (_repeatMode.value) {
@ -535,7 +622,7 @@ class RecordingListViewModel(app: Application) : AndroidViewModel(app) {
val url = repo.audioUrl(nextRecording) val url = repo.audioUrl(nextRecording)
playerManager.playOrToggle(nextRecording.id, url) playerManager.playOrToggle(nextRecording.id, url)
} else { } else {
// End of queue with Repeat Off stop. // End of queue with Repeat Off -> stop.
playerManager.stop() playerManager.stop()
} }
} }
@ -561,7 +648,7 @@ class RecordingListViewModel(app: Application) : AndroidViewModel(app) {
val nextId = when { val nextId = when {
idx != -1 && idx + 1 < shuffleOrder.size -> shuffleOrder[idx + 1] idx != -1 && idx + 1 < shuffleOrder.size -> shuffleOrder[idx + 1]
_repeatMode.value == RepeatMode.ALL -> { _repeatMode.value == RepeatMode.ALL -> {
// Completed a pass reshuffle for the next one. // Completed a pass -- reshuffle for the next one.
rebuildShuffleOrder(startId = null) rebuildShuffleOrder(startId = null)
shuffleOrder.firstOrNull() shuffleOrder.firstOrNull()
} }
@ -582,7 +669,7 @@ class RecordingListViewModel(app: Application) : AndroidViewModel(app) {
*/ */
fun refresh() { fun refresh() {
if (_isRefreshing.value) return // ignore overlapping pulls 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. // indicator doesn't spin against a network call that can only fail.
if (!isOnline.value) return if (!isOnline.value) return
_isRefreshing.value = true _isRefreshing.value = true
@ -614,7 +701,7 @@ class RecordingListViewModel(app: Application) : AndroidViewModel(app) {
} }
} }
// ── State types ──────────────────────────────────────────────────────── // -- State types --
sealed interface SyncState { sealed interface SyncState {
data object Syncing : SyncState data object Syncing : SyncState
@ -625,7 +712,7 @@ sealed interface SyncState {
/** Three-state repeat, like a normal audio player. */ /** Three-state repeat, like a normal audio player. */
enum class RepeatMode { OFF, ALL, ONE } enum class RepeatMode { OFF, ALL, ONE }
// ── Private helpers ──────────────────────────────────────────────────── // -- Private helpers --
/** /**
* Case-insensitive, token-based search. * Case-insensitive, token-based search.

View file

@ -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"
}
}

View file

@ -7,6 +7,7 @@
<string name="cd_shuffle">Aleatorio</string> <string name="cd_shuffle">Aleatorio</string>
<string name="cd_repeat">Repetir</string> <string name="cd_repeat">Repetir</string>
<string name="cd_search_filter">Buscar y filtrar</string> <string name="cd_search_filter">Buscar y filtrar</string>
<string name="cd_more_options">M\u00E1s opciones</string>
<!-- Sync states --> <!-- Sync states -->
<string name="syncing">Sincronizando\u2026</string> <string name="syncing">Sincronizando\u2026</string>
@ -56,4 +57,20 @@
<string name="sort_title_az">T\u00EDtulo A \u2192 Z</string> <string name="sort_title_az">T\u00EDtulo A \u2192 Z</string>
<string name="sort_longest">M\u00E1s largas</string> <string name="sort_longest">M\u00E1s largas</string>
<string name="sort_shortest">M\u00E1s cortas</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> </resources>

View file

@ -7,6 +7,7 @@
<string name="cd_shuffle">Casuale</string> <string name="cd_shuffle">Casuale</string>
<string name="cd_repeat">Ripeti</string> <string name="cd_repeat">Ripeti</string>
<string name="cd_search_filter">Cerca e filtra</string> <string name="cd_search_filter">Cerca e filtra</string>
<string name="cd_more_options">Altre opzioni</string>
<!-- Sync states --> <!-- Sync states -->
<string name="syncing">Sincronizzazione\u2026</string> <string name="syncing">Sincronizzazione\u2026</string>
@ -56,4 +57,20 @@
<string name="sort_title_az">Titolo A \u2192 Z</string> <string name="sort_title_az">Titolo A \u2192 Z</string>
<string name="sort_longest">Pi\u00F9 lunghi</string> <string name="sort_longest">Pi\u00F9 lunghi</string>
<string name="sort_shortest">Pi\u00F9 brevi</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> </resources>

View file

@ -7,6 +7,7 @@
<string name="cd_shuffle">Shuffle</string> <string name="cd_shuffle">Shuffle</string>
<string name="cd_repeat">Repeat</string> <string name="cd_repeat">Repeat</string>
<string name="cd_search_filter">Search &amp; Filter</string> <string name="cd_search_filter">Search &amp; Filter</string>
<string name="cd_more_options">More options</string>
<!-- Sync states --> <!-- Sync states -->
<string name="syncing">Syncing\u2026</string> <string name="syncing">Syncing\u2026</string>
@ -56,4 +57,20 @@
<string name="sort_title_az">Title A \u2192 Z</string> <string name="sort_title_az">Title A \u2192 Z</string>
<string name="sort_longest">Longest first</string> <string name="sort_longest">Longest first</string>
<string name="sort_shortest">Shortest 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> </resources>