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(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")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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_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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 & Filter</string>
|
<string name="cd_search_filter">Search & 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>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue