From d184990b16b081da26a3c773979b956550bf294a Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Tue, 17 Mar 2026 17:04:27 -0400 Subject: [PATCH 01/15] Improvements to ffmpeg decoders build script (#1110) ## Description A few improvements to the ffmpeg decoder & mpv build scripts. Mainly changes the dependency fetching so the scripts are more idempotent, though some rebuilds still always occur. ### Related issues Related to https://github.com/damontecres/Wholphin/pull/1106#issuecomment-4077678690 ### Testing Local builds ## Screenshots N/A ## AI or LLM usage None --- scripts/ffmpeg/README.md | 11 +++++++++++ scripts/ffmpeg/build_ffmpeg_decoder.sh | 15 ++++++++++++--- scripts/mpv/get_dependencies.sh | 3 ++- 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/scripts/ffmpeg/README.md b/scripts/ffmpeg/README.md index d1fdac02..36a9ca9a 100644 --- a/scripts/ffmpeg/README.md +++ b/scripts/ffmpeg/README.md @@ -1,3 +1,14 @@ # FFmpeg ExoPlayer decoder extension module Builds the ffmpeg decoder extension module for `ExoPlayer` which supports extra codecs during playback. + +## Usage + +```sh +./build_ffmpeg_decoder.sh /path/to/android/ndk +``` + +Or clean build: +```sh +./build_ffmpeg_decoder.sh /path/to/android/ndk --clean +``` diff --git a/scripts/ffmpeg/build_ffmpeg_decoder.sh b/scripts/ffmpeg/build_ffmpeg_decoder.sh index 1741c703..338b850e 100755 --- a/scripts/ffmpeg/build_ffmpeg_decoder.sh +++ b/scripts/ffmpeg/build_ffmpeg_decoder.sh @@ -27,6 +27,12 @@ AV1_MODULE_PATH="$MEDIA_PATH/libraries/decoder_av1/src/main" HOST="$(uname -s | tr '[:upper:]' '[:lower:]')" HOST_PLATFORM="$HOST-x86_64" +if [[ "$2" == "--clean" ]]; then + rm -rf ffmpeg_decoder + rm -f "$TARGET_PATH/lib-decoder-ffmpeg-release.aar" + rm -f "$TARGET_PATH/lib-decoder-av1-release.aar" +fi + mkdir -p "$TARGET_PATH" mkdir -p ffmpeg_decoder @@ -38,14 +44,16 @@ pushd ffmpeg_decoder || exit if [[ -d media ]]; then pushd media || exit - git checkout --force "$media_version" + git fetch origin "$media_version" --depth 1 + git checkout --force FETCH_HEAD else git clone https://github.com/androidx/media.git --depth 1 --single-branch -b "$media_version" media fi if [[ -d ffmpeg ]]; then pushd ffmpeg || exit - git checkout --force "$FFMPEG_BRANCH" + git fetch origin "$FFMPEG_BRANCH" --depth 1 + git checkout --force FETCH_HEAD else git clone https://github.com/FFmpeg/FFmpeg --depth 1 --single-branch -b "$FFMPEG_BRANCH" ffmpeg fi @@ -68,7 +76,8 @@ pushd "$AV1_MODULE_PATH/jni" || exit if [[ -d dav1d ]]; then pushd dav1d || exit - git checkout --force "$DAV1D_BRANCH" + git fetch origin "$DAV1D_BRANCH" --depth 1 + git checkout --force FETCH_HEAD else git clone https://code.videolan.org/videolan/dav1d --depth 1 --single-branch -b "$DAV1D_BRANCH" dav1d fi diff --git a/scripts/mpv/get_dependencies.sh b/scripts/mpv/get_dependencies.sh index 74bff6d6..829b2a06 100755 --- a/scripts/mpv/get_dependencies.sh +++ b/scripts/mpv/get_dependencies.sh @@ -15,7 +15,8 @@ function clone(){ if [[ -d "$dir" ]]; then pushd "$dir" || exit - git checkout --force "$branch" + git fetch origin "$branch" --depth 1 + git checkout --force FETCH_HEAD popd || exit else git clone "$repo" --depth 1 --single-branch -b "$branch" "$dir" "$@" From f9ff04c5b7aeef1793177a85493415f15b99de07 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Wed, 18 Mar 2026 18:09:13 -0400 Subject: [PATCH 02/15] Fix issues when the system kills the app during playback (#1116) ## Description This fixes some issues restoring the app state after it is killed by the system, such as to free up memory while the screensaver is showing. ### Dev notes Reproduce: 1. Start playing media from beginning 2. Pause at 10 minute mark 3. Wait for screensaver to start 4. Press a button 5. Media starts from beginning Note: It may take a few iterations of 2-4 before the OS destroys the app. If the app is killed during playback, ideally Wholphin restores state to _previous_ page, not playback since restoring playback is complex. Before this PR, the back stack was maintained by Compose's `rememberSerializable` and would be restored _after_ the `PlaybackLifecycleObserver` cleans up the playback destination. This meant that the app would be restored, from scratch, to the playback page. The `PlaybackViewModel` would be initialized from scratch as it was originally called, ie play from beginning. The fix is to save and restore the back stack outside of Compose. ### Related issues Fixes #767 ### Testing Emulator & nvidia shield ## Screenshots N/A ## AI or LLM usage None --- .../wholphin/test/InstrumentedBasicUiTests.kt | 7 ++-- .../damontecres/wholphin/MainActivity.kt | 33 ++++++++++++++++--- .../damontecres/wholphin/MainContent.kt | 4 --- .../wholphin/WholphinDreamService.kt | 7 ++-- .../wholphin/services/NavigationManager.kt | 3 +- .../wholphin/ui/nav/ApplicationContent.kt | 15 --------- 6 files changed, 39 insertions(+), 30 deletions(-) diff --git a/app/src/androidTest/java/com/github/damontecres/wholphin/test/InstrumentedBasicUiTests.kt b/app/src/androidTest/java/com/github/damontecres/wholphin/test/InstrumentedBasicUiTests.kt index 72cf2896..09dc6745 100644 --- a/app/src/androidTest/java/com/github/damontecres/wholphin/test/InstrumentedBasicUiTests.kt +++ b/app/src/androidTest/java/com/github/damontecres/wholphin/test/InstrumentedBasicUiTests.kt @@ -9,7 +9,9 @@ import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performKeyInput import androidx.compose.ui.test.pressKey +import androidx.navigation3.runtime.NavBackStack import com.github.damontecres.wholphin.MainContent +import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.ScreensaverService import com.github.damontecres.wholphin.services.ScreensaverState import com.github.damontecres.wholphin.services.SetupDestination @@ -43,16 +45,17 @@ class InstrumentedBasicUiTests { @OptIn(ExperimentalTestApi::class) @Test fun myTest() { + val navigationManager = NavigationManager() + navigationManager.backStack = NavBackStack(Destination.Home()) // Start the app composeTestRule.setContent { WholphinTheme { MainContent( backStack = mutableListOf(SetupDestination.ServerList), - navigationManager = mockk(relaxed = true), + navigationManager = navigationManager, appPreferences = mockk(relaxed = true), backdropService = mockk(relaxed = true), screensaverService = screensaverService, - requestedDestination = Destination.Home(), modifier = Modifier, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt index 9cb740ab..e8791bde 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -24,6 +24,7 @@ import androidx.datastore.core.DataStore import androidx.lifecycle.ViewModel import androidx.lifecycle.lifecycleScope import androidx.lifecycle.viewModelScope +import androidx.navigation3.runtime.NavBackStack import androidx.tv.material3.ExperimentalTvMaterial3Api import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.preferences.AppPreference @@ -67,6 +68,7 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json import okhttp3.OkHttpClient import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.serializer.toUUIDOrNull @@ -127,6 +129,11 @@ class MainActivity : AppCompatActivity() { private var signInAuto = true + private val json = + Json { + classDiscriminator = "_type" + } + @OptIn(ExperimentalTvMaterial3Api::class) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -134,6 +141,23 @@ class MainActivity : AppCompatActivity() { Timber.i("MainActivity.onCreate: savedInstanceState is null=${savedInstanceState == null}") lifecycle.addObserver(playbackLifecycleObserver) + val backStackStr = savedInstanceState?.getString(KEY_BACK_STACK) + if (backStackStr != null) { + Timber.d("Restoring back stack") + var backStack = json.decodeFromString>(backStackStr) + val lastDest = backStack.lastOrNull() + if (lastDest is Destination.Playback || + lastDest is Destination.PlaybackList || + lastDest is Destination.Slideshow + ) { + backStack = backStack.toMutableList().apply { removeAt(lastIndex) } + } + navigationManager.backStack = NavBackStack(*backStack.toTypedArray()) + } else { + val startDestination = intent?.let(::extractDestination) ?: Destination.Home() + navigationManager.backStack = NavBackStack(startDestination) + } + viewModel.serverRepository.currentUser.observe(this) { user -> if (user?.hasPin == true) { window?.setFlags( @@ -206,17 +230,12 @@ class MainActivity : AppCompatActivity() { appThemeColors = appPreferences.interfacePreferences.appThemeColors, ) { ProvideLocalClock { - val requestedDestination = - remember(intent) { - intent?.let(::extractDestination) ?: Destination.Home() - } MainContent( backStack = setupNavigationManager.backStack, navigationManager = navigationManager, appPreferences = appPreferences, backdropService = backdropService, screensaverService = screensaverService, - requestedDestination = requestedDestination, modifier = Modifier.fillMaxSize(), ) } @@ -287,6 +306,8 @@ class MainActivity : AppCompatActivity() { override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) Timber.d("onSaveInstanceState") + val str = json.encodeToString(navigationManager.backStack.toList()) + outState.putString(KEY_BACK_STACK, str) } override fun onRestoreInstanceState(savedInstanceState: Bundle) { @@ -362,6 +383,8 @@ class MainActivity : AppCompatActivity() { const val INTENT_SEASON_NUMBER = "seaNum" const val INTENT_SEASON_ID = "seaId" + private const val KEY_BACK_STACK = "backStack" + lateinit var instance: MainActivity private set } diff --git a/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt b/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt index 5bade867..0b32b9ed 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt @@ -33,10 +33,8 @@ import com.github.damontecres.wholphin.services.ScreensaverService import com.github.damontecres.wholphin.services.SetupDestination import com.github.damontecres.wholphin.ui.components.AppScreensaver import com.github.damontecres.wholphin.ui.nav.ApplicationContent -import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.setup.SwitchServerContent import com.github.damontecres.wholphin.ui.setup.SwitchUserContent -import com.github.damontecres.wholphin.ui.util.ProvideLocalClock @Composable fun MainContent( @@ -45,7 +43,6 @@ fun MainContent( appPreferences: AppPreferences, backdropService: BackdropService, screensaverService: ScreensaverService, - requestedDestination: Destination, modifier: Modifier = Modifier, ) { Surface( @@ -113,7 +110,6 @@ fun MainContent( ApplicationContent( user = current.user, server = current.server, - startDestination = requestedDestination, navigationManager = navigationManager, preferences = preferences, modifier = Modifier.fillMaxSize(), diff --git a/app/src/main/java/com/github/damontecres/wholphin/WholphinDreamService.kt b/app/src/main/java/com/github/damontecres/wholphin/WholphinDreamService.kt index a4c09ad4..1a350905 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/WholphinDreamService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/WholphinDreamService.kt @@ -22,9 +22,7 @@ import androidx.savedstate.SavedStateRegistryOwner import androidx.savedstate.setViewTreeSavedStateRegistryOwner import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.preferences.AppPreferences -import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.ScreensaverService -import com.github.damontecres.wholphin.services.UserPreferencesService import com.github.damontecres.wholphin.ui.components.AppScreensaverContent import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.theme.WholphinTheme @@ -33,6 +31,7 @@ import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.first import org.jellyfin.sdk.model.serializer.toUUIDOrNull +import timber.log.Timber import javax.inject.Inject import kotlin.time.Duration.Companion.milliseconds @@ -61,6 +60,7 @@ class WholphinDreamService : override fun onCreate() { super.onCreate() + Timber.d("onCreate") savedStateRegistryController.performRestore(null) lifecycleRegistry.currentState = Lifecycle.State.CREATED @@ -74,6 +74,7 @@ class WholphinDreamService : override fun onAttachedToWindow() { super.onAttachedToWindow() + Timber.d("onAttachedToWindow") val itemFlow = screensaverService.createItemFlow(lifecycleScope) setContentView( ComposeView(this).apply { @@ -109,11 +110,13 @@ class WholphinDreamService : override fun onDreamingStarted() { super.onDreamingStarted() + Timber.d("onDreamingStarted") lifecycleRegistry.currentState = Lifecycle.State.STARTED } override fun onDreamingStopped() { super.onDreamingStopped() + Timber.d("onDreamingStopped") lifecycleRegistry.currentState = Lifecycle.State.DESTROYED } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/NavigationManager.kt b/app/src/main/java/com/github/damontecres/wholphin/services/NavigationManager.kt index a1754298..1706ba52 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/NavigationManager.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/NavigationManager.kt @@ -1,6 +1,5 @@ package com.github.damontecres.wholphin.services -import androidx.navigation3.runtime.NavKey import com.github.damontecres.wholphin.ui.nav.Destination import org.acra.ACRA import timber.log.Timber @@ -14,7 +13,7 @@ import javax.inject.Singleton class NavigationManager @Inject constructor() { - var backStack: MutableList = mutableListOf() + var backStack: MutableList = mutableListOf() /** * Go to the specified [com.github.damontecres.wholphin.ui.nav.Destination] diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt index 5f01f20d..0eafa0cc 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt @@ -9,7 +9,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.saveable.rememberSerializable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawBehind @@ -28,12 +27,8 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator -import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavEntry -import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator -import androidx.navigation3.runtime.serialization.NavBackStackSerializer -import androidx.navigation3.runtime.serialization.NavKeySerializer import androidx.navigation3.ui.NavDisplay import androidx.tv.material3.DrawerValue import androidx.tv.material3.MaterialTheme @@ -78,22 +73,12 @@ class ApplicationContentViewModel fun ApplicationContent( server: JellyfinServer, user: JellyfinUser, - startDestination: Destination, navigationManager: NavigationManager, preferences: UserPreferences, modifier: Modifier = Modifier, enableTopScrim: Boolean = true, viewModel: ApplicationContentViewModel = hiltViewModel(), ) { - val backStack: MutableList = - rememberSerializable( - server, - user, - serializer = NavBackStackSerializer(elementSerializer = NavKeySerializer()), - ) { - NavBackStack(startDestination) - } - navigationManager.backStack = backStack val backdrop by viewModel.backdropService.backdropFlow.collectAsStateWithLifecycle() val backdropStyle = preferences.appPreferences.interfacePreferences.backdropStyle val drawerState = rememberDrawerState(DrawerValue.Closed) From dac0b7208c65d63170b10ee411b87fa1db074c09 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Fri, 20 Mar 2026 12:07:28 -0400 Subject: [PATCH 03/15] Refresh seasons on series details page (#1123) ## Description Simply fetches the tv show's seasons when returning the details page. This ensures the unwatched counts are updated. ### Related issues Mentioned as a bug in https://github.com/damontecres/Wholphin/issues/767#issuecomment-4071852502 ### Testing Emulator ## Screenshots N/A ## AI or LLM usage None --- .../wholphin/ui/detail/series/SeriesDetails.kt | 12 +++++++++--- .../ui/detail/series/SeriesViewModel.kt | 18 +++++++++++++++--- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt index 4f8e4366..aea22bf1 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt @@ -132,6 +132,14 @@ fun SeriesDetails( var showPlaylistDialog by remember { mutableStateOf>(Optional.absent()) } var showDeleteDialog by remember { mutableStateOf(null) } + LifecycleResumeEffect(destination.itemId) { + viewModel.refresh() + + onPauseOrDispose { + viewModel.release() + } + } + when (val state = loading) { is LoadingState.Error -> { ErrorMessage(state, modifier) @@ -148,9 +156,7 @@ fun SeriesDetails( LifecycleResumeEffect(destination.itemId) { viewModel.onResumePage() - onPauseOrDispose { - viewModel.release() - } + onPauseOrDispose {} } val played = item.data.userData?.played ?: false diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt index 2d044670..cfc43be9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt @@ -271,9 +271,9 @@ class SeriesViewModel } fun onResumePage() { - viewModelScope.launchIO { - item.value?.let { - backdropService.submit(it) + item.value?.let { item -> + viewModelScope.launchDefault { backdropService.submit(item) } + viewModelScope.launchIO { val playThemeSongs = userPreferencesService .getCurrent() @@ -283,6 +283,17 @@ class SeriesViewModel } } + fun refresh() { + item.value?.let { item -> + if (loading.value == LoadingState.Success) { + viewModelScope.launchIO { + val seasons = getSeasons(item, null).await() + this@SeriesViewModel.seasons.setValueOnMain(seasons) + } + } + } + } + fun release() { themeSongPlayer.stop() } @@ -292,6 +303,7 @@ class SeriesViewModel seasonNum: Int?, ): Deferred> = viewModelScope.async(Dispatchers.IO) { + Timber.v("getSeasons for %s", series.id) val request = GetItemsRequest( parentId = series.id, From 920733075bf507482bf7cea29d94f55cfde4c30e Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Sat, 21 Mar 2026 19:17:43 -0400 Subject: [PATCH 04/15] Better error handling for errors on home page rows (#1128) ## Description Adds better error handling when loading home page rows. For example, this fixes loading errors if a playlist row is configured, but the playlist is deleted. Also fixes an issue from #1116 where changing users doesn't go the home page. ### Related issues Related to #1124 ### Testing Emulator ## Screenshots N/A ## AI or LLM usage None --- .../wholphin/services/HomeSettingsService.kt | 35 ++++++++----------- .../wholphin/services/NavigationManager.kt | 3 +- .../services/SetupNavigationManager.kt | 7 +++- .../ui/main/settings/HomeSettingsViewModel.kt | 31 ++++++++++------ 4 files changed, 43 insertions(+), 33 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt index b8a850ef..b28dded4 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt @@ -438,10 +438,7 @@ class HomeSettingsService ): HomeRowConfigDisplay = when (config) { is HomeRowConfig.ByParent -> { - val name = - api.userLibraryApi - .getItem(itemId = config.parentId) - .content.name ?: "" + val name = getItemName(config.parentId) ?: "" HomeRowConfigDisplay( id, name, @@ -466,10 +463,7 @@ class HomeSettingsService } is HomeRowConfig.Genres -> { - val name = - api.userLibraryApi - .getItem(itemId = config.parentId) - .content.name ?: "" + val name = getItemName(config.parentId) ?: "" HomeRowConfigDisplay( id, context.getString(R.string.genres_in, name), @@ -490,10 +484,7 @@ class HomeSettingsService } is HomeRowConfig.RecentlyAdded -> { - val name = - api.userLibraryApi - .getItem(itemId = config.parentId) - .content.name ?: "" + val name = getItemName(config.parentId) ?: "" HomeRowConfigDisplay( id, context.getString(R.string.recently_added_in, name), @@ -502,10 +493,7 @@ class HomeSettingsService } is HomeRowConfig.RecentlyReleased -> { - val name = - api.userLibraryApi - .getItem(itemId = config.parentId) - .content.name ?: "" + val name = getItemName(config.parentId) ?: "" HomeRowConfigDisplay( id, context.getString(R.string.recently_released_in, name), @@ -547,10 +535,7 @@ class HomeSettingsService } is HomeRowConfig.Suggestions -> { - val name = - api.userLibraryApi - .getItem(itemId = config.parentId) - .content.name ?: "" + val name = getItemName(config.parentId) ?: "" HomeRowConfigDisplay( id = id, title = context.getString(R.string.suggestions_for, name), @@ -559,6 +544,16 @@ class HomeSettingsService } } + private suspend fun getItemName(itemId: UUID): String? = + try { + api.userLibraryApi + .getItem(itemId = itemId) + .content.name + } catch (ex: Exception) { + Timber.e(ex, "Could not get name for %s", itemId) + context.getString(R.string.unknown) + } + /** * Fetch the data from the server for a given [HomeRowConfig] */ diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/NavigationManager.kt b/app/src/main/java/com/github/damontecres/wholphin/services/NavigationManager.kt index 1706ba52..3208f4ec 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/NavigationManager.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/NavigationManager.kt @@ -1,5 +1,6 @@ package com.github.damontecres.wholphin.services +import androidx.navigation3.runtime.NavBackStack import com.github.damontecres.wholphin.ui.nav.Destination import org.acra.ACRA import timber.log.Timber @@ -13,7 +14,7 @@ import javax.inject.Singleton class NavigationManager @Inject constructor() { - var backStack: MutableList = mutableListOf() + var backStack: MutableList = NavBackStack(Destination.Home()) /** * Go to the specified [com.github.damontecres.wholphin.ui.nav.Destination] diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SetupNavigationManager.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SetupNavigationManager.kt index e4910031..fa42469e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/SetupNavigationManager.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SetupNavigationManager.kt @@ -16,7 +16,9 @@ import javax.inject.Singleton @Singleton class SetupNavigationManager @Inject - constructor() { + constructor( + private val navigationManager: NavigationManager, + ) { var backStack: MutableList = mutableStateListOf(SetupDestination.Loading) /** @@ -25,6 +27,9 @@ class SetupNavigationManager fun navigateTo(destination: SetupDestination) { backStack[0] = destination log() + if (destination !is SetupDestination.AppContent) { + navigationManager.reloadHome() + } } private fun log() { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt index 67b57e28..4d2864aa 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt @@ -122,19 +122,23 @@ class HomeSettingsViewModel state.value .let { state -> state.rows - .map { it.config } .map { row -> viewModelScope.async(Dispatchers.IO) { semaphore.withPermit { - homeSettingsService.fetchDataForRow( - row = row, - scope = viewModelScope, - prefs = prefs, - userDto = userDto, - libraries = state.libraries, - limit = limit, - isRefresh = false, - ) + try { + homeSettingsService.fetchDataForRow( + row = row.config, + scope = viewModelScope, + prefs = prefs, + userDto = userDto, + libraries = state.libraries, + limit = limit, + isRefresh = false, + ) + } catch (ex: Exception) { + Timber.e(ex, "Error on row %s", row) + HomeRowLoadingState.Error(row.title, exception = ex) + } } } } @@ -195,7 +199,12 @@ class HomeSettingsViewModel viewModelScope.launchIO { updateState { val rows = it.rows.toMutableList().apply { removeAt(index) } - val rowData = it.rowData.toMutableList().apply { removeAt(index) } + val rowData = + if (index in it.rowData.indices) { + it.rowData.toMutableList().apply { removeAt(index) } + } else { + it.rowData + } it.copy( rows = rows, rowData = rowData, From 8a37d63d092bd4262e2392aa1a2f612c2ffd9373 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Mon, 23 Mar 2026 18:04:11 -0400 Subject: [PATCH 05/15] Better playback overlay appearance animations (#1130) ## Description Updates the playback overlay so that individual component animate in more deliberately. For example, the logo & clock slide in from the top instead of the middle of the screen. And the trick play preview now expands and shrinks vertically. ### Related issues I thought there was an issue about this, but I can't find it ### Testing Emulator, nvidia shield ## Screenshots N/A ## AI or LLM usage None --- .../wholphin/ui/playback/PlaybackOverlay.kt | 97 ++++++++++++------- .../wholphin/ui/playback/PlaybackPage.kt | 72 ++++++-------- 2 files changed, 95 insertions(+), 74 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt index 0886590e..2c536d9f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt @@ -1,10 +1,17 @@ package com.github.damontecres.wholphin.ui.playback import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.VisibilityThreshold import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.spring +import androidx.compose.animation.expandVertically import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.slideIn import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOut import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.background import androidx.compose.foundation.focusable @@ -50,6 +57,9 @@ import androidx.compose.ui.input.key.type import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.media3.common.Player @@ -119,17 +129,14 @@ fun PlaybackOverlay( var seekProgressMs by remember(seekBarFocused) { mutableLongStateOf(playerControls.currentPosition) } var seekProgressPercent = (seekProgressMs.toDouble() / playerControls.duration).toFloat() - val chapterInteractionSources = - remember(chapters.size) { List(chapters.size) { MutableInteractionSource() } } - val density = LocalDensity.current val titleHeight = - remember { + remember(item?.title) { if (item?.title.isNotNullOrBlank()) with(density) { titleTextSize.toDp() } else 0.dp } val subtitleHeight = - remember { + remember(item?.subtitleLong) { if (item?.subtitleLong.isNotNullOrBlank()) with(density) { subtitleTextSize.toDp() } else 0.dp } @@ -137,19 +144,6 @@ fun PlaybackOverlay( var controllerHeight by remember { mutableStateOf(0.dp) } var state by remember { mutableStateOf(OverlayViewState.CONTROLLER) } - // Background scrim for OSD readability - val scrimBrush = - remember { - Brush.verticalGradient( - colors = - listOf( - Color.Transparent, - Color.Black.copy(alpha = 0.5f), - Color.Black.copy(alpha = 0.80f), - ), - ) - } - Box( modifier = modifier, contentAlignment = Alignment.BottomCenter, @@ -160,6 +154,18 @@ fun PlaybackOverlay( exit = fadeOut(), modifier = Modifier.matchParentSize(), ) { + // Background scrim for OSD readability + val scrimBrush = + remember { + Brush.verticalGradient( + colors = + listOf( + Color.Transparent, + Color.Black.copy(alpha = 0.5f), + Color.Black.copy(alpha = 0.80f), + ), + ) + } Box( modifier = Modifier @@ -169,10 +175,16 @@ fun PlaybackOverlay( } AnimatedVisibility( - state == OverlayViewState.CONTROLLER, - enter = slideInVertically() + fadeIn(), - exit = slideOutVertically() + fadeOut(), + visible = controllerViewState.controlsVisible && state == OverlayViewState.CONTROLLER, + enter = slideInVertically { it / 2 } + fadeIn(), + exit = slideOutVertically { it / 2 } + fadeOut(), ) { + if (seekBarFocused) { + LaunchedEffect(Unit) { + seekProgressPercent = + (playerControls.currentPosition.toFloat() / playerControls.duration) + } + } val nextState = if (chapters.isNotEmpty()) { OverlayViewState.CHAPTERS @@ -253,11 +265,13 @@ fun PlaybackOverlay( } } AnimatedVisibility( - state == OverlayViewState.CHAPTERS, + visible = controllerViewState.controlsVisible && state == OverlayViewState.CHAPTERS, enter = slideInVertically { it / 2 } + fadeIn(), exit = slideOutVertically { it / 2 } + fadeOut(), ) { if (chapters.isNotEmpty()) { + val chapterInteractionSources = + remember(chapters.size) { List(chapters.size) { MutableInteractionSource() } } val bringIntoViewRequester = remember { BringIntoViewRequester() } val chapterIndex = remember { @@ -368,7 +382,7 @@ fun PlaybackOverlay( } } AnimatedVisibility( - state == OverlayViewState.QUEUE, + visible = controllerViewState.controlsVisible && state == OverlayViewState.QUEUE, enter = slideInVertically { it / 2 } + fadeIn(), exit = slideOutVertically { it / 2 } + fadeOut(), ) { @@ -441,15 +455,17 @@ fun PlaybackOverlay( } } - if (seekBarInteractionSource.collectIsFocusedAsState().value) { - LaunchedEffect(Unit) { - seekProgressPercent = - (playerControls.currentPosition.toFloat() / playerControls.duration) - } - } - + // Trickplay AnimatedVisibility( - seekProgressPercent >= 0 && seekBarFocused, + visible = controllerViewState.controlsVisible && seekProgressPercent >= 0 && seekBarFocused, + enter = + expandVertically( + spring( + stiffness = Spring.StiffnessMedium, + visibilityThreshold = IntSize.VisibilityThreshold, + ), + ) + fadeIn(), + exit = shrinkVertically() + fadeOut(), ) { Box( modifier = @@ -496,9 +512,13 @@ fun PlaybackOverlay( } } } + + // Top val logoImageUrl = LocalImageUrlService.current.rememberImageUrl(item, ImageType.LOGO) AnimatedVisibility( - !showDebugInfo && logoImageUrl.isNotNullOrBlank() && controllerViewState.controlsVisible, + visible = !showDebugInfo && logoImageUrl.isNotNullOrBlank() && controllerViewState.controlsVisible, + enter = slideIn { IntOffset(x = -it.width / 2, y = -it.height / 2) } + fadeIn(), + exit = slideOut { IntOffset(x = -it.width / 2, y = -it.height / 2) } + fadeOut(), modifier = Modifier .align(Alignment.TopStart), @@ -514,7 +534,9 @@ fun PlaybackOverlay( ) } AnimatedVisibility( - !showDebugInfo && showClock && controllerViewState.controlsVisible, + visible = !showDebugInfo && showClock && controllerViewState.controlsVisible, + enter = slideIn { IntOffset(x = it.width / 2, y = -it.height / 2) } + fadeIn(), + exit = slideOut { IntOffset(x = it.width / 2, y = -it.height / 2) } + fadeOut(), modifier = Modifier .align(Alignment.TopEnd), @@ -522,7 +544,9 @@ fun PlaybackOverlay( TimeDisplay() } AnimatedVisibility( - showDebugInfo && controllerViewState.controlsVisible, + visible = showDebugInfo && controllerViewState.controlsVisible, + enter = slideInVertically() + fadeIn(), + exit = slideOutVertically() + fadeOut(), modifier = Modifier .align(Alignment.TopStart), @@ -578,6 +602,11 @@ fun Controller( val verticalOffset by animateDpAsState( targetValue = if (seekBarFocused) (-32).dp else 0.dp, label = "TitleBumpOffset", + animationSpec = + spring( + stiffness = Spring.StiffnessMediumLow, + visibilityThreshold = Dp.VisibilityThreshold, + ), ) Column( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt index 8a6b488f..352c60dd 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt @@ -5,8 +5,6 @@ import androidx.annotation.Dimension import androidx.annotation.OptIn import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.animateFloatAsState -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.Box @@ -364,44 +362,38 @@ fun PlaybackPageContent( } // The playback controls - AnimatedVisibility( - controllerViewState.controlsVisible, - Modifier, - slideInVertically { it }, - slideOutVertically { it }, - ) { - PlaybackOverlay( - modifier = - Modifier - .padding(WindowInsets.systemBars.asPaddingValues()) - .fillMaxSize() - .background(Color.Transparent), - item = currentPlayback?.item, - playerControls = player, - controllerViewState = controllerViewState, - showPlay = playPauseState.showPlay, - previousEnabled = true, - nextEnabled = playlist.hasNext(), - seekEnabled = true, - seekForward = preferences.appPreferences.playbackPreferences.skipForwardMs.milliseconds, - seekBack = preferences.appPreferences.playbackPreferences.skipBackMs.milliseconds, - skipBackOnResume = preferences.appPreferences.playbackPreferences.skipBackOnResume, - onPlaybackActionClick = onPlaybackActionClick, - onClickPlaybackDialogType = { playbackDialog = it }, - onSeekBarChange = seekBarState::onValueChange, - showDebugInfo = showDebugInfo, - currentPlayback = currentPlayback, - chapters = mediaInfo?.chapters ?: listOf(), - trickplayInfo = mediaInfo?.trickPlayInfo, - trickplayUrlFor = viewModel::getTrickplayUrl, - playlist = playlist, - onClickPlaylist = { - viewModel.playItemInPlaylist(it) - }, - currentSegment = currentSegment?.segment, - showClock = preferences.appPreferences.interfacePreferences.showClock, - ) - } + + PlaybackOverlay( + modifier = + Modifier + .padding(WindowInsets.systemBars.asPaddingValues()) + .fillMaxSize() + .background(Color.Transparent), + item = currentPlayback?.item, + playerControls = player, + controllerViewState = controllerViewState, + showPlay = playPauseState.showPlay, + previousEnabled = true, + nextEnabled = playlist.hasNext(), + seekEnabled = true, + seekForward = preferences.appPreferences.playbackPreferences.skipForwardMs.milliseconds, + seekBack = preferences.appPreferences.playbackPreferences.skipBackMs.milliseconds, + skipBackOnResume = preferences.appPreferences.playbackPreferences.skipBackOnResume, + onPlaybackActionClick = onPlaybackActionClick, + onClickPlaybackDialogType = { playbackDialog = it }, + onSeekBarChange = seekBarState::onValueChange, + showDebugInfo = showDebugInfo, + currentPlayback = currentPlayback, + chapters = mediaInfo?.chapters ?: listOf(), + trickplayInfo = mediaInfo?.trickPlayInfo, + trickplayUrlFor = viewModel::getTrickplayUrl, + playlist = playlist, + onClickPlaylist = { + viewModel.playItemInPlaylist(it) + }, + currentSegment = currentSegment?.segment, + showClock = preferences.appPreferences.interfacePreferences.showClock, + ) val subtitleSettings = remember(mediaInfo) { From f2570d4ab03e655aa482849feab7dd1ca24f5a4c Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Tue, 24 Mar 2026 13:42:18 -0400 Subject: [PATCH 06/15] Add support for music (#1059) ## Description Adds support for music playback ### Features - Adds a "Now Playing" page which is accessible from the nav drawer while music is playing - Manage the playback queue, add/remove/re-order songs - Supports synced & non-synced lyrics - Scroll through lyrics - Click on line for synced lyrics to seek to that position ### User interface - Customize what's shown on the "Now Playing" page - Show album cover art, backdrops, and lyrics - Show a basic bar visualizer - Search for albums, artists, & songs - Links between albums, artists, playlists, & music videos when metadata is available - Throughout the app, see which song is playing and which ones are in the queue Note: lyrics are synced by line. `10.11` added support for syncing by word, but Wholphin still supports `10.10` ### Related issues Closes #2 Closes #267 ### Testing Emulator & shield testing ## Screenshots
Click to see screenshots

### Album grid ![music_album_grid Large](https://github.com/user-attachments/assets/4573ad32-0cd7-49db-94db-de37ceb6f641) ### Artist page ![music_artist Large](https://github.com/user-attachments/assets/fdf3b7b9-5c40-434d-867d-7d56e0b3430e) ### Album details ![music_album_details Large](https://github.com/user-attachments/assets/494d1a76-9581-4a07-a8ee-5c0957f542e0) ### Album is playing/queued ![music_album_queued Large](https://github.com/user-attachments/assets/55c72cd2-10a9-411d-8bae-b232150db506) ### Now playing page ![now_playing Large](https://github.com/user-attachments/assets/b773b1bc-71fd-4789-8509-cebfc0d0ffe5) ### Now playing queue ![now_playing_queue Large](https://github.com/user-attachments/assets/db244462-e33d-452a-93dc-dd2123a4862d)

## AI or LLM usage None ## Try it out See instructions at https://github.com/damontecres/Wholphin/releases/tag/develop-music --- .../wholphin/data/model/AudioItem.kt | 43 + .../wholphin/data/model/BaseItem.kt | 4 +- .../preferences/AppPreferencesSerializer.kt | 14 + .../wholphin/services/AppUpgradeHandler.kt | 11 + .../wholphin/services/BackdropService.kt | 2 +- .../wholphin/services/MusicService.kt | 519 ++++++++++++ .../wholphin/services/NavDrawerService.kt | 43 +- .../ui/components/RecommendedContent.kt | 4 +- .../ui/components/RecommendedMusic.kt | 248 ++++++ .../wholphin/ui/data/SortAndDirection.kt | 45 ++ .../ui/detail/CollectionFolderMusic.kt | 245 ++++++ .../wholphin/ui/detail/DetailUtils.kt | 32 + .../wholphin/ui/detail/PlaylistDetails.kt | 588 ++++++++++---- .../ui/detail/music/AlbumDetailsPage.kt | 721 +++++++++++++++++ .../ui/detail/music/ArtistDetailsPage.kt | 750 ++++++++++++++++++ .../wholphin/ui/detail/music/BarVisualizer.kt | 54 ++ .../wholphin/ui/detail/music/LyricsContent.kt | 145 ++++ .../ui/detail/music/MusicExpandableButtons.kt | 95 +++ .../ui/detail/music/MusicPreference.kt | 42 + .../ui/detail/music/MusicViewModel.kt | 142 ++++ .../ui/detail/music/MusicViewOptionsDialog.kt | 133 ++++ .../ui/detail/music/NowPlayingButtons.kt | 320 ++++++++ .../ui/detail/music/NowPlayingOverlay.kt | 229 ++++++ .../ui/detail/music/NowPlayingPage.kt | 480 +++++++++++ .../ui/detail/music/NowPlayingState.kt | 17 + .../ui/detail/music/NowPlayingViewModel.kt | 406 ++++++++++ .../ui/detail/music/SongListDisplay.kt | 237 ++++++ .../wholphin/ui/detail/music/Utils.kt | 269 +++++++ .../wholphin/ui/main/SearchPage.kt | 140 +++- .../ui/main/settings/HomeRowPresets.kt | 23 +- .../main/settings/HomeSettingsFavoriteList.kt | 2 + .../ui/main/settings/HomeSettingsViewModel.kt | 26 +- .../wholphin/ui/nav/ApplicationContent.kt | 166 +--- .../damontecres/wholphin/ui/nav/Backdrop.kt | 205 +++++ .../wholphin/ui/nav/Destination.kt | 3 + .../wholphin/ui/nav/DestinationContent.kt | 36 +- .../damontecres/wholphin/ui/nav/NavDrawer.kt | 62 +- .../wholphin/ui/playback/PlaybackControls.kt | 65 +- .../wholphin/ui/playback/PlaybackViewModel.kt | 8 +- .../ui/preferences/NavDrawerPreference.kt | 8 +- .../damontecres/wholphin/util/BlockingList.kt | 17 + .../damontecres/wholphin/util/Constants.kt | 1 + .../util/TrackActivityPlaybackListener.kt | 140 ++-- app/src/main/proto/WholphinDataStore.proto | 8 + app/src/main/res/values/fa_strings.xml | 3 + app/src/main/res/values/strings.xml | 27 +- 46 files changed, 6342 insertions(+), 436 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/data/model/AudioItem.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMusic.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMusic.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/AlbumDetailsPage.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/ArtistDetailsPage.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/BarVisualizer.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/LyricsContent.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/MusicExpandableButtons.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/MusicPreference.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/MusicViewModel.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/MusicViewOptionsDialog.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingButtons.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingOverlay.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingPage.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingState.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingViewModel.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/SongListDisplay.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/Utils.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/nav/Backdrop.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/AudioItem.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/AudioItem.kt new file mode 100644 index 00000000..49b3c694 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/AudioItem.kt @@ -0,0 +1,43 @@ +package com.github.damontecres.wholphin.data.model + +import androidx.compose.runtime.Stable +import org.jellyfin.sdk.model.extensions.ticks +import java.util.UUID +import kotlin.time.Duration + +@Stable +data class AudioItem( + val key: Long = keyTracker++, + val id: UUID, + val albumId: UUID?, + val artistId: UUID?, + val title: String?, + val albumTitle: String?, + val artistNames: String?, + val runtime: Duration?, + val imageUrl: String?, + val hasLyrics: Boolean, +) { + companion object { + private var keyTracker = 0L + + fun from( + item: BaseItem, + imageUrl: String?, + ): AudioItem = + AudioItem( + id = item.id, + albumId = item.data.albumId, + artistId = + item.data.artistItems + ?.firstOrNull() + ?.id, + title = item.title, + albumTitle = item.data.album, + artistNames = item.data.albumArtist, + runtime = item.data.runTimeTicks?.ticks, + imageUrl = imageUrl, + hasLyrics = item.data.hasLyrics == true, + ) + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt index 3c34851b..7ab11e4b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt @@ -8,6 +8,7 @@ import androidx.compose.ui.text.buildAnnotatedString import com.github.damontecres.wholphin.ui.DateFormatter import com.github.damontecres.wholphin.ui.abbreviateNumber import com.github.damontecres.wholphin.ui.detail.CardGridItem +import com.github.damontecres.wholphin.ui.detail.music.artistsString import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds import com.github.damontecres.wholphin.ui.dot import com.github.damontecres.wholphin.ui.formatDateTime @@ -32,7 +33,7 @@ import kotlin.time.Duration @Stable data class BaseItem( val data: BaseItemDto, - val useSeriesForPrimary: Boolean, + val useSeriesForPrimary: Boolean = false, val imageUrlOverride: String? = null, val destinationOverride: Destination? = null, ) : CardGridItem { @@ -57,6 +58,7 @@ data class BaseItem( when (type) { BaseItemKind.EPISODE -> data.seasonEpisode + " - " + name BaseItemKind.SERIES -> data.seriesProductionYears + BaseItemKind.AUDIO -> listOfNotNull(data.album, artistsString).joinToString(" - ") else -> data.productionYear?.toString() } diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt index bf8ab2a4..f55b4ea9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt @@ -152,6 +152,15 @@ class AppPreferencesSerializer slideshowDuration = AppPreference.SlideshowDuration.defaultValue slideshowPlayVideos = AppPreference.SlideshowPlayVideos.defaultValue }.build() + + musicPreferences = + MusicPreferences + .newBuilder() + .apply { + showBackdrop = true + showLyrics = true + showAlbumArt = true + }.build() }.build() override suspend fun readFrom(input: InputStream): AppPreferences { @@ -220,6 +229,11 @@ inline fun AppPreferences.updateScreensaverPreferences(block: ScreensaverPrefere screensaverPreference = screensaverPreference.toBuilder().apply(block).build() } +inline fun AppPreferences.updateMusicPreferences(block: MusicPreferences.Builder.() -> Unit): AppPreferences = + update { + musicPreferences = musicPreferences.toBuilder().apply(block).build() + } + fun SubtitlePreferences.Builder.resetSubtitles() { fontSize = SubtitleSettings.FontSize.defaultValue.toInt() fontColor = SubtitleSettings.FontColor.defaultValue.toArgb() diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt b/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt index ca720af3..617944fc 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt @@ -16,6 +16,7 @@ import com.github.damontecres.wholphin.preferences.updateHomePagePreferences import com.github.damontecres.wholphin.preferences.updateInterfacePreferences import com.github.damontecres.wholphin.preferences.updateLiveTvPreferences import com.github.damontecres.wholphin.preferences.updateMpvOptions +import com.github.damontecres.wholphin.preferences.updateMusicPreferences import com.github.damontecres.wholphin.preferences.updatePhotoPreferences import com.github.damontecres.wholphin.preferences.updatePlaybackOverrides import com.github.damontecres.wholphin.preferences.updateScreensaverPreferences @@ -280,5 +281,15 @@ class AppUpgradeHandler ) } } + + if (previous.isEqualOrBefore(Version.fromString("0.6.0-0-g0"))) { + appPreferences.updateData { + it.updateMusicPreferences { + showBackdrop = true + showLyrics = true + showAlbumArt = true + } + } + } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/BackdropService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/BackdropService.kt index e0c48cca..832d4137 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/BackdropService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/BackdropService.kt @@ -114,7 +114,7 @@ class BackdropService } } - private suspend fun extractColorsFromBackdrop(imageUrl: String?): ExtractedColors = + suspend fun extractColorsFromBackdrop(imageUrl: String?): ExtractedColors = withContext(Dispatchers.IO) { if (imageUrl.isNullOrBlank()) { return@withContext ExtractedColors.DEFAULT 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..be3ceac7 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt @@ -0,0 +1,519 @@ +package com.github.damontecres.wholphin.services + +import android.content.Context +import androidx.annotation.OptIn +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.remember +import androidx.media3.common.AudioAttributes +import androidx.media3.common.C +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import androidx.media3.common.Timeline +import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.okhttp.OkHttpDataSource +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.source.DefaultMediaSourceFactory +import androidx.media3.session.MediaSession +import com.github.damontecres.wholphin.data.ServerRepository +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.services.hilt.DefaultCoroutineScope +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.seekBack +import com.github.damontecres.wholphin.ui.seekForward +import com.github.damontecres.wholphin.ui.toServerString +import com.github.damontecres.wholphin.util.BlockingList +import com.github.damontecres.wholphin.util.LoadingState +import com.github.damontecres.wholphin.util.PlaybackItemState +import com.github.damontecres.wholphin.util.TrackActivityPlaybackListener +import com.github.damontecres.wholphin.util.profile.Codec +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.instantMixApi +import org.jellyfin.sdk.api.client.extensions.universalAudioApi +import org.jellyfin.sdk.api.sockets.subscribe +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.ImageType +import org.jellyfin.sdk.model.api.PlayMethod +import org.jellyfin.sdk.model.api.PlaystateCommand +import org.jellyfin.sdk.model.api.PlaystateMessage +import org.jellyfin.sdk.model.extensions.ticks +import org.jellyfin.sdk.model.serializer.toUUID +import org.jellyfin.sdk.model.serializer.toUUIDOrNull +import timber.log.Timber +import java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.time.Duration.Companion.seconds + +@OptIn(UnstableApi::class) +@Singleton +class MusicService + @Inject + constructor( + @param:ApplicationContext private val context: Context, + @param:AuthOkHttpClient private val authOkHttpClient: OkHttpClient, + @param:DefaultCoroutineScope private val defaultScope: CoroutineScope, + private val api: ApiClient, + private val serverRepository: ServerRepository, + private val imageUrlService: ImageUrlService, + ) { + private val _state = MutableStateFlow(MusicServiceState.EMPTY) + val state: StateFlow = _state + + val player: Player by lazy { + ExoPlayer + .Builder(context) + .setMediaSourceFactory( + DefaultMediaSourceFactory( + OkHttpDataSource.Factory(authOkHttpClient), + ), + ).build() + .also { + it.setAudioAttributes( + AudioAttributes + .Builder() + .setContentType(C.AUDIO_CONTENT_TYPE_MUSIC) + .build(), + false, + ) + it.addListener(MusicPlayerListener(it, _state)) + } + } + + private val mutex = Mutex() + var mediaSession: MediaSession? = null + private set + private var activityTracker: TrackActivityPlaybackListener? = null + private var websocketJob: Job? = null + + suspend fun start() { + if (mediaSession == null) { + mutex.withLock { + if (mediaSession == null) { + Timber.i("Starting music MediaSession") + mediaSession = MediaSession.Builder(context, player).build() + activityTracker = + TrackActivityPlaybackListener(api, player) { + state.value.currentItemId?.let { itemId -> + PlaybackItemState( + itemId = itemId, + playMethod = PlayMethod.DIRECT_PLAY, +// playSessionId = mediaSession?.id, + ) + } + }.also { player.addListener(it) } + websocketJob = subscribe() + } + } + } + onMain { + player.prepare() + player.play() + } + } + + suspend fun stop() { + mutex.withLock { + Timber.i("Stopping music") + if (mediaSession == null) { + Timber.w("Stopping but no MediaSession") + } + mediaSession?.release() + mediaSession = null + onMain { + websocketJob?.cancel() + websocketJob = null + activityTracker?.let { + it.release() + player.removeListener(it) + } + activityTracker = null + player.stop() + player.setMediaItems(emptyList()) + } + _state.update { + MusicServiceState.EMPTY + } + } + } + + /** + * Fetches instant mix items, replaces the queue, and begins playback + */ + suspend fun startInstantMix(itemId: UUID) = + loading { + val items = + api.instantMixApi + .getInstantMixFromItem( + userId = serverRepository.currentUser.value?.id, + itemId = itemId, + limit = 200, + fields = DefaultItemFields, + ).content.items + .map { BaseItem(it, false) } + setQueue(items, false) + } + + /** + * Replace the queue with the given list and starting playing the song as startIndex as soon as its ready + * + * Fetches each item in a blocking way and adds to the queue + */ + suspend fun setQueue( + items: BlockingList, + startIndex: Int, + shuffled: Boolean, + ) = withContext(Dispatchers.IO) { + Timber.d("setQueue: %s items, startIndex=%s, shuffled=%s", items.size, startIndex, shuffled) + withContext(Dispatchers.Main) { + player.setMediaItems(emptyList()) + player.shuffleModeEnabled = shuffled + } + start() + addAllToQueue(items, startIndex) + } + + suspend fun setQueue( + items: List, + shuffled: Boolean, + ) { + Timber.d("setQueue: %s items, shuffled=%s", items.size, shuffled) + val mediaItems = + items + .filter { it.type == BaseItemKind.AUDIO } + .map(::convert) + withContext(Dispatchers.Main) { + player.setMediaItems(mediaItems) + player.shuffleModeEnabled = shuffled + updateQueueSize() + start() + } + } + + suspend fun addToQueue( + item: BaseItem, + index: Int? = null, + ) { + if (item.type == BaseItemKind.AUDIO) { + val mediaItem = convert(item) + withContext(Dispatchers.Main) { + if (index != null) { + player.addMediaItem(index, mediaItem) + } else { + player.addMediaItem(mediaItem) + } + updateQueueSize() + if (player.mediaItemCount == 1) { + // Start playing if this was the first time added + start() + } + } + } + } + + suspend fun addAllToQueue( + list: BlockingList, + startIndex: Int, + ) = loading { + var remaining = startIndex + list.indices + .chunked(25) + .forEach { + val mediaItems = + it.mapNotNull { + if (remaining == 0) { + list + .getBlocking(it) + ?.takeIf { it.type == BaseItemKind.AUDIO } + ?.let(::convert) + } else { + Timber.v("Skipping $remaining") + remaining-- + null + } + } + onMain { player.addMediaItems(mediaItems) } + } + updateQueueSize() + } + + private fun convert(audio: BaseItem): MediaItem { + val url = + api.universalAudioApi.getUniversalAudioStreamUrl( + itemId = audio.id, + container = + listOf( + Codec.Audio.OPUS, + Codec.Audio.MP3, + Codec.Audio.AAC, + Codec.Audio.FLAC, + ), + ) + val imageUrl = + audio.data.albumId?.let { albumId -> + imageUrlService.getItemImageUrl( + itemId = albumId, + imageType = ImageType.PRIMARY, + ) + } + return MediaItem + .Builder() + .setUri(url) + .setMediaId(audio.id.toServerString()) + .setTag(AudioItem.from(audio, imageUrl)) + .build() + } + + private suspend fun updateQueueSize() { +// val ids = +// withContext(Dispatchers.Default) { +// (0.. loading(block: suspend () -> T): T { + _state.update { it.copy(loadingState = LoadingState.Loading) } + val result = block.invoke() + _state.update { it.copy(loadingState = LoadingState.Success) } + return result + } + + fun subscribe(): Job = + api.webSocket + .subscribe() + .onEach { message -> + message.data?.let { + withContext(Dispatchers.Main) { + when (it.command) { + PlaystateCommand.STOP -> { + stop() + } + + PlaystateCommand.PAUSE -> { + player.pause() + } + + PlaystateCommand.UNPAUSE -> { + player.play() + } + + PlaystateCommand.NEXT_TRACK -> { + player.seekToNext() + } + + PlaystateCommand.PREVIOUS_TRACK -> { + player.seekToPrevious() + } + + PlaystateCommand.SEEK -> { + it.seekPositionTicks?.ticks?.let { + player.seekTo( + it.inWholeMilliseconds, + ) + } + } + + PlaystateCommand.REWIND -> { + player.seekBack(10.seconds) + } + + PlaystateCommand.FAST_FORWARD -> { + player.seekForward(30.seconds) + } + + PlaystateCommand.PLAY_PAUSE -> { + if (player.isPlaying) player.pause() else player.play() + } + } + } + } + }.catch { ex -> + Timber.e(ex, "Error in websocket subscription") + }.launchIn(defaultScope) + } + +@Stable +data class MusicServiceState( + val queueVersion: Long, + val queueSize: Int, + val currentIndex: Int, + val currentItemId: UUID?, + val status: NowPlayingStatus, + val currentItemTitle: String?, + val loadingState: LoadingState = LoadingState.Pending, + val queuedIds: Set, +) { + companion object { + val EMPTY = + MusicServiceState( + 0L, + 0, + 0, + null, + NowPlayingStatus.IDLE, + null, + LoadingState.Pending, + emptySet(), + ) + } +} + +enum class NowPlayingStatus { + PLAYING, + PAUSED, + IDLE, +} + +/** + * Listens to [Player] events and updates the [StateFlow] + */ +private class MusicPlayerListener( + private val player: Player, + private val state: MutableStateFlow, +) : Player.Listener { + init { + Timber.v("MusicPlayerListener init") + state.update { + it.copy( + queueSize = player.mediaItemCount, + currentIndex = player.currentMediaItemIndex, + status = if (player.isPlaying) NowPlayingStatus.PLAYING else NowPlayingStatus.IDLE, + ) + } + } + + override fun onIsPlayingChanged(isPlaying: Boolean) { + Timber.v("MusicPlayerListener onIsPlayingChanged") + state.update { + it.copy( + status = if (isPlaying) NowPlayingStatus.PLAYING else NowPlayingStatus.PAUSED, + ) + } + } + + override fun onMediaItemTransition( + mediaItem: MediaItem?, + reason: Int, + ) { + Timber.v("MusicPlayerListener onMediaItemTransition") + updateCurrentIndex() + } + + override fun onTimelineChanged( + timeline: Timeline, + reason: Int, + ) { +// Timber.v("MusicPlayerListener onTimelineChanged") + if (reason == Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED) { + updateCurrentIndex() + } + } + + private fun updateCurrentIndex() { + state.update { state -> + player.currentMediaItemIndex.takeIf { it >= 0 }?.let { currentMediaItemIndex -> + if (currentMediaItemIndex in (0.. = + remember(queueVersion, queueSize) { + object : AbstractList() { + override val size: Int + get() = player.mediaItemCount + + override fun get(index: Int): AudioItem = player.getMediaItemAt(index).localConfiguration?.tag as AudioItem + } + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/NavDrawerService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/NavDrawerService.kt index b83f0bd0..8dbf49ee 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/NavDrawerService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/NavDrawerService.kt @@ -7,6 +7,7 @@ import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.data.model.NavPinType import com.github.damontecres.wholphin.services.hilt.DefaultCoroutineScope +import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.main.settings.Library import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.NavDrawerItem @@ -16,9 +17,11 @@ import com.github.damontecres.wholphin.util.supportedCollectionTypes import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach @@ -33,6 +36,7 @@ import timber.log.Timber import java.util.UUID import javax.inject.Inject import javax.inject.Singleton +import kotlin.time.Duration.Companion.hours @Singleton class NavDrawerService @@ -44,6 +48,7 @@ class NavDrawerService private val serverRepository: ServerRepository, private val serverPreferencesDao: ServerPreferencesDao, private val seerrServerRepository: SeerrServerRepository, + private val musicService: MusicService, ) { private val _state = MutableStateFlow(NavDrawerItemState.EMPTY) val state: StateFlow = _state @@ -73,6 +78,40 @@ class NavDrawerService .onEach { discoverActive -> _state.update { it.copy(discoverEnabled = discoverActive) } }.launchIn(coroutineScope) + coroutineScope.launchDefault { + musicService.state.collectLatest { music -> + Timber.v("MusicService updated") + when (music.status) { + NowPlayingStatus.PLAYING -> { + _state.update { + it.copy( + nowPlayingEnabled = true, + nowPlayingTitle = music.currentItemTitle, + ) + } + } + + NowPlayingStatus.IDLE -> { + _state.update { + it.copy( + nowPlayingEnabled = false, + nowPlayingTitle = null, + ) + } + } + + NowPlayingStatus.PAUSED -> { + delay(2.hours) + _state.update { + it.copy( + nowPlayingEnabled = false, + nowPlayingTitle = null, + ) + } + } + } + } + } } suspend fun getAllUserLibraries( @@ -185,9 +224,11 @@ data class NavDrawerItemState( val items: List, val moreItems: List, val discoverEnabled: Boolean, + val nowPlayingEnabled: Boolean, + val nowPlayingTitle: String?, ) { companion object { - val EMPTY = NavDrawerItemState(emptyList(), emptyList(), false) + val EMPTY = NavDrawerItemState(emptyList(), emptyList(), false, false, null) } } 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 c3d1a4b6..ae7edb08 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 @@ -19,6 +19,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.HomeRowViewOptions import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.BackdropService @@ -110,13 +111,14 @@ abstract class RecommendedViewModel( fun update( @StringRes title: Int, + viewOptions: HomeRowViewOptions = HomeRowViewOptions(), block: suspend () -> List, ): Deferred = viewModelScope.async(Dispatchers.IO) { val titleStr = context.getString(title) val row = try { - HomeRowLoadingState.Success(titleStr, block.invoke()) + HomeRowLoadingState.Success(titleStr, block.invoke(), viewOptions) } catch (ex: Exception) { HomeRowLoadingState.Error(titleStr, null, ex) } 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..87cc7c0d --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMusic.kt @@ -0,0 +1,248 @@ +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.data.model.HomeRowViewOptions +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.MediaManagementService +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.AspectRatio +import com.github.damontecres.wholphin.ui.Cards +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.Deferred +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, + mediaManagementService: MediaManagementService, + ) : RecommendedViewModel( + context, + navigationManager, + favoriteWatchManager, + mediaReportService, + backdropService, + mediaManagementService, + ) { + @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() + + val jobs = mutableListOf>() + val viewOptions = + HomeRowViewOptions( + aspectRatio = AspectRatio.SQUARE, + heightDp = Cards.HEIGHT_EPISODE, + showTitles = true, + ) + + update(R.string.recently_released, viewOptions) { + 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) + }.also(jobs::add) + + update(R.string.recently_added, viewOptions) { + 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) + }.also(jobs::add) + + update(R.string.top_unwatched, viewOptions) { + 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) + }.also(jobs::add) + + 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, + ), + ) + } + } + + for (i in 0.. + current.toMutableList().apply { set(rowTitles[title]!!, row) } + } + return 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/data/SortAndDirection.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/data/SortAndDirection.kt index 17f3d0ee..ee4b8b26 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/data/SortAndDirection.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/data/SortAndDirection.kt @@ -99,6 +99,45 @@ val BoxSetSortOptions = ItemSortBy.RANDOM, ) +val AlbumSortOptions = + listOf( + ItemSortBy.SORT_NAME, + ItemSortBy.PREMIERE_DATE, + ItemSortBy.DATE_CREATED, + ItemSortBy.DATE_PLAYED, + ItemSortBy.ALBUM_ARTIST, + ItemSortBy.COMMUNITY_RATING, + ItemSortBy.CRITIC_RATING, + ItemSortBy.RANDOM, + ) + +val ArtistSortOptions = + listOf( + ItemSortBy.SORT_NAME, + ItemSortBy.PREMIERE_DATE, + ItemSortBy.DATE_CREATED, + ItemSortBy.DATE_PLAYED, + ItemSortBy.COMMUNITY_RATING, + ItemSortBy.CRITIC_RATING, + ItemSortBy.RANDOM, + ) + +val SongSortOptions = + listOf( + ItemSortBy.SORT_NAME, + ItemSortBy.PREMIERE_DATE, + ItemSortBy.ALBUM, + ItemSortBy.ALBUM_ARTIST, + ItemSortBy.ARTIST, + ItemSortBy.DATE_CREATED, + ItemSortBy.DATE_PLAYED, + ItemSortBy.COMMUNITY_RATING, + ItemSortBy.CRITIC_RATING, + ItemSortBy.PLAY_COUNT, + ItemSortBy.RUNTIME, + ItemSortBy.RANDOM, + ) + @StringRes fun getStringRes(sort: ItemSortBy): Int = when (sort) { @@ -132,5 +171,11 @@ fun getStringRes(sort: ItemSortBy): Int = ItemSortBy.DEFAULT -> R.string.default_track + ItemSortBy.ALBUM -> R.string.album + + ItemSortBy.ALBUM_ARTIST -> R.string.album_artist + + ItemSortBy.ARTIST -> R.string.artist + else -> throw IllegalArgumentException("Unsupported sort option: $sort") } 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..30534ffc --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMusic.kt @@ -0,0 +1,245 @@ +package com.github.damontecres.wholphin.ui.detail + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically +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.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.model.BaseItem +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.services.MusicService +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.AlbumSortOptions +import com.github.damontecres.wholphin.ui.data.ArtistSortOptions +import com.github.damontecres.wholphin.ui.data.SongSortOptions +import com.github.damontecres.wholphin.ui.launchDefault +import com.github.damontecres.wholphin.ui.logTab +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import org.jellyfin.sdk.model.api.BaseItemKind +import javax.inject.Inject + +@HiltViewModel +class CollectionFolderMusicViewModel + @Inject + constructor( + private val musicService: MusicService, + ) : ViewModel() { + fun play(item: BaseItem) { + if (item.type == BaseItemKind.AUDIO) { + viewModelScope.launchDefault { + musicService.setQueue(listOf(item), false) + } + } + } + } + +@Composable +fun CollectionFolderMusic( + preferences: UserPreferences, + destination: Destination.MediaItem, + modifier: Modifier = Modifier, + viewModel: CollectionFolderMusicViewModel = hiltViewModel(), + 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.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 = expandVertically(), + exit = shrinkVertically(), + ) { + 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 = AlbumSortOptions, + 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 = ArtistSortOptions, + defaultViewOptions = ViewOptionsSquare, + modifier = + Modifier + .fillMaxSize() + .focusRequester(focusRequester), + positionCallback = { columns, position -> + showHeader = position < columns + }, + playEnabled = false, + focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex), + ) + } + + // Genres + 3 -> { + GenreCardGrid( + itemId = destination.itemId, + includeItemTypes = listOf(BaseItemKind.MUSIC_ALBUM), + modifier = + Modifier + .fillMaxSize() + .focusRequester(focusRequester), + ) + } + + // Songs + 4 -> { + CollectionFolderGrid( + preferences = preferences, + onClickItem = { _, item -> + viewModel.play(item) + }, + itemId = destination.itemId, + viewModelKey = "${destination.itemId}_songs", + initialFilter = + CollectionFolderFilter( + filter = + GetItemsFilter( + includeItemTypes = listOf(BaseItemKind.AUDIO), + ), + ), + showTitle = false, + recursive = true, + sortOptions = SongSortOptions, + 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/DetailUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DetailUtils.kt index 5fe13871..b4618eda 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DetailUtils.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DetailUtils.kt @@ -170,6 +170,38 @@ fun buildMoreDialogItems( actions.onClickFavorite.invoke(item.id, !favorite) }, ) + item.data.albumId?.let { albumId -> + add( + DialogItem( + context.getString(R.string.go_to_album), + R.string.fa_compact_disc, + ) { + actions.navigateTo( + Destination.MediaItem( + albumId, + BaseItemKind.MUSIC_ALBUM, + null, + ), + ) + }, + ) + } + item.data.artistItems?.firstOrNull()?.id?.let { artistId -> + add( + DialogItem( + context.getString(R.string.go_to_artist), + R.string.fa_user, + ) { + actions.navigateTo( + Destination.MediaItem( + artistId, + BaseItemKind.MUSIC_ARTIST, + null, + ), + ) + }, + ) + } seriesId?.let { add( DialogItem( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt index 57c8c5d6..9c237d8c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt @@ -1,5 +1,6 @@ package com.github.damontecres.wholphin.ui.detail +import android.content.Context import androidx.compose.foundation.background import androidx.compose.foundation.focusGroup import androidx.compose.foundation.focusable @@ -16,11 +17,11 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowForward import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable @@ -44,8 +45,8 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.DialogProperties import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import androidx.tv.material3.ListItem import androidx.tv.material3.MaterialTheme @@ -60,12 +61,19 @@ import com.github.damontecres.wholphin.data.filter.ItemFilterBy import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.GetItemsFilter import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo +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.MediaManagementService +import com.github.damontecres.wholphin.services.MediaReportService +import com.github.damontecres.wholphin.services.MusicService +import com.github.damontecres.wholphin.services.MusicServiceState import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.ui.DefaultItemFields import com.github.damontecres.wholphin.ui.TimeFormatter import com.github.damontecres.wholphin.ui.cards.ItemCardImage -import com.github.damontecres.wholphin.ui.components.DialogItem +import com.github.damontecres.wholphin.ui.components.BasicDialog +import com.github.damontecres.wholphin.ui.components.ConfirmDeleteDialog import com.github.damontecres.wholphin.ui.components.DialogParams import com.github.damontecres.wholphin.ui.components.DialogPopup import com.github.damontecres.wholphin.ui.components.ErrorMessage @@ -73,156 +81,231 @@ import com.github.damontecres.wholphin.ui.components.ExpandableFaButton import com.github.damontecres.wholphin.ui.components.ExpandablePlayButton import com.github.damontecres.wholphin.ui.components.FilterByButton import com.github.damontecres.wholphin.ui.components.LoadingPage +import com.github.damontecres.wholphin.ui.components.Optional import com.github.damontecres.wholphin.ui.components.OverviewText import com.github.damontecres.wholphin.ui.components.SortByButton +import com.github.damontecres.wholphin.ui.components.TextButton +import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel import com.github.damontecres.wholphin.ui.data.BoxSetSortOptions import com.github.damontecres.wholphin.ui.data.SortAndDirection +import com.github.damontecres.wholphin.ui.detail.music.MusicMoreDialogActions +import com.github.damontecres.wholphin.ui.detail.music.MusicQueueMarker +import com.github.damontecres.wholphin.ui.detail.music.MusicViewModel +import com.github.damontecres.wholphin.ui.detail.music.buildMoreDialogForMusic import com.github.damontecres.wholphin.ui.enableMarquee +import com.github.damontecres.wholphin.ui.equalsNotNull import com.github.damontecres.wholphin.ui.formatDateTime import com.github.damontecres.wholphin.ui.ifElse +import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.roundMinutes -import com.github.damontecres.wholphin.ui.setValueOnMain +import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.util.FilterUtils import com.github.damontecres.wholphin.ui.util.LocalClock 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.LoadingExceptionHandler 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.update import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.ItemSortBy +import org.jellyfin.sdk.model.api.MediaType import org.jellyfin.sdk.model.api.SortOrder import org.jellyfin.sdk.model.api.request.GetItemsRequest import org.jellyfin.sdk.model.extensions.ticks import timber.log.Timber import java.util.UUID -import javax.inject.Inject import kotlin.time.Duration -@HiltViewModel +@HiltViewModel(assistedFactory = PlaylistViewModel.Factory::class) class PlaylistViewModel - @Inject + @AssistedInject constructor( + @ApplicationContext context: Context, api: ApiClient, - val navigationManager: NavigationManager, + navigationManager: NavigationManager, + musicService: MusicService, + mediaManagementService: MediaManagementService, private val backdropService: BackdropService, private val serverRepository: ServerRepository, private val libraryDisplayInfoDao: LibraryDisplayInfoDao, - ) : ItemViewModel(api) { - val loading = MutableLiveData(LoadingState.Pending) - val items = MutableLiveData>(listOf()) - val filterAndSort = - MutableStateFlow( - FilterAndSort( - filter = GetItemsFilter(), - sortAndDirection = - SortAndDirection( + private val favoriteWatchManager: FavoriteWatchManager, + private val mediaReportService: MediaReportService, + @Assisted itemId: UUID, + ) : MusicViewModel(itemId, context, api, musicService, navigationManager, mediaManagementService) { + @AssistedFactory + interface Factory { + fun create(itemId: UUID): PlaylistViewModel + } + + val state = MutableStateFlow(PlaylistDetailsState()) + val musicState = musicService.state + + init { + init() + } + + override fun init() { + state.update { it.copy(loading = LoadingState.Loading) } + viewModelScope.launchDefault { + try { + val playlist = + api.userLibraryApi + .getItem(itemId) + .content + .let { BaseItem(it, false) } + state.update { it.copy(playlist = playlist) } + val libraryDisplayInfo = + serverRepository.currentUser.value?.let { user -> + libraryDisplayInfoDao.getItem(user, itemId) + } + val filter = libraryDisplayInfo?.filter ?: GetItemsFilter() + val sortAndDirection = + libraryDisplayInfo?.sortAndDirection ?: SortAndDirection( ItemSortBy.DEFAULT, SortOrder.ASCENDING, - ), - ), - ) - - fun init(playlistId: UUID) { - loading.value = LoadingState.Loading - viewModelScope.launch( - Dispatchers.IO + - LoadingExceptionHandler(loading, "Failed to fetch playlist $playlistId"), - ) { - val playlist = fetchItem(playlistId) - val libraryDisplayInfo = - serverRepository.currentUser.value?.let { user -> - libraryDisplayInfoDao.getItem(user, itemId) - } - val filter = libraryDisplayInfo?.filter ?: GetItemsFilter() - val sortAndDirection = - libraryDisplayInfo?.sortAndDirection ?: SortAndDirection( - ItemSortBy.DEFAULT, - SortOrder.ASCENDING, - ) - loadItems(filter, sortAndDirection) + ) + loadItems(filter, sortAndDirection).join() + determineMediaType() + } catch (ex: Exception) { + Timber.e(ex, "Error fetching playlist %s", itemId) + state.update { it.copy(loading = LoadingState.Error(ex)) } + } } } fun loadItems( filter: GetItemsFilter, sortAndDirection: SortAndDirection, - ) { - viewModelScope.launchIO { - backdropService.clearBackdrop() - loading.setValueOnMain(LoadingState.Loading) - this@PlaylistViewModel.filterAndSort.update { - FilterAndSort(filter, sortAndDirection) - } + ) = viewModelScope.launchIO { + backdropService.clearBackdrop() + state.update { + it.copy( + loading = LoadingState.Loading, + filterAndSort = FilterAndSort(filter, sortAndDirection), + ) + } - serverRepository.currentUser.value?.let { user -> - val playlistId = item.value!!.id - viewModelScope.launchIO { - val libraryDisplayInfo = - libraryDisplayInfoDao.getItem(user, itemId)?.copy( - filter = filter, + serverRepository.currentUser.value?.let { user -> + viewModelScope.launchIO { + val libraryDisplayInfo = + libraryDisplayInfoDao.getItem(user, itemId)?.copy( + filter = filter, + sort = sortAndDirection.sort, + direction = sortAndDirection.direction, + ) + ?: LibraryDisplayInfo( + userId = user.rowId, + itemId = itemId.toServerString(), sort = sortAndDirection.sort, direction = sortAndDirection.direction, + filter = filter, + viewOptions = null, ) - ?: LibraryDisplayInfo( - userId = user.rowId, - itemId = itemId, - sort = sortAndDirection.sort, - direction = sortAndDirection.direction, - filter = filter, - viewOptions = null, - ) - libraryDisplayInfoDao.saveItem(libraryDisplayInfo) - } + libraryDisplayInfoDao.saveItem(libraryDisplayInfo) + } - val request = - filter.applyTo( - GetItemsRequest( - parentId = playlistId, - userId = user.id, - fields = DefaultItemFields, - sortBy = listOf(sortAndDirection.sort), - sortOrder = listOf(sortAndDirection.direction), - ), + val request = + filter.applyTo( + GetItemsRequest( + parentId = itemId, + userId = user.id, + fields = DefaultItemFields, + sortBy = listOf(sortAndDirection.sort), + sortOrder = listOf(sortAndDirection.direction), + ), + ) + try { + val pager = + ApiRequestPager( + api, + request, + GetItemsRequestHandler, + viewModelScope, + ).init() + state.update { + it.copy( + items = pager, + loading = LoadingState.Success, + ) + } + } catch (ex: Exception) { + Timber.e(ex, "Error fetching playlist %s", itemId) + state.update { + it.copy( + items = emptyList(), + loading = LoadingState.Error(ex), ) - try { - val pager = - ApiRequestPager( - api, - request, - GetItemsRequestHandler, - viewModelScope, - ).init() - - withContext(Dispatchers.Main) { - items.value = pager - loading.value = LoadingState.Success - } - } catch (ex: Exception) { - Timber.e(ex, "Error fetching playlist %s", itemId) - withContext(Dispatchers.Main) { - items.value = listOf() - loading.value = LoadingState.Error(ex) - } } } } } + /** + * This method tries to determine the [MediaType] of a playlist + * + * In theory, the server will set the type, but sometime it doesn't + */ + private suspend fun determineMediaType() { + // Use the type the server says + var mediaType = + state.value.playlist + ?.data + ?.mediaType ?: MediaType.UNKNOWN + mediaType = + if (mediaType == MediaType.UNKNOWN) { + // Otherwise, if a most of the list is one type, we can assume that type + val pager = (state.value.items as? ApiRequestPager<*>) + if (pager != null && pager.size <= 50) { + val types = + (0..<50.coerceAtMost(pager.size)).groupBy { index -> + val pagerItem = pager.getBlocking(index) + when (pagerItem?.type) { + BaseItemKind.AUDIO -> MediaType.AUDIO + + BaseItemKind.VIDEO, + BaseItemKind.EPISODE, + BaseItemKind.MOVIE, + BaseItemKind.BOX_SET, + -> MediaType.VIDEO + + else -> MediaType.UNKNOWN + } + } + if (types.keys.size == 1) { + types.keys.first() + } else { + MediaType.UNKNOWN + } + } else { + MediaType.UNKNOWN + } + } else { + mediaType + } + Timber.d("mediaType=%s", mediaType) + state.update { + it.copy(mediaType = mediaType) + } + } + suspend fun getFilterOptionValues(filterOption: ItemFilterBy<*>): List = FilterUtils.getFilterOptionValues( api, serverRepository.currentUser.value?.id, - itemUuid, + itemId, filterOption, ) @@ -231,6 +314,24 @@ class PlaylistViewModel backdropService.submit(item) } } + + fun setWatched( + itemId: UUID, + played: Boolean, + ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { + favoriteWatchManager.setWatched(itemId, played) + } + + fun setFavorite( + itemId: UUID, + favorite: Boolean, + ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { + favoriteWatchManager.setFavorite(itemId, favorite) + } + + fun sendMediaReport(itemId: UUID) { + viewModelScope.launchDefault { mediaReportService.sendReportFor(itemId) } + } } @Immutable @@ -239,52 +340,111 @@ data class FilterAndSort( val sortAndDirection: SortAndDirection, ) +data class PlaylistDetailsState( + val playlist: BaseItem? = null, + val mediaType: MediaType = MediaType.UNKNOWN, + val items: List = emptyList(), + val filterAndSort: FilterAndSort = + FilterAndSort( + filter = GetItemsFilter(), + sortAndDirection = + SortAndDirection( + ItemSortBy.DEFAULT, + SortOrder.ASCENDING, + ), + ), + val loading: LoadingState = LoadingState.Pending, +) + @Composable fun PlaylistDetails( + preferences: UserPreferences, destination: Destination.MediaItem, modifier: Modifier = Modifier, - viewModel: PlaylistViewModel = hiltViewModel(), + viewModel: PlaylistViewModel = + hiltViewModel( + creationCallback = { it.create(destination.itemId) }, + ), + addToPlaylistViewModel: AddPlaylistViewModel = hiltViewModel(), ) { val context = LocalContext.current - LaunchedEffect(Unit) { - viewModel.init(destination.itemId) - } - val loading by viewModel.loading.observeAsState(LoadingState.Pending) - val playlist by viewModel.item.observeAsState(null) - val items by viewModel.items.observeAsState(listOf()) - val filterAndSort by viewModel.filterAndSort.collectAsState() + val state by viewModel.state.collectAsState() + val musicState by viewModel.musicState.collectAsState() var longClickDialog by remember { mutableStateOf(null) } + var showConfirmTypeDialog by remember { mutableStateOf?>(null) } + var showPlaylistDialog by remember { mutableStateOf>(Optional.absent()) } + val addPlaylistState by addToPlaylistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) - val goToString = stringResource(R.string.go_to) - val playFromHereString = stringResource(R.string.play_from_here) + fun play( + index: Int, + item: BaseItem, + shuffle: Boolean, + mediaTypeOverride: MediaType? = null, + ) { + when (mediaTypeOverride ?: state.mediaType) { + MediaType.VIDEO -> { + viewModel.navigationManager.navigateTo( + Destination.PlaybackList( + itemId = destination.itemId, + startIndex = index, + shuffle = shuffle, + filter = state.filterAndSort.filter, + sortAndDirection = state.filterAndSort.sortAndDirection, + ), + ) + } + + MediaType.AUDIO -> { + viewModel.play(item, index, shuffle) + } + + else -> { + showConfirmTypeDialog = Triple(index, item, shuffle) + } + } + } + var showDeleteDialog by remember { mutableStateOf(null) } + val musicMoreActions = + MusicMoreDialogActions( + onNavigate = { viewModel.navigationManager.navigateTo(it) }, + onClickPlay = { index, item -> play(index, item, false, MediaType.AUDIO) }, + onClickPlayNext = { index, item -> viewModel.playNext(item) }, + onClickAddToQueue = { index, item -> viewModel.addToQueue(item, Int.MAX_VALUE) }, + onClickFavorite = { id, favorite -> viewModel.setFavorite(id, favorite) }, + onClickAddPlaylist = { itemId -> + addToPlaylistViewModel.loadPlaylists(MediaType.AUDIO) + showPlaylistDialog.makePresent(itemId) + }, + onClickRemoveFromQueue = {}, + onClickDelete = { showDeleteDialog = it }, + ) + val moreActions = + MoreDialogActions( + navigateTo = { viewModel.navigationManager.navigateTo(it) }, + onClickWatch = { id, watched -> viewModel.setWatched(id, watched) }, + onClickFavorite = { id, favorite -> viewModel.setFavorite(id, favorite) }, + onClickAddPlaylist = { itemId -> + addToPlaylistViewModel.loadPlaylists(MediaType.VIDEO) + showPlaylistDialog.makePresent(itemId) + }, + onSendMediaInfo = viewModel::sendMediaReport, + onClickDelete = { showDeleteDialog = it }, + ) PlaylistDetailsContent( - loadingState = loading, - playlist = playlist, - items = items, + loadingState = state.loading, + playlist = state.playlist, + items = state.items, + musicState = musicState, onChangeBackdrop = viewModel::updateBackdrop, - onClickIndex = { index, _ -> - viewModel.navigationManager.navigateTo( - Destination.PlaybackList( - itemId = destination.itemId, - startIndex = index, - shuffle = false, - filter = filterAndSort.filter, - sortAndDirection = filterAndSort.sortAndDirection, - ), - ) + onClickIndex = { index, item -> + play(index, item, false) }, onClickPlay = { shuffle -> - viewModel.navigationManager.navigateTo( - Destination.PlaybackList( - itemId = destination.itemId, - startIndex = 0, - shuffle = shuffle, - filter = filterAndSort.filter, - sortAndDirection = filterAndSort.sortAndDirection, - ), - ) + state.playlist?.let { + play(0, it, shuffle) + } }, onLongClickIndex = { index, item -> longClickDialog = @@ -292,31 +452,30 @@ fun PlaylistDetails( fromLongClick = true, title = item.name ?: "", items = - listOf( - DialogItem( - goToString, - Icons.Default.ArrowForward, - ) { - viewModel.navigationManager.navigateTo(item.destination()) - }, - DialogItem( - playFromHereString, - Icons.Default.PlayArrow, - ) { - viewModel.navigationManager.navigateTo( - Destination.PlaybackList( - itemId = destination.itemId, - startIndex = index, - shuffle = false, - filter = filterAndSort.filter, - sortAndDirection = filterAndSort.sortAndDirection, - ), - ) - }, - ), + if (item.type == BaseItemKind.AUDIO) { + buildMoreDialogForMusic( + context = context, + actions = musicMoreActions, + item = item, + index = index, + canRemove = false, + canDelete = viewModel.canDelete(item, preferences.appPreferences), + ) + } else { + buildMoreDialogItemsForHome( + context = context, + item = item, + seriesId = item.data.seriesId, + playbackPosition = item.playbackPosition, + watched = item.played, + favorite = item.favorite, + canDelete = viewModel.canDelete(item, preferences.appPreferences), + actions = moreActions, + ) + }, ) }, - filterAndSort = filterAndSort, + filterAndSort = state.filterAndSort, onFilterAndSortChange = viewModel::loadItems, getPossibleFilterValues = viewModel::getFilterOptionValues, modifier = modifier, @@ -327,12 +486,46 @@ fun PlaylistDetails( onDismissRequest = { longClickDialog = null }, ) } + showConfirmTypeDialog?.let { (index, item, shuffle) -> + ConfirmMediaTypeDialog( + onConfirm = { mediaType -> play(index, item, shuffle, mediaType) }, + onCancel = { showConfirmTypeDialog = null }, + ) + } + showPlaylistDialog.compose { itemId -> + PlaylistDialog( + title = stringResource(R.string.add_to_playlist), + state = addPlaylistState, + onDismissRequest = { showPlaylistDialog.makeAbsent() }, + onClick = { + addToPlaylistViewModel.addToPlaylist(it.id, itemId) + showPlaylistDialog.makeAbsent() + }, + createEnabled = true, + onCreatePlaylist = { + addToPlaylistViewModel.createPlaylistAndAddItem(it, itemId) + showPlaylistDialog.makeAbsent() + }, + elevation = 3.dp, + ) + } + showDeleteDialog?.let { item -> + ConfirmDeleteDialog( + itemTitle = item.title ?: "", + onCancel = { showDeleteDialog = null }, + onConfirm = { + viewModel.deleteItem(item) + showDeleteDialog = null + }, + ) + } } @Composable fun PlaylistDetailsContent( playlist: BaseItem?, items: List, + musicState: MusicServiceState, onClickIndex: (Int, BaseItem) -> Unit, onLongClickIndex: (Int, BaseItem) -> Unit, onClickPlay: (shuffle: Boolean) -> Unit, @@ -449,10 +642,18 @@ fun PlaylistDetailsContent( onLongClickIndex.invoke(index, item) } }, + isPlaying = + equalsNotNull( + musicState.currentItemId, + item?.id, + ), + isQueued = item?.id in musicState.queuedIds, modifier = Modifier - .height(80.dp) .ifElse( + item?.type != BaseItemKind.AUDIO, + Modifier.height(80.dp), + ).ifElse( index == savedIndex, Modifier.focusRequester(focus), ).onFocusChanged { @@ -582,6 +783,8 @@ fun PlaylistItem( onLongClick: () -> Unit, modifier: Modifier = Modifier, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + isPlaying: Boolean = false, + isQueued: Boolean = false, ) { val focused by interactionSource.collectIsFocusedAsState() ListItem( @@ -592,14 +795,12 @@ fun PlaylistItem( headlineContent = { Text( text = item?.title ?: "", - style = MaterialTheme.typography.titleLarge, modifier = Modifier.enableMarquee(focused), ) }, supportingContent = { Text( text = item?.subtitle ?: "", - style = MaterialTheme.typography.titleSmall, modifier = Modifier.enableMarquee(focused), ) }, @@ -615,10 +816,12 @@ fun PlaylistItem( Text( text = duration.toString(), ) - Text( - text = stringResource(R.string.ends_at, endTimeStr), - style = MaterialTheme.typography.bodySmall, - ) + if (item.type != BaseItemKind.AUDIO) { + Text( + text = stringResource(R.string.ends_at, endTimeStr), + style = MaterialTheme.typography.bodySmall, + ) + } } } }, @@ -631,20 +834,71 @@ fun PlaylistItem( text = "${index + 1}.", style = MaterialTheme.typography.labelLarge, ) - ItemCardImage( - item = item, - name = item?.name, - showOverlay = true, - favorite = item?.data?.userData?.isFavorite ?: false, - watched = item?.data?.userData?.played ?: false, - unwatchedCount = item?.data?.userData?.unplayedItemCount ?: -1, - watchedPercent = 0.0, - numberOfVersions = item?.data?.mediaSourceCount ?: 0, - modifier = Modifier.width(160.dp), - useFallbackText = false, - ) + if (item?.type == BaseItemKind.AUDIO) { + MusicQueueMarker( + isPlaying = isPlaying, + isQueued = isQueued, + ) + } else { + ItemCardImage( + item = item, + name = item?.name, + showOverlay = true, + favorite = item?.data?.userData?.isFavorite ?: false, + watched = item?.data?.userData?.played ?: false, + unwatchedCount = item?.data?.userData?.unplayedItemCount ?: -1, + watchedPercent = 0.0, + numberOfVersions = item?.data?.mediaSourceCount ?: 0, + modifier = Modifier.width(160.dp), + useFallbackText = false, + ) + } } }, modifier = modifier, ) } + +@Composable +fun ConfirmMediaTypeDialog( + onConfirm: (MediaType) -> Unit, + onCancel: () -> Unit, +) { + BasicDialog( + onDismissRequest = onCancel, + properties = DialogProperties(), + elevation = 3.dp, + ) { + LazyColumn( + verticalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues(16.dp), + modifier = Modifier.wrapContentSize(), + ) { + item { + Text( + text = stringResource(R.string.play_as_type), + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.titleLarge, + textAlign = TextAlign.Center, + modifier = Modifier.fillParentMaxWidth(), + ) + } + + item { + Row( + horizontalArrangement = Arrangement.SpaceEvenly, + modifier = Modifier.fillMaxWidth(), + ) { + TextButton( + stringRes = R.string.audio, + onClick = { onConfirm.invoke(MediaType.AUDIO) }, + ) + TextButton( + stringRes = R.string.video, + onClick = { onConfirm.invoke(MediaType.VIDEO) }, + ) + } + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/AlbumDetailsPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/AlbumDetailsPage.kt new file mode 100644 index 00000000..dfd0aa0b --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/AlbumDetailsPage.kt @@ -0,0 +1,721 @@ +package com.github.damontecres.wholphin.ui.detail.music + +import android.content.Context +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +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.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +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 +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +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.platform.LocalFocusManager +import androidx.compose.ui.res.stringResource +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.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.ServerRepository +import com.github.damontecres.wholphin.data.model.BaseItem +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.ImageUrlService +import com.github.damontecres.wholphin.services.MediaManagementService +import com.github.damontecres.wholphin.services.MediaReportService +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.AspectRatios +import com.github.damontecres.wholphin.ui.DefaultItemFields +import com.github.damontecres.wholphin.ui.SlimItemFields +import com.github.damontecres.wholphin.ui.cards.BannerCardWithTitle +import com.github.damontecres.wholphin.ui.cards.ItemRow +import com.github.damontecres.wholphin.ui.components.ConfirmDeleteDialog +import com.github.damontecres.wholphin.ui.components.DialogParams +import com.github.damontecres.wholphin.ui.components.DialogPopup +import com.github.damontecres.wholphin.ui.components.ErrorMessage +import com.github.damontecres.wholphin.ui.components.GenreText +import com.github.damontecres.wholphin.ui.components.LoadingPage +import com.github.damontecres.wholphin.ui.components.Optional +import com.github.damontecres.wholphin.ui.components.OverviewText +import com.github.damontecres.wholphin.ui.components.QuickDetails +import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel +import com.github.damontecres.wholphin.ui.data.RowColumn +import com.github.damontecres.wholphin.ui.detail.PlaylistDialog +import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState +import com.github.damontecres.wholphin.ui.ifElse +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.rememberPosition +import com.github.damontecres.wholphin.ui.toBaseItems +import com.github.damontecres.wholphin.ui.tryRequestFocus +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.Dispatchers +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.itemsApi +import org.jellyfin.sdk.api.client.extensions.libraryApi +import org.jellyfin.sdk.api.client.extensions.userLibraryApi +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.ImageType +import org.jellyfin.sdk.model.api.MediaType +import org.jellyfin.sdk.model.api.request.GetItemsRequest +import org.jellyfin.sdk.model.api.request.GetSimilarItemsRequest +import java.util.UUID + +@HiltViewModel(assistedFactory = AlbumViewModel.Factory::class) +class AlbumViewModel + @AssistedInject + constructor( + @ApplicationContext context: Context, + api: ApiClient, + musicService: MusicService, + navigationManager: NavigationManager, + mediaManagementService: MediaManagementService, + val serverRepository: ServerRepository, + val mediaReportService: MediaReportService, + private val favoriteWatchManager: FavoriteWatchManager, + private val userPreferencesService: UserPreferencesService, + private val backdropService: BackdropService, + private val imageUrlService: ImageUrlService, + @Assisted itemId: UUID, + ) : MusicViewModel(itemId, context, api, musicService, navigationManager, mediaManagementService) { + @AssistedFactory + interface Factory { + fun create(itemId: UUID): AlbumViewModel + } + + private val _state = MutableStateFlow(AlbumState.EMPTY) + val state: StateFlow = _state + + val currentMusic = musicService.state + + init { + init() + } + + override fun init() { + viewModelScope.launchIO { + try { + val itemDeferred = + async { + api.userLibraryApi + .getItem(itemId = itemId) + .content + .let { BaseItem(it, false) } + } + val songsDeferred = async { getPagerForAlbum(api, itemId) } + val album = itemDeferred.await() + val songs = songsDeferred.await() + val imageUrl = imageUrlService.getItemImageUrl(album, ImageType.PRIMARY) + val allArtists = + album.data.artists.orEmpty() + + album.data.albumArtists + ?.map { it.name } + .orEmpty() + val isVariousArtists = + allArtists.firstOrNull { it?.lowercase() == "various artists" } != null || + album.data.artists + .orEmpty() + .size > 1 + _state.update { + it.copy( + album = album, + isVariousArtists = isVariousArtists, + imageUrl = imageUrl, + songs = songs, + loading = LoadingState.Success, + ) + } + updateBackDrop() + if (state.value.similar.isEmpty()) { + viewModelScope.launchIO { + val similar = + api.libraryApi + .getSimilarItems( + GetSimilarItemsRequest( + userId = serverRepository.currentUser.value?.id, + itemId = itemId, + excludeArtistIds = album.data.albumArtists?.map { it.id }, + fields = SlimItemFields, + limit = 25, + ), + ).content.items + .map { BaseItem.from(it, api) } + _state.update { it.copy(similar = similar) } + } + } + viewModelScope.launchIO { + val request = + GetItemsRequest( + userId = serverRepository.currentUser.value?.id, + albumIds = listOf(itemId), + parentId = null, + fields = DefaultItemFields, + recursive = true, + includeItemTypes = listOf(BaseItemKind.MUSIC_VIDEO), + ) + val musicVideos = + GetItemsRequestHandler.execute(api, request).toBaseItems(api, false) + if (musicVideos.isNotEmpty()) { + _state.update { + it.copy(musicVideos = musicVideos) + } + } + } + } catch (ex: Exception) { + _state.update { it.copy(loading = LoadingState.Error(ex)) } + } + } + } + + fun updateBackDrop() { + state.value.album?.let { album -> + viewModelScope.launchDefault { + getBackdropItemForAlbum(api, album)?.let { + backdropService.submit(it) + } + } + } + } + + fun setFavorite( + itemId: UUID, + favorite: Boolean, + ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { + favoriteWatchManager.setFavorite(itemId, favorite) + val album = + api.userLibraryApi + .getItem(itemId = itemId) + .content + .let { BaseItem(it, false) } + _state.update { it.copy(album = album) } + } + + fun play( + shuffled: Boolean, + startIndex: Int = 0, + ) { + val songs = state.value.songs as ApiRequestPager<*> + play(songs, startIndex, shuffled) + } + } + +suspend fun getBackdropItemForAlbum( + api: ApiClient, + album: BaseItem, +): BaseItem? { + if (album.data.backdropImageTags?.isNotEmpty() == true) { + return album + } else { + val artistIds = + album.data.albumArtists + ?.shuffled() + ?.take(50) + ?.map { it.id } + return api.itemsApi + .getItems( + ids = artistIds, + imageTypes = listOf(ImageType.BACKDROP), + ).content.items + .firstOrNull() + ?.let { BaseItem(it, false) } + } +} + +data class AlbumState( + val album: BaseItem?, + val isVariousArtists: Boolean, + val imageUrl: String?, + val songs: List, + val similar: List, + val loading: LoadingState, + val musicVideos: List = emptyList(), +) { + companion object { + val EMPTY = AlbumState(null, false, null, emptyList(), emptyList(), LoadingState.Pending) + } +} + +private const val HEADER_ROW = 0 +private const val SONG_ROW = HEADER_ROW + 1 +private const val MUSIC_VIDEO_ROW = SONG_ROW + 1 +private const val SIMILAR_ROW = MUSIC_VIDEO_ROW + 1 + +@Composable +fun AlbumDetailsPage( + itemId: UUID, + preferences: UserPreferences, + modifier: Modifier = Modifier, + viewModel: AlbumViewModel = + hiltViewModel( + creationCallback = { it.create(itemId) }, + ), + playlistViewModel: AddPlaylistViewModel = hiltViewModel(), +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val state by viewModel.state.collectAsState() + val currentMusic by viewModel.currentMusic.collectAsState() + + var position by rememberPosition(0, 0) + val focusRequesters = + remember { List(SIMILAR_ROW + 1) { FocusRequester() } } + val focusManager = LocalFocusManager.current + var showPlaylistDialog by remember { mutableStateOf>(Optional.absent()) } + val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) + var moreDialog by remember { mutableStateOf(null) } + var showDeleteDialog by remember { mutableStateOf(null) } + val moreDialogActions = + remember { + MusicMoreDialogActions( + onNavigate = { viewModel.navigationManager.navigateTo(it) }, + onClickPlay = { index, _ -> viewModel.play(false, index) }, + onClickPlayNext = { _, song -> viewModel.playNext(song) }, + onClickAddToQueue = { index, item -> viewModel.addToQueue(item, index) }, + onClickFavorite = { itemId, favorite -> viewModel.setFavorite(itemId, favorite) }, + onClickAddPlaylist = { itemId -> + playlistViewModel.loadPlaylists(MediaType.AUDIO) + showPlaylistDialog.makePresent(itemId) + }, + onClickRemoveFromQueue = {}, + onClickDelete = { showDeleteDialog = it }, + ) + } + when (val loading = state.loading) { + is LoadingState.Error -> { + ErrorMessage(loading, modifier) + } + + LoadingState.Loading, + LoadingState.Pending, + -> { + LoadingPage(modifier) + } + + LoadingState.Success -> { + val album = state.album!! + + val firstFocusRequester = remember { FocusRequester() } + val firstBringIntoViewRequester = remember { BringIntoViewRequester() } + val bringIntoViewRequester = remember { BringIntoViewRequester() } + val listState = rememberLazyListState() + val itemsBefore = 2 + + val songFocusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { + if (position.row == SONG_ROW) { + songFocusRequester.tryRequestFocus() + } else { + focusRequesters.getOrNull(position.row)?.tryRequestFocus() + } + viewModel.updateBackDrop() + } + val backHandlerActive by remember { + derivedStateOf { + listState.firstVisibleItemIndex > itemsBefore + } + } + BackHandler(backHandlerActive) { + scope.launch { + listState.animateScrollToItem(itemsBefore) + firstBringIntoViewRequester.bringIntoView() + firstFocusRequester.tryRequestFocus() + } + } + Box(modifier = modifier) { + LazyColumn( + state = listState, + 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 = album, + imageUrl = state.imageUrl, + overviewOnClick = {}, + bringIntoViewRequester = bringIntoViewRequester, + modifier = Modifier.fillMaxWidth(), + ) + MusicExpandableButtons( + actions = + remember { + MusicButtonActions( + onClickPlay = { viewModel.play(it, 0) }, + onClickInstantMix = { viewModel.startInstantMix(album.id) }, + onClickFavorite = { + viewModel.setFavorite( + album.id, + !album.favorite, + ) + }, + onClickMore = { + moreDialog = + DialogParams( + fromLongClick = false, + title = album.name + " (${album.data.productionYear ?: ""})", + items = + buildMoreDialogForMusic( + context = context, + actions = moreDialogActions, + item = album, + index = 0, + canRemove = false, + canDelete = + viewModel.canDelete( + album, + preferences.appPreferences, + ), + ), + ) + }, + ) + }, + favorite = album.favorite, + buttonOnFocusChanged = { + if (it.isFocused) { + position = RowColumn(HEADER_ROW, 0) + scope.launch { bringIntoViewRequester.bringIntoView() } + } + }, + modifier = + Modifier + .onFocusChanged { + if (it.hasFocus) scope.launch { bringIntoViewRequester.bringIntoView() } + }.focusRequester(focusRequesters[HEADER_ROW]), + ) + } + } + item { + Text( + text = stringResource(R.string.songs) + " (${state.songs.size})", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onBackground, + modifier = Modifier.padding(start = 8.dp), + ) + } + itemsIndexed(state.songs) { index, song -> + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + SongListItem( + song = song, + onClick = { + position = RowColumn(SONG_ROW, index) + viewModel.play(false, index) + }, + onLongClick = { + if (song != null) { + moreDialog = + DialogParams( + fromLongClick = true, + title = song.name ?: "", + items = + buildMoreDialogForMusic( + context = context, + actions = moreDialogActions, + item = song, + index = index, + canRemove = false, + canDelete = + viewModel.canDelete( + song, + preferences.appPreferences, + ), + ), + ) + } + }, + onClickMore = { + if (song != null) { + moreDialog = + DialogParams( + fromLongClick = false, + title = song.name ?: "", + items = + buildMoreDialogForMusic( + context = context, + actions = moreDialogActions, + item = song, + index = index, + canRemove = false, + canDelete = + viewModel.canDelete( + song, + preferences.appPreferences, + ), + ), + ) + } + }, + showArtist = state.isVariousArtists, + isPlaying = song != null && currentMusic.currentItemId == song.id, + isQueued = song != null && song.id in currentMusic.queuedIds, + modifier = + Modifier + .fillMaxWidth(.75f) + .ifElse( + index == 0, + Modifier + .focusRequester(firstFocusRequester) + .bringIntoViewRequester(firstBringIntoViewRequester), + ).ifElse( + position.row == SONG_ROW && position.column == index, + Modifier.focusRequester(songFocusRequester), + ), + ) + } + } + if (state.musicVideos.isNotEmpty()) { + item { + ItemRow( + title = stringResource(R.string.music_videos), + items = state.musicVideos, + onClickItem = { index, item -> + position = RowColumn(MUSIC_VIDEO_ROW, index) + viewModel.navigationManager.navigateTo(item.destination()) + }, + onLongClickItem = { index, item -> + // TODO + }, + cardContent = { index: Int, item: BaseItem?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit -> + BannerCardWithTitle( + title = item?.name, + subtitle = item?.data?.productionYear?.toString(), + item = item, + onClick = onClick, + onLongClick = onLongClick, + aspectRatio = AspectRatios.WIDE, + modifier = mod, + ) + }, + modifier = Modifier.focusRequester(focusRequesters[MUSIC_VIDEO_ROW]), + ) + } + } + if (state.similar.isNotEmpty()) { + item { + ItemRow( + title = stringResource(R.string.more_like_this), + items = state.similar, + onClickItem = { index, item -> + position = RowColumn(SIMILAR_ROW, index) + viewModel.navigationManager.navigateTo(item.destination()) + }, + onLongClickItem = { index, item -> + moreDialog = + DialogParams( + fromLongClick = true, + title = item.name ?: "", + items = + buildMoreDialogForMusic( + context = context, + actions = moreDialogActions, + item = item, + index = index, + canRemove = false, + canDelete = + viewModel.canDelete( + item, + preferences.appPreferences, + ), + ), + ) + }, + cardContent = { index: Int, item: BaseItem?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit -> + BannerCardWithTitle( + title = item?.name, + subtitle = item?.data?.productionYear?.toString(), + item = item, + onClick = onClick, + onLongClick = onLongClick, + aspectRatio = AspectRatios.SQUARE, + modifier = mod, + ) + }, + modifier = Modifier.focusRequester(focusRequesters[SIMILAR_ROW]), + ) + } + } + } + } + } + } + moreDialog?.let { params -> + DialogPopup( + showDialog = true, + title = params.title, + dialogItems = params.items, + onDismissRequest = { moreDialog = null }, + dismissOnClick = true, + waitToLoad = params.fromLongClick, + ) + } + showPlaylistDialog.compose { itemId -> + PlaylistDialog( + title = stringResource(R.string.add_to_playlist), + state = playlistState, + onDismissRequest = { showPlaylistDialog.makeAbsent() }, + onClick = { + playlistViewModel.addToPlaylist(it.id, itemId) + showPlaylistDialog.makeAbsent() + }, + createEnabled = true, + onCreatePlaylist = { + playlistViewModel.createPlaylistAndAddItem(it, itemId) + showPlaylistDialog.makeAbsent() + }, + elevation = 3.dp, + ) + } + showDeleteDialog?.let { item -> + ConfirmDeleteDialog( + itemTitle = item.title ?: "", + onCancel = { showDeleteDialog = null }, + onConfirm = { + viewModel.deleteItem(item) + focusRequesters.getOrNull(position.row)?.tryRequestFocus() + showDeleteDialog = null + }, + ) + } +} + +@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, + contentScale = ContentScale.FillWidth, + modifier = + Modifier + .fillMaxWidth(.20f) + .clip(RoundedCornerShape(16.dp)), + ) + 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() + } + } + }, + ) + } + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/ArtistDetailsPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/ArtistDetailsPage.kt new file mode 100644 index 00000000..050373fb --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/ArtistDetailsPage.kt @@ -0,0 +1,750 @@ +package com.github.damontecres.wholphin.ui.detail.music + +import android.content.Context +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.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 +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +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.platform.LocalFocusManager +import androidx.compose.ui.res.stringResource +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.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.ServerRepository +import com.github.damontecres.wholphin.data.model.BaseItem +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.ImageUrlService +import com.github.damontecres.wholphin.services.MediaManagementService +import com.github.damontecres.wholphin.services.MediaReportService +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.AspectRatios +import com.github.damontecres.wholphin.ui.DefaultItemFields +import com.github.damontecres.wholphin.ui.SlimItemFields +import com.github.damontecres.wholphin.ui.cards.BannerCardWithTitle +import com.github.damontecres.wholphin.ui.cards.ItemRow +import com.github.damontecres.wholphin.ui.components.ConfirmDeleteDialog +import com.github.damontecres.wholphin.ui.components.DialogParams +import com.github.damontecres.wholphin.ui.components.DialogPopup +import com.github.damontecres.wholphin.ui.components.ErrorMessage +import com.github.damontecres.wholphin.ui.components.GenreText +import com.github.damontecres.wholphin.ui.components.LoadingPage +import com.github.damontecres.wholphin.ui.components.Optional +import com.github.damontecres.wholphin.ui.components.OverviewText +import com.github.damontecres.wholphin.ui.components.QuickDetails +import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel +import com.github.damontecres.wholphin.ui.data.RowColumn +import com.github.damontecres.wholphin.ui.detail.PlaylistDialog +import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState +import com.github.damontecres.wholphin.ui.ifElse +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.rememberPosition +import com.github.damontecres.wholphin.ui.toBaseItems +import com.github.damontecres.wholphin.ui.tryRequestFocus +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.Dispatchers +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.libraryApi +import org.jellyfin.sdk.api.client.extensions.userLibraryApi +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.ImageType +import org.jellyfin.sdk.model.api.ItemSortBy +import org.jellyfin.sdk.model.api.MediaType +import org.jellyfin.sdk.model.api.SortOrder +import org.jellyfin.sdk.model.api.request.GetItemsRequest +import org.jellyfin.sdk.model.api.request.GetSimilarItemsRequest +import java.util.UUID + +@HiltViewModel(assistedFactory = ArtistViewModel.Factory::class) +class ArtistViewModel + @AssistedInject + constructor( + @ApplicationContext context: Context, + api: ApiClient, + musicService: MusicService, + navigationManager: NavigationManager, + mediaManagementService: MediaManagementService, + val serverRepository: ServerRepository, + val mediaReportService: MediaReportService, + private val favoriteWatchManager: FavoriteWatchManager, + private val userPreferencesService: UserPreferencesService, + private val backdropService: BackdropService, + private val imageUrlService: ImageUrlService, + @Assisted itemId: UUID, + ) : MusicViewModel(itemId, context, api, musicService, navigationManager, mediaManagementService) { + @AssistedFactory + interface Factory { + fun create(itemId: UUID): ArtistViewModel + } + + private val _state = MutableStateFlow(ArtistState.EMPTY) + val state: StateFlow = _state + + val currentMusic = musicService.state + + init { + init() + } + + override fun init() { + viewModelScope.launchIO { + try { + val itemDeferred = + async { + api.userLibraryApi + .getItem(itemId = itemId) + .content + .let { BaseItem(it, false) } + } + val albumsDeferred = + async { + val request = + GetItemsRequest( + parentId = itemId, + fields = DefaultItemFields, + includeItemTypes = listOf(BaseItemKind.MUSIC_ALBUM), + sortBy = + listOf( + ItemSortBy.PREMIERE_DATE, + ItemSortBy.SORT_NAME, + ), + sortOrder = listOf(SortOrder.DESCENDING, SortOrder.ASCENDING), + ) + ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope).init() + } + val artist = itemDeferred.await() + val albums = albumsDeferred.await() + val imageUrl = imageUrlService.getItemImageUrl(artist, ImageType.PRIMARY) + _state.update { + it.copy( + artist = artist, + imageUrl = imageUrl, + albums = albums, + loading = LoadingState.Success, + ) + } + + viewModelScope.launchIO { + val request = + GetItemsRequest( + artistIds = listOf(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) + } + } + } + if (state.value.similar.isEmpty()) { + viewModelScope.launchIO { + val similar = + api.libraryApi + .getSimilarItems( + GetSimilarItemsRequest( + userId = serverRepository.currentUser.value?.id, + itemId = itemId, + excludeArtistIds = listOf(itemId), + fields = SlimItemFields, + limit = 25, + ), + ).content.items + .map { BaseItem.from(it, api) } + _state.update { it.copy(similar = similar) } + } + } + viewModelScope.launchIO { + val request = + GetItemsRequest( + userId = serverRepository.currentUser.value?.id, + artistIds = listOf(itemId), + parentId = null, + fields = DefaultItemFields, + recursive = true, + includeItemTypes = listOf(BaseItemKind.MUSIC_VIDEO), + ) + val musicVideos = + GetItemsRequestHandler.execute(api, request).toBaseItems(api, false) + if (musicVideos.isNotEmpty()) { + _state.update { + it.copy(musicVideos = musicVideos) + } + } + } + } catch (ex: Exception) { + _state.update { it.copy(loading = LoadingState.Error(ex)) } + } + } + } + + fun refresh() { + viewModelScope.launchDefault { + state.value.artist?.let { + backdropService.submit(it) + } + } + } + + fun setFavorite( + itemId: UUID, + favorite: Boolean, + ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { + favoriteWatchManager.setFavorite(itemId, favorite) + val artist = + api.userLibraryApi + .getItem(itemId = itemId) + .content + .let { BaseItem(it, false) } + _state.update { it.copy(artist = artist) } + } + } + +data class ArtistState( + val artist: BaseItem?, + val imageUrl: String?, + val topSongs: List, + val albums: List, + val similar: List, + val loading: LoadingState, + val musicVideos: List, +) { + companion object { + val EMPTY = + ArtistState( + null, + null, + emptyList(), + emptyList(), + emptyList(), + LoadingState.Pending, + emptyList(), + ) + } +} + +private const val HEADER_ROW = 0 +private const val SONG_ROW = HEADER_ROW + 1 +private const val ALBUM_ROW = SONG_ROW + 1 +private const val MUSIC_VIDEO_ROW = ALBUM_ROW + 1 +private const val SIMILAR_ROW = MUSIC_VIDEO_ROW + 1 + +@Composable +fun ArtistDetailsPage( + preferences: UserPreferences, + itemId: UUID, + modifier: Modifier = Modifier, + viewModel: ArtistViewModel = + hiltViewModel( + creationCallback = { it.create(itemId) }, + ), + playlistViewModel: AddPlaylistViewModel = hiltViewModel(), +) { + val context = LocalContext.current + val focusManager = LocalFocusManager.current + val scope = rememberCoroutineScope() + val state by viewModel.state.collectAsState() + val currentMusic by viewModel.currentMusic.collectAsState() + var position by rememberPosition(0, 0) + val focusRequesters = + remember { List(SIMILAR_ROW + 1) { FocusRequester() } } + + var showPlaylistDialog by remember { mutableStateOf>(Optional.absent()) } + val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) + var moreDialog by remember { mutableStateOf(null) } + var showDeleteDialog by remember { mutableStateOf(null) } + val moreDialogActions = + remember { + MusicMoreDialogActions( + onNavigate = { viewModel.navigationManager.navigateTo(it) }, + onClickPlay = { index, item -> viewModel.play(item) }, + onClickPlayNext = { _, item -> viewModel.playNext(item) }, + onClickAddToQueue = { index, item -> viewModel.addToQueue(item, index) }, + onClickFavorite = { itemId, favorite -> viewModel.setFavorite(itemId, favorite) }, + onClickAddPlaylist = { itemId -> + playlistViewModel.loadPlaylists(MediaType.AUDIO) + showPlaylistDialog.makePresent(itemId) + }, + onClickRemoveFromQueue = {}, + onClickDelete = { showDeleteDialog = it }, + ) + } + + when (val loading = state.loading) { + is LoadingState.Error -> { + ErrorMessage(loading, modifier) + } + + LoadingState.Loading, + LoadingState.Pending, + -> { + LoadingPage(modifier) + } + + LoadingState.Success -> { + val artist = state.artist!! + val songFocusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { + if (position.row == SONG_ROW) { + songFocusRequester.tryRequestFocus() + } else { + focusRequesters.getOrNull(position.row)?.tryRequestFocus() + } + viewModel.refresh() + } + val bringIntoViewRequester = remember { BringIntoViewRequester() } + Box(modifier = modifier) { + LazyColumn( + contentPadding = PaddingValues(bottom = 16.dp), + verticalArrangement = Arrangement.spacedBy(0.dp), + modifier = + Modifier + .fillMaxSize(), + ) { + item { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = + Modifier + .fillMaxWidth() + .bringIntoViewRequester(bringIntoViewRequester) + .padding(bottom = 16.dp), + ) { + ArtistHeader( + artist = artist, + imageUrl = state.imageUrl, + overviewOnClick = {}, + bringIntoViewRequester = bringIntoViewRequester, + modifier = Modifier.fillMaxWidth(), + ) + MusicExpandableButtons( + actions = + remember { + MusicButtonActions( + onClickPlay = { shuffled -> + viewModel.play(artist, shuffled = shuffled) + }, + onClickInstantMix = { viewModel.startInstantMix(artist.id) }, + onClickFavorite = { + viewModel.setFavorite( + artist.id, + !artist.favorite, + ) + }, + onClickMore = { + moreDialog = + DialogParams( + fromLongClick = false, + title = artist.name ?: "", + items = + buildMoreDialogForMusic( + context = context, + actions = moreDialogActions, + item = artist, + index = 0, + canRemove = false, + canDelete = + viewModel.canDelete( + artist, + preferences.appPreferences, + ), + ), + ) + }, + ) + }, + favorite = artist.favorite, + buttonOnFocusChanged = { + if (it.isFocused) { + position = RowColumn(HEADER_ROW, 0) + scope.launch { bringIntoViewRequester.bringIntoView() } + } + }, + modifier = + Modifier + .onFocusChanged { + if (it.hasFocus) scope.launch { bringIntoViewRequester.bringIntoView() } + }.focusRequester(focusRequesters[HEADER_ROW]), + ) + } + } + if (state.topSongs.isNotEmpty()) { + item { + Text( + 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 -> + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + SongListItem( + song = song, + onClick = { + position = RowColumn(SONG_ROW, index) + song?.let { viewModel.play(it) } + }, + onLongClick = { + if (song != null) { + moreDialog = + DialogParams( + fromLongClick = true, + title = song.name ?: "", + items = + buildMoreDialogForMusic( + context = context, + actions = moreDialogActions, + item = song, + index = index, + canRemove = false, + canDelete = + viewModel.canDelete( + song, + preferences.appPreferences, + ), + ), + ) + } + }, + onClickMore = { + if (song != null) { + moreDialog = + DialogParams( + fromLongClick = false, + title = song.name ?: "", + items = + buildMoreDialogForMusic( + context = context, + actions = moreDialogActions, + item = song, + index = index, + canRemove = false, + canDelete = + viewModel.canDelete( + song, + preferences.appPreferences, + ), + ), + ) + } + }, + showArtist = false, + isPlaying = song != null && currentMusic.currentItemId == song.id, + isQueued = song != null && song.id in currentMusic.queuedIds, + modifier = + Modifier + .fillMaxWidth(.75f) + .ifElse( + position.row == SONG_ROW && position.column == index, + Modifier.focusRequester(songFocusRequester), + ), + ) + } + } + } + item { + ItemRow( + title = stringResource(R.string.albums), + items = state.albums, + onClickItem = { index, album -> + position = RowColumn(ALBUM_ROW, index) + viewModel.navigationManager.navigateTo(album.destination()) + }, + onLongClickItem = { index, album -> + moreDialog = + DialogParams( + fromLongClick = true, + title = album.name ?: "", + items = + buildMoreDialogForMusic( + context = context, + actions = moreDialogActions, + item = album, + index = index, + canRemove = false, + canDelete = + viewModel.canDelete( + album, + preferences.appPreferences, + ), + ), + ) + }, + cardContent = { index: Int, album: BaseItem?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit -> + BannerCardWithTitle( + title = album?.name, + subtitle = album?.data?.productionYear?.toString(), + item = album, + onClick = onClick, + onLongClick = onLongClick, + aspectRatio = AspectRatios.SQUARE, + ) + }, + modifier = Modifier.focusRequester(focusRequesters[ALBUM_ROW]), + ) + } + if (state.musicVideos.isNotEmpty()) { + item { + ItemRow( + title = stringResource(R.string.music_videos), + items = state.musicVideos, + onClickItem = { index, item -> + position = RowColumn(MUSIC_VIDEO_ROW, index) + viewModel.navigationManager.navigateTo(item.destination()) + }, + onLongClickItem = { index, item -> + // TODO + }, + cardContent = { index: Int, item: BaseItem?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit -> + BannerCardWithTitle( + title = item?.name, + subtitle = item?.data?.productionYear?.toString(), + item = item, + onClick = onClick, + onLongClick = onLongClick, + aspectRatio = AspectRatios.WIDE, + modifier = mod, + ) + }, + modifier = Modifier.focusRequester(focusRequesters[MUSIC_VIDEO_ROW]), + ) + } + } + if (state.similar.isNotEmpty()) { + item { + ItemRow( + title = stringResource(R.string.more_like_this), + items = state.similar, + onClickItem = { index, item -> + position = RowColumn(SIMILAR_ROW, index) + viewModel.navigationManager.navigateTo(item.destination()) + }, + onLongClickItem = { index, item -> + moreDialog = + DialogParams( + fromLongClick = true, + title = item.name ?: "", + items = + buildMoreDialogForMusic( + context = context, + actions = moreDialogActions, + item = item, + index = index, + canRemove = false, + canDelete = + viewModel.canDelete( + item, + preferences.appPreferences, + ), + ), + ) + }, + cardContent = { index: Int, item: BaseItem?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit -> + BannerCardWithTitle( + title = item?.name, + subtitle = item?.data?.productionYear?.toString(), + item = item, + onClick = onClick, + onLongClick = onLongClick, + aspectRatio = AspectRatios.SQUARE, + modifier = mod, + ) + }, + modifier = Modifier.focusRequester(focusRequesters[SIMILAR_ROW]), + ) + } + } + } + } + } + } + moreDialog?.let { params -> + DialogPopup( + showDialog = true, + title = params.title, + dialogItems = params.items, + onDismissRequest = { moreDialog = null }, + dismissOnClick = true, + waitToLoad = params.fromLongClick, + ) + } + showPlaylistDialog.compose { itemId -> + PlaylistDialog( + title = stringResource(R.string.add_to_playlist), + state = playlistState, + onDismissRequest = { showPlaylistDialog.makeAbsent() }, + onClick = { + playlistViewModel.addToPlaylist(it.id, itemId) + showPlaylistDialog.makeAbsent() + }, + createEnabled = true, + onCreatePlaylist = { + playlistViewModel.createPlaylistAndAddItem(it, itemId) + showPlaylistDialog.makeAbsent() + }, + elevation = 3.dp, + ) + } + showDeleteDialog?.let { item -> + ConfirmDeleteDialog( + itemTitle = item.title ?: "", + onCancel = { showDeleteDialog = null }, + onConfirm = { + viewModel.deleteItem(item) + focusRequesters.getOrNull(position.row)?.tryRequestFocus() + showDeleteDialog = null + }, + ) + } +} + +@Composable +fun ArtistHeader( + artist: 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, + contentScale = ContentScale.FillWidth, + modifier = + Modifier + .fillMaxWidth(.20f) + .clip(RoundedCornerShape(16.dp)), + ) + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + // Artist + Text( + text = artist.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 = artist.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( + artist.ui.quickDetails, + null, + Modifier.padding(start = 8.dp), + ) + + artist.data.genres?.letNotEmpty { + GenreText(it, Modifier.padding(start = 8.dp)) + } + + // Description + artist.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() + } + } + }, + ) + } + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/BarVisualizer.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/BarVisualizer.kt new file mode 100644 index 00000000..d5ea2037 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/BarVisualizer.kt @@ -0,0 +1,54 @@ +package com.github.damontecres.wholphin.ui.detail.music + +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import androidx.tv.material3.MaterialTheme + +@Composable +fun BarVisualizer( + data: IntArray, + modifier: Modifier, +) { + var size by remember { mutableStateOf(IntSize.Zero) } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier.onSizeChanged { size = it }, + ) { + val size = with(LocalDensity.current) { DpSize(size.width.toDp(), size.height.toDp()) } + val padding = 1.dp + val width = size.width / data.size + + data.forEachIndexed { index, data -> + val height by animateDpAsState( + targetValue = size.height * data / 256f, + animationSpec = tween(easing = LinearEasing), + ) + Box( + Modifier + .height(height) + .width(width) + .padding(start = if (index == 0) 0.dp else padding) + .background(MaterialTheme.colorScheme.border), + ) + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/LyricsContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/LyricsContent.kt new file mode 100644 index 00000000..6ccea46e --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/LyricsContent.kt @@ -0,0 +1,145 @@ +package com.github.damontecres.wholphin.ui.detail.music + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.tween +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +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.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusProperties +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.unit.dp +import androidx.tv.material3.ClickableSurfaceDefaults +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Surface +import androidx.tv.material3.Text +import com.github.damontecres.wholphin.ui.ifElse +import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.ui.util.rememberDelayedNestedScroll +import org.jellyfin.sdk.model.api.LyricDto +import org.jellyfin.sdk.model.api.LyricLine + +@Composable +fun LyricsContent( + lyricsHaveFocus: Boolean, + lyrics: LyricDto?, + currentLyricPosition: Int?, + onClick: (LyricLine) -> Unit, + onFocusLyrics: (Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + val focusRequesters = + remember(lyrics) { List(lyrics?.lyrics.orEmpty().size) { FocusRequester() } } + val listState = rememberLazyListState(currentLyricPosition ?: 0) + + val scrollConnection = rememberDelayedNestedScroll(yDelay = .66f) + + val bringIntoViewRequester = remember { BringIntoViewRequester() } + if (!lyricsHaveFocus) { + LaunchedEffect(currentLyricPosition) { + if (currentLyricPosition != null) { + listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index?.let { + if (currentLyricPosition !in listState.firstVisibleItemIndex..it) { + listState.animateScrollToItem(currentLyricPosition) + } + } + bringIntoViewRequester.bringIntoView() + } + } + } + Column( + modifier + .nestedScroll(scrollConnection), + ) { + LazyColumn( + state = listState, + contentPadding = PaddingValues(), + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = + Modifier + .fillMaxSize() + .focusProperties { + onEnter = { + if (currentLyricPosition != null) { + currentLyricPosition.let { + focusRequesters + .getOrNull(currentLyricPosition) + ?.tryRequestFocus() + } + } else { + focusRequesters.getOrNull(0)?.tryRequestFocus() + } + } + }.onFocusChanged { + onFocusLyrics.invoke(it.hasFocus) + }, + ) { + if (lyrics?.lyrics?.isNotEmpty() == true) { + itemsIndexed(lyrics.lyrics) { index, lyric -> + val interactionSource = remember { MutableInteractionSource() } + val focused by interactionSource.collectIsFocusedAsState() + val color by animateColorAsState( + if (index == currentLyricPosition || currentLyricPosition == null) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = .4f) + }, + animationSpec = tween(durationMillis = 500, easing = LinearEasing), + ) + Surface( + onClick = { onClick.invoke(lyric) }, + interactionSource = interactionSource, + colors = + ClickableSurfaceDefaults.colors( + containerColor = Color.Transparent, + focusedContainerColor = MaterialTheme.colorScheme.border.copy(alpha = .33f), + ), + shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(8.dp)), + scale = + ClickableSurfaceDefaults.scale( + focusedScale = 1f, + pressedScale = .9f, + ), + modifier = + Modifier + .focusRequester(focusRequesters[index]), + ) { + val text = + remember(lyric.text) { lyric.text.ifBlank { " " } } + Text( + text = text, + style = MaterialTheme.typography.bodyLarge, + color = if (focused) MaterialTheme.colorScheme.onSurface else color, + modifier = + Modifier + .padding(8.dp) + .ifElse( + index == currentLyricPosition, + Modifier.bringIntoViewRequester(bringIntoViewRequester), + ), + ) + } + } + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/MusicExpandableButtons.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/MusicExpandableButtons.kt new file mode 100644 index 00000000..78a5603b --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/MusicExpandableButtons.kt @@ -0,0 +1,95 @@ +package com.github.damontecres.wholphin.ui.detail.music + +import androidx.compose.foundation.focusGroup +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +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.graphics.Color +import androidx.compose.ui.unit.dp +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.ui.components.ExpandableFaButton +import com.github.damontecres.wholphin.ui.components.ExpandablePlayButton +import kotlin.time.Duration + +@Composable +fun MusicExpandableButtons( + actions: MusicButtonActions, + favorite: Boolean, + 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("play") { + ExpandablePlayButton( + title = R.string.play, + resume = Duration.ZERO, + icon = Icons.Default.PlayArrow, + onClick = { actions.onClickPlay.invoke(false) }, + modifier = + Modifier + .focusRequester(firstFocus) + .onFocusChanged(buttonOnFocusChanged), + ) + } + item("shuffle") { + ExpandableFaButton( + title = R.string.shuffle, + iconStringRes = R.string.fa_shuffle, + onClick = { actions.onClickPlay.invoke(true) }, + modifier = Modifier.onFocusChanged(buttonOnFocusChanged), + ) + } + item("instant_mix") { + ExpandableFaButton( + title = R.string.instant_mix, + iconStringRes = R.string.fa_compass, + onClick = actions.onClickInstantMix, + modifier = Modifier.onFocusChanged(buttonOnFocusChanged), + ) + } + item("favorite") { + ExpandableFaButton( + title = if (favorite) R.string.remove_favorite else R.string.add_favorite, + iconStringRes = R.string.fa_heart, + onClick = actions.onClickFavorite, + iconColor = if (favorite) Color.Red else Color.Unspecified, + modifier = Modifier.onFocusChanged(buttonOnFocusChanged), + ) + } + item("more") { + ExpandablePlayButton( + title = R.string.more, + resume = Duration.ZERO, + icon = Icons.Default.MoreVert, + onClick = { actions.onClickMore.invoke() }, + modifier = Modifier.onFocusChanged(buttonOnFocusChanged), + ) + } + } +} + +data class MusicButtonActions( + val onClickPlay: (shuffle: Boolean) -> Unit, + val onClickInstantMix: () -> Unit, + val onClickFavorite: () -> Unit, + val onClickMore: () -> Unit, +) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/MusicPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/MusicPreference.kt new file mode 100644 index 00000000..1a519998 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/MusicPreference.kt @@ -0,0 +1,42 @@ +package com.github.damontecres.wholphin.ui.detail.music + +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.AppSwitchPreference +import com.github.damontecres.wholphin.preferences.updateMusicPreferences + +fun getMusicPreferences() = + listOf( + AppSwitchPreference( + title = R.string.show_album_cover, + defaultValue = false, + getter = { it.musicPreferences.showAlbumArt }, + setter = { prefs, value -> + prefs.updateMusicPreferences { showAlbumArt = value } + }, + ), + AppSwitchPreference( + title = R.string.show_visualizer, + defaultValue = false, + getter = { it.musicPreferences.showVisualizer }, + setter = { prefs, value -> + prefs.updateMusicPreferences { showVisualizer = value } + }, + ), + AppSwitchPreference( + title = R.string.show_backdrop, + defaultValue = false, + getter = { it.musicPreferences.showBackdrop }, + setter = { prefs, value -> + prefs.updateMusicPreferences { showBackdrop = value } + }, + ), + AppSwitchPreference( + title = R.string.show_lyrics, + defaultValue = false, + getter = { it.musicPreferences.showLyrics }, + setter = { prefs, value -> + prefs.updateMusicPreferences { showLyrics = value } + }, + ), + ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/MusicViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/MusicViewModel.kt new file mode 100644 index 00000000..e7b85418 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/MusicViewModel.kt @@ -0,0 +1,142 @@ +package com.github.damontecres.wholphin.ui.detail.music + +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.services.MediaManagementService +import com.github.damontecres.wholphin.services.MusicService +import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.deleteItem +import com.github.damontecres.wholphin.ui.launchDefault +import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.util.ApiRequestPager +import com.github.damontecres.wholphin.util.BlockingList +import kotlinx.coroutines.delay +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.model.api.BaseItemKind +import timber.log.Timber +import java.util.UUID + +abstract class MusicViewModel( + internal val itemId: UUID, + internal val context: Context, + internal val api: ApiClient, + internal val musicService: MusicService, + internal val navigationManager: NavigationManager, + internal val mediaManagementService: MediaManagementService, +) : ViewModel() { + fun play( + pager: ApiRequestPager<*>, + startIndex: Int = 0, + shuffled: Boolean = false, + ) { + viewModelScope.launchIO { + musicService.setQueue(pager, startIndex, shuffled) + } + } + + fun play( + item: BaseItem, + startIndex: Int = 0, + shuffled: Boolean = false, + ) { + viewModelScope.launchIO { + Timber.v("Playing %s %s", item.type, item.id) + when (item.type) { + BaseItemKind.AUDIO -> { + musicService.setQueue(listOf(item), shuffled) + } + + BaseItemKind.MUSIC_ALBUM -> { + val pager = getPagerForAlbum(api, item.id) + musicService.setQueue(pager, startIndex, shuffled) + } + + BaseItemKind.MUSIC_ARTIST -> { + val pager = getPagerForArtist(api, item.id) + musicService.setQueue(pager, startIndex, shuffled) + } + + BaseItemKind.PLAYLIST -> { + val pager = getPagerForPlaylist(api, item.id) + musicService.setQueue(pager, startIndex, shuffled) + } + + else -> { + Timber.w("Unknown item type to play for music: %s", item.type) + } + } + } + } + + fun playNext(song: BaseItem) { + viewModelScope.launchDefault { + musicService.playNext(song) + } + } + + fun addToQueue( + item: BaseItem, + index: Int, + ) { + viewModelScope.launchIO { + Timber.v("addToQueue %s %s", item.type, item.id) + when (item.type) { + BaseItemKind.AUDIO -> { + musicService.addAllToQueue(BlockingList.of(listOf(item)), 0) + } + + BaseItemKind.MUSIC_ALBUM -> { + val pager = getPagerForAlbum(api, item.id) + musicService.addAllToQueue(pager, 0) + } + + BaseItemKind.MUSIC_ARTIST -> { + val pager = getPagerForArtist(api, item.id) + musicService.addAllToQueue(pager, 0) + } + + BaseItemKind.PLAYLIST -> { + val pager = getPagerForPlaylist(api, item.id) + musicService.addAllToQueue(pager, 0) + } + + else -> { + Timber.w("Unknown item type to queue for music: %s", item.type) + } + } + } + } + + fun startInstantMix(itemId: UUID) { + viewModelScope.launchIO { + Timber.v("Starting instant mix for %s", itemId) + musicService.startInstantMix(itemId) + } + viewModelScope.launchDefault { + // TODO better way to wait for query above to start + delay(250) + navigationManager.navigateTo(Destination.NowPlaying) + } + } + + fun canDelete( + item: BaseItem, + appPreferences: AppPreferences, + ): Boolean = mediaManagementService.canDelete(item, appPreferences) + + fun deleteItem(item: BaseItem) { + deleteItem(context, mediaManagementService, item) { + if (item.id == itemId) { + navigationManager.goBack() + } else { + init() + } + } + } + + internal abstract fun init() +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/MusicViewOptionsDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/MusicViewOptionsDialog.kt new file mode 100644 index 00000000..cf9f9c7c --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/MusicViewOptionsDialog.kt @@ -0,0 +1,133 @@ +package com.github.damontecres.wholphin.ui.detail.music + +import android.view.Gravity +import androidx.compose.foundation.background +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.heightIn +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.compose.ui.window.DialogWindowProvider +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import androidx.tv.material3.surfaceColorAtElevation +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.preferences.AppPreference +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.ui.preferences.ComposablePreference +import com.github.damontecres.wholphin.ui.tryRequestFocus + +@Composable +fun MusicViewOptionsDialog( + appPreferences: AppPreferences, + onDismissRequest: () -> Unit, + onViewOptionsChange: (AppPreferences) -> Unit, + onEnableVisualizer: () -> Unit, +) { + val context = LocalContext.current + + val focusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } + val columnState = rememberLazyListState() + val items = remember { getMusicPreferences() } + + Dialog( + onDismissRequest = onDismissRequest, + properties = + DialogProperties( + usePlatformDefaultWidth = false, + ), + ) { + val dialogWindowProvider = LocalView.current.parent as? DialogWindowProvider + dialogWindowProvider?.window?.let { window -> + window.setGravity(Gravity.END) + window.setDimAmount(0f) + } + + Column { + Text( + text = stringResource(R.string.view_options), + style = MaterialTheme.typography.titleMedium, + ) + } + LazyColumn( + state = columnState, + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(0.dp), + modifier = + Modifier + .width(256.dp) + .heightIn(max = 380.dp) + .focusRequester(focusRequester) + .background( + MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp), + shape = RoundedCornerShape(8.dp), + ), + ) { + items(items) { pref -> + pref as AppPreference + val interactionSource = remember { MutableInteractionSource() } + val value = pref.getter.invoke(appPreferences) + // Using title is a bit hacky + when (pref.title) { + R.string.show_visualizer -> { + ComposablePreference( + preference = pref, + value = value, + onNavigate = {}, + onValueChange = { newValue -> + newValue as Boolean + if (newValue) { + onEnableVisualizer.invoke() + } else { + onViewOptionsChange.invoke( + pref.setter( + appPreferences, + newValue, + ), + ) + } + }, + interactionSource = interactionSource, + modifier = Modifier, + onClickPreference = { pref -> + }, + ) + } + + else -> { + ComposablePreference( + preference = pref, + value = value, + onNavigate = {}, + onValueChange = { newValue -> + onViewOptionsChange.invoke(pref.setter(appPreferences, newValue)) + }, + interactionSource = interactionSource, + modifier = Modifier, + onClickPreference = { pref -> + }, + ) + } + } + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingButtons.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingButtons.kt new file mode 100644 index 00000000..44f08012 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingButtons.kt @@ -0,0 +1,320 @@ +package com.github.damontecres.wholphin.ui.detail.music + +import androidx.annotation.OptIn +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.focusGroup +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi +import androidx.media3.ui.compose.state.rememberNextButtonState +import androidx.media3.ui.compose.state.rememberPlayPauseButtonState +import androidx.media3.ui.compose.state.rememberPreviousButtonState +import androidx.media3.ui.compose.state.rememberRepeatButtonState +import androidx.media3.ui.compose.state.rememberShuffleButtonState +import androidx.tv.material3.Border +import androidx.tv.material3.ClickableSurfaceDefaults +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.ui.AppColors +import com.github.damontecres.wholphin.ui.FontAwesome +import com.github.damontecres.wholphin.ui.PreviewTvSpec +import com.github.damontecres.wholphin.ui.components.Button +import com.github.damontecres.wholphin.ui.playback.ControllerViewState +import com.github.damontecres.wholphin.ui.playback.PlaybackAction +import com.github.damontecres.wholphin.ui.playback.PlaybackButton +import com.github.damontecres.wholphin.ui.playback.PlaybackButtons +import com.github.damontecres.wholphin.ui.playback.PlaybackDialogType +import com.github.damontecres.wholphin.ui.playback.buttonSpacing +import com.github.damontecres.wholphin.ui.theme.PreviewInteractionSource +import com.github.damontecres.wholphin.ui.theme.WholphinTheme +import kotlin.time.Duration.Companion.seconds + +@OptIn(UnstableApi::class) +@Composable +fun NowPlayingButtons( + player: Player, + controllerViewState: ControllerViewState, + initialFocusRequester: FocusRequester, + onClickMore: () -> Unit, + onClickStop: () -> Unit, + modifier: Modifier = Modifier, +) { + val playPauseState = rememberPlayPauseButtonState(player) + val previousState = rememberPreviousButtonState(player) + val nextState = rememberNextButtonState(player) + val shuffleState = rememberShuffleButtonState(player) + val repeatState = rememberRepeatButtonState(player) + + val onControllerInteraction = remember { { controllerViewState.pulseControls() } } + Box( + modifier = modifier.focusGroup(), + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(buttonSpacing), + modifier = Modifier.align(Alignment.CenterStart), + ) { + PlaybackButton( + iconRes = R.drawable.baseline_more_vert_96, + onClick = { + onControllerInteraction.invoke() + onClickMore.invoke() + }, + enabled = true, + onControllerInteraction = onControllerInteraction, + modifier = Modifier, + ) + PlaybackButton( + iconRes = R.drawable.baseline_stop_24, + onClick = { + onClickStop.invoke() + }, + enabled = true, + onControllerInteraction = onControllerInteraction, + modifier = Modifier, + ) + } + PlaybackButtons( + player = player, + initialFocusRequester = initialFocusRequester, + onControllerInteraction = onControllerInteraction, + onPlaybackActionClick = { + when (it) { + PlaybackAction.Next -> { + nextState.onClick() + } + + PlaybackAction.Previous -> { + previousState.onClick() + } + + is PlaybackAction.ToggleCaptions -> { + TODO() + } + + else -> {} + } + }, + showPlay = playPauseState.showPlay, + previousEnabled = previousState.isEnabled, + nextEnabled = nextState.isEnabled, + seekBack = 10.seconds, + skipBackOnResume = null, + seekForward = 30.seconds, // TODO + modifier = Modifier.align(Alignment.Center), + ) + Row( + horizontalArrangement = Arrangement.spacedBy(buttonSpacing), + modifier = Modifier.align(Alignment.CenterEnd), + ) { + ShuffleButton( + active = shuffleState.shuffleOn, + enabled = shuffleState.isEnabled, + onClick = { + shuffleState.onClick() + }, + onControllerInteraction = onControllerInteraction, + ) + RepeatButton( + repeatMode = repeatState.repeatModeState, + enabled = repeatState.isEnabled, + onClick = { + repeatState.onClick() + }, + onControllerInteraction = onControllerInteraction, + ) + } + } +} + +@Composable +fun ShuffleButton( + active: Boolean, + onClick: () -> Unit, + onControllerInteraction: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, +) { + val selectedColor = MaterialTheme.colorScheme.border + val focused by interactionSource.collectIsFocusedAsState() + Button( + enabled = enabled, + onClick = onClick, +// shape = ButtonDefaults.shape(CircleShape), + colors = + ClickableSurfaceDefaults.colors( + containerColor = AppColors.TransparentBlack25, + focusedContainerColor = selectedColor, + ), + contentPadding = PaddingValues(4.dp), + interactionSource = interactionSource, + modifier = + modifier + .size(36.dp, 36.dp) + .onFocusChanged { onControllerInteraction.invoke() }, + ) { + Text( + text = stringResource(R.string.fa_shuffle), + fontSize = 18.sp, + fontFamily = FontAwesome, + textAlign = TextAlign.Center, + color = + when { + focused && active -> MaterialTheme.colorScheme.onSurface + focused && !active -> MaterialTheme.colorScheme.surface + !focused && active -> MaterialTheme.colorScheme.onSurface + else -> MaterialTheme.colorScheme.onSurface.copy(alpha = .5f) + }, + modifier = + Modifier + .fillMaxWidth() + .align(Alignment.CenterVertically), + ) + } +} + +@Composable +fun RepeatButton( + repeatMode: Int, + onClick: () -> Unit, + onControllerInteraction: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, +) { + val selectedColor = MaterialTheme.colorScheme.border + val focused by interactionSource.collectIsFocusedAsState() + Button( + enabled = enabled, + onClick = onClick, +// shape = ButtonDefaults.shape(CircleShape), + colors = + ClickableSurfaceDefaults.colors( + containerColor = AppColors.TransparentBlack25, + focusedContainerColor = selectedColor, + ), + contentPadding = PaddingValues(4.dp), + interactionSource = interactionSource, + modifier = + modifier + .size(36.dp, 36.dp) + .onFocusChanged { onControllerInteraction.invoke() }, + ) { + Box( + Modifier + .fillMaxWidth() + .align(Alignment.CenterVertically), + ) { + Text( + text = stringResource(R.string.fa_repeat), + fontSize = 18.sp, + fontFamily = FontAwesome, + textAlign = TextAlign.Center, + color = + if (focused) { + when (repeatMode) { + Player.REPEAT_MODE_ALL -> MaterialTheme.colorScheme.onSurface + Player.REPEAT_MODE_ONE -> MaterialTheme.colorScheme.onSurface + else -> MaterialTheme.colorScheme.surface + } + } else { + when (repeatMode) { + Player.REPEAT_MODE_ALL -> MaterialTheme.colorScheme.onSurface + Player.REPEAT_MODE_ONE -> MaterialTheme.colorScheme.onSurface + else -> MaterialTheme.colorScheme.onSurface.copy(alpha = .5f) + } + }, + modifier = + Modifier + .fillMaxWidth() + .align(Alignment.Center), + ) + if (repeatMode == Player.REPEAT_MODE_ONE) { + Text( + text = "1", + fontSize = 10.sp, + color = MaterialTheme.colorScheme.surface, + modifier = + Modifier + .offset(x = 4.dp) + .background( + color = MaterialTheme.colorScheme.onSurface, + shape = RoundedCornerShape(1.dp), + ).align(Alignment.BottomStart), + ) + } + } + } +} + +@PreviewTvSpec +@Composable +fun ShuffleButtonPreview() { + val source = remember { PreviewInteractionSource() } + WholphinTheme { + Column { + Row { + RepeatButton( + repeatMode = Player.REPEAT_MODE_OFF, + onClick = {}, + onControllerInteraction = {}, + ) + RepeatButton( + repeatMode = Player.REPEAT_MODE_ALL, + onClick = {}, + onControllerInteraction = {}, + ) + RepeatButton( + repeatMode = Player.REPEAT_MODE_ONE, + onClick = {}, + onControllerInteraction = {}, + ) + } + Row { + RepeatButton( + repeatMode = Player.REPEAT_MODE_OFF, + onClick = {}, + onControllerInteraction = {}, + interactionSource = source, + ) + RepeatButton( + repeatMode = Player.REPEAT_MODE_ALL, + onClick = {}, + onControllerInteraction = {}, + interactionSource = source, + ) + RepeatButton( + repeatMode = Player.REPEAT_MODE_ONE, + onClick = {}, + onControllerInteraction = {}, + interactionSource = source, + ) + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingOverlay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingOverlay.kt new file mode 100644 index 00000000..d76e560e --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingOverlay.kt @@ -0,0 +1,229 @@ +package com.github.damontecres.wholphin.ui.detail.music + +import androidx.activity.compose.BackHandler +import androidx.annotation.OptIn +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.background +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 +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.size +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusProperties +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi +import androidx.tv.material3.Icon +import androidx.tv.material3.MaterialTheme +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.components.Button +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.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 +import kotlin.time.Duration + +@OptIn(UnstableApi::class) +@Composable +fun NowPlayingOverlay( + state: NowPlayingState, + player: Player, + current: AudioItem?, + queue: List, + controllerViewState: ControllerViewState, + onClickSong: (Int, AudioItem) -> Unit, + onLongClickSong: (Int, AudioItem) -> Unit, + onClickMore: () -> Unit, + onMoveQueue: (Int, MoveDirection) -> Unit, + onClickMoreItem: (Int, AudioItem) -> Unit, + onClickStop: () -> Unit, + lyricsFocusRequester: FocusRequester, + modifier: Modifier = Modifier, +) { + val scope = rememberCoroutineScope() + val focusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } + + var queueHasFocus by remember { mutableStateOf(false) } + val height by animateFloatAsState( + if (queueHasFocus) { + 1f + } else { + .33f + }, + animationSpec = tween(durationMillis = 500), + ) + val listState = rememberLazyListState() + var showButtons by remember { mutableStateOf(true) } + + val firstFocusRequester = remember { FocusRequester() } + BackHandler(!showButtons) { + scope.launch { + listState.animateScrollToItem(0) + firstFocusRequester.tryRequestFocus() + } + } + + Column( + modifier = + modifier + .padding(16.dp) + .fillMaxHeight(height), + ) { + SeekBar( + player = player, + controllerViewState = controllerViewState, + onSeekProgress = {}, + interactionSource = remember { MutableInteractionSource() }, + isEnabled = false, + intervals = 0, + seekBack = Duration.ZERO, + seekForward = Duration.ZERO, + modifier = + Modifier + .align(Alignment.CenterHorizontally) + .padding(top = 8.dp) + .fillMaxWidth(.95f), + ) + AnimatedVisibility( + visible = showButtons, + enter = expandVertically(), + exit = shrinkVertically(), + modifier = + Modifier + .align(Alignment.CenterHorizontally), + ) { + NowPlayingButtons( + player = player, + controllerViewState = controllerViewState, + initialFocusRequester = focusRequester, + onClickMore = onClickMore, + onClickStop = onClickStop, + modifier = + Modifier + .fillMaxWidth() + .align(Alignment.CenterHorizontally), + ) + } + if (queue.isEmpty()) { + Text("No items") + } else { + Text( + text = stringResource(R.string.queue), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(vertical = 8.dp), + ) + LazyColumn( + state = listState, + contentPadding = PaddingValues(bottom = 16.dp, start = 16.dp, end = 16.dp), + modifier = + Modifier + .fillMaxSize() + .onFocusChanged { + queueHasFocus = it.hasFocus + }.focusProperties { + onExit = { + if (requestedFocusDirection == FocusDirection.Up) focusRequester.requestFocus() + } + }, + ) { + itemsIndexed(queue, key = { _, song -> song.key }) { index, song -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .fillMaxWidth() + .background( + color = MaterialTheme.colorScheme.surface.copy(alpha = .5f), + shape = RoundedCornerShape(8.dp), + ).padding(end = 8.dp) + .onFocusChanged { + if (it.hasFocus) showButtons = index < 3 + controllerViewState.pulseControls() + }.animateItem(), + ) { + SongListItem( + title = song.title, + artist = song.artistNames, + indexNumber = index + 1, + runtime = song.runtime?.roundSeconds, + showArtist = true, + isPlaying = current?.id == song.id, + onClick = { onClickSong.invoke(index, song) }, + onLongClick = { onLongClickSong.invoke(index, song) }, + modifier = + 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) }, + ) + Button( + onClick = { onClickMoreItem.invoke(index, song) }, + enabled = true, + modifier = Modifier.size(32.dp), + ) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = stringResource(R.string.more), + ) + } + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingPage.kt new file mode 100644 index 00000000..1f253b82 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingPage.kt @@ -0,0 +1,480 @@ +package com.github.damontecres.wholphin.ui.detail.music + +import android.Manifest +import android.view.Gravity +import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.OptIn +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.expandHorizontally +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkHorizontally +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +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.size +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.media3.common.util.UnstableApi +import androidx.tv.material3.Button +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.model.AudioItem +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.BackdropStyle +import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.services.rememberQueue +import com.github.damontecres.wholphin.ui.components.BasicDialog +import com.github.damontecres.wholphin.ui.components.DialogParams +import com.github.damontecres.wholphin.ui.components.DialogPopup +import com.github.damontecres.wholphin.ui.components.LoadingPage +import com.github.damontecres.wholphin.ui.findActivity +import com.github.damontecres.wholphin.ui.nav.Backdrop +import com.github.damontecres.wholphin.ui.playback.BottomDialog +import com.github.damontecres.wholphin.ui.playback.BottomDialogItem +import com.github.damontecres.wholphin.ui.playback.PlaybackKeyHandler +import com.github.damontecres.wholphin.ui.playback.isUp +import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.util.LoadingState +import org.jellyfin.sdk.model.extensions.ticks +import kotlin.time.Duration.Companion.seconds + +@OptIn(UnstableApi::class) +@Composable +fun NowPlayingPage( + modifier: Modifier = Modifier, + viewModel: NowPlayingViewModel = + hiltViewModel( + creationCallback = { it.create() }, + ), +) { + val context = LocalContext.current + val state by viewModel.state.collectAsState() + val player = viewModel.player + val queue = + rememberQueue( + player, + state.musicServiceState.queueVersion, + state.musicServiceState.queueSize, + ) + val current = queue.getOrNull(state.musicServiceState.currentIndex) + val viz by viewModel.viz.collectAsState() + + val controllerViewState = viewModel.controllerViewState + val preferences = + viewModel.userPreferencesService.flow + .collectAsState( + UserPreferences( + AppPreferences.getDefaultInstance(), + ), + ).value.appPreferences + val musicPrefs = preferences.musicPreferences + + val keyHandler = + remember(preferences) { + PlaybackKeyHandler( + player = player, + controlsEnabled = true, + skipWithLeftRight = false, + seekForward = 30.seconds, + seekBack = 10.seconds, +// seekForward = preferences.playbackPreferences.skipForwardMs.milliseconds, +// seekBack = preferences.playbackPreferences.skipBackMs.milliseconds, + controllerViewState = controllerViewState, + updateSkipIndicator = {}, + skipBackOnResume = null, +// skipBackOnResume = preferences.playbackPreferences.skipBackOnResume, + onInteraction = viewModel::reportInteraction, + oneClickPause = preferences.playbackPreferences.oneClickPause, + onStop = { + viewModel.stop() + }, + onPlaybackDialogTypeClick = { }, + getDurationMs = { player.duration }, + ) + } + + val actions = + remember { + MusicQueueDialogActions( + onNavigate = { viewModel.navigationManager.navigateTo(it) }, + onClickPlay = { index, _ -> viewModel.play(index) }, + onClickPlayNext = { index, _ -> viewModel.playNext(index) }, + onClickRemoveFromQueue = { index, _ -> viewModel.removeFromQueue(index) }, + ) + } + + var showViewOptionsDialog by remember { mutableStateOf(false) } + var itemMoreDialog by remember { mutableStateOf(null) } + var lyricsHaveFocus by remember { mutableStateOf(false) } + + val focusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } + val lyricsFocusRequester = remember { FocusRequester() } + val hasLyrics = musicPrefs.showLyrics && state.hasLyrics + + LaunchedEffect(lyricsHaveFocus) { + if (lyricsHaveFocus) { + controllerViewState.hideControls() + } + } + BackHandler(lyricsHaveFocus) { + focusRequester.tryRequestFocus() + } + + var showRationaleDialog by remember { mutableStateOf(false) } + val launcher = + rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission(), + ) { isGranted -> + viewModel.startVisualizer(isGranted, true) + } + + Box(modifier) { + Backdrop( + backdrop = state.backdropResult, + drawerIsOpen = false, + backdropStyle = BackdropStyle.BACKDROP_DYNAMIC_COLOR, + modifier = Modifier.fillMaxSize(), + enableTopScrim = false, + useExistingImageAsPlaceholder = true, + crossfadeDuration = 1.5.seconds, + ) + Box( + modifier = + Modifier + .fillMaxSize() + .onPreviewKeyEvent { key -> + if (!controllerViewState.controlsVisible && + key.type == KeyEventType.KeyUp && + isUp(key) && + hasLyrics + ) { + lyricsFocusRequester.tryRequestFocus() + true + } else { + keyHandler.onKeyEvent(key) + } + }.focusRequester(focusRequester) + .focusable(), + ) + Row( + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxSize(), + ) { + Box( + modifier = Modifier.wrapContentWidth(), + ) { + val enter = + remember { + expandHorizontally(expandFrom = Alignment.Start) + fadeIn() + } + val exit = + remember { + shrinkHorizontally(shrinkTowards = Alignment.Start) + fadeOut() + } + androidx.compose.animation.AnimatedVisibility( + visible = musicPrefs.showAlbumArt, + enter = enter, + exit = exit, + modifier = + Modifier + .padding(32.dp), + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth(.5f), + ) { + AsyncImage( + contentDescription = null, + model = current?.imageUrl, + modifier = + Modifier + .size(240.dp) + .clip(RoundedCornerShape(16.dp)), + ) + current?.title?.let { + Text( + text = it, + style = MaterialTheme.typography.titleLarge, + ) + } + current?.albumTitle?.let { + Text( + text = it, + style = MaterialTheme.typography.titleMedium, + ) + } + current?.artistNames?.let { + Text( + text = it, + style = MaterialTheme.typography.titleMedium, + ) + } + } + } + androidx.compose.animation.AnimatedVisibility( + visible = musicPrefs.showVisualizer, + enter = enter, + exit = exit, + modifier = + Modifier + .padding(horizontal = 32.dp) + .align(Alignment.CenterStart), + ) { + val visualizerWidth by animateFloatAsState(if (musicPrefs.showLyrics && state.hasLyrics) .5f else 1f) + BarVisualizer( + data = viz, + modifier = + Modifier + .fillMaxHeight(.75f) + .fillMaxWidth(visualizerWidth) + .align(Alignment.CenterStart), + ) + } + } + + AnimatedVisibility( + visible = musicPrefs.showLyrics && state.hasLyrics, + enter = expandHorizontally(expandFrom = Alignment.End), + exit = shrinkHorizontally(shrinkTowards = Alignment.End), + modifier = + Modifier + .focusRequester(lyricsFocusRequester), + ) { + Box( + contentAlignment = Alignment.Center, + modifier = + Modifier + .padding(horizontal = 32.dp, vertical = 100.dp) + .fillMaxHeight(), + ) { + LyricsContent( + lyrics = state.lyrics, + currentLyricPosition = state.currentLyricIndex, + lyricsHaveFocus = lyricsHaveFocus, + onFocusLyrics = { lyricsHaveFocus = it }, + onClick = { + it.start + ?.ticks + ?.inWholeMilliseconds + ?.let { player.seekTo(it) } + }, + modifier = + Modifier + .fillMaxSize(), +// .width(360.dp), + ) + } + } + } + val showContextForItem = + remember { + { fromLongClick: Boolean, index: Int, song: AudioItem -> + itemMoreDialog = + DialogParams( + title = song.title ?: "", + fromLongClick = fromLongClick, + items = + buildMoreDialogForMusicQueue( + context = context, + actions = actions, + item = song, + index = index, + canRemove = true, + ), + ) + } + } + + BackHandler(controllerViewState.controlsVisible) { + controllerViewState.hideControls() + } + AnimatedVisibility( + visible = controllerViewState.controlsVisible, + enter = slideInVertically { it }, + exit = slideOutVertically { it }, + modifier = + Modifier + .align(Alignment.BottomCenter), + ) { + NowPlayingOverlay( + state = state, + player = player, + current = current, + queue = queue, + controllerViewState = controllerViewState, + onMoveQueue = { index, direction -> viewModel.moveQueue(index, direction) }, + onClickMore = { showViewOptionsDialog = true }, + onClickSong = { index, _ -> viewModel.play(index) }, + onClickMoreItem = { index, song -> showContextForItem.invoke(false, index, song) }, + onLongClickSong = { index, song -> showContextForItem.invoke(true, index, song) }, + onClickStop = { viewModel.stop() }, + lyricsFocusRequester = lyricsFocusRequester, + modifier = + Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter) + .drawBehind { + drawRect( + brush = + Brush.verticalGradient( + colors = listOf(Color.Transparent, Color.Black), + startY = 0f, + endY = size.height, + ), + ) + }, + ) + } + if (state.musicServiceState.loadingState is LoadingState.Loading) { + LoadingPage(focusEnabled = false) + } + } + itemMoreDialog?.let { params -> + DialogPopup( + showDialog = true, + title = params.title, + dialogItems = params.items, + onDismissRequest = { itemMoreDialog = null }, + dismissOnClick = true, + waitToLoad = params.fromLongClick, + ) + } + if (showViewOptionsDialog) { + MusicViewOptionsDialog( + appPreferences = preferences, + onDismissRequest = { showViewOptionsDialog = false }, + onViewOptionsChange = { viewModel.updatePreferences(it) }, + onEnableVisualizer = { + val showRationale = + context + .findActivity() + ?.shouldShowRequestPermissionRationale(Manifest.permission.RECORD_AUDIO) == true + when { + !state.visualizerPermissions && showRationale -> { + showRationaleDialog = true + } + + !state.visualizerPermissions -> { + launcher.launch(Manifest.permission.RECORD_AUDIO) + } + + else -> { + viewModel.startVisualizer(true, true) + } + } + }, + ) + } + if (showRationaleDialog) { + RecordAudioRationaleDialog( + onDismissRequest = { showRationaleDialog = false }, + onClick = { + showRationaleDialog = false + launcher.launch(Manifest.permission.RECORD_AUDIO) + }, + ) + } +} + +@Composable +fun NowPlayingBottomDialog( + showDebugInfo: Boolean, + lyricsActive: Boolean, + songHasLyrics: Boolean, + onDismissRequest: () -> Unit, + onClickShowDebug: () -> Unit, + onClickLyrics: () -> Unit, +) { + val choices = + mapOf( + BottomDialogItem( + data = 0, + headline = stringResource(if (showDebugInfo) R.string.hide_debug_info else R.string.show_debug_info), + supporting = null, + ) to onClickShowDebug, + BottomDialogItem( + data = 0, + headline = stringResource(if (lyricsActive) R.string.hide_lyrics else R.string.show_lyrics), + supporting = if (songHasLyrics) stringResource(R.string.song_has_lyrics) else null, + ) to onClickLyrics, + ) + + BottomDialog( + choices = choices.keys.toList(), + onDismissRequest = { + onDismissRequest.invoke() + }, + onSelectChoice = { _, choice -> + choices[choice]?.invoke() + onDismissRequest.invoke() + }, + gravity = Gravity.START, + ) +} + +@Composable +private fun RecordAudioRationaleDialog( + onDismissRequest: () -> Unit, + onClick: () -> Unit, +) { + BasicDialog( + onDismissRequest = onDismissRequest, + ) { + Column( + verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.padding(16.dp), + ) { + Text( + text = stringResource(R.string.visualizer_rationale), + color = MaterialTheme.colorScheme.onSurface, + ) + Button( + onClick = onClick, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) { + Text( + text = stringResource(R.string.continue_string), + ) + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingState.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingState.kt new file mode 100644 index 00000000..f6fe40e4 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingState.kt @@ -0,0 +1,17 @@ +package com.github.damontecres.wholphin.ui.detail.music + +import androidx.compose.runtime.Stable +import com.github.damontecres.wholphin.services.BackdropResult +import com.github.damontecres.wholphin.services.MusicServiceState +import org.jellyfin.sdk.model.api.LyricDto + +@Stable +data class NowPlayingState( + val musicServiceState: MusicServiceState, + val lyrics: LyricDto? = null, + val currentLyricIndex: Int? = null, + val visualizerPermissions: Boolean = false, + val backdropResult: BackdropResult = BackdropResult.NONE, +) { + val hasLyrics: Boolean get() = lyrics != null && lyrics.lyrics.isNotEmpty() +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingViewModel.kt new file mode 100644 index 00000000..f1cc9225 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingViewModel.kt @@ -0,0 +1,406 @@ +package com.github.damontecres.wholphin.ui.detail.music + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.media.audiofx.Visualizer +import androidx.core.content.ContextCompat +import androidx.datastore.core.DataStore +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi +import com.github.damontecres.wholphin.data.model.AudioItem +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.preferences.AppPreference +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.updateMusicPreferences +import com.github.damontecres.wholphin.services.BackdropResult +import com.github.damontecres.wholphin.services.BackdropService +import com.github.damontecres.wholphin.services.ImageUrlService +import com.github.damontecres.wholphin.services.MusicService +import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.NowPlayingStatus +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 +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.isActive +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.itemsApi +import org.jellyfin.sdk.api.client.extensions.lyricsApi +import org.jellyfin.sdk.api.client.extensions.userLibraryApi +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.ImageType +import org.jellyfin.sdk.model.api.ItemSortBy +import org.jellyfin.sdk.model.api.LyricDto +import org.jellyfin.sdk.model.extensions.ticks +import timber.log.Timber +import java.util.UUID +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +@UnstableApi +@HiltViewModel(assistedFactory = NowPlayingViewModel.Factory::class) +class NowPlayingViewModel + @AssistedInject + constructor( + @param:ApplicationContext private val context: Context, + private val api: ApiClient, + private val musicService: MusicService, + private val backdropService: BackdropService, + private val imageUrlService: ImageUrlService, + private val preferencesDataStore: DataStore, + val navigationManager: NavigationManager, + val userPreferencesService: UserPreferencesService, + ) : ViewModel(), + Visualizer.OnDataCaptureListener, + Player.Listener { + @AssistedFactory + interface Factory { + fun create(): NowPlayingViewModel + } + + private val visualizerMutex = Mutex() + private var visualizer: Visualizer? = null + + val controllerViewState = + ControllerViewState( + AppPreference.ControllerTimeout.defaultValue, + true, + ) + + val state = MutableStateFlow(NowPlayingState(musicService.state.value)) + val player get() = musicService.player + + val viz = MutableStateFlow(IntArray(0)) + + private val lyricCache = + InMemoryKache(20) { + creationScope = CoroutineScope(Dispatchers.IO) + } + + init { + player.addListener(this) + addCloseable { + player.removeListener(this) + visualizer?.release() + } + val visualizerPermissions = + ContextCompat.checkSelfPermission( + context, + Manifest.permission.RECORD_AUDIO, + ) == PackageManager.PERMISSION_GRANTED + startVisualizer(visualizerPermissions, false) + viewModelScope.launchDefault { + musicService.state.collectLatest { musicServiceState -> + if (musicServiceState.status != NowPlayingStatus.IDLE) { + visualizer?.enabled = musicServiceState.status == NowPlayingStatus.PLAYING + } + + state.update { it.copy(musicServiceState = musicServiceState) } + } + } + viewModelScope.launchDefault { + viewModelScope + .launchDefault { + controllerViewState.observe() + }.join() + controllerViewState.pulseControls() + } + viewModelScope.launchDefault { + backdropService.clearBackdrop() + updateBackdrop(getCurrent()) + } + playbackLoop() + } + + fun reportInteraction() { + controllerViewState.pulseControls() + } + + private suspend fun getCurrent(): AudioItem? { + val mediaItem = + onMain { + player.currentMediaItemIndex + .takeIf { it in 0.. +// Timber.v("Got current %s", audio.id) + if (audio.hasLyrics) { + val lyrics = + lyricCache.getOrPut(audio.id) { + // TODO remote lyrics? + api.lyricsApi.getLyrics(audio.id).content + } + val lyricIndex = + if (lyrics != null) { + val offset = lyrics.metadata.offset?.ticks ?: Duration.ZERO + val lyricPosition = offset + position + lyrics.lyrics + .indexOfLast { + it.start?.ticks?.let { lyricPosition >= it } == true + }.takeIf { it >= 0 } + } else { + null + } +// Timber.v("lyricIndex=$lyricIndex") + state.update { + it.copy( + lyrics = lyrics, + currentLyricIndex = lyricIndex, + ) + } + } + } + + delay(150) + } + } + } + + override fun onMediaItemTransition( + mediaItem: MediaItem?, + reason: Int, + ) { + val audio = mediaItem?.localConfiguration?.tag as? AudioItem + Timber.v("onMediaItemTransition to %s", audio?.id) + updateBackdrop(audio) + viewModelScope.launchDefault { + state.update { + it.copy( + lyrics = null, + currentLyricIndex = null, + ) + } + audio?.let { audio -> + if (audio.hasLyrics) { + val lyrics = + lyricCache.getOrPut(audio.id) { + // TODO remote lyrics? + api.lyricsApi.getLyrics(audio.id).content + } + Timber.d("Got lyrics for %s: %s", audio.id, lyrics != null) + state.update { + it.copy( + lyrics = lyrics, + ) + } + } + } + } + } + + private var backDropJob: Job? = null + + private fun updateBackdrop(audio: AudioItem?) { + backDropJob?.cancel() + backDropJob = + viewModelScope.launchDefault { + val showBackdrop = + userPreferencesService + .getCurrent() + .appPreferences.musicPreferences.showBackdrop + if (showBackdrop) { + var backdropItem: BaseItem? = null + try { + if (audio?.artistId != null) { + api.userLibraryApi.getItem(audio.artistId).content.let { + if (it.backdropImageTags?.isNotEmpty() == true) { + backdropItem = BaseItem(it, false) + } + } + } + if (backdropItem == null && audio?.albumId != null) { + api.userLibraryApi.getItem(audio.albumId).content.let { + backdropItem = + getBackdropItemForAlbum(api, BaseItem(it, false)) + } + } + if (backdropItem != null) { + doUpdateBackdrop(backdropItem) + } else { + doUpdateBackdropRandom() + } + } catch (ex: Exception) { + Timber.e(ex, "Error fetching backdrop") + doUpdateBackdropRandom() + } + delay(60.seconds) + doUpdateBackdropRandom() + } + } + } + + private suspend fun doUpdateBackdropRandom() { + val randomArtist = + api.itemsApi + .getItems( + recursive = true, + imageTypes = listOf(ImageType.BACKDROP), + includeItemTypes = listOf(BaseItemKind.MUSIC_ARTIST), + sortBy = listOf(ItemSortBy.RANDOM), + limit = 1, + ).content.items + .firstOrNull() + if (randomArtist != null) { + doUpdateBackdrop(BaseItem(randomArtist)) + } else { + clearBackdrop() + } + } + + private suspend fun doUpdateBackdrop(item: BaseItem) { + val imageUrl = imageUrlService.getItemImageUrl(item, ImageType.BACKDROP) + val (primaryColor, secondaryColor, tertiaryColor) = + backdropService.extractColorsFromBackdrop( + imageUrl, + ) + val backdropResult = + BackdropResult( + itemId = item.id.toString(), + imageUrl = imageUrl, + primaryColor = primaryColor, + secondaryColor = secondaryColor, + tertiaryColor = tertiaryColor, + ) + state.update { it.copy(backdropResult = backdropResult) } + } + + private fun clearBackdrop() { + state.update { it.copy(backdropResult = BackdropResult.NONE) } + } + + fun moveQueue( + index: Int, + direction: MoveDirection, + ) = viewModelScope.launchDefault { musicService.moveQueue(index, direction) } + + fun play(index: Int) = viewModelScope.launchDefault { musicService.playIndex(index) } + + fun playNext(index: Int) = viewModelScope.launchDefault { musicService.moveQueue(index, 1) } + + fun removeFromQueue(index: Int) = viewModelScope.launchDefault { musicService.removeFromQueue(index) } + + fun stop() { + viewModelScope.launchDefault { + musicService.stop() + navigationManager.goBack() + } + } + + override fun onFftDataCapture( + visualizer: Visualizer, + fft: ByteArray, + samplingRate: Int, + ) { + } + + override fun onWaveFormDataCapture( + visualizer: Visualizer, + waveform: ByteArray, + samplingRate: Int, + ) { + val resolution = 96 + val captureSize = + Visualizer.getCaptureSizeRange()[1] + val groupSize = (captureSize / resolution.toFloat()).toInt() + val processed = + waveform + .toList() + .chunked(groupSize) + .map { it.average().toInt() + 128 } + .toIntArray() + viz.update { processed } + } + + fun updatePreferences(prefs: AppPreferences) { + viewModelScope.launchDefault { + var backdropChanged = false + preferencesDataStore.updateData { + backdropChanged = + it.musicPreferences.showBackdrop != prefs.musicPreferences.showBackdrop + prefs + } + if (backdropChanged) { + if (prefs.musicPreferences.showBackdrop) { + updateBackdrop(getCurrent()) + } else { + clearBackdrop() + } + } + } + } + + private fun initVisualizer() { + viewModelScope.launchDefault { + visualizerMutex.withLock { + val prefs = preferencesDataStore.data.first() + if (visualizer == null && + state.value.visualizerPermissions && + prefs.musicPreferences.showVisualizer + ) { + Timber.v("Creating visualizer") + visualizer = + Visualizer(onMain { player.audioSessionId }).apply { + captureSize = Visualizer.getCaptureSizeRange()[1] + setDataCaptureListener( + this@NowPlayingViewModel, + Visualizer.getMaxCaptureRate() / 3, + true, + false, + ) + enabled = true + } + } + } + } + } + + fun startVisualizer( + permissionGranted: Boolean, + updatePreferences: Boolean, + ) { + Timber.v("startVisualizer: permissionGranted=%s", permissionGranted) + state.update { + it.copy( + visualizerPermissions = permissionGranted, + ) + } + viewModelScope.launchDefault { + if (updatePreferences || !permissionGranted) { + preferencesDataStore.updateData { + it.updateMusicPreferences { showVisualizer = permissionGranted } + } + } + initVisualizer() + } + } + } 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..82846282 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/SongListDisplay.kt @@ -0,0 +1,237 @@ +package com.github.damontecres.wholphin.ui.detail.music + +import androidx.compose.foundation.background +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.tv.material3.Icon +import androidx.tv.material3.ListItem +import androidx.tv.material3.ListItemDefaults +import androidx.tv.material3.LocalContentColor +import androidx.tv.material3.Text +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.ui.PreviewTvSpec +import com.github.damontecres.wholphin.ui.components.Button +import com.github.damontecres.wholphin.ui.enableMarquee +import com.github.damontecres.wholphin.ui.letNotEmpty +import com.github.damontecres.wholphin.ui.roundSeconds +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 SongListItem( + song: BaseItem?, + onClick: () -> Unit, + onLongClick: () -> Unit, + onClickMore: () -> Unit, + modifier: Modifier = Modifier, + showArtist: Boolean = false, + isPlaying: Boolean = false, + isQueued: Boolean = false, +) = SongListItem( + title = song?.title, + artist = if (showArtist) song?.data?.albumArtist else null, + indexNumber = song?.data?.indexNumber, + runtime = + remember(song) { + song + ?.data + ?.runTimeTicks + ?.ticks + ?.roundSeconds + }, + onClick = onClick, + onLongClick = onLongClick, + modifier = modifier, + showArtist = showArtist, + isPlaying = isPlaying, + isQueued = isQueued, + showMoreButton = true, + onClickMore = onClickMore, +) + +@Composable +fun SongListItem( + title: String?, + artist: String?, + indexNumber: Int?, + runtime: Duration?, + onClick: () -> Unit, + onLongClick: () -> Unit, + modifier: Modifier = Modifier, + showArtist: Boolean = false, + isPlaying: Boolean = false, + isQueued: Boolean = false, + showMoreButton: Boolean = false, + onClickMore: () -> Unit = {}, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier, + ) { + val focused by interactionSource.collectIsFocusedAsState() + val leadingContent: @Composable (BoxScope.() -> Unit) = { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = indexNumber?.toString() ?: "", + ) + MusicQueueMarker( + isPlaying = isPlaying, + isQueued = isQueued, + ) + } + } + val headlineContent = @Composable { + Text( + text = title ?: "", + maxLines = 1, + modifier = Modifier.enableMarquee(focused), + ) + } + val trailingContent = @Composable { + Text( + text = runtime.toString(), + ) + } + + 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.weight(1f), + ) + } 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.weight(1f), + ) + } + if (showMoreButton) { + Button( + onClick = onClickMore, + enabled = true, + modifier = Modifier.size(32.dp), + ) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = stringResource(R.string.more), + ) + } + } + } +} + +val BaseItem.artistsString: String? get() = data.artists?.letNotEmpty { it.joinToString(", ") } + +/** + * Add an indicator for if the item is currently playing or queued + */ +@Composable +fun MusicQueueMarker( + isPlaying: Boolean, + isQueued: Boolean, + modifier: Modifier = Modifier, +) { + if (isPlaying) { + Icon( + imageVector = Icons.Default.PlayArrow, + contentDescription = null, + modifier = modifier, + ) + } else if (isQueued) { + Box( + modifier = + modifier + .padding(horizontal = 8.dp) + .clip(CircleShape) + .background(LocalContentColor.current) + .size(8.dp), + ) + } +} + +@PreviewTvSpec +@Composable +fun SongListItemPreview() { + WholphinTheme { + Column { + SongListItem( + title = "Song title", + artist = "Artists", + indexNumber = 1, + runtime = 2.minutes + 30.seconds, + onClick = {}, + onLongClick = { }, + modifier = Modifier, + showArtist = false, + ) + SongListItem( + title = "Song title", + artist = "Artists", + indexNumber = 1, + runtime = 2.minutes + 30.seconds, + onClick = {}, + onLongClick = { }, + modifier = Modifier, + showArtist = false, + isQueued = true, + showMoreButton = true, + ) + SongListItem( + title = "Song title", + artist = "Artists", + indexNumber = 2, + runtime = 2.minutes + 30.seconds, + onClick = {}, + onLongClick = { }, + modifier = Modifier, + showArtist = true, + isPlaying = true, + ) + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/Utils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/Utils.kt new file mode 100644 index 00000000..5fae787c --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/Utils.kt @@ -0,0 +1,269 @@ +package com.github.damontecres.wholphin.ui.detail.music + +import android.content.Context +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.ArrowForward +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.ui.graphics.Color +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.model.AudioItem +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.ui.DefaultItemFields +import com.github.damontecres.wholphin.ui.components.DialogItem +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.util.ApiRequestPager +import com.github.damontecres.wholphin.util.GetItemsRequestHandler +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.request.GetItemsRequest +import java.util.UUID + +data class MusicMoreDialogActions( + val onNavigate: (Destination) -> Unit, + val onClickPlay: (Int, BaseItem) -> Unit, + val onClickPlayNext: (Int, BaseItem) -> Unit, + val onClickAddToQueue: (Int, BaseItem) -> Unit, + val onClickFavorite: (UUID, Boolean) -> Unit, + val onClickAddPlaylist: (UUID) -> Unit, + val onClickGoToAlbum: (UUID) -> Unit = { + onNavigate.invoke(Destination.MediaItem(itemId = it, type = BaseItemKind.MUSIC_ALBUM)) + }, + val onClickGoToArtist: (UUID) -> Unit = { + onNavigate.invoke(Destination.MediaItem(itemId = it, type = BaseItemKind.MUSIC_ARTIST)) + }, + val onClickRemoveFromQueue: (Int) -> Unit, + val onClickDelete: (BaseItem) -> Unit, +) + +fun buildMoreDialogForMusic( + context: Context, + actions: MusicMoreDialogActions, + item: BaseItem, + index: Int, + canRemove: Boolean, + canDelete: Boolean, +): List = + buildList { + add( + DialogItem( + context.getString(R.string.play), + Icons.Default.PlayArrow, + iconColor = Color.Green.copy(alpha = .8f), + ) { + actions.onClickPlay(index, item) + }, + ) + add( + DialogItem( + context.getString(R.string.play_next), + Icons.Default.PlayArrow, + iconColor = Color.Green.copy(alpha = .8f), + ) { + actions.onClickPlayNext(index, item) + }, + ) + if (canRemove) { + add( + DialogItem( + context.getString(R.string.remove_from_queue), + Icons.Default.Delete, + ) { + actions.onClickRemoveFromQueue(index) + }, + ) + } + add( + DialogItem( + context.getString(R.string.add_to_queue), + Icons.Default.Add, + ) { + actions.onClickAddToQueue(index, item) + }, + ) + add( + DialogItem( + text = R.string.add_to_playlist, + iconStringRes = R.string.fa_list_ul, + ) { + actions.onClickAddPlaylist.invoke(item.id) + }, + ) + if (canDelete) { + add( + DialogItem( + context.getString(R.string.delete), + Icons.Default.Delete, + iconColor = Color.Red.copy(alpha = .8f), + ) { + actions.onClickDelete.invoke(item) + }, + ) + } + add( + DialogItem( + text = if (item.favorite) R.string.remove_favorite else R.string.add_favorite, + iconStringRes = R.string.fa_heart, + iconColor = if (item.favorite) Color.Red else Color.Unspecified, + ) { + actions.onClickFavorite.invoke(item.id, !item.favorite) + }, + ) + if (item.type == BaseItemKind.AUDIO && item.data.albumId != null) { + add( + DialogItem( + context.getString(R.string.go_to_album), + R.string.fa_compact_disc, + ) { + actions.onClickGoToAlbum.invoke(item.data.albumId!!) + }, + ) + } + if ((item.type == BaseItemKind.AUDIO || item.type == BaseItemKind.MUSIC_ALBUM) && item.data.artistItems?.isNotEmpty() == true) { + add( + DialogItem( + context.getString(R.string.go_to_artist), + R.string.fa_user, + ) { + actions.onClickGoToArtist.invoke( + item.data.artistItems!! + .first() + .id, + ) + }, + ) + } + } + +data class MusicQueueDialogActions( + val onNavigate: (Destination) -> Unit, + val onClickPlay: (Int, AudioItem) -> Unit, + val onClickPlayNext: (Int, AudioItem) -> Unit, + val onClickGoToAlbum: (UUID) -> Unit = { + onNavigate.invoke(Destination.MediaItem(itemId = it, type = BaseItemKind.MUSIC_ALBUM)) + }, + val onClickGoToArtist: (UUID) -> Unit = { + onNavigate.invoke(Destination.MediaItem(itemId = it, type = BaseItemKind.MUSIC_ARTIST)) + }, + val onClickRemoveFromQueue: (Int, AudioItem) -> Unit, +) + +fun buildMoreDialogForMusicQueue( + context: Context, + actions: MusicQueueDialogActions, + item: AudioItem, + index: Int, + canRemove: Boolean, +): List = + buildList { + add( + DialogItem( + context.getString(R.string.play), + Icons.Default.PlayArrow, + iconColor = Color.Green.copy(alpha = .8f), + ) { + actions.onClickPlay(index, item) + }, + ) + add( + DialogItem( + context.getString(R.string.play_next), + Icons.Default.PlayArrow, + iconColor = Color.Green.copy(alpha = .8f), + ) { + actions.onClickPlayNext(index, item) + }, + ) + if (canRemove) { + add( + DialogItem( + context.getString(R.string.remove_from_queue), + Icons.Default.Delete, + ) { + actions.onClickRemoveFromQueue(index, item) + }, + ) + } + if (item.albumId != null) { + add( + DialogItem( + context.getString(R.string.go_to_album), + Icons.Default.ArrowForward, + ) { + actions.onClickGoToAlbum.invoke(item.albumId) + }, + ) + } + if (item.artistId != null) { + add( + DialogItem( + context.getString(R.string.go_to_artist), + Icons.Default.ArrowForward, + ) { + actions.onClickGoToArtist.invoke(item.artistId) + }, + ) + } + } + +suspend fun ViewModel.getPagerForAlbum( + api: ApiClient, + albumId: UUID, +): ApiRequestPager { + val request = + GetItemsRequest( + parentId = albumId, + includeItemTypes = listOf(BaseItemKind.AUDIO), + fields = DefaultItemFields, + sortBy = + listOf( + ItemSortBy.PARENT_INDEX_NUMBER, + ItemSortBy.INDEX_NUMBER, + ItemSortBy.SORT_NAME, + ), + ) + return ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope).init() +} + +suspend fun ViewModel.getPagerForArtist( + api: ApiClient, + artistId: UUID, +): ApiRequestPager { + val request = + GetItemsRequest( + artistIds = listOf(artistId), + recursive = true, + includeItemTypes = listOf(BaseItemKind.AUDIO), + fields = DefaultItemFields, + // TODO better sort + sortBy = + listOf( + ItemSortBy.PARENT_INDEX_NUMBER, + ItemSortBy.INDEX_NUMBER, + ItemSortBy.SORT_NAME, + ), + ) + return ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope).init() +} + +suspend fun ViewModel.getPagerForPlaylist( + api: ApiClient, + playlistId: UUID, +): ApiRequestPager { + val request = + GetItemsRequest( + parentId = playlistId, + recursive = true, + includeItemTypes = listOf(BaseItemKind.AUDIO), + fields = DefaultItemFields, + sortBy = + listOf( + ItemSortBy.DEFAULT, + ), + ) + return ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope).init() +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/SearchPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/SearchPage.kt index a7fade4e..fde8e3aa 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/SearchPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/SearchPage.kt @@ -51,6 +51,7 @@ import com.github.damontecres.wholphin.data.model.SeerrItemType import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.SeerrService +import com.github.damontecres.wholphin.ui.AspectRatios import com.github.damontecres.wholphin.ui.Cards import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard @@ -76,6 +77,8 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.model.api.BaseItemKind @@ -100,10 +103,15 @@ class SearchViewModel val series = MutableLiveData(SearchResult.NoQuery) val episodes = MutableLiveData(SearchResult.NoQuery) val collections = MutableLiveData(SearchResult.NoQuery) + val albums = MutableLiveData(SearchResult.NoQuery) + val artists = MutableLiveData(SearchResult.NoQuery) + val songs = MutableLiveData(SearchResult.NoQuery) val seerrResults = MutableLiveData(SearchResult.NoQuery) private var currentQuery: String? = null + private val semaphore = Semaphore(4) + fun search(query: String?) { if (currentQuery == query) { return @@ -117,6 +125,9 @@ class SearchViewModel searchInternal(query, BaseItemKind.MOVIE, movies) searchInternal(query, BaseItemKind.SERIES, series) searchInternal(query, BaseItemKind.EPISODE, episodes) + searchInternal(query, BaseItemKind.MUSIC_ALBUM, albums) + searchInternal(query, BaseItemKind.MUSIC_ARTIST, artists) + searchInternal(query, BaseItemKind.AUDIO, songs) searchInternal(query, BaseItemKind.BOX_SET, collections) searchSeerr(query) } else { @@ -135,19 +146,21 @@ class SearchViewModel ) { viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { try { - val request = - GetItemsRequest( - searchTerm = query, - recursive = true, - includeItemTypes = listOf(type), - fields = SlimItemFields, - limit = 25, - ) - val pager = - ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope) - pager.init() - withContext(Dispatchers.Main) { - target.value = SearchResult.Success(pager) + semaphore.withPermit { + val request = + GetItemsRequest( + searchTerm = query, + recursive = true, + includeItemTypes = listOf(type), + fields = SlimItemFields, + limit = 25, + ) + val pager = + ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope) + pager.init() + withContext(Dispatchers.Main) { + target.value = SearchResult.Success(pager) + } } } catch (ex: Exception) { Timber.e(ex, "Exception searching for $type") @@ -202,10 +215,13 @@ sealed interface SearchResult { private const val SEARCH_ROW = 0 private const val MOVIE_ROW = SEARCH_ROW + 1 -private const val COLLECTION_ROW = MOVIE_ROW + 1 -private const val SERIES_ROW = COLLECTION_ROW + 1 +private const val SERIES_ROW = MOVIE_ROW + 1 private const val EPISODE_ROW = SERIES_ROW + 1 -private const val SEERR_ROW = EPISODE_ROW + 1 +private const val ALBUM_ROW = EPISODE_ROW + 1 +private const val ARTIST_ROW = ALBUM_ROW + 1 +private const val SONG_ROW = ARTIST_ROW + 1 +private const val COLLECTION_ROW = SONG_ROW + 1 +private const val SEERR_ROW = COLLECTION_ROW + 1 /** Delay for focus to settle after voice search dialog dismisses. */ private const val VOICE_RESULT_FOCUS_DELAY_MS = 350L @@ -223,6 +239,9 @@ fun SearchPage( val collections by viewModel.collections.observeAsState(SearchResult.NoQuery) val series by viewModel.series.observeAsState(SearchResult.NoQuery) val episodes by viewModel.episodes.observeAsState(SearchResult.NoQuery) + val albums by viewModel.albums.observeAsState(SearchResult.NoQuery) + val artists by viewModel.artists.observeAsState(SearchResult.NoQuery) + val songs by viewModel.songs.observeAsState(SearchResult.NoQuery) val seerrResults by viewModel.seerrResults.observeAsState(SearchResult.NoQuery) // val query = rememberTextFieldState() @@ -367,16 +386,6 @@ fun SearchPage( onClickPosition = { position = it }, modifier = Modifier.fillMaxWidth(), ) - searchResultRow( - title = context.getString(R.string.collections), - result = collections, - rowIndex = COLLECTION_ROW, - position = position, - focusRequester = focusRequesters[COLLECTION_ROW], - onClickItem = onClickItem, - onClickPosition = { position = it }, - modifier = Modifier.fillMaxWidth(), - ) searchResultRow( title = context.getString(R.string.tv_shows), result = series, @@ -409,6 +418,85 @@ fun SearchPage( ) }, ) + searchResultRow( + title = context.getString(R.string.albums), + result = albums, + rowIndex = ALBUM_ROW, + position = position, + focusRequester = focusRequesters[ALBUM_ROW], + onClickItem = onClickItem, + onClickPosition = { position = it }, + modifier = Modifier.fillMaxWidth(), + cardContent = { index, item, mod, onClick, onLongClick -> + SeasonCard( + item = item, + onClick = { + position = RowColumn(ALBUM_ROW, index) + onClick.invoke() + }, + onLongClick = onLongClick, + imageHeight = Cards.heightEpisode, + aspectRatio = AspectRatios.SQUARE, + modifier = mod, + ) + }, + ) + searchResultRow( + title = context.getString(R.string.artists), + result = artists, + rowIndex = COLLECTION_ROW, + position = position, + focusRequester = focusRequesters[COLLECTION_ROW], + onClickItem = onClickItem, + onClickPosition = { position = it }, + modifier = Modifier.fillMaxWidth(), + cardContent = { index, item, mod, onClick, onLongClick -> + SeasonCard( + item = item, + onClick = { + position = RowColumn(ALBUM_ROW, index) + onClick.invoke() + }, + onLongClick = onLongClick, + imageHeight = Cards.heightEpisode, + aspectRatio = AspectRatios.SQUARE, + modifier = mod, + ) + }, + ) + searchResultRow( + title = context.getString(R.string.songs), + result = songs, + rowIndex = SONG_ROW, + position = position, + focusRequester = focusRequesters[SONG_ROW], + onClickItem = onClickItem, + onClickPosition = { position = it }, + modifier = Modifier.fillMaxWidth(), + cardContent = { index, item, mod, onClick, onLongClick -> + SeasonCard( + item = item, + onClick = { + position = RowColumn(ALBUM_ROW, index) + onClick.invoke() + }, + onLongClick = onLongClick, + imageHeight = Cards.heightEpisode, + aspectRatio = AspectRatios.SQUARE, + modifier = mod, + ) + }, + ) + searchResultRow( + title = context.getString(R.string.collections), + result = collections, + rowIndex = COLLECTION_ROW, + position = position, + focusRequester = focusRequesters[COLLECTION_ROW], + onClickItem = onClickItem, + onClickPosition = { position = it }, + modifier = Modifier.fillMaxWidth(), + ) searchResultRow( title = context.getString(R.string.discover), result = seerrResults, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowPresets.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowPresets.kt index eaf44d81..5714723e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowPresets.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowPresets.kt @@ -29,6 +29,7 @@ data class HomeRowPresets( val tvLibrary: HomeRowViewOptions, val videoLibrary: HomeRowViewOptions, val photoLibrary: HomeRowViewOptions, + val musicLibrary: HomeRowViewOptions, val playlist: HomeRowViewOptions, val liveTv: HomeRowViewOptions, val genreSize: Int, @@ -51,8 +52,9 @@ data class HomeRowPresets( CollectionType.LIVETV -> liveTv + CollectionType.MUSIC -> musicLibrary + CollectionType.UNKNOWN, - CollectionType.MUSIC, CollectionType.BOOKS, CollectionType.PLAYLISTS, CollectionType.FOLDERS, @@ -74,6 +76,10 @@ data class HomeRowPresets( aspectRatio = AspectRatio.WIDE, contentScale = PrefContentScale.CROP, ), + musicLibrary = + HomeRowViewOptions( + aspectRatio = AspectRatio.SQUARE, + ), playlist = HomeRowViewOptions( aspectRatio = AspectRatio.SQUARE, @@ -111,6 +117,11 @@ data class HomeRowPresets( aspectRatio = AspectRatio.WIDE, contentScale = PrefContentScale.CROP, ), + musicLibrary = + HomeRowViewOptions( + heightDp = epHeight, + aspectRatio = AspectRatio.SQUARE, + ), playlist = HomeRowViewOptions( heightDp = epHeight, @@ -154,6 +165,11 @@ data class HomeRowPresets( aspectRatio = AspectRatio.WIDE, contentScale = PrefContentScale.CROP, ), + musicLibrary = + HomeRowViewOptions( + heightDp = epHeight, + aspectRatio = AspectRatio.SQUARE, + ), playlist = HomeRowViewOptions( heightDp = epHeight, @@ -198,6 +214,11 @@ data class HomeRowPresets( aspectRatio = AspectRatio.WIDE, contentScale = PrefContentScale.CROP, ), + musicLibrary = + HomeRowViewOptions( + heightDp = epHeight, + aspectRatio = AspectRatio.SQUARE, + ), playlist = HomeRowViewOptions( heightDp = epHeight, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsFavoriteList.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsFavoriteList.kt index 3bac590a..5bf3056d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsFavoriteList.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsFavoriteList.kt @@ -59,6 +59,8 @@ val favoriteOptions by lazy { BaseItemKind.VIDEO to R.string.videos, BaseItemKind.PLAYLIST to R.string.playlists, BaseItemKind.PERSON to R.string.people, + BaseItemKind.MUSIC_ARTIST to R.string.artists, + BaseItemKind.MUSIC_ALBUM to R.string.albums, ) } val favoriteOptionsList by lazy { favoriteOptions.keys.toList() } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt index 4d2864aa..50f34513 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt @@ -33,6 +33,7 @@ import com.github.damontecres.wholphin.services.UnsupportedHomeSettingsVersionEx import com.github.damontecres.wholphin.services.UserPreferencesService import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope import com.github.damontecres.wholphin.services.tvAccess +import com.github.damontecres.wholphin.ui.AspectRatio import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.util.HomeRowLoadingState @@ -267,6 +268,24 @@ class HomeSettingsViewModel rowType: LibraryRowType, ): Job = viewModelScope.launchIO { + val viewOptions = + when (library.collectionType) { + CollectionType.MUSIC -> { + HomeRowViewOptions(aspectRatio = AspectRatio.SQUARE) + } + + CollectionType.HOMEVIDEOS, + CollectionType.MUSICVIDEOS, + -> { + HomeRowViewOptions( + aspectRatio = AspectRatio.WIDE, + ) + } + + else -> { + HomeRowViewOptions() + } + } val id = idCounter++ val newRow = when (rowType) { @@ -276,7 +295,7 @@ class HomeSettingsViewModel HomeRowConfigDisplay( id = id, title = title, - config = RecentlyAdded(library.itemId), + config = RecentlyAdded(library.itemId, viewOptions), ) } @@ -291,7 +310,7 @@ class HomeSettingsViewModel HomeRowConfigDisplay( id = id, title = title, - config = RecentlyReleased(library.itemId), + config = RecentlyReleased(library.itemId, viewOptions), ) } @@ -310,7 +329,7 @@ class HomeSettingsViewModel HomeRowConfigDisplay( id = id, title = title, - config = Suggestions(library.itemId), + config = Suggestions(library.itemId, viewOptions), ) } @@ -671,6 +690,7 @@ class HomeSettingsViewModel BaseItemKind.VIDEO -> preset.videoLibrary BaseItemKind.PLAYLIST -> preset.playlist BaseItemKind.PERSON -> preset.movieLibrary + BaseItemKind.MUSIC_ARTIST, BaseItemKind.MUSIC_ALBUM -> preset.musicLibrary else -> preset.movieLibrary } it.config.updateViewOptions(viewOptions) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt index 0eafa0cc..0cf42622 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt @@ -1,53 +1,28 @@ package com.github.damontecres.wholphin.ui.nav -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.core.tween import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.drawBehind -import androidx.compose.ui.draw.drawWithContent -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.BlendMode -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.CompositingStrategy -import androidx.compose.ui.graphics.SolidColor -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.ViewModel -import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator import androidx.navigation3.runtime.NavEntry import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator import androidx.navigation3.ui.NavDisplay import androidx.tv.material3.DrawerValue -import androidx.tv.material3.MaterialTheme import androidx.tv.material3.rememberDrawerState -import coil3.compose.AsyncImage -import coil3.request.ImageRequest -import coil3.request.transitionFactory import com.github.damontecres.wholphin.data.model.JellyfinServer import com.github.damontecres.wholphin.data.model.JellyfinUser -import com.github.damontecres.wholphin.preferences.BackdropStyle import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.NavigationManager -import com.github.damontecres.wholphin.ui.CrossFadeFactory import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.launchIO import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject -import kotlin.time.Duration.Companion.milliseconds // Top scrim configuration for text readability (clock, season tabs) const val TOP_SCRIM_ALPHA = 0.55f @@ -79,144 +54,17 @@ fun ApplicationContent( enableTopScrim: Boolean = true, viewModel: ApplicationContentViewModel = hiltViewModel(), ) { - val backdrop by viewModel.backdropService.backdropFlow.collectAsStateWithLifecycle() - val backdropStyle = preferences.appPreferences.interfacePreferences.backdropStyle val drawerState = rememberDrawerState(DrawerValue.Closed) Box( modifier = modifier, ) { - val baseBackgroundColor = MaterialTheme.colorScheme.background - if (backdrop.hasColors && - (backdropStyle == BackdropStyle.BACKDROP_DYNAMIC_COLOR || backdropStyle == BackdropStyle.UNRECOGNIZED) - ) { - val animPrimary by animateColorAsState( - backdrop.primaryColor, - animationSpec = tween(1250), - label = "dynamic_backdrop_primary", - ) - val animSecondary by animateColorAsState( - backdrop.secondaryColor, - animationSpec = tween(1250), - label = "dynamic_backdrop_secondary", - ) - val animTertiary by animateColorAsState( - backdrop.tertiaryColor, - animationSpec = tween(1250), - label = "dynamic_backdrop_tertiary", - ) - Box( - modifier = - Modifier - .fillMaxSize() - .drawBehind { - drawRect(color = baseBackgroundColor) - // Top Left (Vibrant/Muted) - drawRect( - brush = - Brush.radialGradient( - colors = listOf(animSecondary, Color.Transparent), - center = Offset(0f, 0f), - radius = size.width * 0.8f, - ), - ) - // Bottom Right (DarkVibrant/DarkMuted) - drawRect( - brush = - Brush.radialGradient( - colors = listOf(animPrimary, Color.Transparent), - center = Offset(size.width, size.height), - radius = size.width * 0.8f, - ), - ) - // Bottom Left (Dark / Bridge) - drawRect( - brush = - Brush.radialGradient( - colors = - listOf( - baseBackgroundColor, - Color.Transparent, - ), - center = Offset(0f, size.height), - radius = size.width * 0.8f, - ), - ) - // Top Right (Under Image - Vibrant/Bright) - drawRect( - brush = - Brush.radialGradient( - colors = listOf(animTertiary, Color.Transparent), - center = Offset(size.width, 0f), - radius = size.width * 0.8f, - ), - ) - }, - ) - } - if (backdropStyle != BackdropStyle.BACKDROP_NONE) { - Box( - modifier = Modifier.fillMaxSize(), - ) { - AsyncImage( - model = - ImageRequest - .Builder(LocalContext.current) - .data(backdrop.imageUrl) - .transitionFactory(CrossFadeFactory(800.milliseconds)) - .build(), - contentDescription = null, - contentScale = ContentScale.Fit, - alignment = Alignment.TopEnd, - modifier = - Modifier - .align(Alignment.TopEnd) - .fillMaxHeight(.7f) - .fillMaxWidth(.7f) - .graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen) - .drawWithContent { - drawContent() - if (drawerState.isOpen) { - drawRect( - brush = SolidColor(Color.Black), - alpha = .75f, - ) - } - // Subtle top scrim for system UI readability (clock, tabs) - if (enableTopScrim) { - drawRect( - brush = - Brush.verticalGradient( - colorStops = - arrayOf( - 0f to Color.Black.copy(alpha = TOP_SCRIM_ALPHA), - TOP_SCRIM_END_FRACTION to Color.Transparent, - ), - ), - blendMode = BlendMode.Multiply, - ) - } - drawRect( - brush = - Brush.horizontalGradient( - colors = listOf(Color.Transparent, Color.Black), - startX = 0f, - endX = size.width * 0.6f, - ), - blendMode = BlendMode.DstIn, - ) - drawRect( - brush = - Brush.verticalGradient( - colors = listOf(Color.Black, Color.Transparent), - startY = 0f, - endY = size.height, - ), - blendMode = BlendMode.DstIn, - ) - }, - ) - } - } + val backdropStyle = preferences.appPreferences.interfacePreferences.backdropStyle + Backdrop( + drawerIsOpen = drawerState.isOpen, + backdropStyle = backdropStyle, + enableTopScrim = enableTopScrim, + viewModel = viewModel, + ) val navDrawerListState = rememberLazyListState() NavDisplay( backStack = navigationManager.backStack, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Backdrop.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Backdrop.kt new file mode 100644 index 00000000..21520447 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Backdrop.kt @@ -0,0 +1,205 @@ +@file:OptIn(ExperimentalCoilApi::class) + +package com.github.damontecres.wholphin.ui.nav + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.tv.material3.MaterialTheme +import coil3.annotation.ExperimentalCoilApi +import coil3.compose.AsyncImage +import coil3.compose.useExistingImageAsPlaceholder +import coil3.request.ImageRequest +import coil3.request.transitionFactory +import com.github.damontecres.wholphin.preferences.BackdropStyle +import com.github.damontecres.wholphin.services.BackdropResult +import com.github.damontecres.wholphin.ui.CrossFadeFactory +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds + +@Composable +fun Backdrop( + drawerIsOpen: Boolean, + backdropStyle: BackdropStyle, + viewModel: ApplicationContentViewModel = hiltViewModel(), + modifier: Modifier = Modifier, + enableTopScrim: Boolean = true, + useExistingImageAsPlaceholder: Boolean = false, + crossfadeDuration: Duration = 800.milliseconds, +) { + val backdrop by viewModel.backdropService.backdropFlow.collectAsStateWithLifecycle() + Backdrop( + backdrop = backdrop, + drawerIsOpen = drawerIsOpen, + backdropStyle = backdropStyle, + modifier = modifier, + enableTopScrim = enableTopScrim, + useExistingImageAsPlaceholder = useExistingImageAsPlaceholder, + crossfadeDuration = crossfadeDuration, + ) +} + +@Composable +fun Backdrop( + backdrop: BackdropResult, + drawerIsOpen: Boolean, + backdropStyle: BackdropStyle, + modifier: Modifier = Modifier, + enableTopScrim: Boolean = true, + useExistingImageAsPlaceholder: Boolean = false, + crossfadeDuration: Duration = 800.milliseconds, +) { + val baseBackgroundColor = MaterialTheme.colorScheme.background + if (backdrop.hasColors && + (backdropStyle == BackdropStyle.BACKDROP_DYNAMIC_COLOR || backdropStyle == BackdropStyle.UNRECOGNIZED) + ) { + val animPrimary by animateColorAsState( + backdrop.primaryColor, + animationSpec = tween(1250), + label = "dynamic_backdrop_primary", + ) + val animSecondary by animateColorAsState( + backdrop.secondaryColor, + animationSpec = tween(1250), + label = "dynamic_backdrop_secondary", + ) + val animTertiary by animateColorAsState( + backdrop.tertiaryColor, + animationSpec = tween(1250), + label = "dynamic_backdrop_tertiary", + ) + Box( + modifier = + modifier + .fillMaxSize() + .drawBehind { + drawRect(color = baseBackgroundColor) + // Top Left (Vibrant/Muted) + drawRect( + brush = + Brush.radialGradient( + colors = listOf(animSecondary, Color.Transparent), + center = Offset(0f, 0f), + radius = size.width * 0.8f, + ), + ) + // Bottom Right (DarkVibrant/DarkMuted) + drawRect( + brush = + Brush.radialGradient( + colors = listOf(animPrimary, Color.Transparent), + center = Offset(size.width, size.height), + radius = size.width * 0.8f, + ), + ) + // Bottom Left (Dark / Bridge) + drawRect( + brush = + Brush.radialGradient( + colors = + listOf( + baseBackgroundColor, + Color.Transparent, + ), + center = Offset(0f, size.height), + radius = size.width * 0.8f, + ), + ) + // Top Right (Under Image - Vibrant/Bright) + drawRect( + brush = + Brush.radialGradient( + colors = listOf(animTertiary, Color.Transparent), + center = Offset(size.width, 0f), + radius = size.width * 0.8f, + ), + ) + }, + ) + } + if (backdropStyle != BackdropStyle.BACKDROP_NONE) { + Box( + modifier = modifier.fillMaxSize(), + ) { + AsyncImage( + model = + ImageRequest + .Builder(LocalContext.current) + .data(backdrop.imageUrl) + .useExistingImageAsPlaceholder(useExistingImageAsPlaceholder) + .transitionFactory(CrossFadeFactory(crossfadeDuration)) + .build(), + contentDescription = null, + contentScale = ContentScale.Fit, + alignment = Alignment.TopEnd, + modifier = + Modifier + .align(Alignment.TopEnd) + .fillMaxHeight(.7f) + .fillMaxWidth(.7f) + .graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen) + .drawWithContent { + drawContent() + if (drawerIsOpen) { + drawRect( + brush = SolidColor(Color.Black), + alpha = .75f, + ) + } + // Subtle top scrim for system UI readability (clock, tabs) + if (enableTopScrim) { + drawRect( + brush = + Brush.verticalGradient( + colorStops = + arrayOf( + 0f to Color.Black.copy(alpha = TOP_SCRIM_ALPHA), + TOP_SCRIM_END_FRACTION to Color.Transparent, + ), + ), + blendMode = BlendMode.Multiply, + ) + } + drawRect( + brush = + Brush.horizontalGradient( + colors = listOf(Color.Transparent, Color.Black), + startX = 0f, + endX = size.width * 0.6f, + ), + blendMode = BlendMode.DstIn, + ) + drawRect( + brush = + Brush.verticalGradient( + colors = listOf(Color.Black, Color.Transparent), + startY = 0f, + endY = size.height, + ), + blendMode = BlendMode.DstIn, + ) + }, + ) + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt index 59d102c4..936ce69f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt @@ -129,6 +129,9 @@ sealed class Destination( val item: DiscoverItem, ) : Destination(false) + @Serializable + data object NowPlaying : Destination(true) + @Serializable data object UpdateApp : Destination(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 c0e4ce26..acbcb552 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,9 @@ 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.AlbumDetailsPage +import com.github.damontecres.wholphin.ui.detail.music.ArtistDetailsPage +import com.github.damontecres.wholphin.ui.detail.music.NowPlayingPage 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 @@ -154,6 +158,7 @@ fun DestinationContent( BaseItemKind.PLAYLIST -> { LaunchedEffect(Unit) { onClearBackdrop.invoke() } PlaylistDetails( + preferences = preferences, destination = destination, modifier = modifier, ) @@ -214,6 +219,24 @@ fun DestinationContent( ) } + BaseItemKind.MUSIC_ALBUM -> { + LaunchedEffect(Unit) { onClearBackdrop.invoke() } + AlbumDetailsPage( + preferences = preferences, + itemId = destination.itemId, + modifier = modifier, + ) + } + + BaseItemKind.MUSIC_ARTIST -> { + LaunchedEffect(Unit) { onClearBackdrop.invoke() } + ArtistDetailsPage( + preferences = preferences, + itemId = destination.itemId, + modifier = modifier, + ) + } + else -> { Timber.w("Unsupported item type: ${destination.type}") Text("Unsupported item type: ${destination.type}", modifier) @@ -267,6 +290,10 @@ fun DestinationContent( ) } + Destination.NowPlaying -> { + NowPlayingPage(modifier) + } + Destination.UpdateApp -> { InstallUpdatePage(preferences, modifier) } @@ -386,6 +413,14 @@ fun CollectionFolder( ) } + CollectionType.MUSIC -> { + CollectionFolderMusic( + preferences, + destination, + modifier, + ) + } + CollectionType.HOMEVIDEOS, CollectionType.PHOTOS, -> { @@ -398,7 +433,6 @@ fun CollectionFolder( } CollectionType.MUSICVIDEOS, - CollectionType.MUSIC, CollectionType.BOOKS, -> { CollectionFolderGeneric( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt index f0276ffd..6e2a2260 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt @@ -2,9 +2,12 @@ package com.github.damontecres.wholphin.ui.nav import android.content.Context import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.VisibilityThreshold import androidx.compose.animation.core.animateIntOffsetAsState import androidx.compose.animation.core.spring +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.focusGroup import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsFocusedAsState @@ -24,6 +27,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.filled.KeyboardArrowLeft +import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Settings import androidx.compose.runtime.Composable @@ -72,6 +76,7 @@ import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.preferences.AppThemeColors import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.BackdropService +import com.github.damontecres.wholphin.services.MusicService import com.github.damontecres.wholphin.services.NavDrawerService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.SetupDestination @@ -104,6 +109,7 @@ class NavDrawerViewModel val navigationManager: NavigationManager, val setupNavigationManager: SetupNavigationManager, val backdropService: BackdropService, + private val musicService: MusicService, ) : ViewModel() { val state = navDrawerService.state @@ -178,9 +184,11 @@ class NavDrawerViewModel if (key is Destination) { val index = if (key is Destination.Home) { - -1 + HOME_INDEX } else if (key is Destination.Search) { - -2 + SEARCH_INDEX + } else if (key is Destination.NowPlaying) { + NOW_PLAYING_INDEX } else { val idx = asDestinations.indexOf(key) if (idx >= 0) { @@ -198,6 +206,13 @@ class NavDrawerViewModel } } } + + fun navigateToSetup(userList: SetupDestination) { + viewModelScope.launchDefault { + musicService.stop() + setupNavigationManager.navigateTo(userList) + } + } } sealed interface NavDrawerItem { @@ -242,6 +257,10 @@ data class ServerNavDrawerItem( } } +private const val HOME_INDEX = -1 +private const val SEARCH_INDEX = -2 +private const val NOW_PLAYING_INDEX = -3 + /** * Display the left side navigation drawer with [DestinationContent] on the right */ @@ -324,12 +343,37 @@ fun NavDrawer( drawerOpen = isOpen, interactionSource = interactionSource, onClick = { - viewModel.setupNavigationManager.navigateTo( + viewModel.navigateToSetup( SetupDestination.UserList(server), ) }, modifier = Modifier, ) + AnimatedVisibility( + visible = state.nowPlayingEnabled, + enter = expandVertically(expandFrom = Alignment.Top), + exit = shrinkVertically(shrinkTowards = Alignment.Top), + ) { + val interactionSource = remember { MutableInteractionSource() } + IconNavItem( + text = stringResource(R.string.now_playing), + subtext = state.nowPlayingTitle, + icon = Icons.Default.PlayArrow, + selected = selectedIndex == NOW_PLAYING_INDEX, + drawerOpen = isOpen, + interactionSource = interactionSource, + onClick = { + viewModel.setIndex(NOW_PLAYING_INDEX) + viewModel.navigationManager.navigateTo(Destination.NowPlaying) + }, + modifier = + Modifier + .ifElse( + selectedIndex == NOW_PLAYING_INDEX, + Modifier.focusRequester(focusRequester), + ), + ) + } LazyColumn( state = navDrawerListState, contentPadding = PaddingValues(0.dp), @@ -353,18 +397,18 @@ fun NavDrawer( IconNavItem( text = stringResource(R.string.search), icon = Icons.Default.Search, - selected = selectedIndex == -2, + selected = selectedIndex == SEARCH_INDEX, drawerOpen = isOpen, interactionSource = interactionSource, onClick = { - viewModel.setIndex(-2) + viewModel.setIndex(SEARCH_INDEX) viewModel.navigationManager.navigateToFromDrawer(Destination.Search) }, modifier = Modifier .focusRequester(searchFocusRequester) .ifElse( - selectedIndex == -2, + selectedIndex == SEARCH_INDEX, Modifier.focusRequester(focusRequester), ), ) @@ -374,11 +418,11 @@ fun NavDrawer( IconNavItem( text = stringResource(R.string.home), icon = Icons.Default.Home, - selected = selectedIndex == -1, + selected = selectedIndex == HOME_INDEX, drawerOpen = isOpen, interactionSource = interactionSource, onClick = { - viewModel.setIndex(-1) + viewModel.setIndex(HOME_INDEX) if (destination is Destination.Home) { viewModel.navigationManager.reloadHome() } else { @@ -388,7 +432,7 @@ fun NavDrawer( modifier = Modifier .ifElse( - selectedIndex == -1, + selectedIndex == HOME_INDEX, Modifier.focusRequester(focusRequester), ), ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackControls.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackControls.kt index 8ad9282c..28f9b9d7 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackControls.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackControls.kt @@ -5,6 +5,7 @@ package com.github.damontecres.wholphin.ui.playback import android.view.Gravity import androidx.annotation.DrawableRes import androidx.annotation.OptIn +import androidx.annotation.StringRes import androidx.compose.foundation.background import androidx.compose.foundation.focusGroup import androidx.compose.foundation.interaction.MutableInteractionSource @@ -38,10 +39,15 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.isSpecified import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogWindowProvider @@ -57,6 +63,7 @@ import androidx.tv.material3.surfaceColorAtElevation import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.preferences.AppThemeColors import com.github.damontecres.wholphin.ui.AppColors +import com.github.damontecres.wholphin.ui.FontAwesome import com.github.damontecres.wholphin.ui.PreviewTvSpec import com.github.damontecres.wholphin.ui.components.Button import com.github.damontecres.wholphin.ui.components.SelectedLeadingContent @@ -284,7 +291,7 @@ fun SeekBar( } } -private val buttonSpacing = 12.dp +val buttonSpacing = 12.dp @Composable fun LeftPlaybackButtons( @@ -460,6 +467,54 @@ fun PlaybackButton( } } +@Composable +fun PlaybackFaButton( + @StringRes iconRes: Int, + onClick: () -> Unit, + onControllerInteraction: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + interactionSource: MutableInteractionSource? = null, + textColor: Color = Color.Unspecified, +) { + val selectedColor = MaterialTheme.colorScheme.border + Button( + enabled = enabled, + onClick = onClick, +// shape = ButtonDefaults.shape(CircleShape), + colors = + ClickableSurfaceDefaults.colors( + containerColor = AppColors.TransparentBlack25, + focusedContainerColor = selectedColor, + ), + contentPadding = PaddingValues(4.dp), + interactionSource = interactionSource, + modifier = + modifier + .size(36.dp, 36.dp) + .onFocusChanged { onControllerInteraction.invoke() }, + ) { + Text( + text = stringResource(iconRes), + fontSize = 18.sp, + fontFamily = FontAwesome, + textAlign = TextAlign.Center, + color = + if (textColor.isSpecified) { + textColor + } else if (LocalTheme.current == AppThemeColors.OLED_BLACK) { + LocalContentColor.current + } else { + MaterialTheme.colorScheme.onSurface + }, + modifier = + Modifier + .fillMaxWidth() + .align(Alignment.CenterVertically), + ) + } +} + @Composable fun BottomDialog( choices: List>, @@ -554,12 +609,18 @@ data class BottomDialogItem( @Composable private fun ButtonPreview() { WholphinTheme { - Row { + Row(Modifier.background(Color.Red)) { PlaybackButton( iconRes = R.drawable.baseline_play_arrow_24, onClick = {}, onControllerInteraction = {}, ) + PlaybackFaButton( + iconRes = R.string.fa_shuffle, + onClick = {}, + onControllerInteraction = {}, + textColor = Color.Green, + ) } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt index d3be4324..b54dea6f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt @@ -42,6 +42,7 @@ import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.DatePlayedService import com.github.damontecres.wholphin.services.DeviceProfileService import com.github.damontecres.wholphin.services.ImageUrlService +import com.github.damontecres.wholphin.services.MusicService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.PlayerFactory import com.github.damontecres.wholphin.services.PlaylistCreationResult @@ -62,6 +63,7 @@ import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.LoadingState +import com.github.damontecres.wholphin.util.PlaybackItemState import com.github.damontecres.wholphin.util.TrackActivityPlaybackListener import com.github.damontecres.wholphin.util.checkForSupport import com.github.damontecres.wholphin.util.mpv.mpvDeviceProfile @@ -142,6 +144,7 @@ class PlaybackViewModel private val userPreferencesService: UserPreferencesService, private val imageUrlService: ImageUrlService, private val screensaverService: ScreensaverService, + private val musicService: MusicService, @Assisted private val destination: Destination, ) : ViewModel(), Player.Listener, @@ -270,6 +273,7 @@ class PlaybackViewModel * Initialize from the UI to start playback */ private suspend fun init() { + musicService.stop() nextUp.setValueOnMain(null) this.preferences = userPreferencesService.getCurrent() if (preferences.appPreferences.playbackPreferences.refreshRateSwitching) { @@ -734,12 +738,12 @@ class PlaybackViewModel player.removeListener(it) } + val playbackItemState = PlaybackItemState(playback, currentItemPlayback) val activityListener = TrackActivityPlaybackListener( api = api, player = player, - playback = playback, - itemPlayback = currentItemPlayback, + getState = { playbackItemState }, ) player.addListener(activityListener) this@PlaybackViewModel.activityListener = activityListener diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/NavDrawerPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/NavDrawerPreference.kt index afdc93a1..89fce8fb 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/NavDrawerPreference.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/NavDrawerPreference.kt @@ -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 List.move( direction: MoveDirection, index: Int, @@ -246,7 +242,7 @@ fun NavDrawerPreferenceListItem( } @Composable -private fun MoveButton( +fun MoveButton( @StringRes icon: Int, enabled: Boolean, onClick: () -> Unit, diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/BlockingList.kt b/app/src/main/java/com/github/damontecres/wholphin/util/BlockingList.kt index 75ccc38e..8a6fc939 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/BlockingList.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/BlockingList.kt @@ -1,9 +1,26 @@ package com.github.damontecres.wholphin.util +import java.util.function.IntFunction import java.util.function.Predicate interface BlockingList : List { suspend fun getBlocking(index: Int): T suspend fun indexOfBlocking(predicate: Predicate): Int + + companion object { + fun of(list: List): BlockingList = BlockingListWrapper(list) + } +} + +private class BlockingListWrapper( + private val list: List, +) : BlockingList, + List by list { + override suspend fun getBlocking(index: Int): T = get(index) + + override suspend fun indexOfBlocking(predicate: Predicate): Int = indexOfFirst { predicate.test(it) } + + @Deprecated("Deprecated") + override fun toArray(generator: IntFunction?>): Array = super.toArray(generator) } 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 fa0476cb..0c577571 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 @@ -32,6 +32,7 @@ val supportedCollectionTypes = CollectionType.LIVETV, CollectionType.MUSICVIDEOS, CollectionType.FOLDERS, + CollectionType.MUSIC, null, // Mixed ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/TrackActivityPlaybackListener.kt b/app/src/main/java/com/github/damontecres/wholphin/util/TrackActivityPlaybackListener.kt index b0c1f593..86ba1425 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/TrackActivityPlaybackListener.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/TrackActivityPlaybackListener.kt @@ -11,6 +11,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.playStateApi +import org.jellyfin.sdk.model.api.PlayMethod import org.jellyfin.sdk.model.api.PlaybackOrder import org.jellyfin.sdk.model.api.PlaybackProgressInfo import org.jellyfin.sdk.model.api.PlaybackStartInfo @@ -20,6 +21,7 @@ import org.jellyfin.sdk.model.extensions.inWholeTicks import timber.log.Timber import java.util.Timer import java.util.TimerTask +import java.util.UUID import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds @@ -30,8 +32,7 @@ import kotlin.time.Duration.Companion.seconds class TrackActivityPlaybackListener( private val api: ApiClient, private val player: Player, - val playback: CurrentPlayback, - val itemPlayback: ItemPlayback, + private val getState: () -> PlaybackItemState?, ) : Player.Listener { private val coroutineScope = CoroutineScope(Dispatchers.Main) private val task: TimerTask = @@ -50,44 +51,50 @@ class TrackActivityPlaybackListener( fun init() { launch("reportPlaybackStart") { - Timber.v("reportPlaybackStart for ${itemPlayback.itemId}") - api.playStateApi.reportPlaybackStart( - PlaybackStartInfo( - canSeek = true, - itemId = itemPlayback.itemId, - isPaused = withContext(Dispatchers.Main) { !player.isPlaying }, - playMethod = playback.playMethod, - repeatMode = RepeatMode.REPEAT_NONE, - playbackOrder = PlaybackOrder.DEFAULT, - isMuted = false, - audioStreamIndex = itemPlayback.audioIndex.takeIf { itemPlayback.audioIndexEnabled }, - subtitleStreamIndex = itemPlayback.subtitleIndex.takeIf { itemPlayback.subtitleIndexEnabled }, - playSessionId = playback.playSessionId, - liveStreamId = playback.liveStreamId, - ), - ) - val delay = 5.seconds.inWholeMilliseconds - // Every x seconds, check if the video is playing - TIMER.schedule(task, delay, delay) - initialized = true + getState.invoke()?.let { state -> + Timber.v("reportPlaybackStart for ${state.itemId}") + api.playStateApi.reportPlaybackStart( + PlaybackStartInfo( + canSeek = true, + itemId = state.itemId, + isPaused = withContext(Dispatchers.Main) { !player.isPlaying }, + playMethod = state.playMethod, + repeatMode = RepeatMode.REPEAT_NONE, + playbackOrder = PlaybackOrder.DEFAULT, + isMuted = false, + audioStreamIndex = state.audioStreamIndex, + subtitleStreamIndex = state.subtitleStreamIndex, + playSessionId = state.playSessionId, + liveStreamId = state.liveStreamId, + ), + ) + + val delay = 5.seconds.inWholeMilliseconds + // Every x seconds, check if the video is playing + TIMER.schedule(task, delay, delay) + initialized = true + } } } fun release() { +// player.removeListener(this) task.cancel() TIMER.purge() val position = player.currentPosition.milliseconds launch("reportPlaybackStopped") { - Timber.v("reportPlaybackStopped for ${itemPlayback.itemId} at $position") - api.playStateApi.reportPlaybackStopped( - PlaybackStopInfo( - itemId = itemPlayback.itemId, - positionTicks = position.inWholeTicks, - failed = false, - playSessionId = playback.playSessionId, - liveStreamId = playback.liveStreamId, - ), - ) + getState.invoke()?.let { state -> + Timber.v("reportPlaybackStopped for ${state.itemId} at $position") + api.playStateApi.reportPlaybackStopped( + PlaybackStopInfo( + itemId = state.itemId, + positionTicks = position.inWholeTicks, + failed = false, + playSessionId = state.playSessionId, + liveStreamId = state.liveStreamId, + ), + ) + } } } @@ -108,29 +115,31 @@ class TrackActivityPlaybackListener( private fun saveActivity(position: Long) { launch("saveActivity") { - val calcPosition = - withContext(Dispatchers.Main) { - (if (position >= 0) position else player.currentPosition) + getState.invoke()?.let { state -> + val calcPosition = + withContext(Dispatchers.Main) { + (if (position >= 0) position else player.currentPosition) + } + if (calcPosition > 0) { + val isPaused = withContext(Dispatchers.Main) { !player.isPlaying } + Timber.v("saveActivity: itemId=${state.itemId}, pos=$calcPosition") + api.playStateApi.reportPlaybackProgress( + PlaybackProgressInfo( + itemId = state.itemId, + positionTicks = calcPosition.milliseconds.inWholeTicks, + canSeek = true, + isPaused = isPaused, + isMuted = false, + playMethod = state.playMethod, + repeatMode = RepeatMode.REPEAT_NONE, + playbackOrder = PlaybackOrder.DEFAULT, + audioStreamIndex = state.audioStreamIndex, + subtitleStreamIndex = state.subtitleStreamIndex, + playSessionId = state.playSessionId, + liveStreamId = state.liveStreamId, + ), + ) } - if (calcPosition > 0) { - val isPaused = withContext(Dispatchers.Main) { !player.isPlaying } - Timber.v("saveActivity: itemId=${itemPlayback.itemId}, pos=$calcPosition") - api.playStateApi.reportPlaybackProgress( - PlaybackProgressInfo( - itemId = itemPlayback.itemId, - positionTicks = calcPosition.milliseconds.inWholeTicks, - canSeek = true, - isPaused = isPaused, - isMuted = false, - playMethod = playback.playMethod, - repeatMode = RepeatMode.REPEAT_NONE, - playbackOrder = PlaybackOrder.DEFAULT, - audioStreamIndex = itemPlayback.audioIndex.takeIf { itemPlayback.audioIndexEnabled }, - subtitleStreamIndex = itemPlayback.subtitleIndex.takeIf { itemPlayback.subtitleIndexEnabled }, - playSessionId = playback.playSessionId, - liveStreamId = playback.liveStreamId, - ), - ) } } } @@ -143,7 +152,7 @@ class TrackActivityPlaybackListener( try { block.invoke(this) } catch (ex: Exception) { - Timber.w(ex, "Exception during %s for %s", name, itemPlayback.itemId) + Timber.w(ex, "Exception during %s", name) } } } @@ -154,3 +163,24 @@ class TrackActivityPlaybackListener( private val TIMER by lazy { Timer("$TAG-timer", true) } } } + +data class PlaybackItemState( + val itemId: UUID, + val playMethod: PlayMethod, + val audioStreamIndex: Int? = null, + val subtitleStreamIndex: Int? = null, + val playSessionId: String? = null, + val liveStreamId: String? = null, +) { + constructor( + playback: CurrentPlayback, + itemPlayback: ItemPlayback, + ) : this( + itemId = itemPlayback.itemId, + playMethod = playback.playMethod, + audioStreamIndex = itemPlayback.audioIndex.takeIf { itemPlayback.audioIndexEnabled }, + subtitleStreamIndex = itemPlayback.subtitleIndex.takeIf { itemPlayback.subtitleIndexEnabled }, + playSessionId = playback.playSessionId, + liveStreamId = playback.liveStreamId, + ) +} diff --git a/app/src/main/proto/WholphinDataStore.proto b/app/src/main/proto/WholphinDataStore.proto index 081b25e4..ad8c7a20 100644 --- a/app/src/main/proto/WholphinDataStore.proto +++ b/app/src/main/proto/WholphinDataStore.proto @@ -178,6 +178,13 @@ message PhotoPreferences{ bool slideshow_play_videos = 2; } +message MusicPreferences { + bool show_lyrics = 1; + bool show_visualizer = 2; + bool show_album_Art = 3; + bool show_backdrop = 4; +} + message AppPreferences { // The currently signed in server and user IDs, mostly for restoring a session string current_server_id = 1; @@ -194,4 +201,5 @@ message AppPreferences { AdvancedPreferences advanced_preferences = 10; bool sign_in_automatically = 11; PhotoPreferences photo_preferences = 12; + MusicPreferences music_preferences = 13; } diff --git a/app/src/main/res/values/fa_strings.xml b/app/src/main/res/values/fa_strings.xml index 38cf32c1..770a6b72 100644 --- a/app/src/main/res/values/fa_strings.xml +++ b/app/src/main/res/values/fa_strings.xml @@ -53,4 +53,7 @@ + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a28fbaae..ee667101 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -504,6 +504,14 @@ Send media info log to server No limit Max days in Next Up + Albums + Artists + Songs + Go to artist + Now playing + Instant mix + Go to album + Add to queue Add row Genres in %1$s @@ -707,6 +715,8 @@ @string/photos + Search %s + Actor Composer Writer @@ -719,7 +729,22 @@ Mixer Creator Artist - Search %s + Album artist + Album + Popular songs + Play next + Music videos + Lyrics + Hide lyrics + Show lyrics + Song has lyrics + Remove from queue + Show album cover + Show visualizer + Show backdrop + Play as? + Visualizing the currently playing audio requires permission to record audio. + Continue Select all From 8f9b9813b7bd2145200f65ebe4b2710e77481db5 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Tue, 24 Mar 2026 17:48:48 -0400 Subject: [PATCH 07/15] Better date, time, & bitrate formatting, other small UI improvements (#1139) ## Description Use localized date & time formatting such as using 24 hour clock or Day-Month-Year date formatting. The localization is based on the device's locale/language setting. Some pages require reloading if you switch the locale while Wholphin is running. Format bitrates using SI 1000 magnitudes instead of 1024. File size still uses 1024 but formats with "MiB"/"GiB" suffixes now. This aligns with the server web UI and is more in line with standard practices for display as well. Fixes the playback overlay layout being messed up by extremely long episode names. Finally, adds jumping by letter using keyboard letter keys on grids if letter jumping is enabled. I doubt many users have a full keyboard attached to their TV, but this saves me time using the emulator when I need to repeatedly open the same item for testing. ### Related issues Fixes #1132 ### Testing Emulator ## Screenshots N/A ## AI or LLM usage None --- .../wholphin/data/model/BaseItem.kt | 4 +- .../damontecres/wholphin/ui/Formatting.kt | 44 +++++++++------ .../wholphin/ui/components/QuickDetails.kt | 4 +- .../wholphin/ui/data/ItemDetailsDialogInfo.kt | 8 +-- .../wholphin/ui/detail/CardGrid.kt | 56 +++++++++++-------- .../wholphin/ui/detail/PlaylistDetails.kt | 4 +- .../ui/playback/PlaybackDebugOverlay.kt | 5 +- .../wholphin/ui/playback/PlaybackOverlay.kt | 11 +++- .../wholphin/ui/util/LocalClock.kt | 8 +-- 9 files changed, 85 insertions(+), 59 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt index 7ab11e4b..41c1fd45 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt @@ -5,13 +5,13 @@ import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.buildAnnotatedString -import com.github.damontecres.wholphin.ui.DateFormatter import com.github.damontecres.wholphin.ui.abbreviateNumber import com.github.damontecres.wholphin.ui.detail.CardGridItem import com.github.damontecres.wholphin.ui.detail.music.artistsString import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds import com.github.damontecres.wholphin.ui.dot import com.github.damontecres.wholphin.ui.formatDateTime +import com.github.damontecres.wholphin.ui.getDateFormatter import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.playback.playable import com.github.damontecres.wholphin.ui.roundMinutes @@ -118,7 +118,7 @@ data class BaseItem( buildList { if (type == BaseItemKind.EPISODE) { data.seasonEpisode?.let(::add) - data.premiereDate?.let { add(DateFormatter.format(it)) } + data.premiereDate?.let { add(getDateFormatter().format(it)) } } else if (type == BaseItemKind.SERIES) { data.seriesProductionYears?.let(::add) } else if (type == BaseItemKind.PHOTO) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt index 8f4066bb..0a9b9a2c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt @@ -16,8 +16,25 @@ import java.time.format.DateTimeParseException import java.time.format.FormatStyle import java.util.Locale -val TimeFormatter: DateTimeFormatter = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT) -val DateFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("MMM d, yyyy") +private var timeFormatter: DateTimeFormatter = + DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(Locale.getDefault()) + +fun getTimeFormatter(): DateTimeFormatter { + if (timeFormatter.locale != Locale.getDefault()) { + timeFormatter = timeFormatter.withLocale(Locale.getDefault()) + } + return timeFormatter +} + +private var dateFormatter: DateTimeFormatter = + DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(Locale.getDefault()) + +fun getDateFormatter(): DateTimeFormatter { + if (dateFormatter.locale != Locale.getDefault()) { + dateFormatter = dateFormatter.withLocale(Locale.getDefault()) + } + return dateFormatter +} // TODO server returns in UTC, but sdk converts to local time // eg 2020-02-14T00:00:00.0000000Z => 2020-02-13T17:00:00 PT => Feb 13, 2020 @@ -25,9 +42,9 @@ val DateFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("MMM d, yyyy" /** * Format a [LocalDateTime] as `Aug 24, 2000` */ -fun formatDateTime(dateTime: LocalDateTime): String = DateFormatter.format(dateTime) +fun formatDateTime(dateTime: LocalDateTime): String = getDateFormatter().format(dateTime) -fun formatDate(dateTime: LocalDate): String = DateFormatter.format(dateTime) +fun formatDate(dateTime: LocalDate): String = getDateFormatter().format(dateTime) fun toLocalDate(date: String?): LocalDate? = date?.let { @@ -109,30 +126,25 @@ fun abbreviateNumber(number: Int): String { return String.format(Locale.getDefault(), "%.1f%s", count, abbrevSuffixes[unit]) } -val byteSuffixes = listOf("B", "KB", "MB", "GB", "TB") +val byteSuffixes = listOf("B", "KiB", "MiB", "GiB", "TiB") val byteRateSuffixes = listOf("bps", "kbps", "mbps", "gbps", "tbps") -/** - * Format bytes - */ -fun formatBytes( - bytes: Int, - suffixes: List = byteSuffixes, -) = formatBytes(bytes.toLong(), suffixes) - fun formatBytes( bytes: Long, suffixes: List = byteSuffixes, + divisor: Int = 1024, ): String { var unit = 0 var count = bytes.toDouble() - while (count >= 1024 && unit + 1 < suffixes.size) { - count /= 1024 + while (count >= divisor && unit + 1 < suffixes.size) { + count /= divisor unit++ } - return String.format(Locale.getDefault(), "%.2f%s", count, suffixes[unit]) + return String.format(Locale.getDefault(), "%.2f %s", count, suffixes[unit]) } +fun formatBitrate(bitrate: Int) = formatBytes(bitrate.toLong(), byteRateSuffixes, 1000) + @get:StringRes val MediaSegmentType.stringRes: Int get() = diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/QuickDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/QuickDetails.kt index cf5b0697..d4931d77 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/QuickDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/QuickDetails.kt @@ -21,8 +21,8 @@ import androidx.tv.material3.Icon import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.ui.TimeFormatter import com.github.damontecres.wholphin.ui.dot +import com.github.damontecres.wholphin.ui.getTimeFormatter import com.github.damontecres.wholphin.ui.util.LocalClock import kotlin.time.Duration @@ -107,7 +107,7 @@ fun TimeRemaining( val now by LocalClock.current.now val remainingStr = remember(remaining, now) { - val endTimeStr = TimeFormatter.format(now.plusSeconds(remaining.inWholeSeconds)) + val endTimeStr = getTimeFormatter().format(now.plusSeconds(remaining.inWholeSeconds)) buildAnnotatedString { dot() append(context.getString(R.string.ends_at, endTimeStr)) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/data/ItemDetailsDialogInfo.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/data/ItemDetailsDialogInfo.kt index fa32c595..072de4e1 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/data/ItemDetailsDialogInfo.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/data/ItemDetailsDialogInfo.kt @@ -19,8 +19,8 @@ import androidx.compose.ui.unit.dp import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.ui.byteRateSuffixes import com.github.damontecres.wholphin.ui.components.ScrollableDialog +import com.github.damontecres.wholphin.ui.formatBitrate import com.github.damontecres.wholphin.ui.formatBytes import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.letNotEmpty @@ -116,7 +116,7 @@ fun ItemDetailsDialog( } source.bitrate?.let { add( - bitrateLabel to formatBytes(it, byteRateSuffixes), + bitrateLabel to formatBitrate(it), ) } source.runTimeTicks?.let { @@ -297,7 +297,7 @@ private fun buildVideoStreamInfo( val aspectRatio = calculateAspectRatio(stream.width!!, stream.height!!) add(aspectRatioLabel to aspectRatio) } - stream.bitRate?.let { add(bitrateLabel to formatBytes(it, byteRateSuffixes)) } + stream.bitRate?.let { add(bitrateLabel to formatBitrate(it)) } stream.averageFrameRate?.let { add(framerateLabel to String.format(Locale.getDefault(), "%.3f", it)) } @@ -405,7 +405,7 @@ private fun buildAudioStreamInfo( stream.channelLayout?.let { add(layoutLabel to it) } stream.channels?.let { add(channelsLabel to it.toString()) } stream.profile?.let { add(profileLabel to it) } - stream.bitRate?.let { add(bitrateLabel to formatBytes(it, byteRateSuffixes)) } + stream.bitRate?.let { add(bitrateLabel to formatBitrate(it)) } stream.sampleRate?.let { add(sampleRateLabel to "$it $sampleRateUnit") } stream.isDefault?.let { add(defaultLabel to if (it) yesStr else noStr) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt index ac72a144..a2b9e923 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt @@ -1,5 +1,6 @@ package com.github.damontecres.wholphin.ui.detail +import android.view.KeyEvent import androidx.annotation.StringRes import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background @@ -51,7 +52,6 @@ import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.input.key.type import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp @@ -199,6 +199,25 @@ fun CardGrid( } } + val jumpToLetter: (Char) -> Unit = + remember { + { letter: Char -> + scope.launch(ExceptionHandler()) { + val jumpPosition = + withContext(Dispatchers.IO) { + letterPosition.invoke(letter) + } + Timber.d("Alphabet jump to $jumpPosition") + if (jumpPosition >= 0) { + pager.getOrNull(jumpPosition) + gridState.scrollToItem(jumpPosition) + focusOn(jumpPosition) + alphabetFocus = true + } + } + } + } + if (pager.isEmpty()) { Box( contentAlignment = Alignment.Center, @@ -257,6 +276,12 @@ fun CardGrid( } else if (useJumpRemoteButtons && isBackwardButton(it)) { jump(-jump1) return@onKeyEvent true + } else if (showLetterButtons && pager.isNotEmpty() && + it.nativeKeyEvent.keyCode in (KeyEvent.KEYCODE_A..KeyEvent.KEYCODE_Z) + ) { + val letter = it.nativeKeyEvent.unicodeChar.toChar() + jumpToLetter.invoke(letter) + return@onKeyEvent true } else { return@onKeyEvent false } @@ -372,8 +397,7 @@ fun CardGrid( } } } - val context = LocalContext.current - val letters = context.getString(R.string.jump_letters) + val letters = stringResource(R.string.jump_letters) // Letters val currentLetter = remember(focusedIndex) { @@ -383,12 +407,10 @@ fun CardGrid( ?.firstOrNull() ?.uppercaseChar() ?.let { - if (it >= '0' && it <= '9') { - '#' - } else if (it >= 'A' && it <= 'Z') { - it - } else { - null + when (it) { + in '0'..'9' -> '#' + in 'A'..'Z' -> it + else -> null } } ?: letters[0] @@ -402,21 +424,7 @@ fun CardGrid( .align(Alignment.CenterVertically) .padding(start = 16.dp), // Add end padding to push away from edge - letterClicked = { letter -> - scope.launch(ExceptionHandler()) { - val jumpPosition = - withContext(Dispatchers.IO) { - letterPosition.invoke(letter) - } - Timber.d("Alphabet jump to $jumpPosition") - if (jumpPosition >= 0) { - pager.getOrNull(jumpPosition) - gridState.scrollToItem(jumpPosition) - focusOn(jumpPosition) - alphabetFocus = true - } - } - }, + letterClicked = jumpToLetter, ) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt index 9c237d8c..bd50ce59 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt @@ -70,7 +70,6 @@ import com.github.damontecres.wholphin.services.MusicService import com.github.damontecres.wholphin.services.MusicServiceState import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.ui.DefaultItemFields -import com.github.damontecres.wholphin.ui.TimeFormatter import com.github.damontecres.wholphin.ui.cards.ItemCardImage import com.github.damontecres.wholphin.ui.components.BasicDialog import com.github.damontecres.wholphin.ui.components.ConfirmDeleteDialog @@ -95,6 +94,7 @@ import com.github.damontecres.wholphin.ui.detail.music.buildMoreDialogForMusic import com.github.damontecres.wholphin.ui.enableMarquee import com.github.damontecres.wholphin.ui.equalsNotNull import com.github.damontecres.wholphin.ui.formatDateTime +import com.github.damontecres.wholphin.ui.getTimeFormatter import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchIO @@ -810,7 +810,7 @@ fun PlaylistItem( val endTimeStr = remember(item, now) { val endTime = now.toLocalTime().plusSeconds(duration.inWholeSeconds) - TimeFormatter.format(endTime) + getTimeFormatter().format(endTime) } Column { Text( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDebugOverlay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDebugOverlay.kt index ce31d865..e756a9df 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDebugOverlay.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDebugOverlay.kt @@ -19,8 +19,7 @@ import androidx.tv.material3.MaterialTheme import androidx.tv.material3.ProvideTextStyle import androidx.tv.material3.Text import com.github.damontecres.wholphin.preferences.PlayerBackend -import com.github.damontecres.wholphin.ui.byteRateSuffixes -import com.github.damontecres.wholphin.ui.formatBytes +import com.github.damontecres.wholphin.ui.formatBitrate import com.github.damontecres.wholphin.ui.letNotEmpty import kotlinx.coroutines.delay import kotlinx.coroutines.isActive @@ -104,7 +103,7 @@ fun TranscodeInfo( "Reason:" to info.transcodeReasons.joinToString(", "), "HW Accel:" to info.hardwareAccelerationType?.toString(), "Container:" to info.container, - "Bitrate:" to info.bitrate?.let { formatBytes(it, byteRateSuffixes) }, + "Bitrate:" to info.bitrate?.let { formatBitrate(it) }, ), ) SimpleTable( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt index 2c536d9f..3bc4ddb0 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt @@ -57,6 +57,7 @@ import androidx.compose.ui.input.key.type import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntSize @@ -74,10 +75,10 @@ import com.github.damontecres.wholphin.data.model.aspectRatioFloat import com.github.damontecres.wholphin.ui.AppColors import com.github.damontecres.wholphin.ui.AspectRatios import com.github.damontecres.wholphin.ui.LocalImageUrlService -import com.github.damontecres.wholphin.ui.TimeFormatter import com.github.damontecres.wholphin.ui.cards.ChapterCard import com.github.damontecres.wholphin.ui.cards.SeasonCard import com.github.damontecres.wholphin.ui.components.TimeDisplay +import com.github.damontecres.wholphin.ui.getTimeFormatter import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.tryRequestFocus @@ -625,6 +626,9 @@ fun Controller( text = it, style = MaterialTheme.typography.titleLarge, fontSize = titleTextSize, + maxLines = 1, + overflow = TextOverflow.MiddleEllipsis, + modifier = Modifier.fillMaxWidth(.75f), ) } Row( @@ -639,6 +643,9 @@ fun Controller( text = it, style = MaterialTheme.typography.titleMedium, fontSize = subtitleTextSize, + maxLines = 1, + overflow = TextOverflow.MiddleEllipsis, + modifier = Modifier.fillMaxWidth(.75f), ) } @@ -651,7 +658,7 @@ fun Controller( .toLong() .milliseconds val endTime = LocalTime.now().plusSeconds(remaining.inWholeSeconds) - endTimeStr = TimeFormatter.format(endTime) + endTimeStr = getTimeFormatter().format(endTime) delay(1.seconds) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/util/LocalClock.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/util/LocalClock.kt index bca5ae64..213dc725 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/util/LocalClock.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/util/LocalClock.kt @@ -7,7 +7,7 @@ import androidx.compose.runtime.MutableState import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import com.github.damontecres.wholphin.ui.TimeFormatter +import com.github.damontecres.wholphin.ui.getTimeFormatter import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.isActive @@ -25,9 +25,9 @@ data class Clock( */ val now: MutableState = mutableStateOf(LocalDateTime.now()), /** - * The current time formatted as a string with [TimeFormatter] + * The current time formatted as a string with [getTimeFormatter] */ - val timeString: MutableState = mutableStateOf(TimeFormatter.format(now.value)), + val timeString: MutableState = mutableStateOf(getTimeFormatter().format(now.value)), ) @Composable @@ -37,7 +37,7 @@ fun ProvideLocalClock(content: @Composable () -> Unit) { withContext(Dispatchers.Default) { while (isActive) { val now = LocalDateTime.now() - val time = TimeFormatter.format(now) + val time = getTimeFormatter().format(now) clock.now.value = now clock.timeString.value = time delay(2_000) From 2d863e9c328d9edc8833c6cffdef8abf8f386f9a Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Wed, 25 Mar 2026 13:59:48 -0400 Subject: [PATCH 08/15] Don't show full playback overlay when pausing (#1144) ## Description Instead of displaying the full playback overlay when pausing via a button press, just show a short pause image in the center of the screen. Any button press that will pause triggers this including a remote Pause, Pause/Play, or one-click pause Enter. I decided to make this the standard behavior instead of a toggle because I think if the user intends to do something with the overlay (change subtitles, audio, etc), it's still straightforward to open the overlay, click the pause button (which is focused first), and then move to the desired option versus assuming click-to-pause will open the overlay so the user can move to the desired option. ### Related issues Closes #990 ### Testing Emulator ## Screenshots [pause_osd.webm](https://github.com/user-attachments/assets/8f908164-2a50-43f9-9e13-a463d2914e42) ## AI or LLM usage None --- .../ui/playback/MediaSessionPlayer.kt | 8 +- .../wholphin/ui/playback/PauseIndicator.kt | 99 +++++++++++++++++++ .../ui/playback/PlaybackKeyHandler.kt | 4 +- .../wholphin/ui/playback/PlaybackPage.kt | 9 ++ .../wholphin/ui/playback/PlaybackViewModel.kt | 1 - 5 files changed, 113 insertions(+), 8 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/playback/PauseIndicator.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/MediaSessionPlayer.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/MediaSessionPlayer.kt index 94a41aa1..30165f58 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/MediaSessionPlayer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/MediaSessionPlayer.kt @@ -1,23 +1,23 @@ package com.github.damontecres.wholphin.ui.playback +import androidx.annotation.OptIn import androidx.media3.common.ForwardingSimpleBasePlayer import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi import com.github.damontecres.wholphin.preferences.PlaybackPreferences import com.github.damontecres.wholphin.preferences.skipBackOnResume import com.github.damontecres.wholphin.ui.seekBack import com.google.common.util.concurrent.ListenableFuture import timber.log.Timber +@OptIn(UnstableApi::class) class MediaSessionPlayer( player: Player, - private val controllerViewState: ControllerViewState, private val playbackPreferences: PlaybackPreferences, ) : ForwardingSimpleBasePlayer(player) { override fun handleSetPlayWhenReady(playWhenReady: Boolean): ListenableFuture<*> { Timber.v("handleSetPlayWhenReady: playWhenReady=$playWhenReady") - if (!playWhenReady && player.isPlaying) { - controllerViewState.showControls() - } else if (playWhenReady) { + if (playWhenReady) { playbackPreferences.skipBackOnResume?.let { player.seekBack(it) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PauseIndicator.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PauseIndicator.kt new file mode 100644 index 00000000..52bb4b7a --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PauseIndicator.kt @@ -0,0 +1,99 @@ +package com.github.damontecres.wholphin.ui.playback + +import androidx.annotation.OptIn +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi +import androidx.media3.ui.compose.state.observeState +import com.github.damontecres.wholphin.R +import kotlinx.coroutines.delay +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds + +/** + * Show an animated "pause" image whenever the player is paused + */ +@Composable +fun PauseIndicator( + player: Player, + modifier: Modifier = Modifier, + duration: Duration = 300.milliseconds, +) { + val state = rememberPauseState(player) + var visible by remember { mutableStateOf(false) } + LaunchedEffect(state.isPaused) { + if (state.isPaused) visible = true + } + AnimatedVisibility( + visible = visible, + enter = + scaleIn( + animationSpec = + tween( + durationMillis = duration.inWholeMilliseconds.toInt(), + ), + ), + exit = fadeOut(spring(stiffness = Spring.StiffnessMediumLow)), + modifier = modifier, + ) { + LaunchedEffect(Unit) { + delay(duration) + delay(50) + visible = false + } + Image( + modifier = Modifier.size(64.dp, 64.dp), + painter = painterResource(id = R.drawable.baseline_pause_24), + contentDescription = null, + ) + } +} + +/** + * Remember when a player is paused + */ +@Composable +fun rememberPauseState(player: Player): PauseState { + val state = remember(player) { PauseState(player) } + LaunchedEffect(player) { state.observe() } + return state +} + +class PauseState( + private val player: Player, +) { + var isPaused by mutableStateOf(false) + private set + + @OptIn(UnstableApi::class) + internal suspend fun observe() { + player + .observeState( + Player.EVENT_PLAYBACK_STATE_CHANGED, + Player.EVENT_PLAY_WHEN_READY_CHANGED, + Player.EVENT_AVAILABLE_COMMANDS_CHANGED, + ) { + // Timber.v("isPaused=$isPaused, playWhenReady=${player.playWhenReady}, playbackState=${player.playbackState}") + isPaused = !isPaused && // Not already paused, don't want to trigger more than once + !player.playWhenReady && // Player is actually paused + // Player could play if it was not paused, ie it is not stopped + player.playbackState.let { it == Player.STATE_READY || it == Player.STATE_BUFFERING } + }.observe() + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackKeyHandler.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackKeyHandler.kt index 5ed82729..a7cd4bb1 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackKeyHandler.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackKeyHandler.kt @@ -55,9 +55,7 @@ class PlaybackKeyHandler( } else if (oneClickPause && isEnterKey(it)) { val wasPlaying = player.isPlaying Util.handlePlayPauseButtonAction(player) - if (wasPlaying) { - controllerViewState.showControls() - } else { + if (!wasPlaying) { skipBackOnResume?.let { player.seekBack(it) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt index 352c60dd..cd39dbc2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt @@ -361,6 +361,15 @@ fun PlaybackPageContent( } } + if (!controllerViewState.controlsVisible && skipIndicatorDuration == 0L) { + PauseIndicator( + player = player, + modifier = + Modifier + .align(Alignment.Center), + ) + } + // The playback controls PlaybackOverlay( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt index b54dea6f..f1be78dc 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt @@ -260,7 +260,6 @@ class PlaybackViewModel val sessionPlayer = MediaSessionPlayer( player, - controllerViewState, preferences.appPreferences.playbackPreferences, ) mediaSession = From c92de3c33e0157a6d136c598f3a46f7ad1814512 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Wed, 25 Mar 2026 21:55:00 -0400 Subject: [PATCH 09/15] Fix some UI preference changes not being reflected immediately (#1146) --- .../damontecres/wholphin/MainContent.kt | 6 ++---- .../damontecres/wholphin/ui/Extensions.kt | 7 +++++++ .../ui/detail/episode/EpisodeDetails.kt | 6 ++++-- .../ui/detail/episode/EpisodeViewModel.kt | 21 ++++++++++++++++--- .../wholphin/ui/detail/movie/MovieDetails.kt | 5 +++-- .../ui/detail/movie/MovieViewModel.kt | 19 ++++++++++++++--- 6 files changed, 50 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt b/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt index 0b32b9ed..0794da67 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt @@ -12,6 +12,7 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -45,6 +46,7 @@ fun MainContent( screensaverService: ScreensaverService, modifier: Modifier = Modifier, ) { + val preferences by rememberUpdatedState(UserPreferences(appPreferences)) Surface( modifier = modifier @@ -93,10 +95,6 @@ fun MainContent( backdropService.clearBackdrop() } val current = key.current - val preferences = - remember(appPreferences) { - UserPreferences(appPreferences) - } var showContent by remember { mutableStateOf(true) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/Extensions.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/Extensions.kt index 865c6d29..a42d027f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/Extensions.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/Extensions.kt @@ -38,6 +38,8 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.acra.ACRA @@ -434,3 +436,8 @@ fun Response.toBaseItems( fun Int?.gt(that: Int) = (this ?: 0) > that fun Int?.lt(that: Int) = (this ?: 0) < that + +/** + * Easy way to combine two flows into a [Pair] + */ +fun Flow.combinePair(flow: Flow): Flow> = combine(flow) { t1, t2 -> Pair(t1, t2) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt index e341923b..a89db27b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt @@ -11,6 +11,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.relocation.BringIntoViewRequester import androidx.compose.foundation.relocation.bringIntoViewRequester import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf @@ -85,6 +86,7 @@ fun EpisodeDetails( var showPlaylistDialog by remember { mutableStateOf>(Optional.absent()) } var showDeleteDialog by remember { mutableStateOf(null) } val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) + val canDelete by viewModel.canDelete.collectAsState() val preferredSubtitleLanguage = viewModel.serverRepository.currentUserDto @@ -226,7 +228,7 @@ fun EpisodeDetails( onClearChosenStreams = { viewModel.clearChosenStreams(chosenStreams) }, - canDelete = viewModel.canDelete, + canDelete = canDelete, ), ) }, @@ -236,7 +238,7 @@ fun EpisodeDetails( favoriteOnClick = { viewModel.setFavorite(ep.id, !ep.favorite) }, - canDelete = viewModel.canDelete, + canDelete = canDelete, deleteOnClick = { showDeleteDialog = ep }, modifier = modifier, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeViewModel.kt index 9b8bda5c..df3b180a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeViewModel.kt @@ -3,6 +3,7 @@ package com.github.damontecres.wholphin.ui.detail.episode import android.content.Context import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel +import androidx.lifecycle.asFlow import androidx.lifecycle.viewModelScope import com.github.damontecres.wholphin.data.ChosenStreams import com.github.damontecres.wholphin.data.ItemPlaybackRepository @@ -19,6 +20,8 @@ import com.github.damontecres.wholphin.services.StreamChoiceService import com.github.damontecres.wholphin.services.ThemeSongPlayer import com.github.damontecres.wholphin.services.UserPreferencesService import com.github.damontecres.wholphin.services.deleteItem +import com.github.damontecres.wholphin.ui.combinePair +import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.setValueOnMain @@ -34,6 +37,11 @@ import kotlinx.coroutines.Deferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.async +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient @@ -68,11 +76,19 @@ class EpisodeViewModel val item = MutableLiveData(null) val chosenStreams = MutableLiveData(null) - var canDelete: Boolean = false - private set + val canDelete = MutableStateFlow(false) init { init() + viewModelScope.launchDefault { + item + .asFlow() + .filterNotNull() + .combinePair(userPreferencesService.flow.map { it.appPreferences }) + .collectLatest { (item, preferences) -> + canDelete.update { mediaManagementService.canDelete(item, preferences) } + } + } } private fun fetchAndSetItem(): Deferred = @@ -101,7 +117,6 @@ class EpisodeViewModel ) { val prefs = userPreferencesService.getCurrent() val item = fetchAndSetItem().await() - canDelete = mediaManagementService.canDelete(item) val result = itemPlaybackRepository.getSelectedTracks(item.id, item, prefs) withContext(Dispatchers.Main) { this@EpisodeViewModel.item.value = item diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt index 87a5dd75..4195e9a8 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt @@ -106,6 +106,7 @@ fun MovieDetails( val loading by viewModel.loading.observeAsState(LoadingState.Loading) val chosenStreams by viewModel.chosenStreams.observeAsState(null) val discovered by viewModel.discovered.collectAsState() + val canDelete by viewModel.canDelete.collectAsState() var overviewDialog by remember { mutableStateOf(null) } var moreDialog by remember { mutableStateOf(null) } @@ -211,7 +212,7 @@ fun MovieDetails( seriesId = null, sourceId = chosenStreams?.source?.id?.toUUIDOrNull(), canClearChosenStreams = chosenStreams?.itemPlayback != null || chosenStreams?.plc != null, - canDelete = viewModel.canDelete, + canDelete = canDelete, actions = moreActions, onChooseVersion = { chooseVersion = @@ -323,7 +324,7 @@ fun MovieDetails( onClickDiscover = { index, item -> viewModel.navigateTo(item.destination) }, - canDelete = viewModel.canDelete, + canDelete = canDelete, deleteOnClick = { showDeleteDialog = movie }, modifier = modifier, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt index 993260dc..131c0cd6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt @@ -3,6 +3,7 @@ package com.github.damontecres.wholphin.ui.detail.movie import android.content.Context import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel +import androidx.lifecycle.asFlow import androidx.lifecycle.viewModelScope import com.github.damontecres.wholphin.data.ChosenStreams import com.github.damontecres.wholphin.data.ExtrasItem @@ -29,6 +30,8 @@ import com.github.damontecres.wholphin.services.TrailerService import com.github.damontecres.wholphin.services.UserPreferencesService import com.github.damontecres.wholphin.services.deleteItem import com.github.damontecres.wholphin.ui.SlimItemFields +import com.github.damontecres.wholphin.ui.combinePair +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 @@ -46,6 +49,9 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -93,11 +99,19 @@ class MovieViewModel val chosenStreams = MutableLiveData(null) val discovered = MutableStateFlow>(listOf()) - var canDelete: Boolean = false - private set + val canDelete = MutableStateFlow(false) init { init() + viewModelScope.launchDefault { + item + .asFlow() + .filterNotNull() + .combinePair(userPreferencesService.flow.map { it.appPreferences }) + .collectLatest { (item, preferences) -> + canDelete.update { mediaManagementService.canDelete(item, preferences) } + } + } } private fun fetchAndSetItem(): Deferred = @@ -112,7 +126,6 @@ class MovieViewModel api.userLibraryApi.getItem(itemId).content.let { BaseItem.from(it, api) } - canDelete = mediaManagementService.canDelete(item) this@MovieViewModel.item.setValueOnMain(item) item } From 677cc2527be835f2343a0a4c0a4bded8291b9c33 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 26 Mar 2026 15:50:40 -0400 Subject: [PATCH 10/15] Update android-actions/setup-android action to v4 (#1143) --- .github/actions/setup/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index 774dae38..d7b2f290 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -21,7 +21,7 @@ runs: echo "BUILD_TOOLS_VERSION=36.0.0" >> $GITHUB_ENV echo "NDK_VERSION=29.0.14206865" >> $GITHUB_ENV - name: Setup Android SDK - uses: android-actions/setup-android@v3 + uses: android-actions/setup-android@v4 with: packages: "tools platform-tools build-tools;${{ env.BUILD_TOOLS_VERSION }} ndk;${{ env.NDK_VERSION }}" - name: Add NDK to path From eaaa58755681206b8a77929d3a5e153a5f783b45 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 26 Mar 2026 15:51:00 -0400 Subject: [PATCH 11/15] Update dependency org.openapi.generator to v7.21.0 (#1135) --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 280d183e..54df8e09 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -42,7 +42,7 @@ workRuntimeKtx = "2.11.1" paletteKtx = "1.0.0" kotlinxCoroutinesTest = "1.10.2" coreTesting = "2.2.0" -openapi-generator = "7.20.0" +openapi-generator = "7.21.0" runner = "1.7.0" [libraries] From 2ebbaeb8608a15f1216b5690f947684c03c31a4c Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 26 Mar 2026 15:51:24 -0400 Subject: [PATCH 12/15] Update Dependencies (#1125) --- gradle/libs.versions.toml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 54df8e09..fa197566 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -12,7 +12,7 @@ kotlin = "2.3.20" ksp = "2.3.6" coreKtx = "1.18.0" appcompat = "1.7.1" -composeBom = "2026.03.00" +composeBom = "2026.03.01" mockk = "1.14.9" robolectric = "4.16.1" multiplatformMarkdownRenderer = "0.39.2" @@ -33,12 +33,12 @@ material3AdaptiveNav3 = "1.0.0-alpha03" protobuf = "0.9.6" datastore = "1.2.1" kotlinx-serialization = "1.10.0" -protobuf-javalite = "4.34.0" +protobuf-javalite = "4.34.1" hilt = "2.59.2" room = "2.8.4" preferenceKtx = "1.2.1" tvprovider = "1.1.0" -workRuntimeKtx = "2.11.1" +workRuntimeKtx = "2.11.2" paletteKtx = "1.0.0" kotlinxCoroutinesTest = "1.10.2" coreTesting = "2.2.0" From b8a9cb50274dc61822f0a4095f2b6e57f85509e7 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 26 Mar 2026 15:52:13 -0400 Subject: [PATCH 13/15] Update Gradle to v9.4.1 (#1119) --- gradle/wrapper/gradle-wrapper.properties | 2 +- gradlew | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index dbc3ce4a..c61a118f 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.0-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew b/gradlew index 0262dcbd..739907df 100755 --- a/gradlew +++ b/gradlew @@ -57,7 +57,7 @@ # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/b631911858264c0b6e4d6603d677ff5218766cee/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# https://github.com/gradle/gradle/blob/2d6327017519d23b96af35865dc997fcb544fb40/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. From c35b5cdd0a9ed04aef08f9e33505fb615e5d224f Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Sat, 28 Mar 2026 10:31:03 -0400 Subject: [PATCH 14/15] Better collection pages including more view options (#1137) ## Description This is a large update to the collection page! - Show the collection's details including overview & backdrop - Separate collection by type (Movies, TV, etc) into rows - View options are now shared across all collections - Filter & sort are still saved per collection By default, the collection will be separated by type, but there is a view option to restore the combined grid. ### Related issues Closes #527 Closes #1088 Fixes https://github.com/damontecres/Wholphin/issues/877#issuecomment-3889013124 Related to #737 ### Testing Emulator mostly ## Screenshots ![collection_page Large](https://github.com/user-attachments/assets/2b9a8740-5610-491a-b139-018dabd5f2f1) ### Separated types into rows ![collection_rows Large](https://github.com/user-attachments/assets/34556f64-6089-4d65-b243-6df01bae5423) ### Combined type into grid ![collection_grid Large](https://github.com/user-attachments/assets/feea14ef-2b7a-4c09-bba7-d146ec92eb83) ## AI or LLM usage None --- app/build.gradle.kts | 1 + .../wholphin/data/LibraryDisplayInfoDao.kt | 7 + .../wholphin/data/model/BaseItem.kt | 3 + .../wholphin/data/model/LibraryDisplayInfo.kt | 11 + .../wholphin/services/KeyValueService.kt | 82 +++ .../wholphin/services/hilt/DatabaseModule.kt | 12 + .../damontecres/wholphin/ui/Extensions.kt | 11 + .../damontecres/wholphin/ui/Formatting.kt | 43 ++ .../ui/detail/CollectionFolderBoxSet.kt | 48 -- .../ui/detail/collection/CollectionButtons.kt | 148 +++++ .../ui/detail/collection/CollectionDetails.kt | 551 ++++++++++++++++++ .../collection/CollectionDetailsHeader.kt | 111 ++++ .../detail/collection/CollectionMixedGrid.kt | 89 +++ .../ui/detail/collection/CollectionRows.kt | 90 +++ .../detail/collection/CollectionViewModel.kt | 493 ++++++++++++++++ .../collection/CollectionViewOptionsDialog.kt | 174 ++++++ .../damontecres/wholphin/ui/main/HomePage.kt | 18 +- .../wholphin/ui/nav/DestinationContent.kt | 6 +- app/src/main/res/values/strings.xml | 1 + .../damontecres/wholphin/ui/TestModule.kt | 12 + gradle/libs.versions.toml | 1 + 21 files changed, 1853 insertions(+), 59 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/services/KeyValueService.kt delete mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderBoxSet.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionButtons.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionDetails.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionDetailsHeader.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionMixedGrid.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionRows.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionViewModel.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionViewOptionsDialog.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 22bd0deb..ea0b0a9f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -219,6 +219,7 @@ dependencies { implementation(libs.androidx.lifecycle.livedata.ktx) implementation(libs.androidx.activity.compose) implementation(libs.androidx.datastore) + implementation(libs.androidx.datastore.preferences) implementation(libs.protobuf.kotlin.lite) implementation(libs.androidx.tvprovider) implementation(libs.androidx.work.runtime.ktx) diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/LibraryDisplayInfoDao.kt b/app/src/main/java/com/github/damontecres/wholphin/data/LibraryDisplayInfoDao.kt index 0124ac3f..524281cb 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/LibraryDisplayInfoDao.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/LibraryDisplayInfoDao.kt @@ -7,6 +7,7 @@ import androidx.room.Query import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo import com.github.damontecres.wholphin.ui.toServerString +import kotlinx.coroutines.flow.Flow import java.util.UUID @Dao @@ -27,6 +28,12 @@ interface LibraryDisplayInfoDao { itemId: String, ): LibraryDisplayInfo? + @Query("SELECT * from LibraryDisplayInfo WHERE userId=:userId AND itemId=:itemId") + fun getItemAsFlow( + userId: Int, + itemId: String, + ): Flow + @Insert(onConflict = OnConflictStrategy.REPLACE) fun saveItem(item: LibraryDisplayInfo): Long diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt index 41c1fd45..83683976 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt @@ -127,6 +127,9 @@ data class BaseItem( } else if (data.premiereDate != null) { add(data.premiereDate!!.toLocalDate().toString()) } + } else if (type == BaseItemKind.BOX_SET) { + data.productionYear?.let { add(it.toString()) } + data.childCount?.let { add("$it items") } } else { data.productionYear?.let { add(it.toString()) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/LibraryDisplayInfo.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/LibraryDisplayInfo.kt index eb8ffdd1..e5bcafcb 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/LibraryDisplayInfo.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/LibraryDisplayInfo.kt @@ -9,12 +9,14 @@ import androidx.room.Ignore import androidx.room.Index import com.github.damontecres.wholphin.ui.components.ViewOptions import com.github.damontecres.wholphin.ui.data.SortAndDirection +import com.github.damontecres.wholphin.ui.toServerString import kotlinx.serialization.Serializable import kotlinx.serialization.Transient import kotlinx.serialization.UseSerializers import org.jellyfin.sdk.model.api.ItemSortBy import org.jellyfin.sdk.model.api.SortOrder import org.jellyfin.sdk.model.serializer.UUIDSerializer +import java.util.UUID @Entity( foreignKeys = [ @@ -41,4 +43,13 @@ data class LibraryDisplayInfo( ) { @Ignore @Transient val sortAndDirection = SortAndDirection(sort, direction) + + constructor( + user: JellyfinUser, + itemId: UUID, + sort: ItemSortBy, + direction: SortOrder, + filter: GetItemsFilter, + viewOptions: ViewOptions?, + ) : this(user.rowId, itemId.toServerString(), sort, direction, filter, viewOptions) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/KeyValueService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/KeyValueService.kt new file mode 100644 index 00000000..543ea656 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/KeyValueService.kt @@ -0,0 +1,82 @@ +package com.github.damontecres.wholphin.services + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.stringPreferencesKey +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.serialization.json.Json +import kotlinx.serialization.serializer +import java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Gets/saves a serializable object by name + */ +@Singleton +class KeyValueService + @Inject + constructor( + val dataStore: DataStore, + ) { + val json = + Json { + ignoreUnknownKeys = true + isLenient = true + encodeDefaults = false + } + + inline fun get(key: String): Flow = + dataStore.data.map { preferences -> + preferences[stringPreferencesKey(key)]?.let { + json.decodeFromString(serializer(), it) + } + } + + inline fun get( + key: String, + defaultValue: T, + ): Flow = + dataStore.data.map { preferences -> + preferences[stringPreferencesKey(key)]?.let { + json.decodeFromString(serializer(), it) + } ?: defaultValue + } + + inline fun get( + userId: UUID, + key: String, + defaultValue: T, + ): Flow = + dataStore.data.map { preferences -> + preferences[stringPreferencesKey("${userId}_$key")]?.let { + json.decodeFromString(serializer(), it) + } ?: defaultValue + } + + suspend inline fun save( + key: String, + value: T, + ) { + dataStore.updateData { preferences -> + val valueStr = json.encodeToString(value) + preferences.toMutablePreferences().apply { + set(stringPreferencesKey(key), valueStr) + } + } + } + + suspend inline fun save( + userId: UUID, + key: String, + value: T, + ) { + dataStore.updateData { preferences -> + val valueStr = json.encodeToString(value) + preferences.toMutablePreferences().apply { + set(stringPreferencesKey("${userId}_$key"), valueStr) + } + } + } + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/DatabaseModule.kt b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/DatabaseModule.kt index 31c92774..62609628 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/DatabaseModule.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/DatabaseModule.kt @@ -5,6 +5,9 @@ import androidx.datastore.core.DataStore import androidx.datastore.core.DataStoreFactory import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler import androidx.datastore.dataStoreFile +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.preferencesDataStoreFile import androidx.room.Room import com.github.damontecres.wholphin.data.AppDatabase import com.github.damontecres.wholphin.data.ItemPlaybackDao @@ -85,4 +88,13 @@ object DatabaseModule { produceNewData = { AppPreferences.getDefaultInstance() }, ), ) + + @Provides + @Singleton + fun keyValueDataStore( + @ApplicationContext context: Context, + ): DataStore = + PreferenceDataStoreFactory.create( + produceFile = { context.preferencesDataStoreFile("key_value") }, + ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/Extensions.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/Extensions.kt index a42d027f..4d69fe0c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/Extensions.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/Extensions.kt @@ -39,6 +39,7 @@ import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -437,6 +438,16 @@ fun Int?.gt(that: Int) = (this ?: 0) > that fun Int?.lt(that: Int) = (this ?: 0) < that +/** + * Simplifies endlessly collecting a flow + */ +fun Flow.collectLatestIn( + scope: CoroutineScope, + action: suspend (value: T) -> Unit, +) { + scope.launchDefault { this@collectLatestIn.collectLatest(action) } +} + /** * Easy way to combine two flows into a [Pair] */ diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt index 0a9b9a2c..7435cca8 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt @@ -7,6 +7,7 @@ import androidx.compose.ui.text.buildAnnotatedString import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.WholphinApplication import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.MediaSegmentType import timber.log.Timber import java.time.LocalDate @@ -196,3 +197,45 @@ fun listToDotString( } } } + +@StringRes +fun formatTypeName(type: BaseItemKind): Int = + when (type) { + BaseItemKind.MOVIE -> R.string.movies + BaseItemKind.SERIES -> R.string.tv_shows + BaseItemKind.EPISODE -> R.string.episodes + BaseItemKind.VIDEO -> R.string.videos + BaseItemKind.PLAYLIST -> R.string.playlists + BaseItemKind.PERSON -> R.string.people + BaseItemKind.BOX_SET -> R.string.collections + BaseItemKind.AUDIO -> TODO() + BaseItemKind.CHANNEL -> R.string.channels + BaseItemKind.GENRE -> R.string.genres + BaseItemKind.LIVE_TV_CHANNEL -> R.string.channels + BaseItemKind.MUSIC_ALBUM -> TODO() + BaseItemKind.MUSIC_ARTIST -> TODO() + BaseItemKind.MUSIC_GENRE -> TODO() + BaseItemKind.MUSIC_VIDEO -> TODO() + BaseItemKind.PHOTO -> R.string.photos + BaseItemKind.PHOTO_ALBUM -> TODO() + BaseItemKind.PROGRAM -> TODO() + BaseItemKind.RECORDING -> TODO() + BaseItemKind.SEASON -> R.string.tv_seasons + BaseItemKind.STUDIO -> R.string.studios + BaseItemKind.TRAILER -> R.string.trailers + BaseItemKind.TV_CHANNEL -> R.string.channels + BaseItemKind.TV_PROGRAM -> TODO() + BaseItemKind.USER_ROOT_FOLDER -> TODO() + BaseItemKind.USER_VIEW -> TODO() + BaseItemKind.YEAR -> TODO() + BaseItemKind.AGGREGATE_FOLDER -> TODO() + BaseItemKind.AUDIO_BOOK -> TODO() + BaseItemKind.BASE_PLUGIN_FOLDER -> TODO() + BaseItemKind.BOOK -> TODO() + BaseItemKind.CHANNEL_FOLDER_ITEM -> TODO() + BaseItemKind.COLLECTION_FOLDER -> TODO() + BaseItemKind.FOLDER -> TODO() + BaseItemKind.MANUAL_PLAYLISTS_FOLDER -> TODO() + BaseItemKind.LIVE_TV_PROGRAM -> TODO() + BaseItemKind.PLAYLISTS_FOLDER -> TODO() + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderBoxSet.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderBoxSet.kt deleted file mode 100644 index 07c24c37..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderBoxSet.kt +++ /dev/null @@ -1,48 +0,0 @@ -package com.github.damontecres.wholphin.ui.detail - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import com.github.damontecres.wholphin.data.model.CollectionFolderFilter -import com.github.damontecres.wholphin.preferences.UserPreferences -import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid -import com.github.damontecres.wholphin.ui.components.ViewOptionsPoster -import com.github.damontecres.wholphin.ui.data.BoxSetSortOptions -import com.github.damontecres.wholphin.ui.data.SortAndDirection -import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel -import org.jellyfin.sdk.model.api.ItemSortBy -import org.jellyfin.sdk.model.api.SortOrder -import java.util.UUID - -@Composable -fun CollectionFolderBoxSet( - preferences: UserPreferences, - itemId: UUID, - recursive: Boolean, - modifier: Modifier = Modifier, - filter: CollectionFolderFilter = CollectionFolderFilter(), - preferencesViewModel: PreferencesViewModel = hiltViewModel(), - playEnabled: Boolean = false, -) { - var showHeader by remember { mutableStateOf(true) } - CollectionFolderGrid( - preferences = preferences, - onClickItem = { _, item -> preferencesViewModel.navigationManager.navigateTo(item.destination()) }, - itemId = itemId, - initialFilter = filter, - showTitle = showHeader, - recursive = recursive, - sortOptions = BoxSetSortOptions, - initialSortAndDirection = SortAndDirection(ItemSortBy.DEFAULT, SortOrder.ASCENDING), - modifier = modifier, - positionCallback = { columns, position -> - showHeader = position < columns - }, - defaultViewOptions = ViewOptionsPoster, - playEnabled = playEnabled, - ) -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionButtons.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionButtons.kt new file mode 100644 index 00000000..b10e9a17 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionButtons.kt @@ -0,0 +1,148 @@ +package com.github.damontecres.wholphin.ui.detail.collection + +import androidx.compose.foundation.focusGroup +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.focusRestorer +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.filter.DefaultFilterOptions +import com.github.damontecres.wholphin.data.filter.FilterValueOption +import com.github.damontecres.wholphin.data.filter.ItemFilterBy +import com.github.damontecres.wholphin.data.model.GetItemsFilter +import com.github.damontecres.wholphin.ui.components.DeleteButton +import com.github.damontecres.wholphin.ui.components.ExpandableFaButton +import com.github.damontecres.wholphin.ui.components.ExpandablePlayButton +import com.github.damontecres.wholphin.ui.components.FilterByButton +import com.github.damontecres.wholphin.ui.components.SortByButton +import com.github.damontecres.wholphin.ui.data.MovieSortOptions +import com.github.damontecres.wholphin.ui.data.SortAndDirection +import kotlin.time.Duration + +@Composable +fun CollectionButtons( + state: CollectionState, + onSortChange: (SortAndDirection) -> Unit, + onClickPlayAll: (Boolean) -> Unit, + onFilterChange: (GetItemsFilter) -> Unit, + getPossibleFilterValues: suspend (ItemFilterBy<*>) -> List, + onClickViewOptions: () -> Unit, + favoriteOnClick: () -> Unit, + deleteOnClick: () -> Unit, + canDelete: Boolean, + moreOnClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val sortOptions = MovieSortOptions + val filterOptions = DefaultFilterOptions + val firstFocus = remember { FocusRequester() } + Row( + horizontalArrangement = Arrangement.SpaceBetween, + modifier = modifier, + ) { + LazyRow( + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + contentPadding = PaddingValues(8.dp), + modifier = + Modifier + .focusGroup() + .focusRestorer(firstFocus), + ) { + item { + ExpandablePlayButton( + title = R.string.play, + resume = Duration.ZERO, + icon = Icons.Default.PlayArrow, + onClick = { onClickPlayAll.invoke(false) }, + modifier = Modifier.focusRequester(firstFocus), + ) + } + item { + ExpandableFaButton( + title = R.string.shuffle, + iconStringRes = R.string.fa_shuffle, + onClick = { onClickPlayAll.invoke(true) }, + ) + } + + item("favorite") { + val favorite = remember(state.collection) { state.collection?.favorite == true } + ExpandableFaButton( + title = if (favorite) R.string.remove_favorite else R.string.add_favorite, + iconStringRes = R.string.fa_heart, + onClick = favoriteOnClick, + iconColor = if (favorite) Color.Red else Color.Unspecified, + modifier = Modifier, + ) + } + if (canDelete) { + item("delete") { + DeleteButton( + onClick = deleteOnClick, + modifier = + Modifier, + ) + } + } + + item { + ExpandableFaButton( + title = R.string.view_options, + iconStringRes = R.string.fa_sliders, + onClick = onClickViewOptions, + modifier = Modifier, + ) + } + + // More button + item("more") { + ExpandablePlayButton( + title = R.string.more, + resume = Duration.ZERO, + icon = Icons.Default.MoreVert, + onClick = { moreOnClick.invoke() }, + modifier = Modifier, + ) + } + } + LazyRow( + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + contentPadding = PaddingValues(8.dp), + modifier = + Modifier + .focusGroup(), + ) { + item { + SortByButton( + sortOptions = sortOptions, + current = state.sortAndDirection, + onSortChange = onSortChange, + modifier = Modifier, + ) + } + item { + FilterByButton( + filterOptions = filterOptions, + current = state.itemFilter, + onFilterChange = onFilterChange, + getPossibleValues = getPossibleFilterValues, + modifier = Modifier, + ) + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionDetails.kt new file mode 100644 index 00000000..7f9cc1ca --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionDetails.kt @@ -0,0 +1,551 @@ +package com.github.damontecres.wholphin.ui.detail.collection + +import android.content.Context +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.SharedTransitionLayout +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +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.focusProperties +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.filter.FilterValueOption +import com.github.damontecres.wholphin.data.filter.ItemFilterBy +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.GetItemsFilter +import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.ui.components.ConfirmDeleteDialog +import com.github.damontecres.wholphin.ui.components.DialogItem +import com.github.damontecres.wholphin.ui.components.DialogParams +import com.github.damontecres.wholphin.ui.components.DialogPopup +import com.github.damontecres.wholphin.ui.components.ErrorMessage +import com.github.damontecres.wholphin.ui.components.LoadingPage +import com.github.damontecres.wholphin.ui.components.Optional +import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel +import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog +import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo +import com.github.damontecres.wholphin.ui.data.RowColumn +import com.github.damontecres.wholphin.ui.data.SortAndDirection +import com.github.damontecres.wholphin.ui.detail.MoreDialogActions +import com.github.damontecres.wholphin.ui.detail.PlaylistDialog +import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState +import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome +import com.github.damontecres.wholphin.ui.main.HomePageHeader +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.util.HomeRowLoadingState +import com.github.damontecres.wholphin.util.LoadingState +import org.jellyfin.sdk.model.api.MediaType +import timber.log.Timber +import java.util.UUID + +@Composable +fun CollectionDetails( + preferences: UserPreferences, + itemId: UUID, + modifier: Modifier = Modifier, + viewModel: CollectionViewModel = + hiltViewModel( + creationCallback = { it.create(itemId) }, + ), + playlistViewModel: AddPlaylistViewModel = hiltViewModel(), +) { + val context = LocalContext.current + val state by viewModel.state.collectAsState() + + // Dialogs + var moreDialog by remember { mutableStateOf(null) } + var showPlaylistDialog by remember { mutableStateOf>(Optional.absent()) } + val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) + var showDeleteDialog by remember { mutableStateOf?>(null) } + var showViewOptionsDialog by remember { mutableStateOf(false) } + var overviewDialog by remember { mutableStateOf(null) } + + // Actions + val onClickItem = + remember { + { _: RowColumn, item: BaseItem -> viewModel.navigate(item.destination()) } + } + val onLongClickItem = + remember { + { position: RowColumn, item: BaseItem -> + val dialogItems = + buildMoreDialogItemsForHome( + context = context, + item = item, + seriesId = item.data.seriesId, + playbackPosition = item.playbackPosition, + watched = item.played, + favorite = item.favorite, + canDelete = viewModel.canDelete(item, preferences.appPreferences), + actions = + MoreDialogActions( + navigateTo = viewModel::navigate, + onClickWatch = { itemId, watched -> + viewModel.setWatched(itemId, watched, position) + }, + onClickFavorite = { itemId, favorite -> + viewModel.setFavorite(itemId, favorite, position) + }, + onClickAddPlaylist = { itemId -> + playlistViewModel.loadPlaylists(MediaType.VIDEO) + showPlaylistDialog.makePresent(itemId) + }, + onSendMediaInfo = viewModel.mediaReportService::sendReportFor, + onClickDelete = { item -> showDeleteDialog = Pair(position, item) }, + ), + ) + moreDialog = + DialogParams( + fromLongClick = true, + title = item.title ?: "", + items = dialogItems, + ) + } + } + val onSortChange = + remember { + { sort: SortAndDirection -> viewModel.changeSort(sort) } + } + val onFilterChange = + remember { + { filter: GetItemsFilter -> viewModel.changeFilter(filter) } + } + val onClickPlay = { _: RowColumn, item: BaseItem -> + viewModel.navigate(Destination.Playback(item = item)) + } + val onClickPlayAll = + remember { + { shuffle: Boolean -> + val dest = + Destination.PlaybackList( + itemId = itemId, + startIndex = 0, + shuffle = shuffle, + recursive = true, + sortAndDirection = state.sortAndDirection, + filter = state.itemFilter, + ) + viewModel.navigate(dest) + } + } + val onClickViewOptions = remember { { showViewOptionsDialog = true } } + + when (val s = state.loadingState) { + is LoadingState.Error -> { + ErrorMessage(s, modifier) + } + + LoadingState.Loading, + LoadingState.Pending, + -> { + LoadingPage(modifier) + } + + LoadingState.Success -> { + CollectionDetailsContent( + preferences = preferences, + state = state, + onClickItem = onClickItem, + onLongClickItem = onLongClickItem, + onSortChange = onSortChange, + onClickPlay = onClickPlay, + onClickPlayAll = onClickPlayAll, + onChangeBackdrop = viewModel::updateBackdrop, + onFilterChange = onFilterChange, + getPossibleFilterValues = viewModel::getPossibleFilterValues, + letterPosition = viewModel::letterPosition, + onClickViewOptions = onClickViewOptions, + modifier = modifier, + overviewOnClick = { + val collection = state.collection!! + overviewDialog = + ItemDetailsDialogInfo( + title = collection.title ?: "", + overview = collection.data.overview, + genres = collection.data.genres.orEmpty(), + files = emptyList(), + ) + }, + favoriteOnClick = + remember { + { + state.collection?.let { + viewModel.setFavorite(it.id, !it.favorite, null) + } + } + }, + deleteOnClick = + remember { + { + state.collection?.let { + viewModel.deleteItem(it, null) + } + } + }, + canDelete = + remember(state.collection) { + state.collection?.let { + viewModel.canDelete(it, preferences.appPreferences) + } ?: false + }, + moreOnClick = { + val collection = state.collection!! + val items = + buildMoreDialogItemsForCollection( + context = context, + item = collection, + favorite = collection.favorite, + canDelete = viewModel.canDelete(collection, preferences.appPreferences), + onClickPlayAll = onClickPlayAll, + actions = + MoreDialogActions( + navigateTo = viewModel::navigate, + onClickWatch = { itemId, watched -> + viewModel.setWatched(itemId, watched, null) + }, + onClickFavorite = { itemId, favorite -> + viewModel.setFavorite(itemId, favorite, null) + }, + onClickAddPlaylist = { itemId -> + playlistViewModel.loadPlaylists(MediaType.VIDEO) + showPlaylistDialog.makePresent(itemId) + }, + onSendMediaInfo = viewModel.mediaReportService::sendReportFor, + onClickDelete = { item -> showDeleteDialog = Pair(null, item) }, + ), + ) + moreDialog = + DialogParams( + fromLongClick = false, + title = collection.title ?: "", + items = items, + ) + }, + ) + } + } + if (showViewOptionsDialog) { + CollectionViewOptionsDialog( + viewOptions = state.viewOptions, + onDismissRequest = { showViewOptionsDialog = false }, + onViewOptionsChange = viewModel::changeViewOptions, + ) + } + moreDialog?.let { params -> + DialogPopup( + showDialog = true, + title = params.title, + dialogItems = params.items, + onDismissRequest = { moreDialog = null }, + dismissOnClick = true, + waitToLoad = params.fromLongClick, + ) + } + showPlaylistDialog.compose { itemId -> + PlaylistDialog( + title = stringResource(R.string.add_to_playlist), + state = playlistState, + onDismissRequest = { showPlaylistDialog.makeAbsent() }, + onClick = { + playlistViewModel.addToPlaylist(it.id, itemId) + showPlaylistDialog.makeAbsent() + }, + createEnabled = true, + onCreatePlaylist = { + playlistViewModel.createPlaylistAndAddItem(it, itemId) + showPlaylistDialog.makeAbsent() + }, + elevation = 3.dp, + ) + } + showDeleteDialog?.let { (position, item) -> + ConfirmDeleteDialog( + itemTitle = item.title ?: "", + onCancel = { showDeleteDialog = null }, + onConfirm = { + viewModel.deleteItem(item, position) + showDeleteDialog = null + }, + ) + } + overviewDialog?.let { info -> + ItemDetailsDialog( + info = info, + showFilePath = + viewModel.serverRepository.currentUserDto.value + ?.policy + ?.isAdministrator == true, + onDismissRequest = { overviewDialog = null }, + ) + } +} + +@Composable +fun CollectionDetailsContent( + preferences: UserPreferences, + state: CollectionState, + onClickItem: (RowColumn, BaseItem) -> Unit, + onLongClickItem: (RowColumn, BaseItem) -> Unit, + onSortChange: (SortAndDirection) -> Unit, + onClickPlay: (RowColumn, BaseItem) -> Unit, + onClickPlayAll: (Boolean) -> Unit, + onChangeBackdrop: (BaseItem) -> Unit, + onFilterChange: (GetItemsFilter) -> Unit, + getPossibleFilterValues: suspend (ItemFilterBy<*>) -> List, + letterPosition: suspend (Char) -> Int, + onClickViewOptions: () -> Unit, + overviewOnClick: () -> Unit, + favoriteOnClick: () -> Unit, + deleteOnClick: () -> Unit, + canDelete: Boolean, + moreOnClick: () -> Unit, + modifier: Modifier, +) { + var itemsContentHasFocus by rememberSaveable { mutableStateOf(false) } + val bringIntoViewRequester = remember { BringIntoViewRequester() } + val focusRequester = remember { FocusRequester() } + val contentFocusRequester = remember { FocusRequester() } + + var focusedItem by remember { mutableStateOf(state.collection) } + LaunchedEffect(focusedItem) { + focusedItem?.let { onChangeBackdrop.invoke(it) } + } + + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier, + ) { + SharedTransitionLayout { + AnimatedContent( + targetState = itemsContentHasFocus, + label = "header_transition", + ) { targetState -> + if (targetState) { + // Show item header + LaunchedEffect(Unit) { + contentFocusRequester.tryRequestFocus() + } + Column( + Modifier.sharedBounds( + rememberSharedContentState(key = "header"), + animatedVisibilityScope = this@AnimatedContent, + enter = slideInVertically { it / 2 } + fadeIn(), + exit = slideOutVertically { it / 2 } + fadeOut(), + ), + ) { + // This box exists so that there is something focusable above the item content + // allowing focus to move up to restore the collection's header + Box( + modifier = + Modifier + .fillMaxWidth() + .height(0.dp) + .onFocusChanged { + if (it.isFocused) itemsContentHasFocus = false + }.focusable(), + ) + if (state.viewOptions.cardViewOptions.showDetails) { + HomePageHeader( + item = focusedItem, + modifier = + Modifier + .padding(top = 48.dp, start = 8.dp) + .fillMaxHeight(.33f) + .fillMaxWidth(), + ) + } + } + } else { + // Show collection header + LaunchedEffect(Unit) { + focusRequester.tryRequestFocus() + focusedItem = state.collection + } + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = + Modifier + .sharedBounds( + rememberSharedContentState(key = "header"), + animatedVisibilityScope = this@AnimatedContent, + enter = slideInVertically { -it / 2 } + fadeIn(), + exit = slideOutVertically { -it / 2 } + fadeOut(), + ).padding(bottom = 16.dp) + .fillMaxWidth() + .onFocusChanged { + if (it.hasFocus) { + onChangeBackdrop.invoke(state.collection!!) + } + }, + ) { + CollectionDetailsHeader( + collection = state.collection!!, + logoImageUrl = state.logoImageUrl, + overviewOnClick = overviewOnClick, + bringIntoViewRequester = bringIntoViewRequester, + modifier = + Modifier + .padding(top = 48.dp, start = 8.dp) + // TODO + .fillMaxHeight(.36f) + .fillMaxWidth(), + ) + CollectionButtons( + state = state, + onSortChange = onSortChange, + onClickPlayAll = onClickPlayAll, + onFilterChange = onFilterChange, + getPossibleFilterValues = getPossibleFilterValues, + onClickViewOptions = onClickViewOptions, + favoriteOnClick = favoriteOnClick, + deleteOnClick = deleteOnClick, + canDelete = canDelete, + moreOnClick = moreOnClick, + modifier = + Modifier + .focusRequester(focusRequester) + .fillMaxWidth(), + ) + } + } + } + } + Box( + modifier = + Modifier + .fillMaxSize() + .onFocusChanged { + if (it.hasFocus) itemsContentHasFocus = true + }.focusProperties { + up = focusRequester + }.focusRequester(contentFocusRequester), + ) { + if (state.viewOptions.separateTypes) { + CollectionRows( + preferences = preferences, + state = state, + onClickItem = onClickItem, + onLongClickItem = onLongClickItem, + onClickPlay = onClickPlay, + modifier = Modifier.fillMaxSize(), + onFocusPosition = { position -> + Timber.v("onFocusPosition=%s", position) + focusedItem = + position.let { + val key = + state.separateItems.keys + .toList() + .getOrNull(it.row) + (state.separateItems[key] as? HomeRowLoadingState.Success)?.items?.getOrNull( + it.column, + ) + } + }, + ) + } else { + CollectionMixedGrid( + preferences = preferences, + state = state, + onClickItem = onClickItem, + onLongClickItem = onLongClickItem, + onClickPlay = onClickPlay, + letterPosition = letterPosition, + modifier = Modifier.fillMaxSize(), + onFocusPosition = { + Timber.v("onFocusPosition=%s", it) + focusedItem = state.items.getOrNull(it.column) + }, + ) + } + } + } +} + +fun buildMoreDialogItemsForCollection( + context: Context, + item: BaseItem, + favorite: Boolean, + canDelete: Boolean, + onClickPlayAll: (shuffle: Boolean) -> Unit, + actions: MoreDialogActions, +): List = + buildList { + add( + DialogItem( + context.getString(R.string.play), + Icons.Default.PlayArrow, + iconColor = Color.Green.copy(alpha = .8f), + ) { + onClickPlayAll.invoke(false) + }, + ) + add( + DialogItem( + context.getString(R.string.shuffle), + R.string.fa_shuffle, + ) { + onClickPlayAll.invoke(true) + }, + ) + + add( + DialogItem( + text = R.string.add_to_playlist, + iconStringRes = R.string.fa_list_ul, + ) { + actions.onClickAddPlaylist.invoke(item.id) + }, + ) + if (canDelete) { + add( + DialogItem( + context.getString(R.string.delete), + Icons.Default.Delete, + iconColor = Color.Red.copy(alpha = .8f), + ) { + actions.onClickDelete.invoke(item) + }, + ) + } + add( + DialogItem( + text = if (favorite) R.string.remove_favorite else R.string.add_favorite, + iconStringRes = R.string.fa_heart, + iconColor = if (favorite) Color.Red else Color.Unspecified, + ) { + actions.onClickFavorite.invoke(item.id, !favorite) + }, + ) + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionDetailsHeader.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionDetailsHeader.kt new file mode 100644 index 00000000..a71ac187 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionDetailsHeader.kt @@ -0,0 +1,111 @@ +package com.github.damontecres.wholphin.ui.detail.collection + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontStyle +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.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import coil3.compose.AsyncImage +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.ui.components.GenreText +import com.github.damontecres.wholphin.ui.components.OverviewText +import com.github.damontecres.wholphin.ui.components.QuickDetails +import com.github.damontecres.wholphin.ui.letNotEmpty +import com.github.damontecres.wholphin.util.ExceptionHandler +import kotlinx.coroutines.launch + +@Composable +fun CollectionDetailsHeader( + collection: BaseItem, + logoImageUrl: String?, + overviewOnClick: () -> Unit, + bringIntoViewRequester: BringIntoViewRequester, + modifier: Modifier = Modifier, +) { + val dto = collection.data + val context = LocalContext.current + val scope = rememberCoroutineScope() + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = modifier, + ) { + if (logoImageUrl != null) { + AsyncImage( + model = logoImageUrl, + contentDescription = collection.name, + modifier = Modifier.height(80.dp), + alignment = Alignment.TopStart, + contentScale = ContentScale.Fit, + ) + } else { + // Title + Text( + text = collection.name ?: "", + color = MaterialTheme.colorScheme.onBackground, + style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = + Modifier + .fillMaxWidth(.75f) + .padding(start = 8.dp), + ) + } + + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.fillMaxWidth(.60f), + ) { + QuickDetails( + collection.ui.quickDetails, + collection.timeRemainingOrRuntime, + Modifier.padding(start = 8.dp), + ) + + dto.genres?.letNotEmpty { + GenreText(it, Modifier.padding(start = 8.dp)) + } + dto.taglines?.firstOrNull()?.let { tagline -> + Text( + text = tagline, + style = MaterialTheme.typography.bodyLarge, + fontStyle = FontStyle.Italic, + modifier = Modifier.padding(start = 8.dp), + ) + } + + // Description + dto.overview?.let { overview -> + OverviewText( + overview = overview, + maxLines = 3, + onClick = overviewOnClick, + textBoxHeight = Dp.Unspecified, + modifier = + Modifier.onFocusChanged { + if (it.isFocused) { + scope.launch(ExceptionHandler()) { + bringIntoViewRequester.bringIntoView() + } + } + }, + ) + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionMixedGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionMixedGrid.kt new file mode 100644 index 00000000..64a6eb10 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionMixedGrid.kt @@ -0,0 +1,89 @@ +package com.github.damontecres.wholphin.ui.detail.collection + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.gestures.LocalBringIntoViewSpec +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.ui.cards.GridCard +import com.github.damontecres.wholphin.ui.data.RowColumn +import com.github.damontecres.wholphin.ui.detail.CardGrid +import com.github.damontecres.wholphin.ui.playback.scale +import com.github.damontecres.wholphin.ui.util.ScrollToTopBringIntoViewSpec +import org.jellyfin.sdk.model.api.ItemSortBy + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun CollectionMixedGrid( + preferences: UserPreferences, + state: CollectionState, + onClickItem: (RowColumn, BaseItem) -> Unit, + onLongClickItem: (RowColumn, BaseItem) -> Unit, + onClickPlay: (RowColumn, BaseItem) -> Unit, + letterPosition: suspend (Char) -> Int, + modifier: Modifier = Modifier, + onFocusPosition: (RowColumn) -> Unit = {}, +) { + val gridFocusRequester = remember { FocusRequester() } + + Box(modifier = modifier) { + Column( + verticalArrangement = Arrangement.spacedBy(0.dp), + modifier = Modifier.fillMaxSize(), + ) { + val defaultBringIntoViewSpec = LocalBringIntoViewSpec.current + val density = LocalDensity.current + + val cardViewOptions = state.viewOptions.cardViewOptions + CardGrid( + pager = state.items, + onClickItem = { index: Int, item: BaseItem -> onClickItem.invoke(RowColumn(0, index), item) }, + onLongClickItem = { index: Int, item: BaseItem -> onLongClickItem.invoke(RowColumn(0, index), item) }, + onClickPlay = { index: Int, item: BaseItem -> onClickPlay.invoke(RowColumn(0, index), item) }, + letterPosition = letterPosition, + gridFocusRequester = gridFocusRequester, + showJumpButtons = false, // TODO add preference + showLetterButtons = state.sortAndDirection.sort == ItemSortBy.SORT_NAME, + modifier = + Modifier + .fillMaxSize(), + initialPosition = 0, + positionCallback = { _, newPosition -> + onFocusPosition.invoke(RowColumn(0, newPosition)) + }, + cardContent = { item, onClick, onLongClick, mod -> + GridCard( + item = item, + onClick = onClick, + onLongClick = onLongClick, + imageContentScale = cardViewOptions.contentScale.scale, + imageAspectRatio = cardViewOptions.aspectRatio.ratio, + imageType = cardViewOptions.imageType, + showTitle = cardViewOptions.showTitles, + modifier = mod, + ) + }, + columns = cardViewOptions.columns, + spacing = cardViewOptions.spacing.dp, + bringIntoViewSpec = + remember(cardViewOptions) { + val spacingPx = with(density) { cardViewOptions.spacing.dp.toPx() } + if (cardViewOptions.showDetails) { + ScrollToTopBringIntoViewSpec(spacingPx) + } else { + defaultBringIntoViewSpec + } + }, + ) + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionRows.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionRows.kt new file mode 100644 index 00000000..ef78fb3b --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionRows.kt @@ -0,0 +1,90 @@ +package com.github.damontecres.wholphin.ui.detail.collection + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +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.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.HomeRowViewOptions +import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.ui.AspectRatio +import com.github.damontecres.wholphin.ui.Cards +import com.github.damontecres.wholphin.ui.data.RowColumn +import com.github.damontecres.wholphin.ui.main.HomePageContent +import com.github.damontecres.wholphin.ui.rememberPosition +import com.github.damontecres.wholphin.util.HomeRowLoadingState +import org.jellyfin.sdk.model.api.BaseItemKind + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun CollectionRows( + preferences: UserPreferences, + state: CollectionState, + onClickItem: (RowColumn, BaseItem) -> Unit, + onLongClickItem: (RowColumn, BaseItem) -> Unit, + onClickPlay: (RowColumn, BaseItem) -> Unit, + modifier: Modifier = Modifier, + onFocusPosition: (RowColumn) -> Unit = {}, +) { + var position by rememberPosition(0, 0) + + Box(modifier = modifier) { + Column( + verticalArrangement = Arrangement.spacedBy(0.dp), + modifier = + Modifier + .fillMaxSize() + .padding(top = 8.dp), + ) { + val cardViewOptions = state.viewOptions.cardViewOptions + val homeRows = + remember(state.separateItems, cardViewOptions) { + state.separateItems.map { (type, row) -> + if (row is HomeRowLoadingState.Success) { + // TODO not great to do this in the UI + val viewOptions = + if (type == BaseItemKind.EPISODE) { + HomeRowViewOptions( + heightDp = Cards.HEIGHT_EPISODE, + episodeAspectRatio = AspectRatio.WIDE, + showTitles = cardViewOptions.showTitles, + useSeries = false, + ) + } else { + HomeRowViewOptions( + showTitles = cardViewOptions.showTitles, + ) + } + row.copy(viewOptions = viewOptions) + } else { + row + } + } + } + HomePageContent( + homeRows = homeRows, + position = position, + onFocusPosition = { newPosition -> + position = newPosition + onFocusPosition.invoke(newPosition) + }, + onClickItem = onClickItem, + onLongClickItem = onLongClickItem, + onClickPlay = onClickPlay, + showClock = false, + onUpdateBackdrop = {}, + headerComposable = {}, + takeFocus = false, + modifier = Modifier, + ) + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionViewModel.kt new file mode 100644 index 00000000..badc4e9d --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionViewModel.kt @@ -0,0 +1,493 @@ +package com.github.damontecres.wholphin.ui.detail.collection + +import android.content.Context +import androidx.compose.runtime.Stable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.asFlow +import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.filter.FilterValueOption +import com.github.damontecres.wholphin.data.filter.ItemFilterBy +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.GetItemsFilter +import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.services.BackdropService +import com.github.damontecres.wholphin.services.FavoriteWatchManager +import com.github.damontecres.wholphin.services.ImageUrlService +import com.github.damontecres.wholphin.services.KeyValueService +import com.github.damontecres.wholphin.services.MediaManagementService +import com.github.damontecres.wholphin.services.MediaReportService +import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.ThemeSongPlayer +import com.github.damontecres.wholphin.services.UserPreferencesService +import com.github.damontecres.wholphin.services.deleteItem +import com.github.damontecres.wholphin.ui.SlimItemFields +import com.github.damontecres.wholphin.ui.collectLatestIn +import com.github.damontecres.wholphin.ui.data.RowColumn +import com.github.damontecres.wholphin.ui.data.SortAndDirection +import com.github.damontecres.wholphin.ui.formatTypeName +import com.github.damontecres.wholphin.ui.launchDefault +import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.toServerString +import com.github.damontecres.wholphin.ui.util.FilterUtils +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.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.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.supervisorScope +import kotlinx.coroutines.withContext +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.userLibraryApi +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 + +@OptIn(ExperimentalCoroutinesApi::class) +@HiltViewModel(assistedFactory = CollectionViewModel.Factory::class) +class CollectionViewModel + @AssistedInject + constructor( + @param:ApplicationContext private val context: Context, + private val api: ApiClient, + val serverRepository: ServerRepository, + private val navigationManager: NavigationManager, + private val preferencesService: UserPreferencesService, + private val themeSongPlayer: ThemeSongPlayer, + private val mediaManagementService: MediaManagementService, + private val favoriteWatchManager: FavoriteWatchManager, + private val backdropService: BackdropService, + private val keyValueService: KeyValueService, + private val libraryDisplayInfoDao: LibraryDisplayInfoDao, + private val imageUrlService: ImageUrlService, + val mediaReportService: MediaReportService, + @Assisted private val itemId: UUID, + ) : ViewModel() { + @AssistedFactory + interface Factory { + fun create(itemId: UUID): CollectionViewModel + } + + private val viewOptionsFlow = + serverRepository.currentUser + .asFlow() + .filterNotNull() + .flatMapLatest { + keyValueService.get(it.id, VIEW_OPTIONS_KEY, CollectionViewOptions()) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), CollectionViewOptions()) + + private val libraryDisplayInfoFlow = + serverRepository.currentUser + .asFlow() + .filterNotNull() + .flatMapLatest { + libraryDisplayInfoDao.getItemAsFlow(it.rowId, itemId.toServerString()) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null) + + private val _state = MutableStateFlow(CollectionState()) + val state: StateFlow = _state + + init { + addCloseable { release() } + // Get global per-user view options for collections + viewOptionsFlow.collectLatestIn(viewModelScope) { viewOptions -> + Timber.v("Updated viewOptions") + _state.update { + it.copy(viewOptions = viewOptions) + } + } + libraryDisplayInfoFlow + .filterNotNull() + .collectLatestIn(viewModelScope) { libraryDisplayInfo -> + Timber.v("Updated libraryDisplayInfo") + _state.update { + it.copy( + itemFilter = libraryDisplayInfo.filter, + sortAndDirection = libraryDisplayInfo.sortAndDirection, + ) + } + } + viewModelScope.launchDefault { + val collection = + api.userLibraryApi + .getItem(itemId) + .content + .let { BaseItem(it, false) } + backdropService.submit(collection) + val logoImageUrl = null + // TODO add logo back +// if (ImageType.LOGO in collection.data.imageTags.orEmpty()) { +// imageUrlService.getItemImageUrl(collection, ImageType.LOGO) +// } else { +// null +// } + _state.update { + it.copy( + collection = collection, + logoImageUrl = logoImageUrl, + ) + } + listenForStateUpdates() + themeSongPlayer.playThemeFor( + itemId, + preferencesService + .getCurrent() + .appPreferences.interfacePreferences.playThemeSongs, + ) + } + } + + fun release() { + themeSongPlayer.stop() + } + + /** + * Collects on [state] and fetches data when needed + */ + private fun listenForStateUpdates() = + viewModelScope.launchDefault { + state + .map { + Triple( + it.sortAndDirection, + it.itemFilter, + it.viewOptions.separateTypes, + ) + }.distinctUntilChanged() + .collectLatest { (sort, filter, separateTypes) -> + try { + updateData(sort, filter, separateTypes) + } catch (ex: Exception) { + Timber.e( + ex, + "Error fetching data for collection %s", + itemId, + ) + _state.update { it.copy(loadingState = LoadingState.Error(ex)) } + } + } + } + + private suspend fun updateData( + sort: SortAndDirection, + filter: GetItemsFilter, + separateTypes: Boolean, + ) { + Timber.d("Begin updateData for %s", itemId) + _state.update { + it.copy( + loadingState = LoadingState.Loading, + items = emptyList(), + separateItems = emptyMap(), + ) + } + if (!separateTypes) { + val result = fetchItems(sort, filter, typesInCollection) + _state.update { it.copy(items = result) } + } else { + supervisorScope { + val jobs = + typesInCollection.map { type -> + async(Dispatchers.IO) { + val title = context.getString(formatTypeName(type)) + val result = + try { + val pager = fetchItems(sort, filter, listOf(type)) + HomeRowLoadingState.Success(title, pager) + } catch (ex: Exception) { + Timber.e( + ex, + "Error fetching %s for collection %s", + type, + itemId, + ) + HomeRowLoadingState.Error(title, exception = ex) + } + type to result + } + } + jobs.forEach { job -> + val (type, row) = job.await() + _state.update { + val separateItems = + it.separateItems.toMutableMap().apply { + put(type, row) + } + it.copy(separateItems = separateItems) + } + } + } + } + _state.update { it.copy(loadingState = LoadingState.Success) } + Timber.d("End updateData for %s", itemId) + } + + private suspend fun fetchItems( + sort: SortAndDirection?, + filter: GetItemsFilter?, + types: List, + ): ApiRequestPager { + val request = createGetItemsRequest(sort, filter, types) + val useSeriesForPrimary = !state.value.viewOptions.separateTypes + return ApiRequestPager( + api, + request, + GetItemsRequestHandler, + viewModelScope, + useSeriesForPrimary = useSeriesForPrimary, + ).init() + } + + private fun createGetItemsRequest( + sort: SortAndDirection?, + filter: GetItemsFilter?, + types: List, + ): GetItemsRequest { + val includeItemTypes: List? + val excludeItemTypes: List? + // Workaround for https://github.com/jellyfin/jellyfin/issues/16454 + if (types.size == 1 && types.first() == BaseItemKind.BOX_SET) { + includeItemTypes = null + excludeItemTypes = + BaseItemKind.entries + .toMutableList() + .apply { remove(BaseItemKind.BOX_SET) } + } else { + includeItemTypes = types + excludeItemTypes = null + } + val request = + GetItemsRequest( + userId = serverRepository.currentUser.value?.id, + parentId = itemId, + includeItemTypes = includeItemTypes, + excludeItemTypes = excludeItemTypes, + recursive = false, + sortBy = sort?.let { listOf(sort.sort) }, + sortOrder = sort?.let { listOf(sort.direction) }, + fields = SlimItemFields, + ).let { + filter?.applyTo(it, false) ?: it + } + return request + } + + fun changeSort(sortAndDirection: SortAndDirection) { + viewModelScope.launchIO { + val user = serverRepository.currentUser.value + val state = _state.value + if (user != null) { + libraryDisplayInfoDao.saveItem( + LibraryDisplayInfo( + user = user, + itemId = itemId, + sort = sortAndDirection.sort, + direction = sortAndDirection.direction, + filter = state.itemFilter, + viewOptions = null, + ), + ) + } + } + } + + fun changeFilter(filter: GetItemsFilter) { + viewModelScope.launchIO { + val user = serverRepository.currentUser.value + val state = _state.value + if (user != null) { + libraryDisplayInfoDao.saveItem( + LibraryDisplayInfo( + user = user, + itemId = itemId, + sort = state.sortAndDirection.sort, + direction = state.sortAndDirection.direction, + filter = filter, + viewOptions = null, + ), + ) + } + } + } + + fun changeViewOptions(viewOptions: CollectionViewOptions) { + viewModelScope.launchIO { + if (!viewOptions.cardViewOptions.showDetails) { + val collection = state.value.collection + if (collection != null) { + backdropService.submit(collection) + } else { + backdropService.clearBackdrop() + } + } + serverRepository.currentUser.value?.id?.let { userId -> + keyValueService.save(userId, VIEW_OPTIONS_KEY, viewOptions) + } + } + } + + suspend fun getPossibleFilterValues(filterOption: ItemFilterBy<*>): List = + FilterUtils.getFilterOptionValues( + api, + serverRepository.currentUser.value?.id, + itemId, + filterOption, + ) + + suspend fun letterPosition(letter: Char): Int = + withContext(Dispatchers.IO) { + val sort = state.value.sortAndDirection + val filter = state.value.itemFilter + val request = + createGetItemsRequest( + sort = sort, + filter = filter, + types = typesInCollection, + ).copy( + enableImageTypes = null, + fields = null, + nameLessThan = letter.toString(), + limit = 0, + enableTotalRecordCount = true, + ) + val result by GetItemsRequestHandler.execute(api, request) + result.totalRecordCount + } + + fun navigate(destination: Destination) { + release() + navigationManager.navigateTo(destination) + } + + fun setWatched( + itemId: UUID, + played: Boolean, + position: RowColumn?, + ) = viewModelScope.launch(Dispatchers.IO + ExceptionHandler()) { + favoriteWatchManager.setWatched(itemId, played) + if (position != null) { + refreshItem(itemId, position, false) + } + } + + fun setFavorite( + itemId: UUID, + favorite: Boolean, + position: RowColumn?, + ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { + favoriteWatchManager.setFavorite(itemId, favorite) + if (position != null) { + refreshItem(itemId, position, false) + } + } + + fun canDelete( + item: BaseItem, + appPreferences: AppPreferences, + ): Boolean = mediaManagementService.canDelete(item, appPreferences) + + fun deleteItem( + item: BaseItem, + position: RowColumn?, + ) { + deleteItem(context, mediaManagementService, item) { + viewModelScope.launchIO { + if (position != null) { + refreshItem(itemId, position, true) + } + } + } + } + + private suspend fun refreshItem( + itemId: UUID, + position: RowColumn, + isDelete: Boolean, + ) { + state.value.let { state -> + val items = + if (state.viewOptions.separateTypes) { + val key = + state.separateItems.keys + .toList() + .getOrNull(position.row) + (state.separateItems[key] as? HomeRowLoadingState.Success)?.items as? ApiRequestPager<*> + } else { + state.items as? ApiRequestPager<*> + } + if (isDelete) { + items?.refreshPagesAfter(position.column) + } else { + items?.refreshItem(position.column, itemId) + } + } + } + + fun updateBackdrop(item: BaseItem) { + viewModelScope.launchDefault { + val collection = state.value.collection + if (item.id == collection?.id) { + // Always show the collection's backdrop if requested + backdropService.submit(collection) + } else if (state.value.viewOptions.cardViewOptions.showDetails) { + backdropService.submit(item) + } else { + backdropService.clearBackdrop() + } + } + } + + companion object { + val typesInCollection = + listOf( + BaseItemKind.MOVIE, + BaseItemKind.SERIES, + BaseItemKind.EPISODE, + BaseItemKind.VIDEO, + BaseItemKind.BOX_SET, + ) + + const val VIEW_OPTIONS_KEY = "CollectionViewOptions" + } + } + +@Stable +data class CollectionState( + val loadingState: LoadingState = LoadingState.Pending, + val collection: BaseItem? = null, + val sortAndDirection: SortAndDirection = + SortAndDirection( + ItemSortBy.DEFAULT, + SortOrder.ASCENDING, + ), + val itemFilter: GetItemsFilter = GetItemsFilter(), + val viewOptions: CollectionViewOptions = CollectionViewOptions(), + val items: List = emptyList(), + val separateItems: Map = emptyMap(), + val logoImageUrl: String? = null, +) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionViewOptionsDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionViewOptionsDialog.kt new file mode 100644 index 00000000..b080e817 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionViewOptionsDialog.kt @@ -0,0 +1,174 @@ +package com.github.damontecres.wholphin.ui.detail.collection + +import android.view.Gravity +import androidx.compose.foundation.background +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.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.compose.ui.window.DialogWindowProvider +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import androidx.tv.material3.surfaceColorAtElevation +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.preferences.AppPreference +import com.github.damontecres.wholphin.preferences.AppSwitchPreference +import com.github.damontecres.wholphin.ui.components.ViewOptions +import com.github.damontecres.wholphin.ui.components.ViewOptions.Companion.ViewOptionsAspectRatio +import com.github.damontecres.wholphin.ui.components.ViewOptions.Companion.ViewOptionsColumns +import com.github.damontecres.wholphin.ui.components.ViewOptions.Companion.ViewOptionsContentScale +import com.github.damontecres.wholphin.ui.components.ViewOptions.Companion.ViewOptionsDetailHeader +import com.github.damontecres.wholphin.ui.components.ViewOptions.Companion.ViewOptionsImageType +import com.github.damontecres.wholphin.ui.components.ViewOptions.Companion.ViewOptionsReset +import com.github.damontecres.wholphin.ui.components.ViewOptions.Companion.ViewOptionsShowTitles +import com.github.damontecres.wholphin.ui.components.ViewOptions.Companion.ViewOptionsSpacing +import com.github.damontecres.wholphin.ui.preferences.ComposablePreference +import com.github.damontecres.wholphin.ui.tryRequestFocus +import kotlinx.serialization.Serializable + +@Composable +fun CollectionViewOptionsDialog( + viewOptions: CollectionViewOptions, + onDismissRequest: () -> Unit, + onViewOptionsChange: (CollectionViewOptions) -> Unit, + defaultViewOptions: CollectionViewOptions = CollectionViewOptions(), +) { + val focusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } + val columnState = rememberLazyListState() + val options = + if (viewOptions.separateTypes) CollectionViewOptions.SeparateOptions else CollectionViewOptions.MixedOptions + Dialog( + onDismissRequest = onDismissRequest, + properties = + DialogProperties( + usePlatformDefaultWidth = false, + ), + ) { + val dialogWindowProvider = LocalView.current.parent as? DialogWindowProvider + dialogWindowProvider?.window?.let { window -> + window.setGravity(Gravity.END) + window.setDimAmount(0f) + } + Column( + modifier = + Modifier + .width(256.dp) + .heightIn(max = 380.dp) + .focusRequester(focusRequester) + .background( + MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp), + shape = RoundedCornerShape(8.dp), + ).padding(16.dp), + ) { + Text( + text = stringResource(R.string.view_options), + style = MaterialTheme.typography.titleMedium, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + LazyColumn( + state = columnState, + contentPadding = PaddingValues(0.dp), + verticalArrangement = Arrangement.spacedBy(0.dp), + ) { + item { + val pref = CollectionViewOptions.SeparateTypes + val interactionSource = remember { MutableInteractionSource() } + ComposablePreference( + preference = pref, + value = viewOptions.separateTypes, + onNavigate = {}, + onValueChange = { newValue -> + onViewOptionsChange.invoke(pref.setter(viewOptions, newValue)) + }, + interactionSource = interactionSource, + modifier = Modifier, + onClickPreference = {}, + ) + } + items(options, key = { it.title }) { pref -> + pref as AppPreference + val interactionSource = remember { MutableInteractionSource() } + val value = pref.getter.invoke(viewOptions.cardViewOptions) + ComposablePreference( + preference = pref, + value = value, + onNavigate = {}, + onValueChange = { newValue -> + val newCardViewOptions = + pref.setter(viewOptions.cardViewOptions, newValue) + onViewOptionsChange.invoke(viewOptions.copy(cardViewOptions = newCardViewOptions)) + }, + interactionSource = interactionSource, + modifier = Modifier.animateItem(), + onClickPreference = { pref -> + if (pref == ViewOptionsReset) { + onViewOptionsChange.invoke(defaultViewOptions) + } + }, + ) + } + } + } + } +} + +@Serializable +data class CollectionViewOptions( + val separateTypes: Boolean = true, + val cardViewOptions: ViewOptions = + ViewOptions( + showDetails = true, + showTitles = true, + ), +) { + companion object { + val SeparateTypes = + AppSwitchPreference( + title = R.string.separate_types, + defaultValue = false, + getter = { it.separateTypes }, + setter = { vo, value -> vo.copy(separateTypes = value) }, + ) + + val MixedOptions = + listOf( + ViewOptionsImageType, + ViewOptionsAspectRatio, + ViewOptionsDetailHeader, + ViewOptionsShowTitles, + ViewOptionsColumns, + ViewOptionsSpacing, + ViewOptionsContentScale, + ViewOptionsReset, + ) + + val SeparateOptions = + listOf( + ViewOptionsDetailHeader, + ViewOptionsShowTitles, + ViewOptionsReset, + ) + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt index 3a7a457a..733ddef2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt @@ -227,6 +227,15 @@ fun HomePageContent( listState: LazyListState = rememberLazyListState(), takeFocus: Boolean = true, showEmptyRows: Boolean = false, + headerComposable: @Composable (focusedItem: BaseItem?) -> Unit = { focusedItem -> + HomePageHeader( + item = focusedItem, + modifier = + Modifier + .padding(top = 48.dp, bottom = 32.dp, start = 8.dp) + .fillMaxHeight(.33f), + ) + }, ) { val focusedItem = position.let { @@ -266,13 +275,8 @@ fun HomePageContent( } Box(modifier = modifier) { Column(modifier = Modifier.fillMaxSize()) { - HomePageHeader( - item = focusedItem, - modifier = - Modifier - .padding(top = 48.dp, bottom = 32.dp, start = 8.dp) - .fillMaxHeight(.33f), - ) + headerComposable.invoke(focusedItem) + val density = LocalDensity.current val spaceAbovePx = with(density) { 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 acbcb552..7c41c6d1 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 @@ -11,7 +11,6 @@ import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.components.ItemGrid import com.github.damontecres.wholphin.ui.components.LicenseInfo import com.github.damontecres.wholphin.ui.data.MovieSortOptions -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 @@ -24,6 +23,7 @@ import com.github.damontecres.wholphin.ui.detail.DebugPage import com.github.damontecres.wholphin.ui.detail.FavoritesPage import com.github.damontecres.wholphin.ui.detail.PersonPage import com.github.damontecres.wholphin.ui.detail.PlaylistDetails +import com.github.damontecres.wholphin.ui.detail.collection.CollectionDetails import com.github.damontecres.wholphin.ui.detail.discover.DiscoverMovieDetails import com.github.damontecres.wholphin.ui.detail.discover.DiscoverPersonPage import com.github.damontecres.wholphin.ui.detail.discover.DiscoverSeriesDetails @@ -146,11 +146,9 @@ fun DestinationContent( BaseItemKind.BOX_SET -> { LaunchedEffect(Unit) { onClearBackdrop.invoke() } - CollectionFolderBoxSet( + CollectionDetails( preferences = preferences, itemId = destination.itemId, - recursive = false, - playEnabled = true, modifier = modifier, ) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ee667101..fc71cd52 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -746,5 +746,6 @@ Visualizing the currently playing audio requires permission to record audio. Continue Select all + Separate types diff --git a/app/src/test/java/com/github/damontecres/wholphin/ui/TestModule.kt b/app/src/test/java/com/github/damontecres/wholphin/ui/TestModule.kt index 39705dd3..6ebdde41 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/ui/TestModule.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/ui/TestModule.kt @@ -5,6 +5,9 @@ import androidx.datastore.core.DataStore import androidx.datastore.core.DataStoreFactory import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler import androidx.datastore.dataStoreFile +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.preferencesDataStoreFile import androidx.room.Room import androidx.work.WorkManager import com.github.damontecres.wholphin.BuildConfig @@ -278,5 +281,14 @@ object TestDatabaseModule { produceNewData = { AppPreferences.getDefaultInstance() }, ), ) + + @Provides + @Singleton + fun keyValueDataStore( + @ApplicationContext context: Context, + ): DataStore = + PreferenceDataStoreFactory.create( + produceFile = { context.preferencesDataStoreFile("key_value") }, + ) } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index fa197566..b7337845 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -72,6 +72,7 @@ androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecyc androidx-lifecycle-livedata-ktx = { group = "androidx.lifecycle", name = "lifecycle-livedata-ktx", version.ref = "lifecycleRuntimeKtx" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" } +androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" } androidx-tvprovider = { module = "androidx.tvprovider:tvprovider", version.ref = "tvprovider" } androidx-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" } androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "workRuntimeKtx" } From bd21c2c46e4b282f33fcb9eb86071fedebd3b766 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Sat, 28 Mar 2026 13:13:28 -0400 Subject: [PATCH 15/15] Fix pause indicator color on some devices (#1150) ## Description Ensure the new pause indicator from #1144 is the right color for all devices. ### Related issues Related to #1144 ### Testing Emulator & shield ## Screenshots N/A ## AI or LLM usage None --- .../damontecres/wholphin/ui/playback/PauseIndicator.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PauseIndicator.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PauseIndicator.kt index 52bb4b7a..2f812970 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PauseIndicator.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PauseIndicator.kt @@ -7,7 +7,6 @@ import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn -import androidx.compose.foundation.Image import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -21,6 +20,8 @@ import androidx.compose.ui.unit.dp import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi import androidx.media3.ui.compose.state.observeState +import androidx.tv.material3.Icon +import androidx.tv.material3.MaterialTheme import com.github.damontecres.wholphin.R import kotlinx.coroutines.delay import kotlin.time.Duration @@ -57,9 +58,10 @@ fun PauseIndicator( delay(50) visible = false } - Image( + Icon( modifier = Modifier.size(64.dp, 64.dp), painter = painterResource(id = R.drawable.baseline_pause_24), + tint = MaterialTheme.colorScheme.onSurface, contentDescription = null, ) }