Queue view over Now Playing, Autoplay, favourites

- Swipe up on the full-art Now Playing (or tap the queue button) to slide
  up the queue view; swipe down or the queue button returns to the art.
  Swiping down from the art still closes Now Playing.
- Queue view, top to bottom: current song with star (favourite on the
  server) and more menu; Shuffle / Repeat / Autoplay pills; History of
  played songs with Clear; Up Next (drag handle to reorder, swipe left to
  remove); seek bar; previous / glass play pill / next; Lyrics, Cast and
  queue toggle. Background from the album art.
- Autoplay (on by default, remembered): when the last song in the queue
  starts, the playback service adds similar songs (getSimilarSongs) or,
  failing that, random ones. Works with the app closed.
- The full-art screen's bottom row is now Lyrics / Cast / Queue (shuffle
  and repeat moved to the queue view). Lyrics and Cast say "coming soon".
- Go to album / artist from a menu now closes Now Playing first.
- Queue entries are built by one shared MediaItemFactory (controller and
  service), carrying album, artist, cover and favourite details.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
TySmi 2026-10-13 16:32:29 -04:00
parent d974862745
commit 521693b3b8
21 changed files with 908 additions and 335 deletions

View file

@ -112,6 +112,17 @@ adb -s emulator-5554 install -r app/build/outputs/apk/debug/app-debug.apk
(`PlayOnSkipPlayer` wraps the player in PlaybackService); big auto-fitting title (`FitTitle`:
32→22sp in up to 2 lines, then a slow one-line scroll); purple frosted-glass play pill
(`GlassPlayButton`: blurred cover + translucent BrandPurple + light edge, squishes on press).
8. Queue view (2026-10-13): swipe up on the art (or the queue button) slides up `QueueScreen`
over Now Playing; swipe down / queue button returns. Current song with star (OpenSubsonic
star/unstar) and more menu; Shuffle / Repeat / Autoplay pills; History (queue before the
current song) with Clear; Up Next (drag the handle to reorder, swipe the row left to
remove); seek bar, controls, Lyrics / Cast ("coming soon") / queue toggle. Autoplay lives in
PlaybackService (works with the app closed): when the last song starts it adds
getSimilarSongs, else getRandomSongs; setting saved in PlaybackSettings. Queue entries are
built by the shared `MediaItemFactory`.
9. Server robustness (2026-10-13): `ServerRetry` retries GETs twice on 502/503/504 or a
dropped connection; Songs view limits itself to 2 requests at a time; plain error messages;
`FailedResponseLogger` logs failures (`adb logcat -s SyncWaveHttp`).
## In progress / unverified

View file

@ -10,7 +10,9 @@ import com.tysmi.syncwave.data.navidrome.NavidromeClient
import com.tysmi.syncwave.data.subsonic.SubsonicApiFactory
import com.tysmi.syncwave.data.subsonic.SubsonicAuthInterceptor
import com.tysmi.syncwave.playback.AudioCache
import com.tysmi.syncwave.playback.MediaItemFactory
import com.tysmi.syncwave.playback.PlaybackController
import com.tysmi.syncwave.playback.PlaybackSettings
import com.tysmi.syncwave.playback.PlaybackTrace
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@ -57,6 +59,11 @@ class AppContainer(context: Context) {
/** Recently played audio kept on the phone (see AudioCache). */
val audioCache by lazy { AudioCache(context) }
val playbackController = PlaybackController(context, libraryRepository, appScope)
/** Builds player queue entries; shared by the UI remote and the playback service. */
val mediaItemFactory = MediaItemFactory(libraryRepository)
val playbackSettings = PlaybackSettings(context)
val playbackController = PlaybackController(context, libraryRepository, mediaItemFactory, playbackSettings, appScope)
}

View file

@ -140,6 +140,23 @@ class LibraryRepository(
)
}
// ---- Favourites and suggestions ----
/** Stars (favourites) or un-stars a song on the server. */
suspend fun setStarred(trackId: String, starred: Boolean) {
val api = session.requireApi()
(if (starred) api.star(trackId) else api.unstar(trackId)).orThrow()
}
/** Songs like [trackId], for Autoplay; may be empty if the server has no suggestions. */
suspend fun similarSongs(trackId: String, count: Int): List<Track> =
session.requireApi().getSimilarSongs(trackId, count).orThrow()
.similarSongs?.song.orEmpty().map { it.toTrack() }
suspend fun randomSongs(count: Int): List<Track> =
session.requireApi().getRandomSongs(count).orThrow()
.randomSongs?.song.orEmpty().map { it.toTrack() }
// ---- URLs ----
fun coverArtUrl(coverArtId: String, sizePx: Int): String? =
@ -182,6 +199,7 @@ private fun SongDto.toTrack(album: Album) = Track(
coverArtId = album.coverArtId ?: coverArt,
audio = audioFormat(),
artistId = artistId ?: album.artistId,
starred = starred != null,
)
/** A song outside an album context (playlists). */
@ -197,6 +215,7 @@ private fun SongDto.toTrack() = Track(
coverArtId = coverArt,
audio = audioFormat(),
artistId = artistId,
starred = starred != null,
)
private fun NdSongDto.toTrack() = Track(
@ -211,6 +230,7 @@ private fun NdSongDto.toTrack() = Track(
// Navidrome serves a song's art (embedded, or its album's) under "mf-<songId>".
coverArtId = if (hasCoverArt) "mf-$id" else null,
artistId = artistId,
starred = starred,
)
private fun ArtistDto.toArtist() = Artist(id = id, name = name, albumCount = albumCount, coverArtId = coverArt)

View file

@ -50,4 +50,5 @@ data class NdSongDto(
/** Lower-cased title the server sorts by. */
val orderTitle: String? = null,
val hasCoverArt: Boolean = false,
val starred: Boolean = false,
)

View file

@ -49,6 +49,20 @@ interface SubsonicApi {
@GET("rest/getGenres")
suspend fun getGenres(): SubsonicEnvelope
/** Favourite / un-favourite a song. */
@GET("rest/star")
suspend fun star(@Query("id") id: String): SubsonicEnvelope
@GET("rest/unstar")
suspend fun unstar(@Query("id") id: String): SubsonicEnvelope
/** Songs similar to [id] (Navidrome asks its artist-info agents, e.g. Last.fm). */
@GET("rest/getSimilarSongs")
suspend fun getSimilarSongs(@Query("id") id: String, @Query("count") count: Int): SubsonicEnvelope
@GET("rest/getRandomSongs")
suspend fun getRandomSongs(@Query("size") size: Int): SubsonicEnvelope
companion object {
/** Largest page the Subsonic API allows for album lists. */
const val MAX_ALBUM_PAGE_SIZE = 500

View file

@ -27,6 +27,13 @@ data class SubsonicResponse(
val playlists: PlaylistsDto? = null,
val playlist: PlaylistDto? = null,
val genres: GenresDto? = null,
val similarSongs: SongListDto? = null,
val randomSongs: SongListDto? = null,
)
@Serializable
data class SongListDto(
val song: List<SongDto> = emptyList(),
)
@Serializable
@ -162,6 +169,8 @@ data class SongDto(
val bitDepth: Int? = null,
/** Hz; OpenSubsonic, only sent when known. */
val samplingRate: Int? = null,
/** When the user starred (favourited) this song; absent if not starred. */
val starred: String? = null,
)
/** The server answered, but with `status: "failed"`. See the Subsonic API error codes. */

View file

@ -15,6 +15,8 @@ data class Track(
val audio: AudioFormat? = null,
/** For "Go to artist". */
val artistId: String? = null,
/** Favourited on the server. */
val starred: Boolean = false,
)
/** File format details as Navidrome reads them from the file. Missing values are null. */

View file

@ -0,0 +1,90 @@
package com.tysmi.syncwave.playback
import android.content.Context
import android.os.Bundle
import androidx.core.content.edit
import androidx.core.net.toUri
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import com.tysmi.syncwave.data.library.LibraryRepository
import com.tysmi.syncwave.model.Track
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.util.concurrent.atomic.AtomicLong
/**
* Turns songs into player queue entries. Shared by the UI's [PlaybackController] and the
* [PlaybackService] (Autoplay), so every entry carries the same details: a queue-wide unique
* id plus the album, artist, cover and favourite info the queue screen shows.
*/
class MediaItemFactory(private val library: LibraryRepository) {
private val nextUid = AtomicLong(1)
fun create(track: Track): MediaItem {
val artwork = track.coverArtId?.let { library.coverArtUrl(it, ARTWORK_SIZE_PX) }
return MediaItem.Builder()
.setMediaId(track.id) // the service turns this into a stream URL
.setMediaMetadata(
MediaMetadata.Builder()
.setTitle(track.title)
.setArtist(track.artist)
.setAlbumTitle(track.albumName)
.setTrackNumber(track.trackNumber)
.setDiscNumber(track.discNumber)
.setDurationMs(track.durationSeconds?.let { it * 1000L })
.setArtworkUri(artwork?.toUri())
.setIsPlayable(true)
.setIsBrowsable(false)
.setMediaType(MediaMetadata.MEDIA_TYPE_MUSIC)
.setExtras(
Bundle().apply {
putLong(EXTRA_UID, nextUid.getAndIncrement())
track.albumId?.let { putString(EXTRA_ALBUM_ID, it) }
track.artistId?.let { putString(EXTRA_ARTIST_ID, it) }
track.coverArtId?.let { putString(EXTRA_COVER_ART_ID, it) }
putBoolean(EXTRA_STARRED, track.starred)
},
)
.build(),
)
.build()
}
/** Same entry, ready to hand straight to the player (with its stream address). */
fun createPlayable(track: Track): MediaItem? =
library.streamUrl(track.id)?.let { create(track).buildUpon().setUri(it).build() }
companion object {
const val ARTWORK_SIZE_PX = 400 // same size as the grid, so it's already cached
private const val EXTRA_UID = "syncwave.queueUid"
private const val EXTRA_ALBUM_ID = "syncwave.albumId"
private const val EXTRA_ARTIST_ID = "syncwave.artistId"
private const val EXTRA_COVER_ART_ID = "syncwave.coverArtId"
private const val EXTRA_STARRED = "syncwave.starred"
val MediaItem.uid: Long get() = mediaMetadata.extras?.getLong(EXTRA_UID) ?: 0L
val MediaItem.albumId: String? get() = mediaMetadata.extras?.getString(EXTRA_ALBUM_ID)
val MediaItem.artistId: String? get() = mediaMetadata.extras?.getString(EXTRA_ARTIST_ID)
val MediaItem.coverArtId: String? get() = mediaMetadata.extras?.getString(EXTRA_COVER_ART_ID)
val MediaItem.starred: Boolean get() = mediaMetadata.extras?.getBoolean(EXTRA_STARRED) ?: false
}
}
/** Playback preferences that survive restarts. */
class PlaybackSettings(context: Context) {
private val prefs = context.getSharedPreferences("playback", Context.MODE_PRIVATE)
private val _autoplay = MutableStateFlow(prefs.getBoolean(KEY_AUTOPLAY, true))
/** When the queue ends, keep playing similar songs. On by default. */
val autoplay: StateFlow<Boolean> = _autoplay.asStateFlow()
fun setAutoplay(on: Boolean) {
_autoplay.value = on
prefs.edit { putBoolean(KEY_AUTOPLAY, on) }
}
private companion object {
const val KEY_AUTOPLAY = "autoplay"
}
}

View file

@ -17,6 +17,11 @@ import androidx.media3.session.MediaController
import androidx.media3.session.SessionToken
import com.google.common.util.concurrent.ListenableFuture
import com.tysmi.syncwave.data.library.LibraryRepository
import com.tysmi.syncwave.playback.MediaItemFactory.Companion.albumId
import com.tysmi.syncwave.playback.MediaItemFactory.Companion.artistId
import com.tysmi.syncwave.playback.MediaItemFactory.Companion.coverArtId
import com.tysmi.syncwave.playback.MediaItemFactory.Companion.starred
import com.tysmi.syncwave.playback.MediaItemFactory.Companion.uid
import com.tysmi.syncwave.model.Track
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
@ -34,6 +39,11 @@ data class NowPlaying(
val albumTitle: String,
/** Length of the track, or null while still unknown. */
val durationMs: Long?,
val albumId: String? = null,
val artistId: String? = null,
val coverArtId: String? = null,
/** Favourited on the server. */
val starred: Boolean = false,
)
/** One song in the play queue. [uid] tells apart the same song queued twice. */
@ -58,7 +68,13 @@ data class PlayerUiState(
val currentIndex: Int = -1,
val shuffle: Boolean = false,
val repeat: RepeatMode = RepeatMode.Off,
)
/** When the queue ends, keep playing similar songs. */
val autoplay: Boolean = true,
) {
/** Songs already played in this queue, oldest first. */
val history: List<QueueEntry> get() = if (currentIndex > 0) queue.take(currentIndex) else emptyList()
val upcoming: List<QueueEntry> get() = if (currentIndex >= 0) queue.drop(currentIndex + 1) else queue
}
/**
* The UI's remote control for [PlaybackService]. Connects while the app is on screen
@ -68,9 +84,11 @@ data class PlayerUiState(
class PlaybackController(
private val context: Context,
private val library: LibraryRepository,
private val mediaItems: MediaItemFactory,
private val settings: PlaybackSettings,
private val scope: CoroutineScope,
) {
private val _state = MutableStateFlow(PlayerUiState())
private val _state = MutableStateFlow(PlayerUiState(autoplay = settings.autoplay.value))
val state: StateFlow<PlayerUiState> = _state.asStateFlow()
private var controllerFuture: ListenableFuture<MediaController>? = null
@ -86,8 +104,6 @@ class PlaybackController(
/** Track the UI is showing optimistically until the player confirms it. */
private var pendingTrackId: String? = null
private var nextUid = 1L
/**
* When shuffle is on: the queue's order before shuffling (by [QueueEntry.uid]), so turning
* shuffle off can put the remaining songs back in that order.
@ -130,13 +146,13 @@ class PlaybackController(
*/
fun play(tracks: List<Track>, startIndex: Int, more: (suspend () -> List<Track>)? = null) {
orderBeforeShuffle = null
playItems(tracks.map { it.toMediaItem() }, startIndex, more)
playItems(tracks.map { mediaItems.create(it) }, startIndex, more)
}
/** Plays [tracks] in random order, with shuffle on (turning it off restores their order). */
fun playShuffled(tracks: List<Track>) {
if (tracks.isEmpty()) return
val items = tracks.map { it.toMediaItem() }
val items = tracks.map { mediaItems.create(it) }
orderBeforeShuffle = items.map { it.uid }
playItems(items.shuffled(), 0, more = null)
}
@ -158,7 +174,7 @@ class PlaybackController(
if (more != null) {
val extra = more()
if (generation == queueGeneration && extra.isNotEmpty()) {
player.addMediaItems(extra.map { it.toMediaItem() })
player.addMediaItems(extra.map { mediaItems.create(it) })
}
}
}
@ -171,7 +187,7 @@ class PlaybackController(
if (tracks.isEmpty()) return
val player = controller
if (player == null || player.mediaItemCount == 0) return play(tracks, 0)
val items = tracks.map { it.toMediaItem() }
val items = tracks.map { mediaItems.create(it) }
player.addMediaItems(player.currentMediaItemIndex + 1, items)
orderBeforeShuffle?.let { order -> orderBeforeShuffle = order + items.map { it.uid } }
}
@ -181,7 +197,7 @@ class PlaybackController(
if (tracks.isEmpty()) return
val player = controller
if (player == null || player.mediaItemCount == 0) return play(tracks, 0)
val items = tracks.map { it.toMediaItem() }
val items = tracks.map { mediaItems.create(it) }
player.addMediaItems(items)
orderBeforeShuffle?.let { order -> orderBeforeShuffle = order + items.map { it.uid } }
}
@ -240,6 +256,42 @@ class PlaybackController(
}
}
// ---- Favourite, history, Autoplay ----
/** Stars the user changed this session (a queue entry's own value is fixed when queued). */
private val starOverrides = mutableMapOf<String, Boolean>()
/** Stars or un-stars the current song: the button updates at once, the server follows. */
fun toggleStar() {
val current = _state.value.nowPlaying ?: return
val starred = !current.starred
starOverrides[current.trackId] = starred
_state.value = _state.value.copy(nowPlaying = current.copy(starred = starred))
scope.launch {
try {
library.setStarred(current.trackId, starred)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w(TAG, "Couldn't update favourite", e)
starOverrides[current.trackId] = !starred
controller?.let(::publish)
}
}
}
/** Removes the songs already played from the queue. */
fun clearHistory() {
val player = controller ?: return
val current = player.currentMediaItemIndex
if (current > 0) player.removeMediaItems(0, current)
}
fun setAutoplay(on: Boolean) {
settings.setAutoplay(on)
_state.value = _state.value.copy(autoplay = on)
}
// ---- Transport ----
fun togglePlayPause() {
@ -296,6 +348,10 @@ class PlaybackController(
artworkUrl = meta.artworkUri?.toString(),
albumTitle = meta.albumTitle?.toString().orEmpty(),
durationMs = meta.durationMs,
albumId = item.albumId,
artistId = item.artistId,
coverArtId = item.coverArtId,
starred = starOverrides[item.mediaId] ?: item.starred,
),
isPlaying = true,
hasNext = hasNext,
@ -322,6 +378,10 @@ class PlaybackController(
// The player knows the exact length once loaded; until then use the server's.
durationMs = player.duration.takeIf { d -> d != C.TIME_UNSET && d > 0 }
?: it.mediaMetadata.durationMs,
albumId = it.albumId,
artistId = it.artistId,
coverArtId = it.coverArtId,
starred = starOverrides[it.mediaId] ?: it.starred,
)
},
isPlaying = !Util.shouldShowPlayButton(player),
@ -335,6 +395,7 @@ class PlaybackController(
Player.REPEAT_MODE_ONE -> RepeatMode.One
else -> RepeatMode.Off
},
autoplay = settings.autoplay.value,
)
}
@ -346,34 +407,8 @@ class PlaybackController(
artworkUrl = mediaMetadata.artworkUri?.toString(),
)
private val MediaItem.uid: Long get() = mediaMetadata.extras?.getLong(EXTRA_UID) ?: 0L
private fun Track.toMediaItem(): MediaItem {
val artwork = coverArtId?.let { library.coverArtUrl(it, ARTWORK_SIZE_PX) }
return MediaItem.Builder()
.setMediaId(id) // the service turns this into a stream URL
.setMediaMetadata(
MediaMetadata.Builder()
.setTitle(title)
.setArtist(artist)
.setAlbumTitle(albumName)
.setTrackNumber(trackNumber)
.setDiscNumber(discNumber)
.setDurationMs(durationSeconds?.let { it * 1000L })
.setArtworkUri(artwork?.toUri())
.setIsPlayable(true)
.setIsBrowsable(false)
.setMediaType(MediaMetadata.MEDIA_TYPE_MUSIC)
// A queue-wide unique id, so the same song queued twice can be told apart.
.setExtras(Bundle().apply { putLong(EXTRA_UID, nextUid++) })
.build(),
)
.build()
}
private companion object {
const val TAG = "PlaybackController"
const val ARTWORK_SIZE_PX = 400 // same size as the grid, so it's already cached
const val EXTRA_UID = "syncwave.queueUid"
}
}

View file

@ -27,6 +27,9 @@ import com.tysmi.syncwave.MainActivity
import com.tysmi.syncwave.SyncWaveApplication
import com.tysmi.syncwave.data.auth.SessionState
import com.tysmi.syncwave.data.library.LibraryRepository
import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
@ -102,6 +105,8 @@ class PlaybackService : MediaLibraryService() {
.setBitmapLoader(CacheBitmapLoader(CoilBitmapLoader(this)))
.build()
player.addListener(Autoplay(player, container.libraryRepository, container.mediaItemFactory, container.playbackSettings, scope))
// Signing out stops the music and forgets the queue.
scope.launch {
container.sessionManager.state.collect { state ->
@ -228,3 +233,58 @@ private class PlayOnSkipPlayer(player: Player) : ForwardingPlayer(player) {
if (!playWhenReady) play()
}
}
/**
* Autoplay: when the last song in the queue starts (and repeat is off), quietly adds songs
* similar to it, or random songs from the library if the server has no suggestions, so the
* music keeps going. Runs in the service, so it works with the app closed.
*/
private class Autoplay(
private val player: Player,
private val library: LibraryRepository,
private val items: MediaItemFactory,
private val settings: PlaybackSettings,
private val scope: CoroutineScope,
) : Player.Listener {
private var fetchingFor: String? = null
override fun onEvents(player: Player, events: Player.Events) {
if (events.containsAny(Player.EVENT_MEDIA_ITEM_TRANSITION, Player.EVENT_TIMELINE_CHANGED, Player.EVENT_PLAYBACK_STATE_CHANGED)) {
maybeExtend()
}
}
private fun maybeExtend() {
if (!settings.autoplay.value || player.repeatMode != Player.REPEAT_MODE_OFF) return
val count = player.mediaItemCount
if (count == 0 || player.currentMediaItemIndex != count - 1) return
val seed = player.currentMediaItem?.mediaId ?: return
if (fetchingFor == seed) return
fetchingFor = seed
scope.launch {
try {
val queued = (0 until player.mediaItemCount).map { player.getMediaItemAt(it).mediaId }.toSet()
val suggestions = library.similarSongs(seed, SUGGESTIONS).filter { it.id !in queued }
.ifEmpty { library.randomSongs(SUGGESTIONS).filter { it.id !in queued } }
val newItems = suggestions.mapNotNull(items::createPlayable)
if (newItems.isEmpty() || !settings.autoplay.value) return@launch
val ended = player.playbackState == Player.STATE_ENDED
player.addMediaItems(newItems)
// If the last song already finished while we were fetching, carry on.
if (ended) {
player.seekToNextMediaItem()
player.play()
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w("Autoplay", "Couldn't find songs to continue with", e)
fetchingFor = null
}
}
}
private companion object {
const val SUGGESTIONS = 20
}
}

View file

@ -27,6 +27,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@ -58,6 +59,9 @@ import androidx.navigation.compose.rememberNavController
import androidx.navigation.toRoute
import com.tysmi.syncwave.data.library.LibraryRepository
import com.tysmi.syncwave.model.Album
import com.tysmi.syncwave.ui.player.QueueActions
import com.tysmi.syncwave.playback.RepeatMode
import com.tysmi.syncwave.model.Track
import com.tysmi.syncwave.playback.PlaybackController
import com.tysmi.syncwave.ui.album.AlbumScreen
import com.tysmi.syncwave.ui.common.ConfirmationPill
@ -112,6 +116,9 @@ fun MainScreen(playback: PlaybackController, library: LibraryRepository, onSignO
// Sheet position: 1 = hidden below the screen, 0 = fully open.
var sheetOpen by rememberSaveable { mutableStateOf(false) }
// The queue view over Now Playing, and how far the finger has pulled up past the top.
var showQueue by rememberSaveable { mutableStateOf(false) }
var pullUpPx by remember { mutableFloatStateOf(0f) }
val sheet = remember { Animatable(if (sheetOpen) 0f else 1f) }
val sheetVisible by remember { derivedStateOf { sheet.value < 1f } }
val sheetMostlyOpen by remember { derivedStateOf { sheet.value < 0.5f } }
@ -123,6 +130,7 @@ fun MainScreen(playback: PlaybackController, library: LibraryRepository, onSignO
fun closeSheet(velocity: Float = 0f) {
sheetOpen = false
showQueue = false
scope.launch { sheet.animateTo(1f, CloseSpring, initialVelocity = velocity) }
}
@ -152,12 +160,16 @@ fun MainScreen(playback: PlaybackController, library: LibraryRepository, onSignO
val onMenuAction: (MenuTarget, MenuAction) -> Unit = { target, action ->
when (action) {
MenuAction.GoToAlbum -> (target as? MenuTarget.Song)?.track?.let { t ->
closeSheet()
t.albumId?.let { navController.navigate(AlbumRoute(it, t.albumName, t.coverArtId)) }
}
MenuAction.GoToArtist -> when (target) {
is MenuTarget.Song -> target.track.artistId?.let { navController.navigate(ArtistRoute(it, target.track.artist)) }
is MenuTarget.AlbumTarget -> target.album.artistId?.let { navController.navigate(ArtistRoute(it, target.album.artist)) }
is MenuTarget.ArtistTarget -> navController.navigate(ArtistRoute(target.artist.id, target.artist.name))
MenuAction.GoToArtist -> {
closeSheet()
when (target) {
is MenuTarget.Song -> target.track.artistId?.let { navController.navigate(ArtistRoute(it, target.track.artist)) }
is MenuTarget.AlbumTarget -> target.album.artistId?.let { navController.navigate(ArtistRoute(it, target.album.artist)) }
is MenuTarget.ArtistTarget -> navController.navigate(ArtistRoute(target.artist.id, target.artist.name))
}
}
else -> {
// Confirm at once; albums and artists fetch their songs in the background.
@ -190,6 +202,70 @@ fun MainScreen(playback: PlaybackController, library: LibraryRepository, onSignO
}
}
val queueActions = remember(playback) {
QueueActions(
onPlayPause = playback::togglePlayPause,
onSkipNext = playback::skipToNext,
onSkipPrevious = playback::skipToPrevious,
onSeek = playback::seekTo,
onToggleShuffle = {
confirm(if (playback.state.value.shuffle) "Shuffle off" else "Shuffle on")
playback.toggleShuffle()
},
onCycleRepeat = {
confirm(
when (playback.state.value.repeat) {
RepeatMode.Off -> "Repeat all"
RepeatMode.All -> "Repeat this song"
RepeatMode.One -> "Repeat off"
},
)
playback.cycleRepeat()
},
onToggleAutoplay = {
val on = !playback.state.value.autoplay
playback.setAutoplay(on)
confirm(if (on) "Autoplay on" else "Autoplay off")
},
onToggleStar = {
val starring = playback.state.value.nowPlaying?.starred == false
playback.toggleStar()
confirm(if (starring) "Added to favourites" else "Removed from favourites")
},
onMore = {
playback.state.value.nowPlaying?.let { np ->
menuTarget = MenuTarget.Song(
Track(
id = np.trackId,
title = np.title,
artist = np.artist,
albumId = np.albumId,
albumName = np.albumTitle,
trackNumber = null,
discNumber = null,
durationSeconds = np.durationMs?.let { (it / 1000).toInt() },
coverArtId = np.coverArtId,
artistId = np.artistId,
starred = np.starred,
),
)
}
},
onMove = playback::moveInQueue,
onRemove = { index ->
playback.removeFromQueue(index)
confirm("Removed from queue")
},
onPlayEntry = playback::playQueueEntry,
onClearHistory = {
playback.clearHistory()
confirm("History cleared")
},
onLyrics = { confirm("Lyrics are coming soon") },
onCast = { confirm("Casting is coming soon") },
)
}
CompositionLocalProvider(
LocalItemMenu provides { menuTarget = it },
LocalConfirm provides confirm,
@ -255,16 +331,10 @@ fun MainScreen(playback: PlaybackController, library: LibraryRepository, onSignO
NowPlayingScreen(
state = player,
currentPositionMs = playback::currentPositionMs,
onPlayPause = playback::togglePlayPause,
onSkipNext = playback::skipToNext,
onSkipPrevious = playback::skipToPrevious,
onSeek = playback::seekTo,
actions = queueActions,
showQueue = showQueue,
onShowQueueChange = { showQueue = it },
onCollapse = { closeSheet() },
onToggleShuffle = playback::toggleShuffle,
onCycleRepeat = playback::cycleRepeat,
onMoveInQueue = playback::moveInQueue,
onRemoveFromQueue = playback::removeFromQueue,
onPlayQueueEntry = playback::playQueueEntry,
modifier = Modifier
.graphicsLayer {
translationY = max(sheet.value, 0f) * heightPx
@ -276,15 +346,24 @@ fun MainScreen(playback: PlaybackController, library: LibraryRepository, onSignO
clip = true
}
// Swipe down to close: the sheet follows your finger, then either springs
// closed (dragged far or flicked) or springs back open.
// closed (dragged far or flicked) or springs back open. Swipe up from the
// art opens the queue view; while it's showing, it handles its own
// swipe-down, so this one is off.
.draggable(
orientation = Orientation.Vertical,
enabled = !showQueue,
onDragStarted = { pullUpPx = 0f },
state = rememberDraggableState { deltaPx ->
if (sheet.value <= 0f && deltaPx < 0f) pullUpPx += deltaPx
scope.launch { sheet.snapTo((sheet.value + deltaPx / heightPx).coerceIn(0f, 1f)) }
},
onDragStopped = { velocityPx ->
val velocity = velocityPx / heightPx
if (sheet.value > 0.25f || velocity > 1.2f) closeSheet(velocity) else openSheet()
when {
pullUpPx < -0.08f * heightPx || velocity < -1.0f -> showQueue = true
sheet.value > 0.25f || velocity > 1.2f -> closeSheet(velocity)
else -> openSheet()
}
},
),
)

View file

@ -1,5 +1,17 @@
package com.tysmi.syncwave.ui.player
import androidx.compose.ui.graphics.graphicsLayer
import kotlinx.coroutines.launch
import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.geometry.Offset
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.animation.core.Animatable
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
@ -80,21 +92,19 @@ private val OnArtSecondary = Color.White.copy(alpha = 0.7f)
fun NowPlayingScreen(
state: PlayerUiState,
currentPositionMs: () -> Long,
onPlayPause: () -> Unit,
onSkipNext: () -> Unit,
onSkipPrevious: () -> Unit,
onSeek: (Long) -> Unit,
actions: QueueActions,
/** The queue view is showing (slid up over the art). */
showQueue: Boolean,
onShowQueueChange: (Boolean) -> Unit,
onCollapse: () -> Unit,
onToggleShuffle: () -> Unit,
onCycleRepeat: () -> Unit,
onMoveInQueue: (from: Int, to: Int) -> Unit,
onRemoveFromQueue: (index: Int) -> Unit,
onPlayQueueEntry: (index: Int) -> Unit,
modifier: Modifier = Modifier,
) {
val nowPlaying = state.nowPlaying ?: return
val confirm = LocalConfirm.current
var showQueue by rememberSaveable { mutableStateOf(false) }
val scope = rememberCoroutineScope()
val onPlayPause = actions.onPlayPause
val onSkipNext = actions.onSkipNext
val onSkipPrevious = actions.onSkipPrevious
val onSeek = actions.onSeek
// Poll the position while this screen is showing (it changes too often to push).
var positionMs by remember { mutableLongStateOf(currentPositionMs()) }
@ -154,43 +164,6 @@ fun NowPlayingScreen(
)
}
}
// Shuffle, repeat and the Up Next queue.
val extras: @Composable () -> Unit = {
Row(
Modifier.fillMaxWidth().widthIn(max = 520.dp),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically,
) {
ToggleIcon(
R.drawable.ic_shuffle,
if (state.shuffle) "Shuffle on" else "Shuffle off",
on = state.shuffle,
) {
onToggleShuffle()
confirm(if (state.shuffle) "Shuffle off" else "Shuffle on")
}
ToggleIcon(
if (state.repeat == RepeatMode.One) R.drawable.ic_repeat_one else R.drawable.ic_repeat,
when (state.repeat) {
RepeatMode.Off -> "Repeat off"
RepeatMode.All -> "Repeat all"
RepeatMode.One -> "Repeat this song"
},
on = state.repeat != RepeatMode.Off,
) {
onCycleRepeat()
confirm(
when (state.repeat) {
RepeatMode.Off -> "Repeat all"
RepeatMode.All -> "Repeat this song"
RepeatMode.One -> "Repeat off"
},
)
}
ToggleIcon(R.drawable.ic_queue, "Up Next", on = false) { showQueue = true }
}
}
// The cover's own colors fill anything the art doesn't cover, fading between tracks.
val colors = rememberArtworkColors(nowPlaying.artworkUrl)
val tintTop by animateColorAsState(colors.top, tween(durationMillis = 700), label = "npTop")
@ -244,7 +217,7 @@ fun NowPlayingScreen(
) {
details()
controls()
extras()
PlayerBottomRow(queueOn = false, onLyrics = actions.onLyrics, onCast = actions.onCast, onToggleQueue = { onShowQueueChange(true) })
}
}
Grabber(onCollapse, Modifier.align(Alignment.TopCenter).safeDrawingPadding())
@ -268,28 +241,68 @@ fun NowPlayingScreen(
details()
Spacer(Modifier.height(36.dp))
controls()
Spacer(Modifier.height(24.dp))
extras()
Spacer(Modifier.height(32.dp))
Spacer(Modifier.height(28.dp))
PlayerBottomRow(queueOn = false, onLyrics = actions.onLyrics, onCast = actions.onCast, onToggleQueue = { onShowQueueChange(true) })
Spacer(Modifier.height(16.dp))
}
}
// Up Next slides up over the player; Back closes it first.
BackHandler(enabled = showQueue) { showQueue = false }
AnimatedVisibility(
visible = showQueue,
enter = slideInVertically(spring(dampingRatio = 0.9f, stiffness = 400f)) { it } + fadeIn(),
exit = slideOutVertically(spring(dampingRatio = 1f, stiffness = 500f)) { it } + fadeOut(),
) {
UpNextScreen(
// The queue slides up over the art and follows the finger when dragged down; Back
// closes it first. 0 = fully up, 1 = hidden below the screen.
val heightPx = constraints.maxHeight.toFloat().coerceAtLeast(1f)
val queueOffset = remember { Animatable(if (showQueue) 0f else 1f) }
LaunchedEffect(showQueue) {
queueOffset.animateTo(if (showQueue) 0f else 1f, spring(dampingRatio = 0.88f, stiffness = 380f))
}
fun dragQueue(deltaPx: Float) {
scope.launch { queueOffset.snapTo((queueOffset.value + deltaPx / heightPx).coerceIn(0f, 1f)) }
}
fun settleQueue(velocity: Float) {
val close = queueOffset.value > 0.25f || velocity > 1.2f
if (close) onShowQueueChange(false)
scope.launch { queueOffset.animateTo(if (close) 1f else 0f, spring(dampingRatio = 0.88f, stiffness = 380f)) }
}
// Pulling down past the top of the queue list moves the whole view down.
val pullDown = remember {
object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
if (queueOffset.value > 0f && available.y < 0f) {
dragQueue(available.y)
return Offset(0f, available.y)
}
return Offset.Zero
}
override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset {
if (source == NestedScrollSource.UserInput && available.y > 0f) {
dragQueue(available.y)
return Offset(0f, available.y)
}
return Offset.Zero
}
override suspend fun onPreFling(available: Velocity): Velocity {
if (queueOffset.value <= 0f) return Velocity.Zero
settleQueue(available.y / heightPx)
return available
}
}
}
BackHandler(enabled = showQueue) { onShowQueueChange(false) }
if (showQueue || queueOffset.value < 1f) {
QueueScreen(
state = state,
onMove = onMoveInQueue,
onRemove = { index ->
onRemoveFromQueue(index)
confirm("Removed from queue")
},
onPlay = onPlayQueueEntry,
onClose = { showQueue = false },
positionMs = positionMs,
actions = actions,
onShowArt = { onShowQueueChange(false) },
modifier = Modifier
.graphicsLayer { translationY = queueOffset.value * heightPx }
.nestedScroll(pullDown)
.draggable(
orientation = Orientation.Vertical,
state = rememberDraggableState { dragQueue(it) },
onDragStopped = { velocity -> settleQueue(velocity / heightPx) },
),
)
}
}
@ -352,7 +365,7 @@ private fun FullBleedArtwork(artworkUrl: String?, fadeColor: Color, sideways: Bo
* the bar thickens with a spring while you drag.
*/
@Composable
private fun SeekBar(positionMs: Long, durationMs: Long, onSeek: (Long) -> Unit) {
internal fun SeekBar(positionMs: Long, durationMs: Long, onSeek: (Long) -> Unit) {
var dragFraction by remember { mutableStateOf<Float?>(null) }
val latestOnSeek by rememberUpdatedState(onSeek)
val latestDuration by rememberUpdatedState(durationMs)

View file

@ -0,0 +1,388 @@
package com.tysmi.syncwave.ui.player
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SwipeToDismissBox
import androidx.compose.material3.SwipeToDismissBoxValue
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberSwipeToDismissBoxState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
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.draw.shadow
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalHapticFeedback
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 coil3.compose.AsyncImage
import com.tysmi.syncwave.R
import com.tysmi.syncwave.playback.PlayerUiState
import com.tysmi.syncwave.playback.QueueEntry
import com.tysmi.syncwave.playback.RepeatMode
import com.tysmi.syncwave.ui.common.rememberArtworkColors
import com.tysmi.syncwave.ui.theme.BrandPurple
import com.tysmi.syncwave.ui.theme.BrandPurpleLight
import sh.calvin.reorderable.ReorderableItem
import sh.calvin.reorderable.rememberReorderableLazyListState
private val OnDark = Color.White
private val OnDarkSecondary = Color.White.copy(alpha = 0.68f)
/** Everything the queue view can do; implemented by the player remote in MainScreen. */
class QueueActions(
val onPlayPause: () -> Unit,
val onSkipNext: () -> Unit,
val onSkipPrevious: () -> Unit,
val onSeek: (Long) -> Unit,
val onToggleShuffle: () -> Unit,
val onCycleRepeat: () -> Unit,
val onToggleAutoplay: () -> Unit,
val onToggleStar: () -> Unit,
val onMore: () -> Unit,
val onMove: (from: Int, to: Int) -> Unit,
val onRemove: (index: Int) -> Unit,
val onPlayEntry: (index: Int) -> Unit,
val onClearHistory: () -> Unit,
val onLyrics: () -> Unit,
val onCast: () -> Unit,
)
/**
* The queue view that slides up over Now Playing: current song with favourite and more
* buttons, Shuffle / Repeat / Autoplay pills, History and Up Next (drag to reorder, swipe to
* remove), then the seek bar, controls and Lyrics / Cast / queue buttons. Background colours
* come from the album art.
*/
@Composable
fun QueueScreen(
state: PlayerUiState,
positionMs: Long,
actions: QueueActions,
onShowArt: () -> Unit,
modifier: Modifier = Modifier,
) {
val nowPlaying = state.nowPlaying ?: return
val haptics = LocalHapticFeedback.current
val colors = rememberArtworkColors(nowPlaying.artworkUrl)
val top by animateColorAsState(colors.top, tween(700), label = "qTop")
val bottom by animateColorAsState(colors.bottom, tween(700), label = "qBottom")
// Local copies, so dragging and swiping are smooth; refreshed from the player when idle.
val upcoming = remember { mutableStateListOf<QueueEntry>() }
var dragging by remember { mutableStateOf(false) }
LaunchedEffect(state.queue, state.currentIndex, dragging) {
if (!dragging) {
upcoming.clear()
upcoming.addAll(state.upcoming)
}
}
val history = state.history
val firstUpcoming = state.currentIndex + 1
// Open scrolled to Up Next; History is just above it.
val listState = rememberLazyListState(initialFirstVisibleItemIndex = if (history.isEmpty()) 0 else history.size + 1)
val reorderState = rememberReorderableLazyListState(listState) { from, to ->
val fromPos = upcoming.indexOfFirst { it.uid == from.key }
val toPos = upcoming.indexOfFirst { it.uid == to.key }
if (fromPos < 0 || toPos < 0) return@rememberReorderableLazyListState
upcoming.add(toPos, upcoming.removeAt(fromPos))
actions.onMove(firstUpcoming + fromPos, firstUpcoming + toPos)
haptics.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick)
}
Column(
modifier
.fillMaxSize()
.background(Brush.verticalGradient(listOf(top, bottom)))
.background(Color.Black.copy(alpha = 0.25f))
.safeDrawingPadding()
.padding(horizontal = 20.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
// Current song
Row(Modifier.fillMaxWidth().padding(top = 16.dp), verticalAlignment = Alignment.CenterVertically) {
AsyncImage(
model = nowPlaying.artworkUrl,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.size(56.dp).clip(RoundedCornerShape(8.dp)).background(Color.White.copy(alpha = 0.1f)),
)
Column(Modifier.weight(1f).padding(horizontal = 12.dp)) {
Text(nowPlaying.title, style = MaterialTheme.typography.titleMedium, color = OnDark, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(nowPlaying.artist, style = MaterialTheme.typography.bodyMedium, color = OnDarkSecondary, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
IconButton(onClick = actions.onToggleStar) {
Icon(
painterResource(if (nowPlaying.starred) R.drawable.ic_star else R.drawable.ic_star_outline),
contentDescription = if (nowPlaying.starred) "Remove from favourites" else "Add to favourites",
tint = if (nowPlaying.starred) BrandPurpleLight else OnDark,
)
}
IconButton(onClick = actions.onMore) {
Icon(painterResource(R.drawable.ic_more_vert), contentDescription = "More options", tint = OnDark)
}
}
// Shuffle · Repeat · Autoplay
Row(
Modifier.fillMaxWidth().padding(vertical = 14.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
ModePill(R.drawable.ic_shuffle, "Shuffle", on = state.shuffle, onClick = actions.onToggleShuffle, modifier = Modifier.weight(1f))
ModePill(
if (state.repeat == RepeatMode.One) R.drawable.ic_repeat_one else R.drawable.ic_repeat,
when (state.repeat) {
RepeatMode.Off -> "Repeat"
RepeatMode.All -> "Repeat all"
RepeatMode.One -> "Repeat one"
},
on = state.repeat != RepeatMode.Off,
onClick = actions.onCycleRepeat,
modifier = Modifier.weight(1f),
)
ModePill(R.drawable.ic_autoplay, "Autoplay", on = state.autoplay, onClick = actions.onToggleAutoplay, modifier = Modifier.weight(1f))
}
// History, then Up Next
LazyColumn(state = listState, modifier = Modifier.weight(1f).fillMaxWidth()) {
if (history.isNotEmpty()) {
item(key = "history-label") {
SectionHeader("History") {
TextButton(onClick = actions.onClearHistory) { Text("Clear", color = BrandPurpleLight) }
}
}
items(history.size, key = { "h-${history[it].uid}" }) { i ->
QueueRow(
history[i],
dimmed = true,
modifier = Modifier.clickable { actions.onPlayEntry(i) },
)
}
}
item(key = "next-label") { SectionHeader("Up Next") }
if (upcoming.isEmpty()) {
item(key = "empty") {
Text(
if (state.autoplay) "Autoplay will keep playing similar songs." else "Nothing else is queued.",
style = MaterialTheme.typography.bodyMedium,
color = OnDarkSecondary,
modifier = Modifier.padding(vertical = 12.dp),
)
}
}
items(upcoming, key = { it.uid }) { entry ->
ReorderableItem(reorderState, key = entry.uid) { isDragging ->
val dismissState = rememberSwipeToDismissBoxState()
SwipeToDismissBox(
state = dismissState,
enableDismissFromStartToEnd = false,
// Only drawn while the row is being swiped (rows are see-through).
backgroundContent = {
if (dismissState.dismissDirection == SwipeToDismissBoxValue.EndToStart) {
RemoveBackground(dismissState.targetValue)
}
},
onDismiss = { value ->
if (value == SwipeToDismissBoxValue.EndToStart) {
val pos = upcoming.indexOfFirst { it.uid == entry.uid }
if (pos >= 0) {
upcoming.removeAt(pos)
actions.onRemove(firstUpcoming + pos)
}
}
},
) {
QueueRow(
entry,
dimmed = false,
modifier = Modifier
.shadow(if (isDragging) 12.dp else 0.dp, RoundedCornerShape(8.dp))
.background(if (isDragging) Color.White.copy(alpha = 0.14f) else Color.Transparent, RoundedCornerShape(8.dp))
.clickable {
val pos = upcoming.indexOfFirst { it.uid == entry.uid }
if (pos >= 0) actions.onPlayEntry(firstUpcoming + pos)
},
handle = {
Icon(
painterResource(R.drawable.ic_drag_handle),
contentDescription = "Drag to reorder",
tint = OnDarkSecondary,
modifier = Modifier
.draggableHandle(
onDragStarted = {
dragging = true
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
},
onDragStopped = { dragging = false },
)
.padding(12.dp)
.size(24.dp),
)
},
)
}
}
}
}
// Seek bar and controls, as on the full-art screen.
val durationMs = nowPlaying.durationMs ?: 0L
Box(Modifier.fillMaxWidth().widthIn(max = 520.dp).padding(top = 8.dp)) {
SeekBar(
positionMs = positionMs.coerceIn(0L, durationMs.coerceAtLeast(0L)),
durationMs = durationMs,
onSeek = actions.onSeek,
)
}
Row(
Modifier.fillMaxWidth().widthIn(max = 520.dp).padding(vertical = 12.dp),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically,
) {
BouncyIconButton(R.drawable.ic_skip_previous, "Previous track", actions.onSkipPrevious, iconSize = 40.dp, tint = OnDark)
GlassPlayButton(state.isPlaying, state.isBuffering, nowPlaying.artworkUrl, actions.onPlayPause)
BouncyIconButton(R.drawable.ic_skip_next, "Next track", actions.onSkipNext, iconSize = 40.dp, tint = OnDark, enabled = state.hasNext)
}
PlayerBottomRow(queueOn = true, onLyrics = actions.onLyrics, onCast = actions.onCast, onToggleQueue = onShowArt)
}
}
/** Lyrics · Cast · queue toggle, shared by the full-art and queue views. */
@Composable
fun PlayerBottomRow(queueOn: Boolean, onLyrics: () -> Unit, onCast: () -> Unit, onToggleQueue: () -> Unit) {
Row(
Modifier.fillMaxWidth().widthIn(max = 520.dp).padding(bottom = 12.dp),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically,
) {
BouncyIconButton(R.drawable.ic_lyrics, "Lyrics", onLyrics, iconSize = 22.dp, tint = OnDarkSecondary)
BouncyIconButton(R.drawable.ic_cast, "Cast", onCast, iconSize = 22.dp, tint = OnDarkSecondary)
Box(
Modifier
.clip(RoundedCornerShape(50))
.background(if (queueOn) Color.White.copy(alpha = 0.18f) else Color.Transparent),
) {
BouncyIconButton(
R.drawable.ic_queue,
if (queueOn) "Back to artwork" else "Show queue",
onToggleQueue,
iconSize = 22.dp,
tint = if (queueOn) BrandPurpleLight else OnDarkSecondary,
)
}
}
}
@Composable
private fun ModePill(icon: Int, label: String, on: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) {
val bg by animateColorAsState(if (on) BrandPurple.copy(alpha = 0.75f) else Color.White.copy(alpha = 0.12f), label = "pillBg")
Row(
modifier
.height(40.dp)
.clip(RoundedCornerShape(50))
.background(bg)
.clickable(onClickLabel = "$label ${if (on) "on" else "off"}", onClick = onClick)
.padding(horizontal = 10.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
Icon(painterResource(icon), contentDescription = null, tint = OnDark, modifier = Modifier.size(18.dp))
Text(
label,
style = MaterialTheme.typography.labelLarge,
color = OnDark,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(start = 6.dp),
)
}
}
@Composable
private fun SectionHeader(text: String, action: (@Composable () -> Unit)? = null) {
Row(Modifier.fillMaxWidth().padding(top = 8.dp, bottom = 4.dp), verticalAlignment = Alignment.CenterVertically) {
Text(text, style = MaterialTheme.typography.titleMedium, color = OnDark, modifier = Modifier.weight(1f))
action?.invoke()
}
}
@Composable
private fun QueueRow(
entry: QueueEntry,
dimmed: Boolean,
modifier: Modifier = Modifier,
handle: (@Composable () -> Unit)? = null,
) {
Row(
modifier.fillMaxWidth().height(60.dp).padding(start = 4.dp, end = 0.dp),
verticalAlignment = Alignment.CenterVertically,
) {
AsyncImage(
model = entry.artworkUrl,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.size(44.dp)
.clip(RoundedCornerShape(6.dp))
.background(Color.White.copy(alpha = 0.1f)),
alpha = if (dimmed) 0.6f else 1f,
)
Column(Modifier.weight(1f).padding(start = 12.dp, end = 8.dp)) {
Text(
entry.title,
style = MaterialTheme.typography.bodyLarge,
fontWeight = if (dimmed) null else FontWeight.Medium,
color = if (dimmed) OnDarkSecondary else OnDark,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(entry.artist, style = MaterialTheme.typography.bodySmall, color = OnDarkSecondary, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
handle?.invoke()
}
}
@Composable
private fun RemoveBackground(target: SwipeToDismissBoxValue) {
val color by animateColorAsState(
if (target == SwipeToDismissBoxValue.EndToStart) Color(0xFFD9363E) else Color(0x665A1F24),
label = "removeBg",
)
Box(Modifier.fillMaxSize().clip(RoundedCornerShape(8.dp)).background(color).padding(end = 24.dp), contentAlignment = Alignment.CenterEnd) {
Text("Remove", color = OnDark, style = MaterialTheme.typography.labelLarge)
}
}

View file

@ -1,216 +0,0 @@
package com.tysmi.syncwave.ui.player
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SwipeToDismissBox
import androidx.compose.material3.SwipeToDismissBoxValue
import androidx.compose.material3.Text
import androidx.compose.material3.rememberSwipeToDismissBoxState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
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.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalHapticFeedback
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 coil3.compose.AsyncImage
import com.tysmi.syncwave.R
import com.tysmi.syncwave.playback.PlayerUiState
import com.tysmi.syncwave.playback.QueueEntry
import sh.calvin.reorderable.ReorderableItem
import sh.calvin.reorderable.rememberReorderableLazyListState
private val OnDark = Color.White
private val OnDarkSecondary = Color.White.copy(alpha = 0.65f)
/**
* The play queue, shown over Now Playing: the current song, then what's coming up. Drag the
* handle to reorder, swipe left to remove, tap to play. Changes apply to the player at once.
*/
@Composable
fun UpNextScreen(
state: PlayerUiState,
onMove: (from: Int, to: Int) -> Unit,
onRemove: (index: Int) -> Unit,
onPlay: (index: Int) -> Unit,
onClose: () -> Unit,
modifier: Modifier = Modifier,
) {
val haptics = LocalHapticFeedback.current
val current = state.queue.getOrNull(state.currentIndex)
// A local copy, so dragging is smooth; it's refreshed from the player when not dragging.
val upcoming = remember { mutableStateListOf<QueueEntry>() }
var dragging by remember { mutableStateOf(false) }
LaunchedEffect(state.queue, state.currentIndex, dragging) {
if (!dragging) {
upcoming.clear()
upcoming.addAll(state.queue.drop(state.currentIndex + 1))
}
}
val firstUpcoming = state.currentIndex + 1
val listState = rememberLazyListState()
val reorderState = rememberReorderableLazyListState(listState) { from, to ->
val fromPos = upcoming.indexOfFirst { it.uid == from.key }
val toPos = upcoming.indexOfFirst { it.uid == to.key }
if (fromPos < 0 || toPos < 0) return@rememberReorderableLazyListState
upcoming.add(toPos, upcoming.removeAt(fromPos))
onMove(firstUpcoming + fromPos, firstUpcoming + toPos)
haptics.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick)
}
Column(modifier.fillMaxSize().background(Color(0xF2121016)).safeDrawingPadding()) {
Row(Modifier.fillMaxWidth().padding(start = 20.dp, end = 8.dp, top = 8.dp), verticalAlignment = Alignment.CenterVertically) {
Text("Up Next", style = MaterialTheme.typography.headlineSmall, color = OnDark, modifier = Modifier.weight(1f))
IconButton(onClick = onClose) {
Icon(painterResource(R.drawable.ic_close), contentDescription = "Close Up Next", tint = OnDark)
}
}
LazyColumn(state = listState, modifier = Modifier.fillMaxSize()) {
if (current != null) {
item(key = "now") {
SectionLabel("Now Playing")
QueueRow(current, highlighted = true)
}
}
item(key = "next-label") {
SectionLabel(if (upcoming.isEmpty()) "Nothing else queued" else "Next")
}
items(upcoming, key = { it.uid }) { entry ->
ReorderableItem(reorderState, key = entry.uid) { isDragging ->
val dismissState = rememberSwipeToDismissBoxState()
SwipeToDismissBox(
state = dismissState,
enableDismissFromStartToEnd = false,
backgroundContent = { RemoveBackground(dismissState.targetValue) },
onDismiss = { value ->
if (value == SwipeToDismissBoxValue.EndToStart) {
val pos = upcoming.indexOfFirst { it.uid == entry.uid }
if (pos >= 0) {
upcoming.removeAt(pos)
onRemove(firstUpcoming + pos)
}
}
},
) {
val elevation = if (isDragging) 12.dp else 0.dp
QueueRow(
entry,
highlighted = false,
modifier = Modifier
.shadow(elevation)
.background(if (isDragging) Color(0xFF2A2633) else Color(0xFF121016))
.clickable {
val pos = upcoming.indexOfFirst { it.uid == entry.uid }
if (pos >= 0) onPlay(firstUpcoming + pos)
},
handle = {
Icon(
painterResource(R.drawable.ic_drag_handle),
contentDescription = "Drag to reorder",
tint = OnDarkSecondary,
modifier = Modifier
.draggableHandle(
onDragStarted = {
dragging = true
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
},
onDragStopped = { dragging = false },
)
.padding(12.dp)
.size(24.dp),
)
},
)
}
}
}
item(key = "bottom-space") { Box(Modifier.height(24.dp)) }
}
}
}
@Composable
private fun SectionLabel(text: String) {
Text(
text,
style = MaterialTheme.typography.titleSmall,
color = OnDarkSecondary,
modifier = Modifier.padding(start = 20.dp, end = 20.dp, top = 16.dp, bottom = 6.dp),
)
}
@Composable
private fun QueueRow(
entry: QueueEntry,
highlighted: Boolean,
modifier: Modifier = Modifier,
handle: (@Composable () -> Unit)? = null,
) {
Row(
modifier.fillMaxWidth().height(64.dp).padding(start = 20.dp, end = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Start,
) {
AsyncImage(
model = entry.artworkUrl,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.size(46.dp).clip(RoundedCornerShape(6.dp)).background(Color.White.copy(alpha = 0.1f)),
)
Column(Modifier.weight(1f).padding(start = 12.dp, end = 8.dp)) {
Text(
entry.title,
style = MaterialTheme.typography.bodyLarge,
fontWeight = if (highlighted) FontWeight.SemiBold else null,
color = OnDark,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(entry.artist, style = MaterialTheme.typography.bodySmall, color = OnDarkSecondary, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
handle?.invoke()
}
}
@Composable
private fun RemoveBackground(target: SwipeToDismissBoxValue) {
val color by animateColorAsState(
if (target == SwipeToDismissBoxValue.EndToStart) Color(0xFFD9363E) else Color(0xFF5A1F24),
label = "removeBg",
)
Box(Modifier.fillMaxSize().background(color).padding(end = 24.dp), contentAlignment = Alignment.CenterEnd) {
Text("Remove", color = OnDark, style = MaterialTheme.typography.labelLarge)
}
}

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M18.6,6.62c-1.44,0 -2.8,0.56 -3.77,1.53L12,10.66 10.48,12h0.01L7.8,14.39c-0.64,0.64 -1.49,0.99 -2.4,0.99 -1.87,0 -3.39,-1.51 -3.39,-3.38S3.53,8.62 5.4,8.62c0.91,0 1.76,0.35 2.44,1.03l1.13,1 1.51,-1.34L9.22,8.2C8.2,7.18 6.84,6.62 5.4,6.62 2.42,6.62 0,9.04 0,12s2.42,5.38 5.4,5.38c1.44,0 2.8,-0.56 3.77,-1.53l2.83,-2.5 0.01,0.01L13.52,12h-0.01l2.69,-2.39c0.64,-0.64 1.49,-0.99 2.4,-0.99 1.87,0 3.39,1.51 3.39,3.38s-1.52,3.38 -3.39,3.38c-0.9,0 -1.76,-0.35 -2.44,-1.03l-1.14,-1.01 -1.51,1.34 1.27,1.12c1.02,1.01 2.37,1.57 3.82,1.57 2.98,0 5.4,-2.41 5.4,-5.38s-2.42,-5.37 -5.4,-5.37z" />
</vector>

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M21,3H3c-1.1,0 -2,0.9 -2,2v3h2V5h18v14h-7v2h7c1.1,0 2,-0.9 2,-2V5c0,-1.1 -0.9,-2 -2,-2zM1,18v3h3c0,-1.66 -1.34,-3 -3,-3zM1,14v2c2.76,0 5,2.24 5,5h2c0,-3.87 -3.13,-7 -7,-7zM1,10v2c4.97,0 9,4.03 9,9h2c0,-6.08 -4.93,-11 -11,-11z" />
</vector>

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M4,4h16v12H5.17L4,17.17V4zM4,2c-1.1,0 -2,0.9 -2,2v18l4,-4h14c1.1,0 2,-0.9 2,-2V4c0,-1.1 -0.9,-2 -2,-2H4zM6,12h8v2H6zM6,9h12v2H6zM6,6h12v2H6z" />
</vector>

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M12,8c1.1,0 2,-0.9 2,-2s-0.9,-2 -2,-2 -2,0.9 -2,2 0.9,2 2,2zM12,10c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2zM12,16c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2z" />
</vector>

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M12,17.27L18.18,21l-1.64,-7.03L22,9.24l-7.19,-0.61L12,2 9.19,8.63 2,9.24l5.46,4.73L5.82,21z" />
</vector>

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M22,9.24l-7.19,-0.62L12,2 9.19,8.63 2,9.24l5.46,4.73L5.82,21 12,17.27 18.18,21l-1.63,-7.03L22,9.24zM12,15.4l-3.76,2.27 1,-4.28 -3.32,-2.88 4.38,-0.38L12,6.1l1.71,4.04 4.38,0.38 -3.32,2.88 1,4.28L12,15.4z" />
</vector>

BIN
syncwave-icon-1024.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 558 KiB