Added Shuffle/Repeat functionality
This commit is contained in:
parent
8baa347e81
commit
7118596e1c
8 changed files with 614 additions and 10 deletions
|
|
@ -4,10 +4,10 @@
|
||||||
<selectionStates>
|
<selectionStates>
|
||||||
<SelectionState runConfigName="app">
|
<SelectionState runConfigName="app">
|
||||||
<option name="selectionMode" value="DROPDOWN" />
|
<option name="selectionMode" value="DROPDOWN" />
|
||||||
<DropdownSelection timestamp="2026-06-23T09:36:00.583173984Z">
|
<DropdownSelection timestamp="2026-06-23T10:17:32.872144833Z">
|
||||||
<Target type="DEFAULT_BOOT">
|
<Target type="DEFAULT_BOOT">
|
||||||
<handle>
|
<handle>
|
||||||
<DeviceId pluginId="LocalEmulator" identifier="path=/home/sylar/.android/avd/Medium_Phone.avd" />
|
<DeviceId pluginId="PhysicalDevice" identifier="serial=RFCY20YA30P" />
|
||||||
</handle>
|
</handle>
|
||||||
</Target>
|
</Target>
|
||||||
</DropdownSelection>
|
</DropdownSelection>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
package com.keylightpiano.pianotimeline.player
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.annotation.OptIn
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import androidx.media3.database.StandaloneDatabaseProvider
|
||||||
|
import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor
|
||||||
|
import androidx.media3.datasource.cache.SimpleCache
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Holds the ONE and only SimpleCache instance for the whole app.
|
||||||
|
*
|
||||||
|
* SimpleCache is extremely strict: you may construct exactly one instance
|
||||||
|
* pointing at a given directory for the lifetime of the process. A second
|
||||||
|
* instance on the same folder throws. So this MUST be a singleton.
|
||||||
|
*
|
||||||
|
* The cache lives in the app's cache dir (`cache/media`), so the OS can reclaim
|
||||||
|
* it under storage pressure and "clear app data" wipes it. Capacity is capped
|
||||||
|
* with an LRU evictor — once we exceed the limit, least-recently-used media is
|
||||||
|
* dropped automatically.
|
||||||
|
*/
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
|
object MediaCache {
|
||||||
|
|
||||||
|
// 512 MB cap — generous for piano recordings, which are a few MB each.
|
||||||
|
private const val MAX_CACHE_BYTES = 512L * 1024L * 1024L
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var instance: SimpleCache? = null
|
||||||
|
|
||||||
|
fun get(context: Context): SimpleCache {
|
||||||
|
return instance ?: synchronized(this) {
|
||||||
|
instance ?: createCache(context).also { instance = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createCache(context: Context): SimpleCache {
|
||||||
|
val dir = File(context.cacheDir, "media")
|
||||||
|
val evictor = LeastRecentlyUsedCacheEvictor(MAX_CACHE_BYTES)
|
||||||
|
val dbProvider = StandaloneDatabaseProvider(context)
|
||||||
|
return SimpleCache(dir, evictor, dbProvider)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Total bytes currently cached. Useful for a "clear cache" settings screen
|
||||||
|
* that wants to show how much is stored.
|
||||||
|
*/
|
||||||
|
fun cacheSizeBytes(context: Context): Long = get(context).cacheSpace
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears everything from the cache. Must be called when no player is actively
|
||||||
|
* reading — call it while playback is stopped.
|
||||||
|
*/
|
||||||
|
fun clear(context: Context) {
|
||||||
|
val cache = get(context)
|
||||||
|
cache.keys.toList().forEach { key ->
|
||||||
|
cache.removeResource(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
package com.keylightpiano.pianotimeline.player
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Immutable snapshot of what the player is doing right now.
|
||||||
|
* The UI observes this to know which card is playing, button states, etc.
|
||||||
|
*
|
||||||
|
* `nowPlayingId` is the recording id currently loaded (or null if nothing).
|
||||||
|
* The list cards compare their own id against this to decide whether to show
|
||||||
|
* the pause icon + red title.
|
||||||
|
*/
|
||||||
|
data class PlaybackState(
|
||||||
|
val nowPlayingId: String? = null,
|
||||||
|
val isPlaying: Boolean = false,
|
||||||
|
val isBuffering: Boolean = false,
|
||||||
|
val positionMs: Long = 0L,
|
||||||
|
val durationMs: Long = 0L
|
||||||
|
) {
|
||||||
|
val hasActiveTrack: Boolean get() = nowPlayingId != null
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,156 @@
|
||||||
|
package com.keylightpiano.pianotimeline.player
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.annotation.OptIn
|
||||||
|
import androidx.media3.common.MediaItem
|
||||||
|
import androidx.media3.common.Player
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import androidx.media3.datasource.DefaultHttpDataSource
|
||||||
|
import androidx.media3.datasource.cache.CacheDataSource
|
||||||
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
|
import androidx.media3.exoplayer.source.ProgressiveMediaSource
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owns a single ExoPlayer and exposes its state as a Flow.
|
||||||
|
*
|
||||||
|
* Caching: media is read through a CacheDataSource backed by the app-wide
|
||||||
|
* MediaCache singleton. First play of a track streams from the network and
|
||||||
|
* writes to disk simultaneously; subsequent plays read straight from disk.
|
||||||
|
*
|
||||||
|
* Threading: ExoPlayer must be created and called on a single thread (the main
|
||||||
|
* thread here, which is where Compose callbacks land). The position-polling loop
|
||||||
|
* runs on a coroutine but only reads player state, which is main-safe because we
|
||||||
|
* launch it on the main dispatcher.
|
||||||
|
*/
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
|
class PlayerManager(
|
||||||
|
private val context: Context,
|
||||||
|
private val scope: CoroutineScope,
|
||||||
|
private val onTrackEnded: () -> Unit = {}
|
||||||
|
) {
|
||||||
|
private val _state = MutableStateFlow(PlaybackState())
|
||||||
|
val state: StateFlow<PlaybackState> = _state.asStateFlow()
|
||||||
|
|
||||||
|
// The recording id currently loaded into the player.
|
||||||
|
private var currentId: String? = null
|
||||||
|
|
||||||
|
private var positionJob: Job? = null
|
||||||
|
|
||||||
|
private val cacheDataSourceFactory: CacheDataSource.Factory by lazy {
|
||||||
|
val upstream = DefaultHttpDataSource.Factory()
|
||||||
|
CacheDataSource.Factory()
|
||||||
|
.setCache(MediaCache.get(context))
|
||||||
|
.setUpstreamDataSourceFactory(upstream)
|
||||||
|
.setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val player: ExoPlayer by lazy {
|
||||||
|
ExoPlayer.Builder(context)
|
||||||
|
.setMediaSourceFactory(ProgressiveMediaSource.Factory(cacheDataSourceFactory))
|
||||||
|
.build()
|
||||||
|
.also { it.addListener(playerListener) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private val playerListener = object : Player.Listener {
|
||||||
|
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||||
|
updateState()
|
||||||
|
if (playbackState == Player.STATE_READY) startPositionLoop()
|
||||||
|
if (playbackState == Player.STATE_ENDED) {
|
||||||
|
stopPositionLoop()
|
||||||
|
onTrackEnded()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||||
|
updateState()
|
||||||
|
if (isPlaying) startPositionLoop() else stopPositionLoop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play a recording from its full (already percent-encoded) URL.
|
||||||
|
* If the same recording is tapped while playing, this toggles pause/resume.
|
||||||
|
*/
|
||||||
|
fun playOrToggle(recordingId: String, url: String) {
|
||||||
|
if (recordingId == currentId) {
|
||||||
|
// Same track — toggle play/pause.
|
||||||
|
if (player.isPlaying) player.pause() else player.play()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// New track.
|
||||||
|
currentId = recordingId
|
||||||
|
player.setMediaItem(MediaItem.fromUri(url))
|
||||||
|
player.prepare()
|
||||||
|
player.play()
|
||||||
|
updateState()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun pause() {
|
||||||
|
player.pause()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resume() {
|
||||||
|
player.play()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Restart the current track from the beginning (used by Repeat One). */
|
||||||
|
fun replayCurrent() {
|
||||||
|
player.seekTo(0)
|
||||||
|
player.play()
|
||||||
|
updateState()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun seekTo(positionMs: Long) {
|
||||||
|
player.seekTo(positionMs)
|
||||||
|
updateState()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stop and clear the current track entirely (mini-player dismissed). */
|
||||||
|
fun stop() {
|
||||||
|
player.stop()
|
||||||
|
player.clearMediaItems()
|
||||||
|
currentId = null
|
||||||
|
stopPositionLoop()
|
||||||
|
_state.value = PlaybackState()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startPositionLoop() {
|
||||||
|
if (positionJob?.isActive == true) return
|
||||||
|
positionJob = scope.launch {
|
||||||
|
while (true) {
|
||||||
|
updateState()
|
||||||
|
delay(200) // 5x/sec — smooth enough for a progress bar
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopPositionLoop() {
|
||||||
|
positionJob?.cancel()
|
||||||
|
positionJob = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateState() {
|
||||||
|
_state.value = PlaybackState(
|
||||||
|
nowPlayingId = currentId,
|
||||||
|
isPlaying = player.isPlaying,
|
||||||
|
isBuffering = player.playbackState == Player.STATE_BUFFERING,
|
||||||
|
positionMs = player.currentPosition.coerceAtLeast(0L),
|
||||||
|
durationMs = player.duration.coerceAtLeast(0L)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Release the player. Call when the owning ViewModel is cleared. */
|
||||||
|
fun release() {
|
||||||
|
stopPositionLoop()
|
||||||
|
player.removeListener(playerListener)
|
||||||
|
player.release()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,169 @@
|
||||||
|
package com.keylightpiano.pianotimeline.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Slider
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.keylightpiano.pianotimeline.domain.Recording
|
||||||
|
import com.keylightpiano.pianotimeline.player.PlaybackState
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compact now-playing bar pinned to the bottom. Shows the active track's
|
||||||
|
* artist/title, a play/pause toggle, a thin progress bar, and a dismiss button.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun MiniPlayer(
|
||||||
|
recording: Recording,
|
||||||
|
playback: PlaybackState,
|
||||||
|
onTogglePlayPause: () -> Unit,
|
||||||
|
onSeek: (Long) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
tonalElevation = 3.dp,
|
||||||
|
shadowElevation = 8.dp
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
// 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) }
|
||||||
|
var dragValueMs by remember { mutableFloatStateOf(0f) }
|
||||||
|
|
||||||
|
val duration = playback.durationMs.coerceAtLeast(1L)
|
||||||
|
val sliderValue = if (isDragging) dragValueMs
|
||||||
|
else playback.positionMs.toFloat().coerceIn(0f, duration.toFloat())
|
||||||
|
|
||||||
|
Slider(
|
||||||
|
value = sliderValue,
|
||||||
|
onValueChange = { v ->
|
||||||
|
isDragging = true
|
||||||
|
dragValueMs = v
|
||||||
|
},
|
||||||
|
onValueChangeFinished = {
|
||||||
|
onSeek(dragValueMs.toLong())
|
||||||
|
isDragging = false
|
||||||
|
},
|
||||||
|
valueRange = 0f..duration.toFloat(),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 2.dp),
|
||||||
|
thumb = {
|
||||||
|
// Wider than tall, rounded — a little "pill" instead of a circle.
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(width = 12.dp, height = 13.dp)
|
||||||
|
.clip(RoundedCornerShape(6.dp))
|
||||||
|
.background(MaterialTheme.colorScheme.primary)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
track = { sliderState ->
|
||||||
|
val fraction = if (sliderState.valueRange.endInclusive > sliderState.valueRange.start) {
|
||||||
|
(sliderState.value - sliderState.valueRange.start) /
|
||||||
|
(sliderState.valueRange.endInclusive - sliderState.valueRange.start)
|
||||||
|
} else 0f
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(4.dp) // ~half the default 4dp track
|
||||||
|
) {
|
||||||
|
// Inactive (full width, dim)
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(2.dp)
|
||||||
|
.clip(RoundedCornerShape(1.dp))
|
||||||
|
.background(MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f))
|
||||||
|
)
|
||||||
|
// Active (filled portion)
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth(fraction.coerceIn(0f, 1f))
|
||||||
|
.height(2.dp)
|
||||||
|
.clip(RoundedCornerShape(1.dp))
|
||||||
|
.background(MaterialTheme.colorScheme.primary)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
IconButton(onClick = onTogglePlayPause, modifier = Modifier.size(44.dp)) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(
|
||||||
|
if (playback.isPlaying) android.R.drawable.ic_media_pause
|
||||||
|
else android.R.drawable.ic_media_play
|
||||||
|
),
|
||||||
|
contentDescription = if (playback.isPlaying) "Pause" else "Play",
|
||||||
|
modifier = Modifier.size(24.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = recording.artist,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = recording.title,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
IconButton(onClick = onDismiss, modifier = Modifier.size(40.dp)) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Close,
|
||||||
|
contentDescription = "Stop",
|
||||||
|
modifier = Modifier.size(20.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -35,10 +35,12 @@ import com.keylightpiano.pianotimeline.domain.Recording
|
||||||
|
|
||||||
private val NotesGreen = Color(0xFF25B300)
|
private val NotesGreen = Color(0xFF25B300)
|
||||||
private val NewBadgeRed = Color(0xFFE53935)
|
private val NewBadgeRed = Color(0xFFE53935)
|
||||||
|
private val PlayingRed = Color(0xFFFF3D3D)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun RecordingItem(
|
fun RecordingItem(
|
||||||
recording: Recording,
|
recording: Recording,
|
||||||
|
isPlaying: Boolean,
|
||||||
onPlayClick: () -> Unit,
|
onPlayClick: () -> Unit,
|
||||||
onFavoriteClick: () -> Unit,
|
onFavoriteClick: () -> Unit,
|
||||||
onNotesClick: (() -> Unit)?,
|
onNotesClick: (() -> Unit)?,
|
||||||
|
|
@ -63,8 +65,11 @@ fun RecordingItem(
|
||||||
modifier = Modifier.size(44.dp)
|
modifier = Modifier.size(44.dp)
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
painter = painterResource(android.R.drawable.ic_media_play),
|
painter = painterResource(
|
||||||
contentDescription = "Play",
|
if (isPlaying) android.R.drawable.ic_media_pause
|
||||||
|
else android.R.drawable.ic_media_play
|
||||||
|
),
|
||||||
|
contentDescription = if (isPlaying) "Pause" else "Play",
|
||||||
modifier = Modifier.size(22.dp)
|
modifier = Modifier.size(22.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -75,7 +80,7 @@ fun RecordingItem(
|
||||||
Text(
|
Text(
|
||||||
text = recording.artist,
|
text = recording.artist,
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
color = MaterialTheme.colorScheme.primary,
|
color = Color(0xFFE6A900),
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
|
|
@ -83,6 +88,8 @@ fun RecordingItem(
|
||||||
text = recording.title,
|
text = recording.title,
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = if (isPlaying) PlayingRed
|
||||||
|
else MaterialTheme.colorScheme.onSurface,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
modifier = Modifier.basicMarquee(
|
modifier = Modifier.basicMarquee(
|
||||||
iterations = Int.MAX_VALUE,
|
iterations = Int.MAX_VALUE,
|
||||||
|
|
@ -98,21 +105,21 @@ fun RecordingItem(
|
||||||
Text(
|
Text(
|
||||||
text = recording.formattedDate,
|
text = recording.formattedDate,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = Color(0xFFA3D1FF)
|
||||||
)
|
)
|
||||||
Text("\u00B7", style = MaterialTheme.typography.bodySmall,
|
Text("\u00B7", style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
Text(
|
Text(
|
||||||
text = recording.formattedDuration,
|
text = recording.formattedDuration,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = Color(0xFFA3FFBC)
|
||||||
)
|
)
|
||||||
Text("\u00B7", style = MaterialTheme.typography.bodySmall,
|
Text("\u00B7", style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
Text(
|
Text(
|
||||||
text = recording.performanceLabel,
|
text = recording.performanceLabel,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = Color(0xFF999999)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,10 @@ 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
|
||||||
|
|
@ -70,6 +73,9 @@ fun RecordingListScreen(
|
||||||
val syncState by vm.syncState.collectAsStateWithLifecycle()
|
val syncState by vm.syncState.collectAsStateWithLifecycle()
|
||||||
val recordings by vm.recordings.collectAsStateWithLifecycle()
|
val recordings by vm.recordings.collectAsStateWithLifecycle()
|
||||||
val isRefreshing by vm.isRefreshing.collectAsStateWithLifecycle()
|
val isRefreshing by vm.isRefreshing.collectAsStateWithLifecycle()
|
||||||
|
val playbackState by vm.playbackState.collectAsStateWithLifecycle()
|
||||||
|
val shuffle by vm.shuffle.collectAsStateWithLifecycle()
|
||||||
|
val repeatMode by vm.repeatMode.collectAsStateWithLifecycle()
|
||||||
val searchQuery by vm.searchQuery.collectAsStateWithLifecycle()
|
val searchQuery by vm.searchQuery.collectAsStateWithLifecycle()
|
||||||
val sortMode by vm.sortMode.collectAsStateWithLifecycle()
|
val sortMode by vm.sortMode.collectAsStateWithLifecycle()
|
||||||
val hiddenArtists by vm.hiddenArtists.collectAsStateWithLifecycle()
|
val hiddenArtists by vm.hiddenArtists.collectAsStateWithLifecycle()
|
||||||
|
|
@ -116,6 +122,13 @@ fun RecordingListScreen(
|
||||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||||
val hasActiveFilter = searchQuery.isNotEmpty() || hiddenArtists.isNotEmpty()
|
val hasActiveFilter = searchQuery.isNotEmpty() || hiddenArtists.isNotEmpty()
|
||||||
|
|
||||||
|
// The recording matching the active track, if any — used by the mini-player.
|
||||||
|
// We look in the full unfiltered list so the bar stays even if a filter would
|
||||||
|
// hide the playing track.
|
||||||
|
val nowPlaying = remember(playbackState.nowPlayingId, recordings) {
|
||||||
|
playbackState.nowPlayingId?.let { id -> recordings.firstOrNull { it.id == id } }
|
||||||
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||||
topBar = {
|
topBar = {
|
||||||
|
|
@ -133,6 +146,27 @@ fun RecordingListScreen(
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
|
// Shuffle toggle
|
||||||
|
IconButton(onClick = { vm.toggleShuffle() }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Shuffle,
|
||||||
|
contentDescription = "Shuffle",
|
||||||
|
tint = if (shuffle) MaterialTheme.colorScheme.primary
|
||||||
|
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 = "Repeat",
|
||||||
|
tint = if (repeatMode == RepeatMode.OFF)
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
else MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Search & filter
|
||||||
IconButton(onClick = { showControls = !showControls }) {
|
IconButton(onClick = { showControls = !showControls }) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Search,
|
imageVector = Icons.Default.Search,
|
||||||
|
|
@ -144,6 +178,17 @@ fun RecordingListScreen(
|
||||||
},
|
},
|
||||||
scrollBehavior = scrollBehavior
|
scrollBehavior = scrollBehavior
|
||||||
)
|
)
|
||||||
|
},
|
||||||
|
bottomBar = {
|
||||||
|
if (nowPlaying != null) {
|
||||||
|
MiniPlayer(
|
||||||
|
recording = nowPlaying,
|
||||||
|
playback = playbackState,
|
||||||
|
onTogglePlayPause = { vm.togglePlayPause() },
|
||||||
|
onSeek = { vm.seekTo(it) },
|
||||||
|
onDismiss = { vm.stopPlayback() }
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
when (val s = syncState) {
|
when (val s = syncState) {
|
||||||
|
|
@ -288,6 +333,8 @@ fun RecordingListScreen(
|
||||||
items(items = recordings, key = { it.id }) { recording ->
|
items(items = recordings, key = { it.id }) { recording ->
|
||||||
RecordingItem(
|
RecordingItem(
|
||||||
recording = recording,
|
recording = recording,
|
||||||
|
isPlaying = playbackState.nowPlayingId == recording.id
|
||||||
|
&& playbackState.isPlaying,
|
||||||
onPlayClick = { vm.onPlay(recording) },
|
onPlayClick = { vm.onPlay(recording) },
|
||||||
onFavoriteClick = { onFavoriteToggle(recording) },
|
onFavoriteClick = { onFavoriteToggle(recording) },
|
||||||
onNotesClick = if (recording.hasMidi) {
|
onNotesClick = if (recording.hasMidi) {
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ import androidx.lifecycle.viewModelScope
|
||||||
import com.keylightpiano.pianotimeline.data.MusicRepository
|
import com.keylightpiano.pianotimeline.data.MusicRepository
|
||||||
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.PlaybackState
|
||||||
|
import com.keylightpiano.pianotimeline.player.PlayerManager
|
||||||
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
|
||||||
|
|
@ -26,6 +28,26 @@ class RecordingListViewModel(app: Application) : AndroidViewModel(app) {
|
||||||
|
|
||||||
private val repo = MusicRepository(app)
|
private val repo = MusicRepository(app)
|
||||||
private val syncManager = SyncManager(app)
|
private val syncManager = SyncManager(app)
|
||||||
|
private val playerManager = PlayerManager(app, viewModelScope, onTrackEnded = { advanceToNext() })
|
||||||
|
|
||||||
|
/** Playback state the UI observes (which card is playing, position, etc). */
|
||||||
|
val playbackState: StateFlow<PlaybackState> = playerManager.state
|
||||||
|
|
||||||
|
// ── Continuous play: repeat + shuffle ──────────────────────────────
|
||||||
|
|
||||||
|
private val _repeatMode = MutableStateFlow(RepeatMode.OFF)
|
||||||
|
val repeatMode: StateFlow<RepeatMode> = _repeatMode.asStateFlow()
|
||||||
|
|
||||||
|
private val _shuffle = MutableStateFlow(false)
|
||||||
|
val shuffle: StateFlow<Boolean> = _shuffle.asStateFlow()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When shuffle is on, this holds a shuffled permutation of recording IDs
|
||||||
|
* representing the play order for the current pass. Rebuilt when shuffle is
|
||||||
|
* toggled on, when a track is manually started, or when a shuffled pass
|
||||||
|
* completes under Repeat All.
|
||||||
|
*/
|
||||||
|
private var shuffleOrder: List<String> = emptyList()
|
||||||
|
|
||||||
// ── Sync state ─────────────────────────────────────────────────────
|
// ── Sync state ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -134,12 +156,132 @@ class RecordingListViewModel(app: Application) : AndroidViewModel(app) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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).
|
||||||
|
*/
|
||||||
fun onPlay(recording: Recording) {
|
fun onPlay(recording: Recording) {
|
||||||
if (recording.isNew) {
|
if (recording.isNew) {
|
||||||
viewModelScope.launch { repo.clearNew(recording.id) }
|
viewModelScope.launch { repo.clearNew(recording.id) }
|
||||||
}
|
}
|
||||||
// TODO: actual playback wiring comes with the player layer
|
// A manual play (re)bases the shuffle order around the chosen track.
|
||||||
|
if (_shuffle.value) rebuildShuffleOrder(startId = recording.id)
|
||||||
|
val url = repo.audioUrl(recording)
|
||||||
|
playerManager.playOrToggle(recording.id, url)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mini-player controls. */
|
||||||
|
fun togglePlayPause() {
|
||||||
|
val s = playbackState.value
|
||||||
|
if (s.isPlaying) playerManager.pause() else playerManager.resume()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun seekTo(positionMs: Long) = playerManager.seekTo(positionMs)
|
||||||
|
|
||||||
|
fun stopPlayback() = playerManager.stop()
|
||||||
|
|
||||||
|
// ── Repeat / shuffle controls ──────────────────────────────────────
|
||||||
|
|
||||||
|
fun cycleRepeatMode() {
|
||||||
|
_repeatMode.value = when (_repeatMode.value) {
|
||||||
|
RepeatMode.OFF -> RepeatMode.ALL
|
||||||
|
RepeatMode.ALL -> RepeatMode.ONE
|
||||||
|
RepeatMode.ONE -> RepeatMode.OFF
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggleShuffle() {
|
||||||
|
val newValue = !_shuffle.value
|
||||||
|
_shuffle.value = newValue
|
||||||
|
if (newValue) {
|
||||||
|
// Seed the shuffle around whatever's currently playing (if anything).
|
||||||
|
rebuildShuffleOrder(startId = playbackState.value.nowPlayingId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a fresh shuffled permutation of the currently-visible recordings.
|
||||||
|
* If startId is given, that track is placed first so the current song isn't
|
||||||
|
* interrupted and shuffle continues from it.
|
||||||
|
*/
|
||||||
|
private fun rebuildShuffleOrder(startId: String?) {
|
||||||
|
val ids = recordings.value.map { it.id }.toMutableList()
|
||||||
|
ids.shuffle()
|
||||||
|
if (startId != null) {
|
||||||
|
ids.remove(startId)
|
||||||
|
ids.add(0, startId)
|
||||||
|
}
|
||||||
|
shuffleOrder = ids
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Advance to the next track when one finishes naturally.
|
||||||
|
* Honors repeat-one (replay), shuffle (next in permutation), and
|
||||||
|
* repeat-all (wrap / reshuffle). Repeat-off stops at the end.
|
||||||
|
*/
|
||||||
|
private fun advanceToNext() {
|
||||||
|
val current = playbackState.value.nowPlayingId ?: return
|
||||||
|
|
||||||
|
// Repeat One: just replay the same track.
|
||||||
|
if (_repeatMode.value == RepeatMode.ONE) {
|
||||||
|
playerManager.replayCurrent()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val visible = recordings.value
|
||||||
|
if (visible.isEmpty()) {
|
||||||
|
playerManager.stop()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val nextRecording: Recording? = if (_shuffle.value) {
|
||||||
|
nextShuffled(current, visible)
|
||||||
|
} else {
|
||||||
|
nextSequential(current, visible)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextRecording != null) {
|
||||||
|
val url = repo.audioUrl(nextRecording)
|
||||||
|
playerManager.playOrToggle(nextRecording.id, url)
|
||||||
|
} else {
|
||||||
|
// End of queue with Repeat Off → stop.
|
||||||
|
playerManager.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun nextSequential(currentId: String, visible: List<Recording>): Recording? {
|
||||||
|
val idx = visible.indexOfFirst { it.id == currentId }
|
||||||
|
if (idx == -1) return visible.firstOrNull() // current fell out of the list
|
||||||
|
val nextIdx = idx + 1
|
||||||
|
return when {
|
||||||
|
nextIdx < visible.size -> visible[nextIdx]
|
||||||
|
_repeatMode.value == RepeatMode.ALL -> visible.firstOrNull() // wrap
|
||||||
|
else -> null // end, repeat off
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun nextShuffled(currentId: String, visible: List<Recording>): Recording? {
|
||||||
|
// Keep the shuffle order in sync with what's actually visible.
|
||||||
|
val visibleIds = visible.map { it.id }.toSet()
|
||||||
|
shuffleOrder = shuffleOrder.filter { it in visibleIds }
|
||||||
|
if (shuffleOrder.isEmpty()) rebuildShuffleOrder(startId = currentId)
|
||||||
|
|
||||||
|
val idx = shuffleOrder.indexOf(currentId)
|
||||||
|
val nextId = when {
|
||||||
|
idx != -1 && idx + 1 < shuffleOrder.size -> shuffleOrder[idx + 1]
|
||||||
|
_repeatMode.value == RepeatMode.ALL -> {
|
||||||
|
// Completed a pass — reshuffle for the next one.
|
||||||
|
rebuildShuffleOrder(startId = null)
|
||||||
|
shuffleOrder.firstOrNull()
|
||||||
|
}
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
return nextId?.let { id -> visible.firstOrNull { it.id == id } }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCleared() {
|
||||||
|
super.onCleared()
|
||||||
|
playerManager.release()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -180,6 +322,9 @@ sealed interface SyncState {
|
||||||
data class Error(val message: String) : SyncState
|
data class Error(val message: String) : SyncState
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Three-state repeat, like a normal audio player. */
|
||||||
|
enum class RepeatMode { OFF, ALL, ONE }
|
||||||
|
|
||||||
// ── Private helpers ────────────────────────────────────────────────────
|
// ── Private helpers ────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue