diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt new file mode 100644 index 00000000..ae546e65 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt @@ -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() + } + } + } + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedContent.kt index 24b3d502..b0e86e3e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedContent.kt @@ -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.main.HomePageContent 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.HomeRowLoadingState import com.github.damontecres.wholphin.util.LoadingState @@ -114,6 +115,8 @@ abstract class RecommendedViewModel( HomeRowLoadingState.Error(titleStr, null, ex) } update(title, row) + // TODO + loading.setValueOnMain(LoadingState.Success) } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMusic.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMusic.kt new file mode 100644 index 00000000..77a82e3b --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMusic.kt @@ -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, + 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>( + 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( + creationCallback = { it.create(parentId) }, + ), +) { + RecommendedContent( + preferences = preferences, + viewModel = viewModel, + onFocusPosition = onFocusPosition, + modifier = modifier, + ) +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMusic.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMusic.kt new file mode 100644 index 00000000..cb5e6b27 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMusic.kt @@ -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) + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/AlbumDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/AlbumDetails.kt new file mode 100644 index 00000000..d9c47650 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/AlbumDetails.kt @@ -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 = _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, + val loading: LoadingState, +) { + companion object { + val EMPTY = AlbumState(null, null, emptyList(), LoadingState.Pending) + } +} + +@Composable +fun AlbumDetails( + itemId: UUID, + modifier: Modifier = Modifier, + viewModel: AlbumViewModel = + hiltViewModel( + 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), + ) + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/SongListDisplay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/SongListDisplay.kt new file mode 100644 index 00000000..67c27973 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/SongListDisplay.kt @@ -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, + 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, + ) + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt index c1e70880..14a50f78 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt @@ -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.CollectionFolderLiveTv 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.CollectionFolderPlaylist 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.episode.EpisodeDetails 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.SeriesOverview 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 -> { Timber.w("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.MUSICVIDEOS, - CollectionType.MUSIC, CollectionType.BOOKS, CollectionType.PHOTOS, -> { diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/Constants.kt b/app/src/main/java/com/github/damontecres/wholphin/util/Constants.kt index c0e93556..44e4fd2a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/Constants.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/Constants.kt @@ -31,6 +31,7 @@ val supportedCollectionTypes = CollectionType.LIVETV, CollectionType.MUSICVIDEOS, CollectionType.FOLDERS, + CollectionType.MUSIC, null, // Mixed ) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2dbad4f1..594709d0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -492,6 +492,10 @@ Send media info log to server No limit Max days in Next Up + Albums + Artists + Songs + Go to artist Disabled