Added offline mode
This commit is contained in:
parent
21752f8c4c
commit
b195ef940f
15 changed files with 410 additions and 68 deletions
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -28,7 +28,8 @@ fun RecordingEntity.toDomainOrNull(): Recording? {
|
|||
fileSize = fileSize,
|
||||
isFavorite = isFavorite,
|
||||
isNew = isNew,
|
||||
favoritedAt = favoritedAt
|
||||
favoritedAt = favoritedAt,
|
||||
isCached = cachedAudioPath != null
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 ->
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue