Added offline mode

This commit is contained in:
OmniaX-Dev 2026-07-05 05:56:58 +02:00
parent 21752f8c4c
commit b195ef940f
15 changed files with 410 additions and 68 deletions

View file

@ -4,7 +4,7 @@
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2026-06-24T10:28:35.006754299Z">
<DropdownSelection timestamp="2026-07-04T03:43:05.320175813Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=RFCY20YA30P" />

View file

@ -17,8 +17,8 @@ android {
applicationId = "com.keylightpiano.pianotimeline"
minSdk = 26
targetSdk = 36
versionCode = 2
versionName = "1.0"
versionCode = 3
versionName = "1.0.1"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}

View file

@ -2,6 +2,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application
android:allowBackup="true"

View file

@ -64,6 +64,11 @@ class MusicRepository(context: Context) {
dao.clearNew(id)
}
/** Re-apply the "new" badge to a recording (manual action from the long-press menu). */
suspend fun markAsNew(id: String) = withContext(Dispatchers.IO) {
dao.setNew(id, true)
}
/**
* Clear all cached audio/midi files from disk and reset paths in the database.
* Used by the "clear cache" setting.

View file

@ -0,0 +1,86 @@
package com.keylightpiano.pianotimeline.data
import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
/**
* Observes whether the device currently has usable internet.
*
* Two signals feed the same stream:
* 1. NetworkCallback the fast path. Fires instantly on transitions.
* 2. A periodic poll (every 3s) of isOnlineNow() a backstop, because on some
* devices (notably Samsung, and worse with a VPN active) the onLost /
* onCapabilitiesChanged callbacks for LOSING a connection don't reliably
* fire while the app is running. Reconnect callbacks are reliable; loss
* ones are not. The poll catches the misses. isOnlineNow() is a cheap
* synchronous OS query (no network I/O), so 3s polling is negligible.
*
* distinctUntilChanged collapses the inevitable duplicate emissions from having
* two sources, so collectors only see actual state changes.
*
* Note: on a device with an always-on VPN, the active network stays validated
* even in airplane mode (the tunnel counts as a validated network). This class
* reports that as "online", which is technically correct reachability of a
* specific server would need an actual probe. For our purposes that VPN edge
* case is intentionally not handled.
*/
class NetworkMonitor(context: Context) {
private val connectivityManager =
context.applicationContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
/** Ask the OS, right now, whether the active network can reach the internet. */
fun isOnlineNow(): Boolean {
val active = connectivityManager.activeNetwork ?: return false
val caps = connectivityManager.getNetworkCapabilities(active) ?: return false
return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
}
fun observe(): Flow<Boolean> = callbackFlow {
// A request with NO capability filter: we want to hear about ALL network
// transitions (including a network being lost), then decide online-ness
// ourselves via isOnlineNow(). A filtered request only reports networks
// that already match, which is part of why loss can be missed.
val request = NetworkRequest.Builder().build()
val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) { trySend(isOnlineNow()) }
override fun onLost(network: Network) { trySend(isOnlineNow()) }
override fun onUnavailable() { trySend(isOnlineNow()) }
override fun onCapabilitiesChanged(network: Network, caps: NetworkCapabilities) {
trySend(isOnlineNow())
}
}
connectivityManager.registerNetworkCallback(request, callback)
// Seed the initial value so collectors don't wait for the first transition.
trySend(isOnlineNow())
// Backstop poll: re-check the real state every 3s to catch loss events the
// callbacks miss. distinctUntilChanged means quiet periods cost nothing
// downstream — identical values never propagate.
val pollJob = launch {
while (isActive) {
delay(3000L)
trySend(isOnlineNow())
}
}
awaitClose {
pollJob.cancel()
connectivityManager.unregisterNetworkCallback(callback)
}
}.distinctUntilChanged()
}

View file

@ -28,7 +28,8 @@ fun RecordingEntity.toDomainOrNull(): Recording? {
fileSize = fileSize,
isFavorite = isFavorite,
isNew = isNew,
favoritedAt = favoritedAt
favoritedAt = favoritedAt,
isCached = cachedAudioPath != null
)
}

View file

@ -23,7 +23,10 @@ data class Recording(
val fileSize: Long,
val isFavorite: Boolean = false,
val isNew: Boolean = false,
val favoritedAt: Long = 0L
val favoritedAt: Long = 0L,
// Computed live from the ExoPlayer disk cache (not persisted). True when the
// full audio file is cached and therefore playable with no network.
val isCached: Boolean = false
) {
val hasMidi: Boolean get() = midiFileName != null

View file

@ -4,6 +4,7 @@ import android.content.Context
import androidx.annotation.OptIn
import androidx.media3.common.util.UnstableApi
import androidx.media3.database.StandaloneDatabaseProvider
import androidx.media3.datasource.cache.ContentMetadata
import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor
import androidx.media3.datasource.cache.SimpleCache
import java.io.File
@ -48,6 +49,27 @@ object MediaCache {
*/
fun cacheSizeBytes(context: Context): Long = get(context).cacheSpace
/**
* Whether the media at [url] is FULLY cached on disk i.e. playable with no
* network. SimpleCache keys entries by the data-spec key, which for our
* ProgressiveMediaSource is the URL string. `getCachedLength(key, 0, length)`
* returns the number of contiguous cached bytes starting at position 0 (or a
* negative "hole" length if position 0 isn't cached at all); if that equals
* the total content length, the whole file is present.
*
* The total length comes from ContentMetadata, which SimpleCache populates
* once the file has been read through at least once. If we don't yet know the
* length (<= 0), we treat it as not-fully-cached a safe default, since a
* partially-streamed track shouldn't count as offline-playable.
*/
fun isFullyCached(context: Context, url: String): Boolean {
val cache = get(context)
val contentLength = ContentMetadata.getContentLength(cache.getContentMetadata(url))
if (contentLength <= 0L) return false
val cachedFromStart = cache.getCachedLength(url, 0, contentLength)
return cachedFromStart >= contentLength
}
/**
* Clears everything from the cache. Must be called when no player is actively
* reading call it while playback is stopped.

View file

@ -18,6 +18,9 @@ import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Repeat
import androidx.compose.material.icons.filled.RepeatOne
import androidx.compose.material.icons.filled.Shuffle
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@ -58,7 +61,15 @@ fun MiniPlayer(
onPrevious: () -> Unit,
onNext: () -> Unit,
onDismiss: () -> Unit,
modifier: Modifier = Modifier
shuffle: Boolean,
repeatMode: RepeatMode,
onToggleShuffle: () -> Unit,
onCycleRepeat: () -> Unit,
modifier: Modifier = Modifier,
// When something else sits BELOW the mini-player (e.g. the offline banner),
// that lower element should own the navigation-bar inset instead — otherwise
// both would reserve nav-bar space and leave a gap. Callers pass false then.
applyNavigationBarInset: Boolean = true
) {
Surface(
modifier = modifier.fillMaxWidth(),
@ -69,7 +80,12 @@ fun MiniPlayer(
) {
// Inset the content above the system navigation bar so the controls
// aren't hidden under it (the surface background still fills to the edge).
Column(modifier = Modifier.windowInsetsPadding(WindowInsets.navigationBars)) {
// Skipped when a lower bar (offline banner) owns the inset instead.
Column(
modifier = if (applyNavigationBarInset)
Modifier.windowInsetsPadding(WindowInsets.navigationBars)
else Modifier
) {
// While the user is dragging, we hold a local position so the thumb
// follows the finger instead of snapping back to the polled playhead.
var isDragging by remember { mutableStateOf(false) }
@ -85,6 +101,30 @@ fun MiniPlayer(
.padding(start = 8.dp, end = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Shuffle + repeat, moved here from the top bar. Compact so they
// don't eat too much of the seek bar's width.
IconButton(onClick = onToggleShuffle, modifier = Modifier.size(32.dp)) {
Icon(
imageVector = Icons.Default.Shuffle,
contentDescription = stringResource(R.string.cd_shuffle),
tint = if (shuffle) Color(0xFF70D66B)
else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(18.dp)
)
}
IconButton(onClick = onCycleRepeat, modifier = Modifier.size(32.dp)) {
Icon(
imageVector = if (repeatMode == RepeatMode.ONE)
Icons.Default.RepeatOne else Icons.Default.Repeat,
contentDescription = stringResource(R.string.cd_repeat),
tint = if (repeatMode == RepeatMode.OFF)
MaterialTheme.colorScheme.onSurfaceVariant
else Color(0xFF70D66B),
modifier = Modifier.size(18.dp)
)
}
Spacer(Modifier.width(4.dp))
Slider(
value = sliderValue,
onValueChange = { v ->

View file

@ -19,6 +19,7 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Block
import androidx.compose.material.icons.filled.DownloadDone
import androidx.compose.material.icons.filled.MusicNote
import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.outlined.StarBorder
@ -62,12 +63,14 @@ private val PlayingRed = Color(0xFFFF3D3D)
fun RecordingItem(
recording: Recording,
isPlaying: Boolean,
isDisabledOffline: Boolean,
onPlayClick: () -> Unit,
onFavoriteClick: () -> Unit,
onNotesClick: (() -> Unit)?, // null = no MIDI -> Notes button disabled
onShareAudio: () -> Unit,
onShareVisualization: (() -> Unit)?, // null when no MIDI
onCopyLink: () -> Unit,
onMarkAsNew: () -> Unit,
modifier: Modifier = Modifier
) {
var menuExpanded by remember { mutableStateOf(false) }
@ -77,6 +80,7 @@ fun RecordingItem(
Card(
modifier = Modifier
.fillMaxWidth()
.graphicsLayer { alpha = if (isDisabledOffline) 0.4f else 1f }
.combinedClickable(
onClick = {}, // normal taps handled by inner buttons
onLongClick = { menuExpanded = true }
@ -174,6 +178,7 @@ fun RecordingItem(
) {
FilledTonalIconButton(
onClick = onPlayClick,
enabled = !isDisabledOffline,
modifier = Modifier.size(44.dp)
) {
Icon(
@ -196,20 +201,35 @@ fun RecordingItem(
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = recording.title,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
color = if (isPlaying) PlayingRed
else MaterialTheme.colorScheme.onSurface,
maxLines = 1,
modifier = Modifier.basicMarquee(
iterations = Int.MAX_VALUE,
repeatDelayMillis = 2000,
initialDelayMillis = 3000,
velocity = 30.dp
Row(verticalAlignment = Alignment.CenterVertically) {
// Small green "downloaded" glyph, shown only when the
// full audio is cached (playable offline).
if (recording.isCached) {
Icon(
imageVector = Icons.Filled.DownloadDone,
contentDescription = stringResource(R.string.cd_downloaded),
tint = NotesGreen,
modifier = Modifier.size(14.dp)
)
Spacer(Modifier.width(4.dp))
}
Text(
text = recording.title,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
color = if (isPlaying) PlayingRed
else MaterialTheme.colorScheme.onSurface,
maxLines = 1,
modifier = Modifier
.weight(1f)
.basicMarquee(
iterations = Int.MAX_VALUE,
repeatDelayMillis = 2000,
initialDelayMillis = 3000,
velocity = 30.dp
)
)
)
}
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
@ -289,6 +309,16 @@ fun RecordingItem(
onCopyLink()
}
)
// Only offer "Mark as new" when it isn't already flagged.
if (!recording.isNew) {
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_mark_as_new)) },
onClick = {
menuExpanded = false
onMarkAsNew()
}
)
}
}
}
}

View file

@ -4,6 +4,9 @@ import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.basicMarquee
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@ -12,20 +15,20 @@ import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.Repeat
import androidx.compose.material.icons.filled.RepeatOne
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Shuffle
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
@ -58,7 +61,9 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.keylightpiano.pianotimeline.R
@ -83,6 +88,7 @@ fun RecordingListScreen(
val availableArtists by vm.availableArtists.collectAsStateWithLifecycle()
val hiddenYears by vm.hiddenYears.collectAsStateWithLifecycle()
val availableYears by vm.availableYears.collectAsStateWithLifecycle()
val isOnline by vm.isOnline.collectAsStateWithLifecycle()
var showControls by rememberSaveable { mutableStateOf(false) }
var showSortMenu by remember { mutableStateOf(false) }
@ -198,26 +204,6 @@ fun RecordingListScreen(
}
},
actions = {
// Shuffle toggle
IconButton(onClick = { vm.toggleShuffle() }) {
Icon(
imageVector = Icons.Default.Shuffle,
contentDescription = stringResource(R.string.cd_shuffle),
tint = if (shuffle) Color(0xFF70D66B)
else MaterialTheme.colorScheme.onSurfaceVariant
)
}
// 3-state repeat
IconButton(onClick = { vm.cycleRepeatMode() }) {
Icon(
imageVector = if (repeatMode == RepeatMode.ONE)
Icons.Default.RepeatOne else Icons.Default.Repeat,
contentDescription = stringResource(R.string.cd_repeat),
tint = if (repeatMode == RepeatMode.OFF)
MaterialTheme.colorScheme.onSurfaceVariant
else Color(0xFF70D66B)
)
}
// Search & filter
IconButton(onClick = { showControls = !showControls }) {
Icon(
@ -232,16 +218,26 @@ fun RecordingListScreen(
)
},
bottomBar = {
if (nowPlaying != null) {
MiniPlayer(
recording = nowPlaying,
playback = playbackState,
onTogglePlayPause = { vm.togglePlayPause() },
onSeek = { vm.seekTo(it) },
onPrevious = { vm.skipPrevious() },
onNext = { vm.skipNext() },
onDismiss = { vm.stopPlayback() }
)
Column {
if (nowPlaying != null) {
MiniPlayer(
recording = nowPlaying,
playback = playbackState,
onTogglePlayPause = { vm.togglePlayPause() },
onSeek = { vm.seekTo(it) },
onPrevious = { vm.skipPrevious() },
onNext = { vm.skipNext() },
onDismiss = { vm.stopPlayback() },
shuffle = shuffle,
repeatMode = repeatMode,
onToggleShuffle = { vm.toggleShuffle() },
onCycleRepeat = { vm.cycleRepeatMode() },
// When the offline banner is showing below, IT owns the
// nav-bar inset, so the mini-player must not also apply it.
applyNavigationBarInset = isOnline
)
}
OfflineBanner(visible = !isOnline)
}
}
) { innerPadding ->
@ -323,7 +319,17 @@ fun RecordingListScreen(
}
}
TextButton(onClick = { showArtistFilter = !showArtistFilter }) {
TextButton(
onClick = {
showArtistFilter = !showArtistFilter
if (showArtistFilter) showYearFilter = false
},
modifier = Modifier.background(
color = if (showArtistFilter) Color(0xFF70D66B).copy(alpha = 0.18f)
else Color.Transparent,
shape = RoundedCornerShape(50)
)
) {
Text(
if (hiddenArtists.isEmpty()) stringResource(R.string.filter_artists)
else stringResource(R.string.filter_artists_hidden, hiddenArtists.size),
@ -332,7 +338,17 @@ fun RecordingListScreen(
)
}
TextButton(onClick = { showYearFilter = !showYearFilter }) {
TextButton(
onClick = {
showYearFilter = !showYearFilter
if (showYearFilter) showArtistFilter = false
},
modifier = Modifier.background(
color = if (showYearFilter) Color(0xFF70D66B).copy(alpha = 0.18f)
else Color.Transparent,
shape = RoundedCornerShape(50)
)
) {
Text(
if (hiddenYears.isEmpty()) stringResource(R.string.filter_years)
else stringResource(R.string.filter_years_hidden, hiddenYears.size),
@ -358,17 +374,48 @@ fun RecordingListScreen(
color = Color(0xFF70D66B))
}
}
FlowRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
availableArtists.forEach { artist ->
FilterChip(
selected = artist !in hiddenArtists,
onClick = { vm.toggleArtist(artist) },
label = { Text(artist, style = MaterialTheme.typography.labelSmall,
color = Color(0xFF70D66B)) }
)
// Fixed 3-per-row grid: every chip takes an
// equal third of the width so they line up in a
// regular grid spanning the screen. Long artist
// names scroll (marquee) within their cell.
val artistRows = availableArtists.chunked(3)
Column(verticalArrangement = Arrangement.spacedBy(1.dp)) {
artistRows.forEach { rowArtists ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
rowArtists.forEach { artist ->
FilterChip(
selected = artist !in hiddenArtists,
onClick = { vm.toggleArtist(artist) },
modifier = Modifier
.weight(1f)
.height(30.dp),
label = {
Text(
artist,
style = MaterialTheme.typography.labelSmall,
color = Color(0xFF70D66B),
maxLines = 1,
modifier = Modifier
.fillMaxWidth()
.basicMarquee(
iterations = Int.MAX_VALUE,
repeatDelayMillis = 2000,
initialDelayMillis = 3000,
velocity = 30.dp
)
)
}
)
}
// Pad the final row so a trailing
// 1- or 2-chip row keeps grid alignment.
repeat(3 - rowArtists.size) {
Spacer(Modifier.weight(1f))
}
}
}
}
}
@ -435,6 +482,7 @@ fun RecordingListScreen(
recording = recording,
isPlaying = playbackState.nowPlayingId == recording.id
&& playbackState.isPlaying,
isDisabledOffline = !isOnline && !recording.isCached,
onPlayClick = { vm.onPlay(recording) },
onFavoriteClick = { onFavoriteToggle(recording) },
onNotesClick = if (recording.hasMidi) {
@ -449,6 +497,7 @@ fun RecordingListScreen(
onCopyLink = {
copyLink(context, vm.audioShareUrl(recording))
},
onMarkAsNew = { vm.markAsNew(recording) },
modifier = Modifier.animateItem()
)
}
@ -460,6 +509,37 @@ fun RecordingListScreen(
}
}
/**
* Thin bar that slides up from the bottom (under the mini-player) whenever the
* device is offline. Dark red, small white centered text Spotify-style.
* Respects the navigation-bar inset so the text isn't tucked under system nav.
*/
@Composable
private fun OfflineBanner(visible: Boolean) {
AnimatedVisibility(
visible = visible,
enter = expandVertically(),
exit = shrinkVertically()
) {
Box(
modifier = Modifier
.fillMaxWidth()
.background(Color(0xFF8B0000)) // dark red
.windowInsetsPadding(WindowInsets.navigationBars)
.padding(vertical = 4.dp),
contentAlignment = Alignment.Center
) {
Text(
text = stringResource(R.string.offline_banner),
color = Color.White,
fontSize = 11.sp,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
}
}
}
// ── Share / clipboard helpers ──────────────────────────────────────────
/** Fire Android's standard share sheet with a media URL as plain text. */

View file

@ -5,16 +5,21 @@ import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.keylightpiano.pianotimeline.data.DeepLinkParser
import com.keylightpiano.pianotimeline.data.MusicRepository
import com.keylightpiano.pianotimeline.data.NetworkMonitor
import com.keylightpiano.pianotimeline.data.SettingsStore
import com.keylightpiano.pianotimeline.data.SyncManager
import com.keylightpiano.pianotimeline.domain.Recording
import com.keylightpiano.pianotimeline.player.MediaCache
import com.keylightpiano.pianotimeline.player.PlaybackState
import com.keylightpiano.pianotimeline.player.PlayerManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
@ -36,6 +41,24 @@ class RecordingListViewModel(app: Application) : AndroidViewModel(app) {
/** Playback state the UI observes (which card is playing, position, etc). */
val playbackState: StateFlow<PlaybackState> = playerManager.state
// ── Connectivity ───────────────────────────────────────────────────
private val networkMonitor = NetworkMonitor(app)
/**
* True while the device has usable internet.
*
* Debounced by 800ms: when a connection is (re)established, Android fires a
* burst of capability callbacks and can momentarily report conflicting
* states. Without debouncing that surfaced as the whole list graying out for
* a split second on reconnect. Debounce waits for the signal to settle before
* emitting, so brief blips never reach the UI.
*/
val isOnline: StateFlow<Boolean> =
networkMonitor.observe()
.debounce(800L)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), networkMonitor.isOnlineNow())
// ── Continuous play: repeat + shuffle ──────────────────────────────
private val _repeatMode = MutableStateFlow(RepeatMode.OFF)
@ -141,7 +164,7 @@ class RecordingListViewModel(app: Application) : AndroidViewModel(app) {
// ── The final filtered + sorted list the UI renders ────────────────
val recordings: StateFlow<List<Recording>> = combine(
private val filteredRecordings: kotlinx.coroutines.flow.Flow<List<Recording>> = combine(
allRecordings,
_searchQuery,
_sortMode,
@ -158,7 +181,24 @@ class RecordingListViewModel(app: Application) : AndroidViewModel(app) {
// user's chosen sort.
val (favorites, others) = filteredSorted.partition { it.isFavorite }
favorites.sortedBy { it.favoritedAt } + others
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
}
/**
* 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
* cache (MediaCache), not the unused Room column. Recomputed whenever
* connectivity flips (isOnline) so "playable offline" is fresh when it
* matters. The map runs on IO because it touches cache metadata on disk.
*/
val recordings: StateFlow<List<Recording>> =
combine(filteredRecordings, isOnline) { list, _ ->
list.map { rec ->
val cached = MediaCache.isFullyCached(app, repo.audioUrl(rec))
if (cached == rec.isCached) rec else rec.copy(isCached = cached)
}
}
.flowOn(Dispatchers.IO)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
// ── Init: run sync on creation ─────────────────────────────────────
@ -180,6 +220,15 @@ class RecordingListViewModel(app: Application) : AndroidViewModel(app) {
}
viewModelScope.launch {
// No connection → don't even attempt a network sync. Show whatever is
// cached in Room if we have it; otherwise surface an offline error.
if (!isOnline.value) {
_syncState.value =
if (syncManager.hasCachedData()) SyncState.Ready
else SyncState.Error("You're offline and no recordings are cached yet.")
return@launch
}
_syncState.value = SyncState.Syncing
val result = syncManager.sync()
_syncState.value = when (result) {
@ -239,6 +288,13 @@ class RecordingListViewModel(app: Application) : AndroidViewModel(app) {
}
}
/** Manually re-apply the "new" badge (from the long-press menu). */
fun markAsNew(recording: Recording) {
viewModelScope.launch {
repo.markAsNew(recording.id)
}
}
/**
* 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).
@ -490,6 +546,9 @@ class RecordingListViewModel(app: Application) : AndroidViewModel(app) {
*/
fun refresh() {
if (_isRefreshing.value) return // ignore overlapping pulls
// Offline → there's nothing to fetch. Bail immediately so the pull
// indicator doesn't spin against a network call that can only fail.
if (!isOnline.value) return
_isRefreshing.value = true
viewModelScope.launch {
try {
@ -503,6 +562,12 @@ class RecordingListViewModel(app: Application) : AndroidViewModel(app) {
fun retry() {
_syncState.value = SyncState.Syncing
viewModelScope.launch {
if (!isOnline.value) {
_syncState.value =
if (syncManager.hasCachedData()) SyncState.Ready
else SyncState.Error("You're offline and no recordings are cached yet.")
return@launch
}
val result = syncManager.sync()
_syncState.value = when (result) {
is SyncManager.SyncResult.UpToDate -> SyncState.Ready

View file

@ -12,6 +12,7 @@
<string name="syncing">Sincronizando\u2026</string>
<string name="error_load_title">No se pudieron cargar las grabaciones</string>
<string name="retry">Reintentar</string>
<string name="offline_banner">Sin conexion a internet</string>
<!-- Search & filters -->
<string name="search_placeholder">Buscar artista, t\u00EDtulo\u2026</string>
@ -33,12 +34,14 @@
<string name="cd_unfavorite">Quitar de favoritos</string>
<string name="notes">Notas</string>
<string name="badge_new">NUEVO</string>
<string name="cd_downloaded">Descargada</string>
<!-- Long-press menu -->
<string name="menu_share_audio">Compartir audio</string>
<string name="menu_share_visualization">Compartir visualizaci\u00F3n</string>
<string name="menu_copy_link">Copiar enlace</string>
<string name="toast_link_copied">Enlace copiado</string>
<string name="menu_mark_as_new">Marcar como nuevo</string>
<!-- Mini-player -->
<string name="cd_previous">Anterior</string>

View file

@ -12,6 +12,7 @@
<string name="syncing">Sincronizzazione\u2026</string>
<string name="error_load_title">Impossibile caricare le registrazioni</string>
<string name="retry">Riprova</string>
<string name="offline_banner">Nessuna connessione a internet</string>
<!-- Search & filters -->
<string name="search_placeholder">Cerca artista, titolo\u2026</string>
@ -33,12 +34,14 @@
<string name="cd_unfavorite">Rimuovi dai preferiti</string>
<string name="notes">Note</string>
<string name="badge_new">NUOVO</string>
<string name="cd_downloaded">Scaricata</string>
<!-- Long-press menu -->
<string name="menu_share_audio">Condividi audio</string>
<string name="menu_share_visualization">Condividi visualizzazione</string>
<string name="menu_copy_link">Copia link</string>
<string name="toast_link_copied">Link copiato</string>
<string name="menu_mark_as_new">Segna come nuovo</string>
<!-- Mini-player -->
<string name="cd_previous">Precedente</string>

View file

@ -12,6 +12,7 @@
<string name="syncing">Syncing\u2026</string>
<string name="error_load_title">Couldn\'t load recordings</string>
<string name="retry">Retry</string>
<string name="offline_banner">You\'re offline</string>
<!-- Search & filters -->
<string name="search_placeholder">Search artist, title\u2026</string>
@ -33,12 +34,14 @@
<string name="cd_unfavorite">Unfavorite</string>
<string name="notes">Notes</string>
<string name="badge_new">NEW</string>
<string name="cd_downloaded">Downloaded</string>
<!-- Long-press menu -->
<string name="menu_share_audio">Share audio</string>
<string name="menu_share_visualization">Share visualization</string>
<string name="menu_copy_link">Copy link</string>
<string name="toast_link_copied">Link copied</string>
<string name="menu_mark_as_new">Mark as new</string>
<!-- Mini-player -->
<string name="cd_previous">Previous</string>