feat: player + search + M2 events foundation #19
@@ -21,6 +21,8 @@ import androidx.compose.foundation.lazy.grid.items
|
|||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -28,7 +30,9 @@ import androidx.compose.material3.Scaffold
|
|||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
@@ -59,6 +63,12 @@ fun ArtistDetailScreen(
|
|||||||
viewModel: ArtistDetailViewModel = hiltViewModel(),
|
viewModel: ArtistDetailViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
viewModel.transientMessages.collect { msg ->
|
||||||
|
snackbarHostState.showSnackbar(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
topBar = {
|
topBar = {
|
||||||
@@ -71,6 +81,7 @@ fun ArtistDetailScreen(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
) { inner ->
|
) { inner ->
|
||||||
PullToRefreshScaffold(
|
PullToRefreshScaffold(
|
||||||
onRefresh = { viewModel.refresh().join() },
|
onRefresh = { viewModel.refresh().join() },
|
||||||
|
|||||||
+14
-5
@@ -13,10 +13,13 @@ import com.fabledsword.minstrel.nav.DetailSeedCache
|
|||||||
import com.fabledsword.minstrel.player.PlayerController
|
import com.fabledsword.minstrel.player.PlayerController
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.receiveAsFlow
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -45,6 +48,10 @@ class ArtistDetailViewModel @Inject constructor(
|
|||||||
MutableStateFlow<ArtistDetailUiState>(ArtistDetailUiState.Loading())
|
MutableStateFlow<ArtistDetailUiState>(ArtistDetailUiState.Loading())
|
||||||
val uiState: StateFlow<ArtistDetailUiState> = internal.asStateFlow()
|
val uiState: StateFlow<ArtistDetailUiState> = internal.asStateFlow()
|
||||||
|
|
||||||
|
/** Transient one-shot messages for the screen-level snackbar (e.g. Play-button failure). */
|
||||||
|
private val transientMessagesChannel = Channel<String>(Channel.BUFFERED)
|
||||||
|
val transientMessages: Flow<String> = transientMessagesChannel.receiveAsFlow()
|
||||||
|
|
||||||
val artistLiked: StateFlow<Boolean> =
|
val artistLiked: StateFlow<Boolean> =
|
||||||
likes.observeIsLiked(LikesRepository.ENTITY_ARTIST, artistId)
|
likes.observeIsLiked(LikesRepository.ENTITY_ARTIST, artistId)
|
||||||
.stateIn(
|
.stateIn(
|
||||||
@@ -92,7 +99,9 @@ class ArtistDetailViewModel @Inject constructor(
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
val tracks = repository.fetchArtistTracks(artistId).shuffled()
|
val tracks = repository.fetchArtistTracks(artistId).shuffled()
|
||||||
if (tracks.isNotEmpty()) {
|
if (tracks.isEmpty()) {
|
||||||
|
transientMessagesChannel.trySend("No tracks to play for this artist.")
|
||||||
|
} else {
|
||||||
player.setQueue(
|
player.setQueue(
|
||||||
tracks = tracks,
|
tracks = tracks,
|
||||||
initialIndex = 0,
|
initialIndex = 0,
|
||||||
@@ -100,11 +109,11 @@ class ArtistDetailViewModel @Inject constructor(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
} catch (
|
} catch (
|
||||||
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
||||||
) {
|
) {
|
||||||
// Silent failure — the play button has no inline error
|
transientMessagesChannel.trySend(
|
||||||
// affordance; user can retry by re-tapping. A future
|
"Couldn't start playback: ${e.message ?: "unknown error"}",
|
||||||
// refinement surfaces this via a snackbar.
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user