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>
|
<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" />
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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,
|
fileSize = fileSize,
|
||||||
isFavorite = isFavorite,
|
isFavorite = isFavorite,
|
||||||
isNew = isNew,
|
isNew = isNew,
|
||||||
favoritedAt = favoritedAt
|
favoritedAt = favoritedAt,
|
||||||
|
isCached = cachedAudioPath != null
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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 ->
|
||||||
|
|
|
||||||
|
|
@ -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,6 +201,18 @@ fun RecordingItem(
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
|
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(
|
||||||
text = recording.title,
|
text = recording.title,
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
|
@ -203,13 +220,16 @@ fun RecordingItem(
|
||||||
color = if (isPlaying) PlayingRed
|
color = if (isPlaying) PlayingRed
|
||||||
else MaterialTheme.colorScheme.onSurface,
|
else MaterialTheme.colorScheme.onSurface,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
modifier = Modifier.basicMarquee(
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.basicMarquee(
|
||||||
iterations = Int.MAX_VALUE,
|
iterations = Int.MAX_VALUE,
|
||||||
repeatDelayMillis = 2000,
|
repeatDelayMillis = 2000,
|
||||||
initialDelayMillis = 3000,
|
initialDelayMillis = 3000,
|
||||||
velocity = 30.dp
|
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()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,6 +218,7 @@ fun RecordingListScreen(
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
bottomBar = {
|
bottomBar = {
|
||||||
|
Column {
|
||||||
if (nowPlaying != null) {
|
if (nowPlaying != null) {
|
||||||
MiniPlayer(
|
MiniPlayer(
|
||||||
recording = nowPlaying,
|
recording = nowPlaying,
|
||||||
|
|
@ -240,9 +227,18 @@ fun RecordingListScreen(
|
||||||
onSeek = { vm.seekTo(it) },
|
onSeek = { vm.seekTo(it) },
|
||||||
onPrevious = { vm.skipPrevious() },
|
onPrevious = { vm.skipPrevious() },
|
||||||
onNext = { vm.skipNext() },
|
onNext = { vm.skipNext() },
|
||||||
onDismiss = { vm.stopPlayback() }
|
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 ->
|
||||||
when (val s = syncState) {
|
when (val s = syncState) {
|
||||||
|
|
@ -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.
|
||||||
|
val artistRows = availableArtists.chunked(3)
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(1.dp)) {
|
||||||
|
artistRows.forEach { rowArtists ->
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||||
) {
|
) {
|
||||||
availableArtists.forEach { artist ->
|
rowArtists.forEach { artist ->
|
||||||
FilterChip(
|
FilterChip(
|
||||||
selected = artist !in hiddenArtists,
|
selected = artist !in hiddenArtists,
|
||||||
onClick = { vm.toggleArtist(artist) },
|
onClick = { vm.toggleArtist(artist) },
|
||||||
label = { Text(artist, style = MaterialTheme.typography.labelSmall,
|
modifier = Modifier
|
||||||
color = Color(0xFF70D66B)) }
|
.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. */
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue