This commit is contained in:
Damontecres 2026-02-28 23:30:26 -05:00
parent 6a1725fd3d
commit ea243975ad
No known key found for this signature in database
11 changed files with 234 additions and 84 deletions

View file

@ -325,8 +325,8 @@ class MainActivity : AppCompatActivity() {
// ?: Destination.Home(),
// TODO undo
?: Destination.MediaItem(
itemId = "bbc3e562-6045-5521-dfda-7effa56e14f2".toUUID(),
type = BaseItemKind.MUSIC_ARTIST,
itemId = "011ef0c7ca45684f2cd9dd3b020ca5f6".toUUID(),
type = BaseItemKind.MUSIC_ALBUM,
),
navigationManager = navigationManager,
preferences = preferences,

View file

@ -17,6 +17,7 @@ import com.github.damontecres.wholphin.data.model.AudioItem
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient
import com.github.damontecres.wholphin.ui.DefaultItemFields
import com.github.damontecres.wholphin.ui.main.settings.MoveDirection
import com.github.damontecres.wholphin.ui.onMain
import com.github.damontecres.wholphin.ui.toServerString
import com.github.damontecres.wholphin.util.BlockingList
@ -200,6 +201,14 @@ class MusicService
}
}
}
suspend fun moveQueue(
index: Int,
direction: MoveDirection,
) = withContext(Dispatchers.Main) {
player.moveMediaItem(index, if (direction == MoveDirection.UP) index - 1 else index + 1)
updateQueueSize()
}
}
@Stable

View file

@ -12,6 +12,7 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.relocation.BringIntoViewRequester
import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
@ -21,9 +22,11 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
@ -330,6 +333,9 @@ fun AlbumDetailsPage(
item {
Text(
text = stringResource(R.string.songs),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onBackground,
modifier = Modifier.padding(start = 8.dp),
)
}
itemsIndexed(state.songs) { index, song ->
@ -389,7 +395,11 @@ fun AlbumHeader(
AsyncImage(
model = imageUrl,
contentDescription = null,
modifier = Modifier.fillMaxWidth(.25f),
contentScale = ContentScale.FillWidth,
modifier =
Modifier
.fillMaxWidth(.20f)
.clip(RoundedCornerShape(16.dp)),
)
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),

View file

@ -13,6 +13,7 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.relocation.BringIntoViewRequester
import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
@ -22,9 +23,11 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
@ -58,9 +61,11 @@ import com.github.damontecres.wholphin.ui.components.GenreText
import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.components.OverviewText
import com.github.damontecres.wholphin.ui.components.QuickDetails
import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.letNotEmpty
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.toBaseItems
import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
@ -149,13 +154,44 @@ class ArtistViewModel
loading = LoadingState.Success,
)
}
backdropService.submit(artist)
viewModelScope.launchIO {
val request =
GetItemsRequest(
parentId = itemId,
fields = DefaultItemFields,
recursive = true,
includeItemTypes = listOf(BaseItemKind.AUDIO),
minCommunityRating = 1.0,
sortBy =
listOf(
ItemSortBy.COMMUNITY_RATING,
),
sortOrder = listOf(SortOrder.DESCENDING),
limit = 10,
)
val topSongs =
GetItemsRequestHandler.execute(api, request).toBaseItems(api, false)
if (topSongs.isNotEmpty()) {
_state.update {
it.copy(topSongs = topSongs)
}
}
}
} catch (ex: Exception) {
_state.update { it.copy(loading = LoadingState.Error(ex)) }
}
}
}
fun init() {
viewModelScope.launchDefault {
state.value.artist?.let {
backdropService.submit(it)
}
}
}
fun setFavorite(
itemId: UUID,
favorite: Boolean,
@ -169,14 +205,25 @@ class ArtistViewModel
_state.update { it.copy(artist = artist) }
}
fun play(
shuffled: Boolean,
startIndex: Int = 0,
) {
fun play(shuffled: Boolean) {
viewModelScope.launchIO {
Timber.v("Playing artist %s from %s", itemId, startIndex)
val songs = state.value.topSongs as ApiRequestPager<*>
musicService.setQueue(songs, startIndex, shuffled)
Timber.v("Playing artist %s", itemId)
val request =
GetItemsRequest(
parentId = itemId,
fields = DefaultItemFields,
recursive = true,
includeItemTypes = listOf(BaseItemKind.AUDIO),
sortBy =
listOf(
ItemSortBy.PREMIERE_DATE,
ItemSortBy.INDEX_NUMBER,
),
sortOrder = listOf(SortOrder.DESCENDING, SortOrder.ASCENDING),
)
val pager =
ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope).init()
musicService.setQueue(pager, 0, shuffled)
}
}
@ -199,6 +246,12 @@ class ArtistViewModel
navigationManager.navigateTo(Destination.NowPlaying)
}
}
fun playSong(song: BaseItem) {
viewModelScope.launchDefault {
musicService.setQueue(listOf(song), false)
}
}
}
data class ArtistState(
@ -233,7 +286,7 @@ fun ArtistDetailsPage(
remember {
MusicMoreDialogActions(
onNavigate = { viewModel.navigationManager.navigateTo(it) },
onClickPlay = { index, _ -> viewModel.play(false, index) },
onClickPlay = { index, song -> TODO() },
onClickAddToQueue = { index, itemId -> viewModel.addToQueue(itemId, index) },
onClickFavorite = { itemId, favorite -> viewModel.setFavorite(itemId, favorite) },
onClickAddPlaylist = {},
@ -254,7 +307,10 @@ fun ArtistDetailsPage(
LoadingState.Success -> {
val artist = state.artist!!
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { focusRequester.requestFocus() }
LaunchedEffect(Unit) {
focusRequester.requestFocus()
viewModel.init()
}
val bringIntoViewRequester = remember { BringIntoViewRequester() }
Box(modifier = modifier) {
LazyColumn(
@ -284,7 +340,7 @@ fun ArtistDetailsPage(
actions =
remember {
MusicButtonActions(
onClickPlay = { viewModel.play(it, 0) },
onClickPlay = { viewModel.play(it) },
onClickInstantMix = viewModel::startInstantMix,
onClickFavorite = {
viewModel.setFavorite(
@ -320,13 +376,16 @@ fun ArtistDetailsPage(
if (state.topSongs.isNotEmpty()) {
item {
Text(
text = stringResource(R.string.songs),
text = stringResource(R.string.popular_songs),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onBackground,
modifier = Modifier.padding(start = 8.dp),
)
}
itemsIndexed(state.topSongs) { index, song ->
SongListItem(
song = song,
onClick = { viewModel.play(false, index) },
onClick = { song?.let { viewModel.playSong(it) } },
onLongClick = {
if (song != null) {
moreDialog =
@ -403,7 +462,11 @@ fun ArtistHeader(
AsyncImage(
model = imageUrl,
contentDescription = null,
modifier = Modifier.fillMaxWidth(.25f),
contentScale = ContentScale.FillWidth,
modifier =
Modifier
.fillMaxWidth(.20f)
.clip(RoundedCornerShape(16.dp)),
)
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),

View file

@ -8,6 +8,7 @@ import androidx.compose.animation.core.tween
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
@ -15,6 +16,7 @@ import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
@ -46,11 +48,13 @@ import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.AudioItem
import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.main.settings.MoveDirection
import com.github.damontecres.wholphin.ui.playback.ControllerViewState
import com.github.damontecres.wholphin.ui.playback.PlaybackAction
import com.github.damontecres.wholphin.ui.playback.PlaybackButtons
import com.github.damontecres.wholphin.ui.playback.PlaybackFaButton
import com.github.damontecres.wholphin.ui.playback.SeekBar
import com.github.damontecres.wholphin.ui.preferences.MoveButton
import com.github.damontecres.wholphin.ui.roundSeconds
import com.github.damontecres.wholphin.ui.tryRequestFocus
import kotlinx.coroutines.launch
@ -65,6 +69,7 @@ fun NowPlayingOverlay(
current: AudioItem?,
queue: List<AudioItem>,
controllerViewState: ControllerViewState,
onMoveQueue: (Int, MoveDirection) -> Unit,
modifier: Modifier = Modifier,
) {
val scope = rememberCoroutineScope()
@ -228,6 +233,10 @@ fun NowPlayingOverlay(
},
) {
itemsIndexed(queue) { index, song ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
SongListItem(
title = song.title,
artist = song.artistNames,
@ -240,11 +249,30 @@ fun NowPlayingOverlay(
},
onLongClick = {},
modifier =
Modifier.ifElse(
Modifier
.weight(1f)
.ifElse(
index == 0,
Modifier.focusRequester(firstFocusRequester),
),
)
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.wrapContentWidth(),
) {
MoveButton(
icon = R.string.fa_caret_up,
enabled = index > 0,
onClick = { onMoveQueue.invoke(index, MoveDirection.UP) },
)
MoveButton(
icon = R.string.fa_caret_down,
enabled = index < queue.lastIndex,
onClick = { onMoveQueue.invoke(index, MoveDirection.DOWN) },
)
}
}
}
}
}

View file

@ -5,6 +5,8 @@ import androidx.annotation.OptIn
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandHorizontally
import androidx.compose.animation.shrinkHorizontally
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Arrangement
@ -131,7 +133,9 @@ fun NowPlayingPage(
controllerViewState.hideControls()
}
AnimatedVisibility(
controllerViewState.controlsVisible,
visible = controllerViewState.controlsVisible,
enter = slideInVertically { it },
exit = slideOutVertically { it },
modifier =
Modifier
.align(Alignment.BottomCenter),
@ -142,6 +146,7 @@ fun NowPlayingPage(
current = current,
queue = queue,
controllerViewState = controllerViewState,
onMoveQueue = { index, direction -> viewModel.moveQueue(index, direction) },
modifier =
Modifier
.background(AppColors.TransparentBlack50)

View file

@ -14,6 +14,7 @@ import com.github.damontecres.wholphin.services.MusicService
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.UserPreferencesService
import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.main.settings.MoveDirection
import com.github.damontecres.wholphin.ui.onMain
import com.github.damontecres.wholphin.ui.playback.ControllerViewState
import com.mayakapps.kache.InMemoryKache
@ -164,4 +165,9 @@ class NowPlayingViewModel
}
}
}
fun moveQueue(
index: Int,
direction: MoveDirection,
) = viewModelScope.launchDefault { musicService.moveQueue(index, direction) }
}

View file

@ -3,6 +3,7 @@ package com.github.damontecres.wholphin.ui.detail.music
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.lazy.LazyColumn
@ -22,7 +23,6 @@ import androidx.tv.material3.Text
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.PreviewTvSpec
import com.github.damontecres.wholphin.ui.enableMarquee
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.letNotEmpty
import com.github.damontecres.wholphin.ui.roundMinutes
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
@ -98,12 +98,7 @@ fun SongListItem(
modifier = modifier,
) {
val focused by interactionSource.collectIsFocusedAsState()
ListItem(
selected = isPlaying,
onClick = onClick,
onLongClick = onLongClick,
interactionSource = interactionSource,
leadingContent = {
val leadingContent: @Composable (BoxScope.() -> Unit) = {
Row(
verticalAlignment = Alignment.CenterVertically,
) {
@ -117,32 +112,52 @@ fun SongListItem(
)
}
}
},
headlineContent = {
}
val headlineContent = @Composable {
Text(
text = title ?: "",
maxLines = 1,
modifier = Modifier.enableMarquee(focused),
)
},
supportingContent =
if (showArtist && artist.isNotNullOrBlank()) {
{
Text(
text = artist,
)
}
} else {
null
},
trailingContent = {
val trailingContent = @Composable {
Text(
text = runtime.toString(),
)
},
scale = ListItemDefaults.scale(1f, 1f, .95f),
modifier = Modifier.weight(1f),
}
if (showArtist) {
// TODO use dense?
ListItem(
selected = isPlaying,
onClick = onClick,
onLongClick = onLongClick,
interactionSource = interactionSource,
leadingContent = leadingContent,
headlineContent = headlineContent,
supportingContent = {
Text(
text = artist ?: "",
)
},
trailingContent = trailingContent,
scale = ListItemDefaults.scale(1f, 1f, .95f),
modifier = Modifier,
)
} else {
ListItem(
selected = isPlaying,
onClick = onClick,
onLongClick = onLongClick,
interactionSource = interactionSource,
leadingContent = leadingContent,
headlineContent = headlineContent,
supportingContent = null,
trailingContent = trailingContent,
scale = ListItemDefaults.scale(1f, 1f, .95f),
modifier = Modifier,
)
}
}
}

View file

@ -53,6 +53,7 @@ import com.github.damontecres.wholphin.ui.components.BasicDialog
import com.github.damontecres.wholphin.ui.components.Button
import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.main.settings.MoveDirection
import com.github.damontecres.wholphin.ui.nav.NavDrawerItem
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import com.github.damontecres.wholphin.util.ExceptionHandler
@ -85,11 +86,6 @@ data class NavDrawerPin(
}
}
enum class MoveDirection {
UP,
DOWN,
}
private fun <T> List<T>.move(
direction: MoveDirection,
index: Int,
@ -246,7 +242,7 @@ fun NavDrawerPreferenceListItem(
}
@Composable
private fun MoveButton(
fun MoveButton(
@StringRes icon: Int,
enabled: Boolean,
onClick: () -> Unit,

View file

@ -1,9 +1,26 @@
package com.github.damontecres.wholphin.util
import java.util.function.IntFunction
import java.util.function.Predicate
interface BlockingList<T> : List<T> {
suspend fun getBlocking(index: Int): T
suspend fun indexOfBlocking(predicate: Predicate<T>): Int
companion object {
fun <T> of(list: List<T>): BlockingList<T> = BlockingListWrapper(list)
}
}
private class BlockingListWrapper<T>(
private val list: List<T>,
) : BlockingList<T>,
List<T> by list {
override suspend fun getBlocking(index: Int): T = get(index)
override suspend fun indexOfBlocking(predicate: Predicate<T>): Int = indexOfFirst { predicate.test(it) }
@Deprecated("Deprecated")
override fun <T> toArray(generator: IntFunction<Array<out T?>?>): Array<out T?> = super<List>.toArray(generator)
}

View file

@ -726,5 +726,6 @@
<string name="artist">Artist</string>
<string name="album_artist">Album artist</string>
<string name="album">Album</string>
<string name="popular_songs">Popular songs</string>
</resources>