v2026.05.19.3 — playback stall resilience + legacy home cleanup #56

Merged
bvandeusen merged 262 commits from dev into main 2026-05-19 15:48:08 -04:00
15 changed files with 158 additions and 92 deletions
Showing only changes of commit dc68156d98 - Show all commits
@@ -30,7 +30,7 @@ import com.fabledsword.minstrel.models.TrackRef
import com.fabledsword.minstrel.player.PlayerController import com.fabledsword.minstrel.player.PlayerController
import com.fabledsword.minstrel.shared.widgets.EmptyState import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb
import com.fabledsword.minstrel.shared.widgets.track_actions.TrackActionsButton import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsButton
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -56,8 +56,8 @@ import com.fabledsword.minstrel.nav.AlbumDetail
import com.fabledsword.minstrel.nav.ArtistDetail import com.fabledsword.minstrel.nav.ArtistDetail
import com.fabledsword.minstrel.shared.widgets.EmptyState import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.LikeButton import com.fabledsword.minstrel.shared.widgets.LikeButton
import com.fabledsword.minstrel.shared.widgets.track_actions.TrackActionsButton import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsButton
import com.fabledsword.minstrel.shared.widgets.track_actions.TrackActionsViewModel import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsViewModel
import com.fabledsword.minstrel.theme.FabledSwordFlatTokens import com.fabledsword.minstrel.theme.FabledSwordFlatTokens
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -44,7 +44,7 @@ import com.fabledsword.minstrel.nav.Library
import com.fabledsword.minstrel.shared.widgets.EmptyState import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.ErrorRetry import com.fabledsword.minstrel.shared.widgets.ErrorRetry
import com.fabledsword.minstrel.shared.widgets.MainAppBarActions import com.fabledsword.minstrel.shared.widgets.MainAppBarActions
import com.fabledsword.minstrel.shared.widgets.track_actions.TrackActionsViewModel import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsViewModel
/** /**
* Library tab. Five-tab TabBar (Artists / Albums / History / Liked / * Library tab. Five-tab TabBar (Artists / Albums / History / Liked /
@@ -41,7 +41,7 @@ import com.fabledsword.minstrel.player.PlayerController
import com.fabledsword.minstrel.shared.widgets.EmptyState import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.HorizontalScrollRow import com.fabledsword.minstrel.shared.widgets.HorizontalScrollRow
import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb
import com.fabledsword.minstrel.shared.widgets.track_actions.TrackActionsButton import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsButton
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -38,6 +38,13 @@ import kotlin.coroutines.resumeWithException
* the StateFlow projection means each ViewModel doesn't have to attach * the StateFlow projection means each ViewModel doesn't have to attach
* its own Player.Listener. * its own Player.Listener.
*/ */
// PlayerController is the UI-side facade over the full Media3 surface
// (transport + queue + radio + lifecycle). All the public methods are
// legitimately part of one cohesive controller — splitting into
// "TransportController", "QueueController", etc. would scatter related
// state for no gain. Suppress the per-class function-count cap rather
// than fragment.
@Suppress("TooManyFunctions")
@Singleton @Singleton
class PlayerController @Inject constructor( class PlayerController @Inject constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
@@ -49,8 +49,8 @@ import com.fabledsword.minstrel.nav.AlbumDetail
import com.fabledsword.minstrel.nav.ArtistDetail import com.fabledsword.minstrel.nav.ArtistDetail
import com.fabledsword.minstrel.nav.Queue import com.fabledsword.minstrel.nav.Queue
import com.fabledsword.minstrel.shared.widgets.EmptyState import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.track_actions.TrackActionsButton import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsButton
import com.fabledsword.minstrel.shared.widgets.track_actions.TrackActionsViewModel import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsViewModel
import com.fabledsword.minstrel.theme.FabledSwordFlatTokens import com.fabledsword.minstrel.theme.FabledSwordFlatTokens
import com.fabledsword.minstrel.theme.LocalActionColors import com.fabledsword.minstrel.theme.LocalActionColors
@@ -121,28 +121,36 @@ fun NowPlayingScreen(
onNext = viewModel::skipToNext, onNext = viewModel::skipToNext,
) )
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
Row( BottomActionsRow(navController = navController, track = track)
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
ViewQueueButton(onClick = { navController.navigate(Queue) })
TrackActionsButton(
track = track,
hideQueueActions = true,
onNavigateToAlbum = { id ->
navController.popBackStack()
navController.navigate(AlbumDetail(id))
},
onNavigateToArtist = { id ->
navController.popBackStack()
navController.navigate(ArtistDetail(id))
},
)
}
} }
} }
} }
@Composable
private fun BottomActionsRow(
navController: NavHostController,
track: com.fabledsword.minstrel.models.TrackRef,
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
ViewQueueButton(onClick = { navController.navigate(Queue) })
TrackActionsButton(
track = track,
hideQueueActions = true,
onNavigateToAlbum = { id ->
navController.popBackStack()
navController.navigate(AlbumDetail(id))
},
onNavigateToArtist = { id ->
navController.popBackStack()
navController.navigate(ArtistDetail(id))
},
)
}
}
@Composable @Composable
private fun ViewQueueButton(onClick: () -> Unit) { private fun ViewQueueButton(onClick: () -> Unit) {
IconButton(onClick = onClick) { IconButton(onClick = onClick) {
@@ -67,8 +67,8 @@ import com.fabledsword.minstrel.playlists.data.PlaylistDetailRef
import com.fabledsword.minstrel.playlists.data.PlaylistsRepository import com.fabledsword.minstrel.playlists.data.PlaylistsRepository
import com.fabledsword.minstrel.shared.widgets.LikeButton import com.fabledsword.minstrel.shared.widgets.LikeButton
import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb
import com.fabledsword.minstrel.shared.widgets.track_actions.TrackActionsButton import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsButton
import com.fabledsword.minstrel.shared.widgets.track_actions.TrackActionsViewModel import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsViewModel
import com.fabledsword.minstrel.theme.FabledSwordFlatTokens import com.fabledsword.minstrel.theme.FabledSwordFlatTokens
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -1,3 +1,9 @@
@file:Suppress("TooManyFunctions")
// SearchScreen legitimately owns the screen-shell composable + a handful
// of small section/row helpers (ResultsPane, ResultsList, three section
// builders, two row variants, hint, loader). Splitting into a widgets/
// subdirectory for a single screen would be over-engineering.
package com.fabledsword.minstrel.search.ui package com.fabledsword.minstrel.search.ui
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@@ -51,8 +57,8 @@ import com.fabledsword.minstrel.models.TrackRef
import com.fabledsword.minstrel.nav.AlbumDetail import com.fabledsword.minstrel.nav.AlbumDetail
import com.fabledsword.minstrel.nav.ArtistDetail import com.fabledsword.minstrel.nav.ArtistDetail
import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb
import com.fabledsword.minstrel.shared.widgets.track_actions.TrackActionsButton import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsButton
import com.fabledsword.minstrel.shared.widgets.track_actions.TrackActionsViewModel import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsViewModel
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -1,4 +1,4 @@
package com.fabledsword.minstrel.shared.widgets.track_actions package com.fabledsword.minstrel.shared.widgets.trackactions
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@@ -1,4 +1,4 @@
package com.fabledsword.minstrel.shared.widgets.track_actions package com.fabledsword.minstrel.shared.widgets.trackactions
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
@@ -1,4 +1,4 @@
package com.fabledsword.minstrel.shared.widgets.track_actions package com.fabledsword.minstrel.shared.widgets.trackactions
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -93,17 +93,23 @@ fun HideTrackSheet(
maxLines = 2, maxLines = 2,
) )
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
Row( HideSheetButtons(
modifier = Modifier.fillMaxWidth(), onCancel = onDismiss,
horizontalArrangement = Arrangement.End, onSubmit = { onSubmit(reason, notes.trim()) },
) { )
TextButton(onClick = onDismiss) { Text("Cancel") }
Spacer(Modifier.padding(horizontal = 4.dp))
Button(onClick = { onSubmit(reason, notes.trim()) }) {
Text("Hide")
}
}
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
} }
} }
} }
@Composable
private fun HideSheetButtons(onCancel: () -> Unit, onSubmit: () -> Unit) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
TextButton(onClick = onCancel) { Text("Cancel") }
Spacer(Modifier.padding(horizontal = 4.dp))
Button(onClick = onSubmit) { Text("Hide") }
}
}
@@ -1,4 +1,4 @@
package com.fabledsword.minstrel.shared.widgets.track_actions package com.fabledsword.minstrel.shared.widgets.trackactions
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
@@ -1,4 +1,4 @@
package com.fabledsword.minstrel.shared.widgets.track_actions package com.fabledsword.minstrel.shared.widgets.trackactions
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@@ -63,7 +63,6 @@ fun TrackActionsSheet(
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val isLiked by viewModel.isLikedFlow(track.id).collectAsStateWithLifecycle(initialValue = false) val isLiked by viewModel.isLikedFlow(track.id).collectAsStateWithLifecycle(initialValue = false)
val isHidden by viewModel.isHiddenFlow(track.id).collectAsStateWithLifecycle(initialValue = false) val isHidden by viewModel.isHiddenFlow(track.id).collectAsStateWithLifecycle(initialValue = false)
var showAddToPlaylist by remember { mutableStateOf(false) } var showAddToPlaylist by remember { mutableStateOf(false) }
var showHideSheet by remember { mutableStateOf(false) } var showHideSheet by remember { mutableStateOf(false) }
@@ -71,71 +70,111 @@ fun TrackActionsSheet(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
sheetState = sheetState, sheetState = sheetState,
) { ) {
Column(modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp)) { TrackActionMenuItems(
if (!hideQueueActions) { track = track,
MenuItem(Lucide.ListVideo, "Play next") { hideQueueActions = hideQueueActions,
viewModel.playNext(track) isLiked = isLiked,
onDismiss() isHidden = isHidden,
} viewModel = viewModel,
MenuItem(Lucide.ListMusic, "Add to queue") { onDismiss = onDismiss,
viewModel.enqueue(track) onNavigateToAlbum = onNavigateToAlbum,
onDismiss() onNavigateToArtist = onNavigateToArtist,
} onShowAddToPlaylist = { showAddToPlaylist = true },
HorizontalDivider() onShowHideSheet = { showHideSheet = true },
)
}
TrackActionSubSheets(
track = track,
showAddToPlaylist = showAddToPlaylist,
showHideSheet = showHideSheet,
viewModel = viewModel,
onDismissParent = onDismiss,
onCloseAddToPlaylist = { showAddToPlaylist = false },
onCloseHideSheet = { showHideSheet = false },
)
}
@Composable
private fun TrackActionMenuItems(
track: TrackRef,
hideQueueActions: Boolean,
isLiked: Boolean,
isHidden: Boolean,
viewModel: TrackActionsViewModel,
onDismiss: () -> Unit,
onNavigateToAlbum: (String) -> Unit,
onNavigateToArtist: (String) -> Unit,
onShowAddToPlaylist: () -> Unit,
onShowHideSheet: () -> Unit,
) {
Column(modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp)) {
if (!hideQueueActions) {
MenuItem(Lucide.ListVideo, "Play next") {
viewModel.playNext(track); onDismiss()
} }
MenuItem(Lucide.Heart, if (isLiked) "Unlike" else "Like") { MenuItem(Lucide.ListMusic, "Add to queue") {
viewModel.toggleLike(track.id, isLiked) viewModel.enqueue(track); onDismiss()
onDismiss()
}
MenuItem(Lucide.ListPlus, "Add to playlist…") {
showAddToPlaylist = true
}
MenuItem(Lucide.Radio, "Start radio") {
viewModel.startRadio(track.id)
onDismiss()
} }
HorizontalDivider() HorizontalDivider()
MenuItem(Lucide.Disc3, "Go to album") { }
onDismiss() MenuItem(Lucide.Heart, if (isLiked) "Unlike" else "Like") {
if (track.albumId.isNotEmpty()) onNavigateToAlbum(track.albumId) viewModel.toggleLike(track.id, isLiked); onDismiss()
} }
MenuItem(Lucide.User, "Go to artist") { MenuItem(Lucide.ListPlus, "Add to playlist", onShowAddToPlaylist)
onDismiss() MenuItem(Lucide.Radio, "Start radio") {
if (track.artistId.isNotEmpty()) onNavigateToArtist(track.artistId) viewModel.startRadio(track.id); onDismiss()
} }
HorizontalDivider() HorizontalDivider()
MenuItem( MenuItem(Lucide.Disc3, "Go to album") {
icon = if (isHidden) Lucide.Eye else Lucide.EyeOff, onDismiss()
label = if (isHidden) "Unhide" else "Hide", if (track.albumId.isNotEmpty()) onNavigateToAlbum(track.albumId)
) { }
if (isHidden) { MenuItem(Lucide.User, "Go to artist") {
viewModel.unflag(track.id) onDismiss()
onDismiss() if (track.artistId.isNotEmpty()) onNavigateToArtist(track.artistId)
} else { }
showHideSheet = true HorizontalDivider()
} MenuItem(
icon = if (isHidden) Lucide.Eye else Lucide.EyeOff,
label = if (isHidden) "Unhide" else "Hide",
) {
if (isHidden) {
viewModel.unflag(track.id); onDismiss()
} else {
onShowHideSheet()
} }
} }
} }
}
@Composable
private fun TrackActionSubSheets(
track: TrackRef,
showAddToPlaylist: Boolean,
showHideSheet: Boolean,
viewModel: TrackActionsViewModel,
onDismissParent: () -> Unit,
onCloseAddToPlaylist: () -> Unit,
onCloseHideSheet: () -> Unit,
) {
if (showAddToPlaylist) { if (showAddToPlaylist) {
AddToPlaylistSheet( AddToPlaylistSheet(
onPick = { playlistId -> onPick = { playlistId ->
viewModel.appendToPlaylist(playlistId, track.id) viewModel.appendToPlaylist(playlistId, track.id)
showAddToPlaylist = false onCloseAddToPlaylist()
onDismiss() onDismissParent()
}, },
onDismiss = { showAddToPlaylist = false }, onDismiss = onCloseAddToPlaylist,
) )
} }
if (showHideSheet) { if (showHideSheet) {
HideTrackSheet( HideTrackSheet(
onSubmit = { reason, notes -> onSubmit = { reason, notes ->
viewModel.flag(track, reason, notes) viewModel.flag(track, reason, notes)
showHideSheet = false onCloseHideSheet()
onDismiss() onDismissParent()
}, },
onDismiss = { showHideSheet = false }, onDismiss = onCloseHideSheet,
) )
} }
} }
@@ -1,4 +1,4 @@
package com.fabledsword.minstrel.shared.widgets.track_actions package com.fabledsword.minstrel.shared.widgets.trackactions
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope