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:
parent
d974862745
commit
521693b3b8
21 changed files with 908 additions and 335 deletions
11
CLAUDE.md
11
CLAUDE.md
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
90
app/src/main/java/com/tysmi/syncwave/playback/MediaItems.kt
Normal file
90
app/src/main/java/com/tysmi/syncwave/playback/MediaItems.kt
Normal 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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
388
app/src/main/java/com/tysmi/syncwave/ui/player/QueueScreen.kt
Normal file
388
app/src/main/java/com/tysmi/syncwave/ui/player/QueueScreen.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
10
app/src/main/res/drawable/ic_autoplay.xml
Normal file
10
app/src/main/res/drawable/ic_autoplay.xml
Normal 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>
|
||||
10
app/src/main/res/drawable/ic_cast.xml
Normal file
10
app/src/main/res/drawable/ic_cast.xml
Normal 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>
|
||||
10
app/src/main/res/drawable/ic_lyrics.xml
Normal file
10
app/src/main/res/drawable/ic_lyrics.xml
Normal 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>
|
||||
10
app/src/main/res/drawable/ic_more_vert.xml
Normal file
10
app/src/main/res/drawable/ic_more_vert.xml
Normal 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>
|
||||
10
app/src/main/res/drawable/ic_star.xml
Normal file
10
app/src/main/res/drawable/ic_star.xml
Normal 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>
|
||||
10
app/src/main/res/drawable/ic_star_outline.xml
Normal file
10
app/src/main/res/drawable/ic_star_outline.xml
Normal 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
BIN
syncwave-icon-1024.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 558 KiB |
Loading…
Reference in a new issue