This commit is contained in:
Damontecres 2026-02-10 19:58:29 -05:00
parent 724d4d0da3
commit e8eb975966
No known key found for this signature in database
9 changed files with 1102 additions and 1 deletions

View file

@ -0,0 +1,72 @@
package com.github.damontecres.wholphin.services
import android.content.Context
import androidx.annotation.OptIn
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.okhttp.OkHttpDataSource
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient
import com.github.damontecres.wholphin.ui.toServerString
import com.github.damontecres.wholphin.util.profile.Codec
import dagger.hilt.android.qualifiers.ApplicationContext
import okhttp3.OkHttpClient
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.universalAudioApi
import org.jellyfin.sdk.model.api.BaseItemKind
import javax.inject.Inject
import javax.inject.Singleton
@OptIn(UnstableApi::class)
@Singleton
class MusicService
@Inject
constructor(
@param:ApplicationContext private val context: Context,
@param:AuthOkHttpClient private val authOkHttpClient: OkHttpClient,
private val api: ApiClient,
) {
private val player: Player by lazy {
ExoPlayer
.Builder(context)
.setMediaSourceFactory(
DefaultMediaSourceFactory(
OkHttpDataSource.Factory(authOkHttpClient),
),
).build()
}
fun addToQueue(
item: BaseItem,
position: Int? = null,
) {
if (item.type == BaseItemKind.AUDIO) {
val url =
api.universalAudioApi.getUniversalAudioStreamUrl(
itemId = item.id,
container =
listOf(
Codec.Audio.OPUS,
Codec.Audio.MP3,
Codec.Audio.AAC,
Codec.Audio.FLAC,
),
)
val mediaItem =
MediaItem
.Builder()
.setUri(url)
.setMediaId(item.id.toServerString())
.setTag(item)
.build()
player.addMediaItem(mediaItem)
if (player.mediaItemCount == 1) {
// Start playing if this was the first time added
player.play()
}
}
}
}

View file

@ -34,6 +34,7 @@ import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome
import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.main.HomePageContent import com.github.damontecres.wholphin.ui.main.HomePageContent
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.HomeRowLoadingState import com.github.damontecres.wholphin.util.HomeRowLoadingState
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
@ -114,6 +115,8 @@ abstract class RecommendedViewModel(
HomeRowLoadingState.Error(titleStr, null, ex) HomeRowLoadingState.Error(titleStr, null, ex)
} }
update(title, row) update(title, row)
// TODO
loading.setValueOnMain(LoadingState.Success)
} }
} }
} }

View file

@ -0,0 +1,227 @@
package com.github.damontecres.wholphin.ui.components
import android.content.Context
import androidx.annotation.StringRes
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.datastore.core.DataStore
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.MediaReportService
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.SuggestionService
import com.github.damontecres.wholphin.services.SuggestionsResource
import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.data.RowColumn
import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.ui.toBaseItems
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import com.github.damontecres.wholphin.util.HomeRowLoadingState
import com.github.damontecres.wholphin.util.LoadingState
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.SortOrder
import org.jellyfin.sdk.model.api.request.GetItemsRequest
import timber.log.Timber
import java.util.UUID
@HiltViewModel(assistedFactory = RecommendedMusicViewModel.Factory::class)
class RecommendedMusicViewModel
@AssistedInject
constructor(
@ApplicationContext context: Context,
private val api: ApiClient,
private val serverRepository: ServerRepository,
private val preferencesDataStore: DataStore<AppPreferences>,
private val suggestionService: SuggestionService,
@Assisted val parentId: UUID,
navigationManager: NavigationManager,
favoriteWatchManager: FavoriteWatchManager,
mediaReportService: MediaReportService,
backdropService: BackdropService,
) : RecommendedViewModel(
context,
navigationManager,
favoriteWatchManager,
mediaReportService,
backdropService,
) {
@AssistedFactory
interface Factory {
fun create(parentId: UUID): RecommendedMusicViewModel
}
override val rows =
MutableStateFlow<List<HomeRowLoadingState>>(
rowTitles.keys.map {
HomeRowLoadingState.Pending(
context.getString(it),
)
},
)
override fun init() {
viewModelScope.launch(Dispatchers.IO + ExceptionHandler()) {
val itemsPerRow =
preferencesDataStore.data
.firstOrNull()
?.homePagePreferences
?.maxItemsPerRow
?: AppPreference.HomePageItems.defaultValue.toInt()
update(R.string.recently_released) {
val request =
GetItemsRequest(
parentId = parentId,
fields = SlimItemFields,
includeItemTypes = listOf(BaseItemKind.MUSIC_ALBUM),
recursive = true,
enableUserData = true,
sortBy = listOf(ItemSortBy.PREMIERE_DATE),
sortOrder = listOf(SortOrder.DESCENDING),
startIndex = 0,
limit = itemsPerRow,
enableTotalRecordCount = false,
)
GetItemsRequestHandler.execute(api, request).toBaseItems(api, false)
}
update(R.string.recently_added) {
val request =
GetItemsRequest(
parentId = parentId,
fields = SlimItemFields,
includeItemTypes = listOf(BaseItemKind.MUSIC_ALBUM),
recursive = true,
enableUserData = true,
sortBy = listOf(ItemSortBy.DATE_CREATED),
sortOrder = listOf(SortOrder.DESCENDING),
startIndex = 0,
limit = itemsPerRow,
enableTotalRecordCount = false,
)
GetItemsRequestHandler.execute(api, request).toBaseItems(api, false)
}
update(R.string.top_unwatched) {
val request =
GetItemsRequest(
parentId = parentId,
fields = SlimItemFields,
includeItemTypes = listOf(BaseItemKind.MUSIC_ALBUM),
recursive = true,
enableUserData = true,
isPlayed = false,
sortBy = listOf(ItemSortBy.COMMUNITY_RATING),
sortOrder = listOf(SortOrder.DESCENDING),
startIndex = 0,
limit = itemsPerRow,
enableTotalRecordCount = false,
)
GetItemsRequestHandler.execute(api, request).toBaseItems(api, false)
}
viewModelScope.launch(Dispatchers.IO) {
try {
suggestionService
.getSuggestionsFlow(parentId, BaseItemKind.MUSIC_ALBUM)
.collect { resource ->
val state =
when (resource) {
is SuggestionsResource.Loading -> {
HomeRowLoadingState.Loading(
context.getString(R.string.suggestions),
)
}
is SuggestionsResource.Success -> {
HomeRowLoadingState.Success(
context.getString(R.string.suggestions),
resource.items,
)
}
is SuggestionsResource.Empty -> {
HomeRowLoadingState.Success(
context.getString(R.string.suggestions),
emptyList(),
)
}
}
update(R.string.suggestions, state)
}
} catch (ex: Exception) {
Timber.e(ex, "Failed to fetch suggestions")
update(
R.string.suggestions,
HomeRowLoadingState.Error(
title = context.getString(R.string.suggestions),
exception = ex,
),
)
}
}
if (loading.value == LoadingState.Loading || loading.value == LoadingState.Pending) {
loading.setValueOnMain(LoadingState.Success)
}
}
}
override fun update(
@StringRes title: Int,
row: HomeRowLoadingState,
) {
rows.update { current ->
current.toMutableList().apply { set(rowTitles[title]!!, row) }
}
}
companion object {
private val rowTitles =
listOf(
R.string.recently_released,
R.string.recently_added,
R.string.suggestions,
R.string.top_unwatched,
).mapIndexed { index, i -> i to index }.toMap()
}
}
@Composable
fun RecommendedMusic(
preferences: UserPreferences,
parentId: UUID,
onFocusPosition: (RowColumn) -> Unit,
modifier: Modifier = Modifier,
viewModel: RecommendedMusicViewModel =
hiltViewModel<RecommendedMusicViewModel, RecommendedMusicViewModel.Factory>(
creationCallback = { it.create(parentId) },
),
) {
RecommendedContent(
preferences = preferences,
viewModel = viewModel,
onFocusPosition = onFocusPosition,
modifier = modifier,
)
}

View file

@ -0,0 +1,231 @@
package com.github.damontecres.wholphin.ui.detail
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.CollectionFolderFilter
import com.github.damontecres.wholphin.data.model.GetItemsFilter
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.GenreCardGrid
import com.github.damontecres.wholphin.ui.components.RecommendedMusic
import com.github.damontecres.wholphin.ui.components.TabRow
import com.github.damontecres.wholphin.ui.components.ViewOptionsSquare
import com.github.damontecres.wholphin.ui.data.MovieSortOptions
import com.github.damontecres.wholphin.ui.data.VideoSortOptions
import com.github.damontecres.wholphin.ui.logTab
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel
import org.jellyfin.sdk.model.api.BaseItemKind
@Composable
fun CollectionFolderMusic(
preferences: UserPreferences,
destination: Destination.MediaItem,
modifier: Modifier = Modifier,
preferencesViewModel: PreferencesViewModel = hiltViewModel(),
) {
val rememberedTabIndex =
remember { preferencesViewModel.getRememberedTab(preferences, destination.itemId, 0) }
val tabs =
listOf(
stringResource(R.string.recommended),
stringResource(R.string.albums),
stringResource(R.string.artists),
stringResource(R.string.playlists),
stringResource(R.string.genres),
stringResource(R.string.songs),
)
var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) }
val focusRequester = remember { FocusRequester() }
val tabFocusRequesters = remember { List(tabs.size) { FocusRequester() } }
val firstTabFocusRequester = remember { FocusRequester() }
// LaunchedEffect(Unit) { firstTabFocusRequester.tryRequestFocus() }
LaunchedEffect(selectedTabIndex) {
logTab("music", selectedTabIndex)
preferencesViewModel.saveRememberedTab(preferences, destination.itemId, selectedTabIndex)
preferencesViewModel.backdropService.clearBackdrop()
}
var showHeader by rememberSaveable { mutableStateOf(true) }
Column(
modifier = modifier,
) {
AnimatedVisibility(
showHeader,
enter = slideInVertically() + fadeIn(),
exit = slideOutVertically() + fadeOut(),
) {
TabRow(
selectedTabIndex = selectedTabIndex,
modifier =
Modifier
.padding(vertical = 16.dp)
.focusRequester(firstTabFocusRequester),
tabs = tabs,
onClick = { selectedTabIndex = it },
focusRequesters = tabFocusRequesters,
)
}
when (selectedTabIndex) {
// Recommended
0 -> {
RecommendedMusic(
preferences = preferences,
parentId = destination.itemId,
onFocusPosition = { pos ->
showHeader = pos.row < 1
},
modifier =
Modifier
.fillMaxSize()
.focusRequester(focusRequester),
)
}
// Albums
1 -> {
CollectionFolderGrid(
preferences = preferences,
onClickItem = { _, item ->
preferencesViewModel.navigationManager.navigateTo(item.destination())
},
itemId = destination.itemId,
viewModelKey = "${destination.itemId}_albums",
initialFilter =
CollectionFolderFilter(
filter =
GetItemsFilter(
includeItemTypes = listOf(BaseItemKind.MUSIC_ALBUM),
),
),
showTitle = false,
recursive = true,
sortOptions = MovieSortOptions,
defaultViewOptions = ViewOptionsSquare,
modifier =
Modifier
.fillMaxSize()
.focusRequester(focusRequester),
positionCallback = { columns, position ->
showHeader = position < columns
},
playEnabled = true,
focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex),
)
}
// Artists
2 -> {
CollectionFolderGrid(
preferences = preferences,
onClickItem = { _, item ->
preferencesViewModel.navigationManager.navigateTo(item.destination())
},
itemId = destination.itemId,
viewModelKey = "${destination.itemId}_artists",
initialFilter =
CollectionFolderFilter(
filter =
GetItemsFilter(
includeItemTypes = listOf(BaseItemKind.MUSIC_ARTIST),
),
),
showTitle = false,
recursive = true,
sortOptions = VideoSortOptions,
defaultViewOptions = ViewOptionsSquare,
modifier =
Modifier
.fillMaxSize()
.focusRequester(focusRequester),
positionCallback = { columns, position ->
showHeader = position < columns
},
playEnabled = false,
focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex),
)
}
// Playlists
3 -> {
// TODO
Text("TODO")
}
// Genres
4 -> {
GenreCardGrid(
itemId = destination.itemId,
includeItemTypes = listOf(BaseItemKind.MUSIC_ALBUM, BaseItemKind.AUDIO),
modifier =
Modifier
.fillMaxSize()
.focusRequester(focusRequester),
)
}
// Songs
5 -> {
CollectionFolderGrid(
preferences = preferences,
onClickItem = { _, item ->
preferencesViewModel.navigationManager.navigateTo(item.destination())
},
itemId = destination.itemId,
viewModelKey = "${destination.itemId}_songs",
initialFilter =
CollectionFolderFilter(
filter =
GetItemsFilter(
includeItemTypes = listOf(BaseItemKind.AUDIO),
),
),
showTitle = false,
recursive = true,
sortOptions = MovieSortOptions,
defaultViewOptions = ViewOptionsSquare,
modifier =
Modifier
.fillMaxSize()
.focusRequester(focusRequester),
positionCallback = { columns, position ->
showHeader = position < columns
},
playEnabled = true,
focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex),
)
}
else -> {
ErrorMessage("Invalid tab index $selectedTabIndex", null)
}
}
}
}

View file

@ -0,0 +1,377 @@
package com.github.damontecres.wholphin.ui.detail.music
import android.content.Context
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.relocation.BringIntoViewRequester
import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AccountCircle
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.FocusState
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import coil3.compose.AsyncImage
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.ItemPlaybackRepository
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.ExtrasService
import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.ImageUrlService
import com.github.damontecres.wholphin.services.MediaReportService
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.PeopleFavorites
import com.github.damontecres.wholphin.services.StreamChoiceService
import com.github.damontecres.wholphin.services.ThemeSongPlayer
import com.github.damontecres.wholphin.services.TrailerService
import com.github.damontecres.wholphin.services.UserPreferencesService
import com.github.damontecres.wholphin.ui.DefaultItemFields
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.ExpandableFaButton
import com.github.damontecres.wholphin.ui.components.ExpandablePlayButton
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.launchIO
import com.github.damontecres.wholphin.ui.letNotEmpty
import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import com.github.damontecres.wholphin.util.LoadingState
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.request.GetItemsRequest
import java.util.UUID
import kotlin.time.Duration
@HiltViewModel(assistedFactory = AlbumViewModel.Factory::class)
class AlbumViewModel
@AssistedInject
constructor(
private val api: ApiClient,
@param:ApplicationContext private val context: Context,
private val navigationManager: NavigationManager,
val serverRepository: ServerRepository,
val itemPlaybackRepository: ItemPlaybackRepository,
val streamChoiceService: StreamChoiceService,
val mediaReportService: MediaReportService,
private val themeSongPlayer: ThemeSongPlayer,
private val favoriteWatchManager: FavoriteWatchManager,
private val peopleFavorites: PeopleFavorites,
private val trailerService: TrailerService,
private val extrasService: ExtrasService,
private val userPreferencesService: UserPreferencesService,
private val backdropService: BackdropService,
private val imageUrlService: ImageUrlService,
@Assisted val itemId: UUID,
) : ViewModel() {
@AssistedFactory
interface Factory {
fun create(itemId: UUID): AlbumViewModel
}
private val _state = MutableStateFlow(AlbumState.EMPTY)
val state: StateFlow<AlbumState> = _state
init {
viewModelScope.launchIO {
try {
val itemDeferred =
async {
api.userLibraryApi
.getItem(itemId = itemId)
.content
.let { BaseItem(it, false) }
}
val songsDeferred =
async {
val request =
GetItemsRequest(
parentId = itemId,
fields = DefaultItemFields,
sortBy =
listOf(
ItemSortBy.PARENT_INDEX_NUMBER,
ItemSortBy.INDEX_NUMBER,
ItemSortBy.SORT_NAME,
),
)
ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope).init()
}
val album = itemDeferred.await()
val songs = songsDeferred.await()
val imageUrl = imageUrlService.getItemImageUrl(album, ImageType.PRIMARY)
_state.update {
AlbumState(
album = album,
imageUrl = imageUrl,
songs = songs,
loading = LoadingState.Success,
)
}
} catch (ex: Exception) {
_state.update { it.copy(loading = LoadingState.Error(ex)) }
}
}
}
}
data class AlbumState(
val album: BaseItem?,
val imageUrl: String?,
val songs: List<BaseItem?>,
val loading: LoadingState,
) {
companion object {
val EMPTY = AlbumState(null, null, emptyList(), LoadingState.Pending)
}
}
@Composable
fun AlbumDetails(
itemId: UUID,
modifier: Modifier = Modifier,
viewModel: AlbumViewModel =
hiltViewModel<AlbumViewModel, AlbumViewModel.Factory>(
creationCallback = { it.create(itemId) },
),
) {
val state by viewModel.state.collectAsState()
when (val loading = state.loading) {
is LoadingState.Error -> {
ErrorMessage(loading, modifier)
}
LoadingState.Loading,
LoadingState.Pending,
-> {
LoadingPage(modifier)
}
LoadingState.Success -> {
val bringIntoViewRequester = remember { BringIntoViewRequester() }
Box(modifier = modifier) {
LazyColumn(
verticalArrangement = Arrangement.spacedBy(0.dp),
modifier = Modifier.fillMaxSize(),
) {
item {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier =
Modifier
.fillMaxWidth()
.bringIntoViewRequester(bringIntoViewRequester)
.padding(bottom = 32.dp),
) {
AlbumHeader(
album = state.album!!,
imageUrl = state.imageUrl,
overviewOnClick = {},
bringIntoViewRequester = bringIntoViewRequester,
modifier = Modifier.fillMaxWidth(),
)
AlbumButtons(
onClickPlay = {},
onClickAddToPlaylist = {},
onClickGoToArtist = {},
onClickMore = { },
buttonOnFocusChanged = {},
modifier = Modifier,
)
}
}
itemsIndexed(state.songs) { index, song ->
SongListItem(
song = song,
onClick = {},
onClickAddToQueue = {},
onClickAddToPlaylist = {},
modifier = Modifier.padding(horizontal = 16.dp),
showArtist = false,
)
}
}
}
}
}
}
@Composable
fun AlbumHeader(
album: BaseItem,
imageUrl: String?,
overviewOnClick: () -> Unit,
bringIntoViewRequester: BringIntoViewRequester,
modifier: Modifier,
) {
val scope = rememberCoroutineScope()
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier = modifier.padding(top = 32.dp),
) {
AsyncImage(
model = imageUrl,
contentDescription = null,
modifier = Modifier.fillMaxWidth(.25f),
)
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
// Artist
Text(
text = album.artistsString ?: "",
color = MaterialTheme.colorScheme.onBackground,
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier =
Modifier
.fillMaxWidth(.75f)
.padding(start = 8.dp),
)
Text(
text = album.name ?: "",
color = MaterialTheme.colorScheme.onBackground,
style = MaterialTheme.typography.headlineSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier =
Modifier
.fillMaxWidth(.75f)
.padding(start = 8.dp),
)
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.fillMaxWidth(.60f),
) {
QuickDetails(
album.ui.quickDetails,
null,
Modifier.padding(start = 8.dp),
)
album.data.genres?.letNotEmpty {
GenreText(it, Modifier.padding(start = 8.dp))
}
// Description
album.data.overview?.let { overview ->
OverviewText(
overview = overview,
maxLines = 3,
onClick = overviewOnClick,
textBoxHeight = Dp.Unspecified,
modifier =
Modifier.onFocusChanged {
if (it.isFocused) {
scope.launch(ExceptionHandler()) {
bringIntoViewRequester.bringIntoView()
}
}
},
)
}
}
}
}
}
@Composable
fun AlbumButtons(
onClickPlay: (Boolean) -> Unit,
onClickAddToPlaylist: () -> Unit,
onClickGoToArtist: () -> Unit,
onClickMore: () -> Unit,
buttonOnFocusChanged: (FocusState) -> Unit,
modifier: Modifier = Modifier,
) {
val firstFocus = remember { FocusRequester() }
LazyRow(
horizontalArrangement = Arrangement.spacedBy(16.dp),
contentPadding = PaddingValues(8.dp),
modifier =
modifier
.focusGroup()
.focusRestorer(firstFocus),
) {
item {
ExpandablePlayButton(
title = R.string.play,
resume = Duration.ZERO,
icon = Icons.Default.PlayArrow,
onClick = { onClickPlay.invoke(false) },
modifier =
Modifier
.onFocusChanged(buttonOnFocusChanged)
.focusRequester(firstFocus),
)
}
item {
ExpandableFaButton(
title = R.string.shuffle,
iconStringRes = R.string.fa_shuffle,
onClick = { onClickPlay.invoke(true) },
modifier =
Modifier
.onFocusChanged(buttonOnFocusChanged),
)
}
item {
ExpandablePlayButton(
title = R.string.go_to_artist,
resume = Duration.ZERO,
icon = Icons.Default.AccountCircle,
onClick = { onClickGoToArtist.invoke() },
modifier =
Modifier
.onFocusChanged(buttonOnFocusChanged),
)
}
}
}

View file

@ -0,0 +1,169 @@
package com.github.damontecres.wholphin.ui.detail.music
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.tv.material3.Button
import androidx.tv.material3.ButtonDefaults
import androidx.tv.material3.ListItem
import androidx.tv.material3.ListItemDefaults
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.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.letNotEmpty
import com.github.damontecres.wholphin.ui.roundMinutes
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import org.jellyfin.sdk.model.extensions.ticks
import kotlin.time.Duration
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
@Composable
fun SongListDisplay(
songs: List<BaseItem?>,
showArtist: Boolean,
onClick: (Int, BaseItem) -> Unit,
onClickAddToQueue: (Int, BaseItem) -> Unit,
onClickAddToPlaylist: (Int, BaseItem) -> Unit,
modifier: Modifier = Modifier,
) {
LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier,
) {
itemsIndexed(songs) { index, song ->
SongListItem(
song = song,
onClick = { song?.let { onClick.invoke(index, song) } },
onClickAddToQueue = { song?.let { onClick.invoke(index, song) } },
onClickAddToPlaylist = { song?.let { onClick.invoke(index, song) } },
showArtist = showArtist,
modifier = Modifier,
)
}
}
}
@Composable
fun SongListItem(
song: BaseItem?,
onClick: () -> Unit,
onClickAddToQueue: () -> Unit,
onClickAddToPlaylist: () -> Unit,
modifier: Modifier = Modifier,
showArtist: Boolean = false,
) = SongListItem(
title = song?.title,
artist = if (showArtist) song?.artistsString else null,
indexNumber = song?.data?.indexNumber,
runtime =
song
?.data
?.runTimeTicks
?.ticks
?.roundMinutes,
onClick = onClick,
onClickAddToQueue = onClickAddToQueue,
onClickAddToPlaylist = onClickAddToPlaylist,
modifier = modifier,
showArtist = showArtist,
)
@Composable
fun SongListItem(
title: String?,
artist: String?,
indexNumber: Int?,
runtime: Duration?,
onClick: () -> Unit,
onClickAddToQueue: () -> Unit,
onClickAddToPlaylist: () -> Unit,
modifier: Modifier = Modifier,
showArtist: Boolean = false,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier,
) {
ListItem(
selected = false,
onClick = onClick,
leadingContent = {
Text(
text = indexNumber?.toString() ?: "",
)
},
headlineContent = {
Text(
text = title ?: "",
)
},
supportingContent =
if (showArtist && artist.isNotNullOrBlank()) {
{
Text(
text = artist,
)
}
} else {
null
},
trailingContent = {
Text(
text = runtime.toString(),
)
},
scale = ListItemDefaults.scale(1f, 1f),
modifier = Modifier.weight(1f),
)
Button(
onClick = onClickAddToQueue,
shape = ButtonDefaults.shape(shape = RoundedCornerShape(8.dp)),
) {
Text(
text = "Add to queue",
)
}
}
}
val BaseItem.artistsString: String? get() = data.artists?.letNotEmpty { it.joinToString(", ") }
@PreviewTvSpec
@Composable
fun SongListItemPreview() {
WholphinTheme {
Column {
SongListItem(
title = "Song title",
artist = "Artists",
indexNumber = 1,
runtime = 2.minutes + 30.seconds,
onClick = {},
onClickAddToQueue = { },
onClickAddToPlaylist = {},
modifier = Modifier,
showArtist = false,
)
SongListItem(
title = "Song title",
artist = "Artists",
indexNumber = 1,
runtime = 2.minutes + 30.seconds,
onClick = {},
onClickAddToQueue = { },
onClickAddToPlaylist = {},
modifier = Modifier,
showArtist = true,
)
}
}
}

View file

@ -15,6 +15,7 @@ import com.github.damontecres.wholphin.ui.detail.CollectionFolderBoxSet
import com.github.damontecres.wholphin.ui.detail.CollectionFolderGeneric import com.github.damontecres.wholphin.ui.detail.CollectionFolderGeneric
import com.github.damontecres.wholphin.ui.detail.CollectionFolderLiveTv import com.github.damontecres.wholphin.ui.detail.CollectionFolderLiveTv
import com.github.damontecres.wholphin.ui.detail.CollectionFolderMovie import com.github.damontecres.wholphin.ui.detail.CollectionFolderMovie
import com.github.damontecres.wholphin.ui.detail.CollectionFolderMusic
import com.github.damontecres.wholphin.ui.detail.CollectionFolderPhotoAlbum import com.github.damontecres.wholphin.ui.detail.CollectionFolderPhotoAlbum
import com.github.damontecres.wholphin.ui.detail.CollectionFolderPlaylist import com.github.damontecres.wholphin.ui.detail.CollectionFolderPlaylist
import com.github.damontecres.wholphin.ui.detail.CollectionFolderRecordings import com.github.damontecres.wholphin.ui.detail.CollectionFolderRecordings
@ -28,6 +29,7 @@ import com.github.damontecres.wholphin.ui.detail.discover.DiscoverPersonPage
import com.github.damontecres.wholphin.ui.detail.discover.DiscoverSeriesDetails import com.github.damontecres.wholphin.ui.detail.discover.DiscoverSeriesDetails
import com.github.damontecres.wholphin.ui.detail.episode.EpisodeDetails import com.github.damontecres.wholphin.ui.detail.episode.EpisodeDetails
import com.github.damontecres.wholphin.ui.detail.movie.MovieDetails import com.github.damontecres.wholphin.ui.detail.movie.MovieDetails
import com.github.damontecres.wholphin.ui.detail.music.AlbumDetails
import com.github.damontecres.wholphin.ui.detail.series.SeriesDetails import com.github.damontecres.wholphin.ui.detail.series.SeriesDetails
import com.github.damontecres.wholphin.ui.detail.series.SeriesOverview import com.github.damontecres.wholphin.ui.detail.series.SeriesOverview
import com.github.damontecres.wholphin.ui.discover.DiscoverPage import com.github.damontecres.wholphin.ui.discover.DiscoverPage
@ -207,6 +209,14 @@ fun DestinationContent(
) )
} }
BaseItemKind.MUSIC_ALBUM -> {
LaunchedEffect(Unit) { onClearBackdrop.invoke() }
AlbumDetails(
itemId = destination.itemId,
modifier = modifier,
)
}
else -> { else -> {
Timber.w("Unsupported item type: ${destination.type}") Timber.w("Unsupported item type: ${destination.type}")
Text("Unsupported item type: ${destination.type}") Text("Unsupported item type: ${destination.type}")
@ -379,9 +389,16 @@ fun CollectionFolder(
) )
} }
CollectionType.MUSIC -> {
CollectionFolderMusic(
preferences,
destination,
modifier,
)
}
CollectionType.HOMEVIDEOS, CollectionType.HOMEVIDEOS,
CollectionType.MUSICVIDEOS, CollectionType.MUSICVIDEOS,
CollectionType.MUSIC,
CollectionType.BOOKS, CollectionType.BOOKS,
CollectionType.PHOTOS, CollectionType.PHOTOS,
-> { -> {

View file

@ -31,6 +31,7 @@ val supportedCollectionTypes =
CollectionType.LIVETV, CollectionType.LIVETV,
CollectionType.MUSICVIDEOS, CollectionType.MUSICVIDEOS,
CollectionType.FOLDERS, CollectionType.FOLDERS,
CollectionType.MUSIC,
null, // Mixed null, // Mixed
) )

View file

@ -492,6 +492,10 @@
<string name="send_media_info_log_to_server">Send media info log to server</string> <string name="send_media_info_log_to_server">Send media info log to server</string>
<string name="no_limit">No limit</string> <string name="no_limit">No limit</string>
<string name="max_days_next_up">Max days in Next Up</string> <string name="max_days_next_up">Max days in Next Up</string>
<string name="albums">Albums</string>
<string name="artists">Artists</string>
<string name="songs">Songs</string>
<string name="go_to_artist">Go to artist</string>
<string-array name="theme_song_volume"> <string-array name="theme_song_volume">
<item>Disabled</item> <item>Disabled</item>