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> <selectionStates>
<SelectionState runConfigName="app"> <SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" /> <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"> <Target type="DEFAULT_BOOT">
<handle> <handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=RFCY20YA30P" /> <DeviceId pluginId="PhysicalDevice" identifier="serial=RFCY20YA30P" />

View file

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

View file

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

View file

@ -64,6 +64,11 @@ class MusicRepository(context: Context) {
dao.clearNew(id) 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. * 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.

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, fileSize = fileSize,
isFavorite = isFavorite, isFavorite = isFavorite,
isNew = isNew, isNew = isNew,
favoritedAt = favoritedAt favoritedAt = favoritedAt,
isCached = cachedAudioPath != null
) )
} }

View file

@ -23,7 +23,10 @@ data class Recording(
val fileSize: Long, val fileSize: Long,
val isFavorite: Boolean = false, val isFavorite: Boolean = false,
val isNew: 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 val hasMidi: Boolean get() = midiFileName != null

View file

@ -4,6 +4,7 @@ import android.content.Context
import androidx.annotation.OptIn import androidx.annotation.OptIn
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.media3.database.StandaloneDatabaseProvider import androidx.media3.database.StandaloneDatabaseProvider
import androidx.media3.datasource.cache.ContentMetadata
import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor
import androidx.media3.datasource.cache.SimpleCache import androidx.media3.datasource.cache.SimpleCache
import java.io.File import java.io.File
@ -48,6 +49,27 @@ object MediaCache {
*/ */
fun cacheSizeBytes(context: Context): Long = get(context).cacheSpace 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 * Clears everything from the cache. Must be called when no player is actively
* reading call it while playback is stopped. * 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.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close 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.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
@ -58,7 +61,15 @@ fun MiniPlayer(
onPrevious: () -> Unit, onPrevious: () -> Unit,
onNext: () -> Unit, onNext: () -> Unit,
onDismiss: () -> 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( Surface(
modifier = modifier.fillMaxWidth(), modifier = modifier.fillMaxWidth(),
@ -69,7 +80,12 @@ fun MiniPlayer(
) { ) {
// Inset the content above the system navigation bar so the controls // Inset the content above the system navigation bar so the controls
// aren't hidden under it (the surface background still fills to the edge). // 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 // While the user is dragging, we hold a local position so the thumb
// follows the finger instead of snapping back to the polled playhead. // follows the finger instead of snapping back to the polled playhead.
var isDragging by remember { mutableStateOf(false) } var isDragging by remember { mutableStateOf(false) }
@ -85,6 +101,30 @@ fun MiniPlayer(
.padding(start = 8.dp, end = 12.dp), .padding(start = 8.dp, end = 12.dp),
verticalAlignment = Alignment.CenterVertically 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( Slider(
value = sliderValue, value = sliderValue,
onValueChange = { v -> onValueChange = { v ->

View file

@ -19,6 +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.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
@ -62,12 +63,14 @@ private val PlayingRed = Color(0xFFFF3D3D)
fun RecordingItem( fun RecordingItem(
recording: Recording, recording: Recording,
isPlaying: Boolean, isPlaying: Boolean,
isDisabledOffline: Boolean,
onPlayClick: () -> Unit, onPlayClick: () -> Unit,
onFavoriteClick: () -> Unit, onFavoriteClick: () -> Unit,
onNotesClick: (() -> Unit)?, // null = no MIDI -> Notes button disabled onNotesClick: (() -> Unit)?, // null = no MIDI -> Notes button disabled
onShareAudio: () -> Unit, onShareAudio: () -> Unit,
onShareVisualization: (() -> Unit)?, // null when no MIDI onShareVisualization: (() -> Unit)?, // null when no MIDI
onCopyLink: () -> Unit, onCopyLink: () -> Unit,
onMarkAsNew: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
var menuExpanded by remember { mutableStateOf(false) } var menuExpanded by remember { mutableStateOf(false) }
@ -77,6 +80,7 @@ fun RecordingItem(
Card( Card(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.graphicsLayer { alpha = if (isDisabledOffline) 0.4f else 1f }
.combinedClickable( .combinedClickable(
onClick = {}, // normal taps handled by inner buttons onClick = {}, // normal taps handled by inner buttons
onLongClick = { menuExpanded = true } onLongClick = { menuExpanded = true }
@ -174,6 +178,7 @@ fun RecordingItem(
) { ) {
FilledTonalIconButton( FilledTonalIconButton(
onClick = onPlayClick, onClick = onPlayClick,
enabled = !isDisabledOffline,
modifier = Modifier.size(44.dp) modifier = Modifier.size(44.dp)
) { ) {
Icon( Icon(
@ -196,20 +201,35 @@ fun RecordingItem(
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
Text( Row(verticalAlignment = Alignment.CenterVertically) {
text = recording.title, // Small green "downloaded" glyph, shown only when the
style = MaterialTheme.typography.bodyLarge, // full audio is cached (playable offline).
fontWeight = FontWeight.Medium, if (recording.isCached) {
color = if (isPlaying) PlayingRed Icon(
else MaterialTheme.colorScheme.onSurface, imageVector = Icons.Filled.DownloadDone,
maxLines = 1, contentDescription = stringResource(R.string.cd_downloaded),
modifier = Modifier.basicMarquee( tint = NotesGreen,
iterations = Int.MAX_VALUE, modifier = Modifier.size(14.dp)
repeatDelayMillis = 2000, )
initialDelayMillis = 3000, Spacer(Modifier.width(4.dp))
velocity = 30.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( Row(
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
@ -289,6 +309,16 @@ fun RecordingItem(
onCopyLink() 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.expandVertically
import androidx.compose.animation.shrinkVertically import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.Image 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.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column 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.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items 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.Repeat
import androidx.compose.material.icons.filled.RepeatOne
import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Shuffle
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu 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.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight 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.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.keylightpiano.pianotimeline.R import com.keylightpiano.pianotimeline.R
@ -83,6 +88,7 @@ fun RecordingListScreen(
val availableArtists by vm.availableArtists.collectAsStateWithLifecycle() val availableArtists by vm.availableArtists.collectAsStateWithLifecycle()
val hiddenYears by vm.hiddenYears.collectAsStateWithLifecycle() val hiddenYears by vm.hiddenYears.collectAsStateWithLifecycle()
val availableYears by vm.availableYears.collectAsStateWithLifecycle() val availableYears by vm.availableYears.collectAsStateWithLifecycle()
val isOnline by vm.isOnline.collectAsStateWithLifecycle()
var showControls by rememberSaveable { mutableStateOf(false) } var showControls by rememberSaveable { mutableStateOf(false) }
var showSortMenu by remember { mutableStateOf(false) } var showSortMenu by remember { mutableStateOf(false) }
@ -198,26 +204,6 @@ fun RecordingListScreen(
} }
}, },
actions = { 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 // Search & filter
IconButton(onClick = { showControls = !showControls }) { IconButton(onClick = { showControls = !showControls }) {
Icon( Icon(
@ -232,16 +218,26 @@ fun RecordingListScreen(
) )
}, },
bottomBar = { bottomBar = {
if (nowPlaying != null) { Column {
MiniPlayer( if (nowPlaying != null) {
recording = nowPlaying, MiniPlayer(
playback = playbackState, recording = nowPlaying,
onTogglePlayPause = { vm.togglePlayPause() }, playback = playbackState,
onSeek = { vm.seekTo(it) }, onTogglePlayPause = { vm.togglePlayPause() },
onPrevious = { vm.skipPrevious() }, onSeek = { vm.seekTo(it) },
onNext = { vm.skipNext() }, onPrevious = { vm.skipPrevious() },
onDismiss = { vm.stopPlayback() } 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 -> ) { 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( Text(
if (hiddenArtists.isEmpty()) stringResource(R.string.filter_artists) if (hiddenArtists.isEmpty()) stringResource(R.string.filter_artists)
else stringResource(R.string.filter_artists_hidden, hiddenArtists.size), 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( Text(
if (hiddenYears.isEmpty()) stringResource(R.string.filter_years) if (hiddenYears.isEmpty()) stringResource(R.string.filter_years)
else stringResource(R.string.filter_years_hidden, hiddenYears.size), else stringResource(R.string.filter_years_hidden, hiddenYears.size),
@ -358,17 +374,48 @@ fun RecordingListScreen(
color = Color(0xFF70D66B)) color = Color(0xFF70D66B))
} }
} }
FlowRow( // Fixed 3-per-row grid: every chip takes an
horizontalArrangement = Arrangement.spacedBy(6.dp), // equal third of the width so they line up in a
verticalArrangement = Arrangement.spacedBy(2.dp) // regular grid spanning the screen. Long artist
) { // names scroll (marquee) within their cell.
availableArtists.forEach { artist -> val artistRows = availableArtists.chunked(3)
FilterChip( Column(verticalArrangement = Arrangement.spacedBy(1.dp)) {
selected = artist !in hiddenArtists, artistRows.forEach { rowArtists ->
onClick = { vm.toggleArtist(artist) }, Row(
label = { Text(artist, style = MaterialTheme.typography.labelSmall, modifier = Modifier.fillMaxWidth(),
color = Color(0xFF70D66B)) } 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, recording = recording,
isPlaying = playbackState.nowPlayingId == recording.id isPlaying = playbackState.nowPlayingId == recording.id
&& playbackState.isPlaying, && playbackState.isPlaying,
isDisabledOffline = !isOnline && !recording.isCached,
onPlayClick = { vm.onPlay(recording) }, onPlayClick = { vm.onPlay(recording) },
onFavoriteClick = { onFavoriteToggle(recording) }, onFavoriteClick = { onFavoriteToggle(recording) },
onNotesClick = if (recording.hasMidi) { onNotesClick = if (recording.hasMidi) {
@ -449,6 +497,7 @@ fun RecordingListScreen(
onCopyLink = { onCopyLink = {
copyLink(context, vm.audioShareUrl(recording)) copyLink(context, vm.audioShareUrl(recording))
}, },
onMarkAsNew = { vm.markAsNew(recording) },
modifier = Modifier.animateItem() 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 ────────────────────────────────────────── // ── Share / clipboard helpers ──────────────────────────────────────────
/** Fire Android's standard share sheet with a media URL as plain text. */ /** 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 androidx.lifecycle.viewModelScope
import com.keylightpiano.pianotimeline.data.DeepLinkParser import com.keylightpiano.pianotimeline.data.DeepLinkParser
import com.keylightpiano.pianotimeline.data.MusicRepository import com.keylightpiano.pianotimeline.data.MusicRepository
import com.keylightpiano.pianotimeline.data.NetworkMonitor
import com.keylightpiano.pianotimeline.data.SettingsStore import com.keylightpiano.pianotimeline.data.SettingsStore
import com.keylightpiano.pianotimeline.data.SyncManager import com.keylightpiano.pianotimeline.data.SyncManager
import com.keylightpiano.pianotimeline.domain.Recording import com.keylightpiano.pianotimeline.domain.Recording
import com.keylightpiano.pianotimeline.player.MediaCache
import com.keylightpiano.pianotimeline.player.PlaybackState import com.keylightpiano.pianotimeline.player.PlaybackState
import com.keylightpiano.pianotimeline.player.PlayerManager import com.keylightpiano.pianotimeline.player.PlayerManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch 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). */ /** Playback state the UI observes (which card is playing, position, etc). */
val playbackState: StateFlow<PlaybackState> = playerManager.state 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 ────────────────────────────── // ── Continuous play: repeat + shuffle ──────────────────────────────
private val _repeatMode = MutableStateFlow(RepeatMode.OFF) private val _repeatMode = MutableStateFlow(RepeatMode.OFF)
@ -141,7 +164,7 @@ class RecordingListViewModel(app: Application) : AndroidViewModel(app) {
// ── The final filtered + sorted list the UI renders ──────────────── // ── 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, allRecordings,
_searchQuery, _searchQuery,
_sortMode, _sortMode,
@ -158,7 +181,24 @@ class RecordingListViewModel(app: Application) : AndroidViewModel(app) {
// user's chosen sort. // user's chosen sort.
val (favorites, others) = filteredSorted.partition { it.isFavorite } val (favorites, others) = filteredSorted.partition { it.isFavorite }
favorites.sortedBy { it.favoritedAt } + others 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 ───────────────────────────────────── // ── Init: run sync on creation ─────────────────────────────────────
@ -180,6 +220,15 @@ class RecordingListViewModel(app: Application) : AndroidViewModel(app) {
} }
viewModelScope.launch { 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 _syncState.value = SyncState.Syncing
val result = syncManager.sync() val result = syncManager.sync()
_syncState.value = when (result) { _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, * 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).
@ -490,6 +546,9 @@ 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
// indicator doesn't spin against a network call that can only fail.
if (!isOnline.value) return
_isRefreshing.value = true _isRefreshing.value = true
viewModelScope.launch { viewModelScope.launch {
try { try {
@ -503,6 +562,12 @@ class RecordingListViewModel(app: Application) : AndroidViewModel(app) {
fun retry() { fun retry() {
_syncState.value = SyncState.Syncing _syncState.value = SyncState.Syncing
viewModelScope.launch { 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() val result = syncManager.sync()
_syncState.value = when (result) { _syncState.value = when (result) {
is SyncManager.SyncResult.UpToDate -> SyncState.Ready is SyncManager.SyncResult.UpToDate -> SyncState.Ready

View file

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

View file

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

View file

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