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 001/148] 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 002/148] 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 003/148] 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 004/148] 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 005/148] 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 006/148] 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 007/148] 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 008/148] 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 009/148] 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 010/148] 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 011/148] 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 012/148] 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 013/148] 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 014/148] 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 015/148] 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, ) } From 253b203c024b4b46f9d56f31a2564543f2171f7e Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sat, 28 Mar 2026 18:44:41 -0400 Subject: [PATCH 016/148] Update Androidx Media3 to v1.10.0 (#1149) --- 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 b7337845..c462981e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -24,7 +24,7 @@ tvFoundation = "1.0.0-beta01" tvMaterial = "1.0.1" lifecycleRuntimeKtx = "2.10.0" activityCompose = "1.13.0" -androidx-media3 = "1.9.3" +androidx-media3 = "1.10.0" coil = "3.4.0" jellyfin-sdk = "1.7.1" nav3Core = "1.0.1" From 2485d2c644290c2a1e690cbe45c3e98cd5520c1d Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Sun, 29 Mar 2026 10:38:23 -0400 Subject: [PATCH 017/148] Add prefer logos instead of text titles setting (#1151) ## Description Adds an advanced settings to prefer logos for titles instead of text. It is enabled by default. ### Related issues Closes #383 ### Testing Emulator & onn ## Screenshots
Click to show

### Home page ![logo_1 Large](https://github.com/user-attachments/assets/629a65ab-cf43-4050-af4c-5925763a5f4f) ### Series overview ![logo_2 Large](https://github.com/user-attachments/assets/13ca2d28-cc61-4f4e-abaa-61a310b5c707) ### Movie Page ![logo_movie Large](https://github.com/user-attachments/assets/a590e6cf-452d-4313-b6a3-35c6f59dc1ff) ### Square-ish logo ![logo_square Large](https://github.com/user-attachments/assets/7d1340c2-4a3e-42c3-b7bd-ae1114cb6645)

## AI or LLM usage None --- .../wholphin/preferences/AppPreference.kt | 13 +++ .../preferences/AppPreferencesSerializer.kt | 1 + .../wholphin/services/AppUpgradeHandler.kt | 10 +- .../wholphin/services/HomeSettingsService.kt | 2 +- .../wholphin/services/LatestNextUpService.kt | 21 ++-- .../wholphin/services/PlaylistCreator.kt | 7 +- .../wholphin/ui/cards/BannerCard.kt | 2 +- .../ui/components/CollectionFolderGrid.kt | 4 +- .../wholphin/ui/components/HeaderUtils.kt | 24 +++++ .../ui/components/RecommendedContent.kt | 1 + .../wholphin/ui/components/TitleOrLogo.kt | 95 +++++++++++++++++++ .../ui/detail/collection/CollectionDetails.kt | 19 ++-- .../collection/CollectionDetailsHeader.kt | 46 +++------ .../ui/detail/collection/CollectionRows.kt | 1 + .../detail/collection/CollectionViewModel.kt | 14 +-- .../ui/detail/episode/EpisodeDetails.kt | 5 +- .../wholphin/ui/detail/movie/MovieDetails.kt | 5 +- .../ui/detail/movie/MovieDetailsHeader.kt | 30 +++--- .../ui/detail/series/FocusedEpisodeHeader.kt | 11 ++- .../ui/detail/series/SeriesDetails.kt | 32 +++---- .../ui/detail/series/SeriesOverviewContent.kt | 19 ++-- .../wholphin/ui/discover/SeerrDiscoverPage.kt | 2 + .../damontecres/wholphin/ui/main/HomePage.kt | 41 ++++---- .../ui/main/settings/HomeSettingsPage.kt | 3 + .../wholphin/ui/nav/DestinationContent.kt | 2 +- app/src/main/proto/WholphinDataStore.proto | 1 + app/src/main/res/values/strings.xml | 1 + 27 files changed, 290 insertions(+), 122 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/components/HeaderUtils.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/components/TitleOrLogo.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt index ef3f14e7..e88c1ffb 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt @@ -501,6 +501,18 @@ sealed interface AppPreference { valueToIndex = { if (it != AppThemeColors.UNRECOGNIZED) it.number else 0 }, ) + val ShowLogos = + AppSwitchPreference( + title = R.string.prefer_logos, + defaultValue = true, + getter = { it.interfacePreferences.showLogos }, + setter = { prefs, value -> + prefs.updateInterfacePreferences { showLogos = value } + }, + summaryOn = R.string.enabled, + summaryOff = R.string.disabled, + ) + val InstalledVersion = AppClickablePreference( title = R.string.installed_version, @@ -1122,6 +1134,7 @@ val advancedPreferences = preferences = listOf( AppPreference.ShowClock, + AppPreference.ShowLogos, AppPreference.ManageMedia, AppPreference.CombineContinueNext, // Temporarily disabled, see https://github.com/damontecres/Wholphin/pull/127#issuecomment-3478058418 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 f55b4ea9..e202fca3 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 @@ -95,6 +95,7 @@ class AppPreferencesSerializer AppPreference.NavDrawerSwitchOnFocus.defaultValue showClock = AppPreference.ShowClock.defaultValue backdropStyle = AppPreference.BackdropStylePref.defaultValue + showLogos = AppPreference.ShowLogos.defaultValue subtitlesPreferences = SubtitlePreferences 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 617944fc..07b5fc7e 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 @@ -282,7 +282,7 @@ class AppUpgradeHandler } } - if (previous.isEqualOrBefore(Version.fromString("0.6.0-0-g0"))) { + if (previous.isEqualOrBefore(Version.fromString("0.5.4-6-g0"))) { appPreferences.updateData { it.updateMusicPreferences { showBackdrop = true @@ -291,5 +291,13 @@ class AppUpgradeHandler } } } + + if (previous.isEqualOrBefore(Version.fromString("0.5.4-15-g0"))) { + appPreferences.updateData { + it.updateInterfacePreferences { + showLogos = AppPreference.ShowLogos.defaultValue + } + } + } } } 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 b28dded4..0b3b0887 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 @@ -886,7 +886,7 @@ class HomeSettingsService limit = limit, enableUserData = true, enableImages = true, - enableImageTypes = listOf(ImageType.PRIMARY), + enableImageTypes = listOf(ImageType.PRIMARY, ImageType.LOGO), imageTypeLimit = 1, ) api.liveTvApi diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt index e30a39ca..f6e02df2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt @@ -20,7 +20,6 @@ import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.api.client.extensions.userViewsApi import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.CollectionType -import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.api.UserDto import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest import org.jellyfin.sdk.model.api.request.GetNextUpRequest @@ -61,12 +60,13 @@ class LatestNextUpService remove(BaseItemKind.EPISODE) } }, - enableImageTypes = - listOf( - ImageType.PRIMARY, - ImageType.THUMB, - ImageType.BACKDROP, - ), +// enableImageTypes = +// listOf( +// ImageType.PRIMARY, +// ImageType.THUMB, +// ImageType.BACKDROP, +// ImageType.LOGO, +// ), ) val items = api.itemsApi @@ -98,6 +98,13 @@ class LatestNextUpService enableUserData = true, enableRewatching = enableRewatching, nextUpDateCutoff = nextUpDateCutoff, +// enableImageTypes = +// listOf( +// ImageType.PRIMARY, +// ImageType.THUMB, +// ImageType.BACKDROP, +// ImageType.LOGO, +// ), ) val nextUp = api.tvShowsApi diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/PlaylistCreator.kt b/app/src/main/java/com/github/damontecres/wholphin/services/PlaylistCreator.kt index 4231d9b9..b47ae33f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/PlaylistCreator.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/PlaylistCreator.kt @@ -114,7 +114,12 @@ class PlaylistCreator req = GetItemsRequest( parentId = item.id, - enableImageTypes = listOf(ImageType.PRIMARY, ImageType.THUMB), + enableImageTypes = + listOf( + ImageType.PRIMARY, + ImageType.THUMB, + ImageType.LOGO, + ), includeItemTypes = includeItemTypes, recursive = true, excludeItemIds = listOf(item.id), diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/BannerCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/BannerCard.kt index 51d003fe..a05af761 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/BannerCard.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/BannerCard.kt @@ -214,7 +214,7 @@ fun BannerCardWithTitle( ) { val focused by interactionSource.collectIsFocusedAsState() val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp) - val spaceBelow by animateDpAsState(if (focused) 4.dp else 12.dp) + val spaceBelow by animateDpAsState(if (focused) 0.dp else 8.dp) val focusedAfterDelay by rememberFocusedAfterDelay(interactionSource) val aspectRationToUse = aspectRatio.coerceAtLeast(AspectRatios.MIN) val width = cardHeight * aspectRationToUse diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt index a4e4ef22..4c8090e3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt @@ -398,6 +398,7 @@ class CollectionFolderViewModel ImageType.PRIMARY, ImageType.THUMB, ImageType.BACKDROP, + ImageType.LOGO, ), includeItemTypes = includeItemTypes, recursive = recursive, @@ -960,11 +961,12 @@ fun CollectionFolderGridContent( AnimatedVisibility(viewOptions.showDetails) { HomePageHeader( item = focusedItem, + showLogo = preferences.appPreferences.interfacePreferences.showLogos, modifier = Modifier .fillMaxWidth() .height(200.dp) - .padding(top = 48.dp, bottom = 32.dp, start = 8.dp), + .padding(HeaderUtils.padding), ) } when (val state = loadingState) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/HeaderUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/HeaderUtils.kt new file mode 100644 index 00000000..0ebdd1db --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/HeaderUtils.kt @@ -0,0 +1,24 @@ +package com.github.damontecres.wholphin.ui.components + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +object HeaderUtils { + val topPadding = 48.dp + val bottomPadding = 32.dp + val startPadding = 8.dp + + val padding = PaddingValues(top = topPadding, bottom = bottomPadding, start = startPadding) + + val height = 180.dp + + val logoHeight = 60.dp + + val modifier = + Modifier + .padding(padding) + .height(height) +} 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 ae7edb08..db40ff0e 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 @@ -207,6 +207,7 @@ fun RecommendedContent( }, showClock = preferences.appPreferences.interfacePreferences.showClock, onUpdateBackdrop = viewModel::updateBackdrop, + showLogo = preferences.appPreferences.interfacePreferences.showLogos, modifier = modifier, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/TitleOrLogo.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/TitleOrLogo.kt new file mode 100644 index 00000000..5af10b8b --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/TitleOrLogo.kt @@ -0,0 +1,95 @@ +package com.github.damontecres.wholphin.ui.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.widthIn +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.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +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.LocalImageUrlService +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.ImageType + +@Composable +fun TitleOrLogo( + title: String?, + logoImageUrl: String?, + showLogo: Boolean, + modifier: Modifier = Modifier, +) { + var imageError by remember { mutableStateOf(false) } + Box( + modifier = modifier.heightIn(max = HeaderUtils.logoHeight), + ) { + if (showLogo && logoImageUrl != null && !imageError) { + AsyncImage( + model = logoImageUrl, + contentDescription = title, + contentScale = ContentScale.Fit, + modifier = + Modifier + .height(HeaderUtils.logoHeight) + .widthIn(max = 320.dp), + ) + } else { + Title(title, Modifier) + } + } +} + +@Composable +private fun Title( + title: String?, + modifier: Modifier = Modifier, +) { + Text( + text = title ?: "", + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = modifier, + ) +} + +@Composable +fun TitleOrLogo( + item: BaseItem?, + showLogo: Boolean, + modifier: Modifier = Modifier, +) { + val logoImageUrl = rememberLogoUrl(item) + TitleOrLogo( + title = item?.title, + logoImageUrl = logoImageUrl, + showLogo = showLogo, + modifier = modifier, + ) +} + +@Composable +fun rememberLogoUrl(item: BaseItem?): String? { + val imageUrlService = LocalImageUrlService.current + return remember(item?.id) { + if (item?.type == BaseItemKind.EPISODE && item.data.seriesId != null && item.data.parentLogoImageTag != null) { + imageUrlService.getItemImageUrl(item.data.seriesId!!, ImageType.LOGO) + } else if (ImageType.LOGO in item?.data?.imageTags.orEmpty()) { + imageUrlService.getItemImageUrl(item, ImageType.LOGO) + } else { + null + } + } +} 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 index 7f9cc1ca..bd647d47 100644 --- 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 @@ -11,7 +11,6 @@ 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 @@ -50,6 +49,7 @@ 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.HeaderUtils import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.components.Optional import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel @@ -379,11 +379,13 @@ fun CollectionDetailsContent( if (state.viewOptions.cardViewOptions.showDetails) { HomePageHeader( item = focusedItem, + showLogo = preferences.appPreferences.interfacePreferences.showLogos, modifier = Modifier - .padding(top = 48.dp, start = 8.dp) - .fillMaxHeight(.33f) - .fillMaxWidth(), + .padding( + top = HeaderUtils.topPadding, + bottom = 8.dp, + ).height(HeaderUtils.height), ) } } @@ -412,15 +414,16 @@ fun CollectionDetailsContent( ) { CollectionDetailsHeader( collection = state.collection!!, + showLogo = preferences.appPreferences.interfacePreferences.showLogos, logoImageUrl = state.logoImageUrl, overviewOnClick = overviewOnClick, bringIntoViewRequester = bringIntoViewRequester, modifier = Modifier - .padding(top = 48.dp, start = 8.dp) - // TODO - .fillMaxHeight(.36f) - .fillMaxWidth(), + .padding( + top = HeaderUtils.topPadding, + bottom = HeaderUtils.bottomPadding, + ).height(HeaderUtils.height), ) CollectionButtons( state = state, 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 index a71ac187..cbdf1560 100644 --- 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 @@ -3,28 +3,24 @@ 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.HeaderUtils import com.github.damontecres.wholphin.ui.components.OverviewText import com.github.damontecres.wholphin.ui.components.QuickDetails +import com.github.damontecres.wholphin.ui.components.TitleOrLogo import com.github.damontecres.wholphin.ui.letNotEmpty import com.github.damontecres.wholphin.util.ExceptionHandler import kotlinx.coroutines.launch @@ -32,6 +28,7 @@ import kotlinx.coroutines.launch @Composable fun CollectionDetailsHeader( collection: BaseItem, + showLogo: Boolean, logoImageUrl: String?, overviewOnClick: () -> Unit, bringIntoViewRequester: BringIntoViewRequester, @@ -44,28 +41,15 @@ fun CollectionDetailsHeader( 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), - ) - } + TitleOrLogo( + title = collection.name, + showLogo = showLogo, + logoImageUrl = logoImageUrl, + modifier = + Modifier + .fillMaxWidth(.75f) + .padding(start = HeaderUtils.startPadding), + ) Column( verticalArrangement = Arrangement.spacedBy(4.dp), @@ -74,18 +58,18 @@ fun CollectionDetailsHeader( QuickDetails( collection.ui.quickDetails, collection.timeRemainingOrRuntime, - Modifier.padding(start = 8.dp), + Modifier.padding(start = HeaderUtils.startPadding), ) dto.genres?.letNotEmpty { - GenreText(it, Modifier.padding(start = 8.dp)) + GenreText(it, Modifier.padding(start = HeaderUtils.startPadding)) } dto.taglines?.firstOrNull()?.let { tagline -> Text( text = tagline, style = MaterialTheme.typography.bodyLarge, fontStyle = FontStyle.Italic, - modifier = Modifier.padding(start = 8.dp), + modifier = Modifier.padding(start = HeaderUtils.startPadding), ) } 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 index ef78fb3b..b33e751f 100644 --- 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 @@ -83,6 +83,7 @@ fun CollectionRows( onUpdateBackdrop = {}, headerComposable = {}, takeFocus = false, + showLogo = preferences.appPreferences.interfacePreferences.showLogos, 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 index badc4e9d..4a717ee0 100644 --- 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 @@ -62,6 +62,7 @@ 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.ImageType import org.jellyfin.sdk.model.api.ItemSortBy import org.jellyfin.sdk.model.api.SortOrder import org.jellyfin.sdk.model.api.request.GetItemsRequest @@ -139,13 +140,12 @@ class CollectionViewModel .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 -// } + val logoImageUrl = + if (ImageType.LOGO in collection.data.imageTags.orEmpty()) { + imageUrlService.getItemImageUrl(collection, ImageType.LOGO) + } else { + null + } _state.update { it.copy( collection = collection, 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 a89db27b..e4b5a047 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 @@ -36,6 +36,7 @@ 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.ExpandablePlayButtons +import com.github.damontecres.wholphin.ui.components.HeaderUtils import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.components.Optional import com.github.damontecres.wholphin.ui.components.chooseStream @@ -332,7 +333,7 @@ fun EpisodeDetailsContent( Box(modifier = modifier) { LazyColumn( verticalArrangement = Arrangement.spacedBy(16.dp), - contentPadding = PaddingValues(vertical = 8.dp), + contentPadding = PaddingValues(bottom = 8.dp), modifier = Modifier.fillMaxSize(), ) { item { @@ -352,7 +353,7 @@ fun EpisodeDetailsContent( modifier = Modifier .fillMaxWidth() - .padding(top = 32.dp, bottom = 16.dp), + .padding(top = HeaderUtils.topPadding, bottom = 16.dp), ) ExpandablePlayButtons( resumePosition = resumePosition, 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 4195e9a8..47a80e49 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 @@ -51,6 +51,7 @@ 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.ExpandablePlayButtons +import com.github.damontecres.wholphin.ui.components.HeaderUtils import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.components.Optional import com.github.damontecres.wholphin.ui.components.chooseStream @@ -439,7 +440,7 @@ fun MovieDetailsContent( Box(modifier = modifier) { LazyColumn( verticalArrangement = Arrangement.spacedBy(16.dp), - contentPadding = PaddingValues(vertical = 8.dp), + contentPadding = PaddingValues(bottom = 8.dp), modifier = Modifier.fillMaxSize(), ) { item { @@ -459,7 +460,7 @@ fun MovieDetailsContent( modifier = Modifier .fillMaxWidth() - .padding(top = 40.dp, bottom = 16.dp), + .padding(top = HeaderUtils.topPadding, bottom = 16.dp), ) ExpandablePlayButtons( resumePosition = resumePosition, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt index 45e3ee54..ee8f74f0 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt @@ -13,8 +13,6 @@ import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource 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 @@ -24,8 +22,10 @@ import com.github.damontecres.wholphin.data.ChosenStreams import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.components.GenreText +import com.github.damontecres.wholphin.ui.components.HeaderUtils import com.github.damontecres.wholphin.ui.components.OverviewText import com.github.damontecres.wholphin.ui.components.QuickDetails +import com.github.damontecres.wholphin.ui.components.TitleOrLogo import com.github.damontecres.wholphin.ui.components.VideoStreamDetails import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.letNotEmpty @@ -50,16 +50,13 @@ fun MovieDetailsHeader( modifier = modifier, ) { // Title - Text( - text = movie.name ?: "", - color = MaterialTheme.colorScheme.onBackground, - style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold), - maxLines = 1, - overflow = TextOverflow.Ellipsis, + TitleOrLogo( + item = movie, + showLogo = preferences.appPreferences.interfacePreferences.showLogos, modifier = Modifier .fillMaxWidth(.75f) - .padding(start = 8.dp), + .padding(start = HeaderUtils.startPadding), ) Column( @@ -69,24 +66,29 @@ fun MovieDetailsHeader( QuickDetails( movie.ui.quickDetails, movie.timeRemainingOrRuntime, - Modifier.padding(start = 8.dp), + Modifier.padding(start = HeaderUtils.startPadding), ) dto.genres?.letNotEmpty { - GenreText(it, Modifier.padding(start = 8.dp)) + GenreText(it, Modifier.padding(start = HeaderUtils.startPadding)) } VideoStreamDetails( chosenStreams = chosenStreams, numberOfVersions = movie.data.mediaSourceCount ?: 0, - modifier = Modifier.padding(start = 8.dp, top = 4.dp, bottom = 16.dp), + modifier = + Modifier.padding( + start = HeaderUtils.startPadding, + top = 4.dp, + bottom = 16.dp, + ), ) dto.taglines?.firstOrNull()?.let { tagline -> Text( text = tagline, style = MaterialTheme.typography.bodyLarge, fontStyle = FontStyle.Italic, - modifier = Modifier.padding(start = 8.dp), + modifier = Modifier.padding(start = HeaderUtils.startPadding), ) } @@ -122,7 +124,7 @@ fun MovieDetailsHeader( text = stringResource(R.string.directed_by, it), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.padding(start = 8.dp), + modifier = Modifier.padding(start = HeaderUtils.startPadding), ) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeHeader.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeHeader.kt index aab6e056..b63f1cbc 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeHeader.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeHeader.kt @@ -13,6 +13,7 @@ import com.github.damontecres.wholphin.data.ChosenStreams import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.components.EpisodeName +import com.github.damontecres.wholphin.ui.components.HeaderUtils import com.github.damontecres.wholphin.ui.components.OverviewText import com.github.damontecres.wholphin.ui.components.QuickDetails import com.github.damontecres.wholphin.ui.components.VideoStreamDetails @@ -32,17 +33,21 @@ fun FocusedEpisodeHeader( verticalArrangement = Arrangement.spacedBy(8.dp), modifier = modifier, ) { - EpisodeName(dto, modifier = Modifier.padding(start = 8.dp)) + EpisodeName(dto, modifier = Modifier.padding(start = HeaderUtils.startPadding)) ep?.ui?.quickDetails?.let { - QuickDetails(it, ep.timeRemainingOrRuntime, Modifier.padding(start = 8.dp)) + QuickDetails( + it, + ep.timeRemainingOrRuntime, + Modifier.padding(start = HeaderUtils.startPadding), + ) } if (dto != null) { VideoStreamDetails( chosenStreams = chosenStreams, numberOfVersions = dto.mediaSourceCount ?: 0, - modifier = Modifier.padding(start = 8.dp), + modifier = Modifier.padding(start = HeaderUtils.startPadding), ) } OverviewText( 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 aea22bf1..3a95440f 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 @@ -37,14 +37,10 @@ import androidx.compose.ui.graphics.Color 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.compose.LifecycleResumeEffect -import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.Text import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.ExtrasItem import com.github.damontecres.wholphin.data.model.BaseItem @@ -69,10 +65,12 @@ import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.ExpandableFaButton import com.github.damontecres.wholphin.ui.components.ExpandablePlayButton import com.github.damontecres.wholphin.ui.components.GenreText +import com.github.damontecres.wholphin.ui.components.HeaderUtils 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.components.TitleOrLogo import com.github.damontecres.wholphin.ui.components.TrailerButton import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog @@ -386,7 +384,6 @@ fun SeriesDetailsContent( Column( modifier = Modifier - .padding(vertical = 16.dp) .fillMaxSize(), ) { LazyColumn( @@ -397,18 +394,19 @@ fun SeriesDetailsContent( item { SeriesDetailsHeader( series = series, + showLogo = preferences.appPreferences.interfacePreferences.showLogos, overviewOnClick = overviewOnClick, modifier = Modifier .fillMaxWidth() .bringIntoViewRequester(bringIntoViewRequester) - .padding(top = 32.dp, bottom = 16.dp), + .padding(top = HeaderUtils.topPadding, bottom = 16.dp), ) Row( horizontalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier - .padding(start = 8.dp) + .padding(start = HeaderUtils.startPadding) .focusRequester(focusRequesters[HEADER_ROW]) .focusRestorer(playFocusRequester) .focusGroup() @@ -684,6 +682,7 @@ fun SeriesDetailsContent( @Composable fun SeriesDetailsHeader( series: BaseItem, + showLogo: Boolean, overviewOnClick: () -> Unit, modifier: Modifier = Modifier, ) { @@ -693,24 +692,25 @@ fun SeriesDetailsHeader( verticalArrangement = Arrangement.spacedBy(4.dp), modifier = modifier, ) { - Text( - text = series.name ?: stringResource(R.string.unknown), - color = MaterialTheme.colorScheme.onBackground, - style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold), - maxLines = 1, - overflow = TextOverflow.Ellipsis, + TitleOrLogo( + item = series, + showLogo = showLogo, modifier = Modifier .fillMaxWidth(.75f) - .padding(start = 8.dp), + .padding(start = HeaderUtils.startPadding), ) Column( verticalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier.fillMaxWidth(.60f), ) { - QuickDetails(series.ui.quickDetails, null, Modifier.padding(start = 8.dp)) + QuickDetails( + series.ui.quickDetails, + null, + Modifier.padding(start = HeaderUtils.startPadding), + ) dto.genres?.letNotEmpty { - GenreText(it, Modifier.padding(start = 8.dp, bottom = 8.dp)) + GenreText(it, Modifier.padding(start = HeaderUtils.startPadding, bottom = 8.dp)) } dto.overview?.let { overview -> OverviewText( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt index 8acc32f1..b32bbd30 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt @@ -52,9 +52,10 @@ import com.github.damontecres.wholphin.ui.AspectRatios import com.github.damontecres.wholphin.ui.cards.BannerCard import com.github.damontecres.wholphin.ui.cards.PersonRow import com.github.damontecres.wholphin.ui.components.ErrorMessage +import com.github.damontecres.wholphin.ui.components.HeaderUtils import com.github.damontecres.wholphin.ui.components.LoadingPage -import com.github.damontecres.wholphin.ui.components.SeriesName import com.github.damontecres.wholphin.ui.components.TabRow +import com.github.damontecres.wholphin.ui.components.TitleOrLogo import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.logTab import com.github.damontecres.wholphin.ui.playback.isPlayKeyUp @@ -143,10 +144,12 @@ fun SeriesOverviewContent( .bringIntoViewRequester(bringIntoViewRequester), ) { val paddingValues = - if (preferences.appPreferences.interfacePreferences.showClock) { - PaddingValues(start = 0.dp, end = 184.dp) - } else { - PaddingValues(start = 0.dp, end = 16.dp) + remember(preferences.appPreferences.interfacePreferences.showClock) { + if (preferences.appPreferences.interfacePreferences.showClock) { + PaddingValues(start = 0.dp, end = 184.dp) + } else { + PaddingValues(start = 0.dp, end = 16.dp) + } } TabRow( selectedTabIndex = selectedTabIndex, @@ -164,7 +167,11 @@ fun SeriesOverviewContent( .padding(bottom = 4.dp) .fillMaxWidth(), ) - SeriesName(series.name, Modifier.padding(start = 8.dp)) + TitleOrLogo( + item = series, + showLogo = preferences.appPreferences.interfacePreferences.showLogos, + modifier = Modifier.padding(start = HeaderUtils.startPadding), + ) FocusedEpisodeHeader( preferences = preferences, ep = focusedEpisode, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt index 97b974ff..e30be9b3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt @@ -250,6 +250,8 @@ fun SeerrDiscoverPage( overviewTwoLines = true, quickDetails = details, timeRemaining = null, + showLogo = preferences.appPreferences.interfacePreferences.showLogos, + logoImageUrl = null, // TODO modifier = Modifier .padding(top = 24.dp, bottom = 16.dp, start = 32.dp) 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 733ddef2..94281b78 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 @@ -8,7 +8,6 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -39,7 +38,6 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel @@ -61,9 +59,12 @@ import com.github.damontecres.wholphin.ui.components.DialogPopup import com.github.damontecres.wholphin.ui.components.EpisodeName import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.FocusableItemRow +import com.github.damontecres.wholphin.ui.components.HeaderUtils import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.components.QuickDetails import com.github.damontecres.wholphin.ui.components.RowColumnItem +import com.github.damontecres.wholphin.ui.components.TitleOrLogo +import com.github.damontecres.wholphin.ui.components.rememberLogoUrl import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel import com.github.damontecres.wholphin.ui.data.RowColumn import com.github.damontecres.wholphin.ui.detail.MoreDialogActions @@ -172,6 +173,7 @@ fun HomePage( loadingState = refreshing, showClock = preferences.appPreferences.interfacePreferences.showClock, onUpdateBackdrop = viewModel::updateBackdrop, + showLogo = preferences.appPreferences.interfacePreferences.showLogos, modifier = modifier, ) dialog?.let { params -> @@ -222,6 +224,7 @@ fun HomePageContent( onClickPlay: (RowColumn, BaseItem) -> Unit, showClock: Boolean, onUpdateBackdrop: (BaseItem) -> Unit, + showLogo: Boolean, modifier: Modifier = Modifier, loadingState: LoadingState? = null, listState: LazyListState = rememberLazyListState(), @@ -230,10 +233,8 @@ fun HomePageContent( headerComposable: @Composable (focusedItem: BaseItem?) -> Unit = { focusedItem -> HomePageHeader( item = focusedItem, - modifier = - Modifier - .padding(top = 48.dp, bottom = 32.dp, start = 8.dp) - .fillMaxHeight(.33f), + showLogo = showLogo, + modifier = HeaderUtils.modifier, ) }, ) { @@ -410,6 +411,7 @@ fun HomePageContent( @Composable fun HomePageHeader( item: BaseItem?, + showLogo: Boolean, modifier: Modifier = Modifier, ) { val isEpisode = item?.type == BaseItemKind.EPISODE @@ -421,6 +423,8 @@ fun HomePageHeader( overviewTwoLines = isEpisode, quickDetails = item?.ui?.quickDetails ?: AnnotatedString(""), timeRemaining = item?.timeRemainingOrRuntime, + showLogo = showLogo, + logoImageUrl = rememberLogoUrl(item), modifier = modifier, ) } @@ -433,31 +437,28 @@ fun HomePageHeader( overviewTwoLines: Boolean, quickDetails: AnnotatedString?, timeRemaining: Duration?, + showLogo: Boolean, + logoImageUrl: String?, modifier: Modifier = Modifier, ) { Column( verticalArrangement = Arrangement.spacedBy(4.dp), modifier = modifier, ) { - title?.let { - Text( - text = it, - style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold), - color = MaterialTheme.colorScheme.onBackground, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.fillMaxWidth(.75f), - ) - } + TitleOrLogo( + title = title, + logoImageUrl = logoImageUrl, + showLogo = showLogo, + modifier = Modifier.fillMaxWidth(.75f), + ) Column( verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier - .fillMaxWidth(.6f) - .fillMaxHeight(), + .fillMaxWidth(.6f), ) { - subtitle?.let { - EpisodeName(it) + if (subtitle != null) { + EpisodeName(subtitle) } QuickDetails(quickDetails ?: AnnotatedString(""), timeRemaining) val overviewModifier = diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt index fb025c02..a2a1ac63 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt @@ -32,6 +32,7 @@ import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.HomeRowConfig 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.ui.components.ConfirmDialog import com.github.damontecres.wholphin.ui.data.RowColumn import com.github.damontecres.wholphin.ui.detail.search.SearchForDialog @@ -51,6 +52,7 @@ val settingsWidth = 360.dp @Composable fun HomeSettingsPage( + preferences: UserPreferences, modifier: Modifier, viewModel: HomeSettingsViewModel = hiltViewModel(), ) { @@ -310,6 +312,7 @@ fun HomeSettingsPage( listState = listState, takeFocus = false, showEmptyRows = true, + showLogo = preferences.appPreferences.interfacePreferences.showLogos, modifier = Modifier .fillMaxHeight() 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 7c41c6d1..5591f56d 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 @@ -69,7 +69,7 @@ fun DestinationContent( } is Destination.HomeSettings -> { - HomeSettingsPage(modifier) + HomeSettingsPage(preferences, modifier) } is Destination.PlaybackList, diff --git a/app/src/main/proto/WholphinDataStore.proto b/app/src/main/proto/WholphinDataStore.proto index ad8c7a20..ce9080a7 100644 --- a/app/src/main/proto/WholphinDataStore.proto +++ b/app/src/main/proto/WholphinDataStore.proto @@ -167,6 +167,7 @@ message InterfacePreferences { SubtitlePreferences hdr_subtitles_preferences = 10; ScreensaverPreferences screensaver_preference = 11; bool enable_media_management = 12; + bool show_logos = 13; } message AdvancedPreferences { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fc71cd52..b7dbbf3b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -747,5 +747,6 @@ Continue Select all Separate types + Prefer showing logos for titles From 66f060dccbc66b1aa0fa5b155ad3c5251c260bc5 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Sun, 29 Mar 2026 10:52:25 -0400 Subject: [PATCH 018/148] Add a lot of code documentation (#1145) ## Description A brain dump documenting many classes and functions throughout the app. Definitely does not document everything, but covers most of the major components. This should be useful for new contributors. There is a small amount of code clean up too. There are no user facing changes. ### Related issues N/A ### Testing N/A ## Screenshots N/A ## AI or LLM usage None --- DEVELOPMENT.md | 1 + .../damontecres/wholphin/data/ExtrasItem.kt | 15 +++ .../wholphin/data/filter/ItemFilterBy.kt | 5 + .../wholphin/data/model/AudioItem.kt | 8 ++ .../wholphin/data/model/BaseItem.kt | 13 +++ .../wholphin/data/model/Chapter.kt | 3 + .../wholphin/data/model/DiscoverItem.kt | 11 +- .../wholphin/data/model/GetItemsFilter.kt | 11 +- .../wholphin/data/model/ItemPlayback.kt | 4 + .../data/model/ItemTrackModification.kt | 5 + .../wholphin/data/model/JellyfinServer.kt | 9 ++ .../wholphin/data/model/LibraryDisplayInfo.kt | 5 + ...rPreferences.kt => NavDrawerPinnedItem.kt} | 3 + .../damontecres/wholphin/data/model/Person.kt | 3 + .../wholphin/data/model/PlaybackEffect.kt | 3 + .../data/model/PlaybackLanguageChoice.kt | 4 + .../wholphin/data/model/Playlist.kt | 5 + .../wholphin/data/model/SeerrPermission.kt | 3 + .../wholphin/data/model/SeerrServer.kt | 12 ++ .../wholphin/data/model/Trailer.kt | 9 ++ .../wholphin/data/model/VideoFilter.kt | 2 +- .../wholphin/services/AppUpgradeHandler.kt | 10 ++ .../wholphin/services/BackdropService.kt | 23 ++++ .../wholphin/services/DatePlayedService.kt | 20 ++++ .../wholphin/services/DeviceProfileService.kt | 7 +- .../wholphin/services/ExtrasService.kt | 11 ++ .../wholphin/services/FavoriteWatchManager.kt | 3 + .../wholphin/services/HomeSettingsService.kt | 12 ++ .../wholphin/services/LatestNextUpService.kt | 108 +++--------------- .../services/MediaManagementService.kt | 15 ++- .../wholphin/services/MediaReportService.kt | 9 ++ .../wholphin/services/MusicService.kt | 55 ++++++++- .../wholphin/services/NavDrawerService.kt | 16 +++ .../wholphin/services/PeopleFavorites.kt | 3 + .../wholphin/services/PlaylistCreator.kt | 16 +++ .../wholphin/services/RefreshRateService.kt | 9 ++ .../wholphin/services/ScreensaverService.kt | 18 +++ .../wholphin/services/ServerEventListener.kt | 3 + .../wholphin/services/StreamChoiceService.kt | 15 +++ .../wholphin/services/TrailerService.kt | 3 + .../wholphin/services/UpdateChecker.kt | 22 +++- .../services/UserPreferencesService.kt | 3 + .../wholphin/services/hilt/AppModule.kt | 22 ++++ .../tvprovider/TvProviderSchedulerService.kt | 7 ++ .../services/tvprovider/TvProviderWorker.kt | 5 + .../wholphin/ui/components/Button.kt | 5 + .../wholphin/ui/components/Dialogs.kt | 3 + .../wholphin/ui/components/EditTextBox.kt | 5 +- .../wholphin/ui/components/FilterByButton.kt | 8 ++ .../wholphin/ui/components/GenreCardGrid.kt | 6 + .../wholphin/ui/components/GenreText.kt | 5 + .../wholphin/ui/components/ItemGrid.kt | 3 + .../wholphin/ui/components/LicenseInfo.kt | 3 + .../wholphin/ui/components/OverviewText.kt | 3 + .../ui/components/RecommendedContent.kt | 6 +- .../ui/components/SelectedLeadingContent.kt | 5 + .../wholphin/ui/components/SliderBar.kt | 18 ++- .../wholphin/ui/components/TimeDisplay.kt | 3 + .../ui/components/ViewOptionsDialog.kt | 10 ++ .../wholphin/ui/data/AddPlaylistViewModel.kt | 4 + .../wholphin/ui/data/ItemDetailsDialogInfo.kt | 3 + .../wholphin/ui/detail/CardGrid.kt | 3 + .../wholphin/ui/detail/DetailUtils.kt | 3 +- .../wholphin/ui/detail/PlaylistDetails.kt | 2 +- .../ui/detail/livetv/LiveTvViewModel.kt | 23 +++- .../damontecres/wholphin/ui/nav/Backdrop.kt | 6 + .../damontecres/wholphin/ui/nav/NavDrawer.kt | 11 ++ .../ui/nav/NavigationDrawerAndroid.kt | 5 + .../wholphin/ui/playback/CurrentMediaInfo.kt | 5 + .../wholphin/ui/playback/CurrentPlayback.kt | 5 + .../ui/playback/DownloadSubtitlesDialog.kt | 10 +- .../wholphin/ui/playback/PlaybackControls.kt | 15 ++- .../wholphin/ui/playback/PlaybackDialog.kt | 8 ++ .../wholphin/ui/playback/PlaybackOverlay.kt | 2 + .../wholphin/ui/playback/PlaybackPage.kt | 2 +- .../wholphin/ui/playback/PlaybackViewModel.kt | 2 +- .../ui/playback/PlayerLoadingState.kt | 39 ------- .../wholphin/ui/playback/SeekBar.kt | 16 ++- .../wholphin/ui/playback/SeekPreviewImage.kt | 30 ++--- .../wholphin/ui/playback/SimpleMediaStream.kt | 5 + .../wholphin/ui/playback/SkipIndicator.kt | 3 + .../wholphin/ui/playback/SubtitleDelay.kt | 3 + .../ui/playback/SubtitleSearchUtils.kt | 32 +++--- .../ui/playback/TrackSelectionUtils.kt | 6 + .../damontecres/wholphin/util/BlockingList.kt | 12 ++ .../wholphin/util/CrashReportSenderFactory.kt | 3 + .../damontecres/wholphin/util/DebugLogTree.kt | 3 + .../damontecres/wholphin/util/FocusPair.kt | 9 -- .../wholphin/util/RememberTabManager.kt | 3 + .../wholphin/util/TransformList.kt | 3 + .../damontecres/wholphin/util/Version.kt | 3 + app/src/patches/play_store.patch | 30 +++-- 92 files changed, 719 insertions(+), 222 deletions(-) rename app/src/main/java/com/github/damontecres/wholphin/data/model/{ServerPreferences.kt => NavDrawerPinnedItem.kt} (85%) delete mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlayerLoadingState.kt delete mode 100644 app/src/main/java/com/github/damontecres/wholphin/util/FocusPair.kt diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 6856ebb2..390b2d63 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -14,6 +14,7 @@ The app uses: * [Room](https://developer.android.com/training/data-storage/room) & [DataStore](https://developer.android.com/topic/libraries/architecture/datastore) for local data storage * [Hilt](https://developer.android.com/training/dependency-injection/hilt-android) for dependency injection * [Media3/ExoPlayer](https://developer.android.com/media/media3/exoplayer) for media playback +* [MPV/libmpv](https://github.com/mpv-player/mpv) for media playback * [Coil](https://coil-kt.github.io/coil/) for image loading * [OkHttp](https://square.github.io/okhttp/) for HTTP requests diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/ExtrasItem.kt b/app/src/main/java/com/github/damontecres/wholphin/data/ExtrasItem.kt index 585e1079..ede44e08 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/ExtrasItem.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/ExtrasItem.kt @@ -8,12 +8,18 @@ import com.github.damontecres.wholphin.ui.nav.Destination import org.jellyfin.sdk.model.UUID import org.jellyfin.sdk.model.api.ExtraType +/** + * Represents "extras" for media such as behind-the-scenes or deleted scenes + */ sealed interface ExtrasItem { val parentId: UUID val type: ExtraType val destination: Destination val title: String? + /** + * Represents multiple extras of the same type + */ data class Group( override val parentId: UUID, override val type: ExtraType, @@ -25,6 +31,9 @@ sealed interface ExtrasItem { override val title: String? = null } + /** + * Represents a single extra + */ data class Single( override val parentId: UUID, override val type: ExtraType, @@ -38,6 +47,9 @@ sealed interface ExtrasItem { } } +/** + * Converts [ExtraType] to the string resource ID + */ @get:StringRes val ExtraType.stringRes: Int get() = @@ -56,6 +68,9 @@ val ExtraType.stringRes: Int ExtraType.SHORT -> R.string.shorts } +/** + * Converts [ExtraType] to the plural resource ID + */ @get:PluralsRes val ExtraType.pluralRes: Int get() = diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/filter/ItemFilterBy.kt b/app/src/main/java/com/github/damontecres/wholphin/data/filter/ItemFilterBy.kt index fec0fb8d..1d536f28 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/filter/ItemFilterBy.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/filter/ItemFilterBy.kt @@ -50,6 +50,11 @@ val DefaultPlaylistItemsOptions = DecadeFilter, ) +/** + * A way to filter libraries + * + * Gets and sets values within a [GetItemsFilter] + */ sealed interface ItemFilterBy { @get:StringRes val stringRes: Int 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 index 49b3c694..9c8009db 100644 --- 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 @@ -5,6 +5,14 @@ import org.jellyfin.sdk.model.extensions.ticks import java.util.UUID import kotlin.time.Duration +/** + * Represents audio or a song as a stripped down [BaseItem] since there may be a lot of these created. + * + * Typically added to a MediaItem as the tag for reference later + * + * The "key" can be used by a Compose LazyList key function as it will uniquely identify this particular + * audio even if the same song is added to the queue multiple times + */ @Stable data class AudioItem( val key: Long = keyTracker++, 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 83683976..cf7461ea 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 @@ -29,6 +29,9 @@ import java.util.Locale import java.util.UUID import kotlin.time.Duration +/** + * Wrapper for [BaseItemDto] with shortcuts for various UI elements + */ @Serializable @Stable data class BaseItem( @@ -92,6 +95,9 @@ data class BaseItem( @Transient val timeRemainingOrRuntime: Duration? = data.timeRemaining ?: data.runTimeTicks?.ticks + /** + * Contains pre computed UI elements that would be expensive to create on the main thread + */ @Transient val ui = BaseItemUi( @@ -178,6 +184,9 @@ data class BaseItem( it.dayOfMonth.toString().padStart(2, '0') }?.toIntOrNull() + /** + * Convert this [BaseItem] into a [Destination] to navigate to its page in the app + */ fun destination(index: Int? = null): Destination { if (destinationOverride != null) return destinationOverride val result = @@ -228,6 +237,7 @@ data class BaseItem( } companion object { + @Deprecated("Use regular constructor instead") fun from( dto: BaseItemDto, api: ApiClient, @@ -249,6 +259,9 @@ data class BaseItemUi( val quickDetails: AnnotatedString, ) +/** + * Create the special [Destination.FilteredCollection] for the given genre information + */ fun createGenreDestination( genreId: UUID, genreName: String, diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/Chapter.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/Chapter.kt index 3444ac54..3e07964c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/Chapter.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/Chapter.kt @@ -7,6 +7,9 @@ import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.extensions.ticks import kotlin.time.Duration +/** + * Represents a chapter within a video + */ data class Chapter( val name: String?, val position: Duration, diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/DiscoverItem.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/DiscoverItem.kt index 0cf88ff1..db03ff48 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/DiscoverItem.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/DiscoverItem.kt @@ -16,6 +16,9 @@ import org.jellyfin.sdk.model.serializer.UUIDSerializer import java.time.LocalDate import java.util.UUID +/** + * The type of a Seerr/Discover object with mapping to the Jellyfin [BaseItemKind] + */ @Serializable enum class SeerrItemType( val baseItemKind: BaseItemKind?, @@ -46,6 +49,9 @@ enum class SeerrItemType( } } +/** + * How available is a particular discovered item within the Jellyfin server + */ @Serializable enum class SeerrAvailability( val status: Int, @@ -64,7 +70,7 @@ enum class SeerrAvailability( } /** - * An item provided by a discovery service (ie Seerr). It may exist on the JF server as well. + * An item provided by a discovery service (ie Seerr). It may exist on the JF server as well, see [availability]. */ @Stable @Serializable @@ -104,6 +110,9 @@ data class DiscoverItem( } } +/** + * A rating for a discovered item which is usually fetched separately from the item + */ data class DiscoverRating( val criticRating: Int?, val audienceRating: Float?, diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/GetItemsFilter.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/GetItemsFilter.kt index f819c7c3..d0d32988 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/GetItemsFilter.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/GetItemsFilter.kt @@ -14,6 +14,9 @@ import org.jellyfin.sdk.model.api.request.GetPersonsRequest import org.jellyfin.sdk.model.serializer.UUIDSerializer import java.util.UUID +/** + * Filter for a collection folder + */ @Serializable data class CollectionFolderFilter( val nameOverride: String? = null, @@ -24,6 +27,9 @@ data class CollectionFolderFilter( val useSavedLibraryDisplayInfo: Boolean = true, ) +/** + * A sort of simplified filter which can be [applyTo] a [GetItemsRequest] or [GetPersonsRequest] to add or remove filters + */ @Serializable data class GetItemsFilter( val favorite: Boolean? = null, @@ -52,7 +58,7 @@ data class GetItemsFilter( } /** - * Clear all of the values for the given filters + * Clear all the values for the given filters */ fun delete(filterOptions: List>): GetItemsFilter { var newFilter = this @@ -128,6 +134,9 @@ data class GetItemsFilter( isFavorite = favorite, ) + /** + * Merge another [GetItemsFilter] onto this one, replacing only unset values + */ fun merge(filter: GetItemsFilter): GetItemsFilter = this.copy( favorite = favorite ?: filter.favorite, diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/ItemPlayback.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/ItemPlayback.kt index 94a30475..b31261fd 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/ItemPlayback.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/ItemPlayback.kt @@ -13,6 +13,10 @@ import kotlinx.serialization.UseSerializers import org.jellyfin.sdk.model.serializer.UUIDSerializer import java.util.UUID +/** + * Store the media source and audio/subtitle tracks chosen for a specific media item + * + */ @Entity( foreignKeys = [ ForeignKey( diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/ItemTrackModification.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/ItemTrackModification.kt index e8bbfeb4..3888e438 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/ItemTrackModification.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/ItemTrackModification.kt @@ -9,6 +9,11 @@ import kotlinx.serialization.UseSerializers import org.jellyfin.sdk.model.serializer.UUIDSerializer import java.util.UUID +/** + * Store modifications to an audio/subtitle track in a media item + * + * For example, the subtitle delay + */ @Entity( foreignKeys = [ ForeignKey( diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/JellyfinServer.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/JellyfinServer.kt index 2c516272..0b1759ee 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/JellyfinServer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/JellyfinServer.kt @@ -17,6 +17,9 @@ import org.jellyfin.sdk.model.ServerVersion import org.jellyfin.sdk.model.serializer.UUIDSerializer import java.util.UUID +/** + * Represents a Jellyfin server + */ @Entity(tableName = "servers") @Serializable data class JellyfinServer( @@ -29,6 +32,9 @@ data class JellyfinServer( val serverVersion: ServerVersion? by lazy { version?.let(ServerVersion::fromString) } } +/** + * Represents a Jellyfin user for a particular server + */ @Entity( tableName = "users", foreignKeys = [ @@ -59,6 +65,9 @@ data class JellyfinUser( "JellyfinUser(rowId=$rowId, id=$id, name=$name, serverId=$serverId, accessToken?=${accessToken.isNotNullOrBlank()}, pin?=${pin.isNotNullOrBlank()})" } +/** + * Represents the relationship between [JellyfinServer] and its [JellyfinUser] + */ data class JellyfinServerUsers( @Embedded val server: JellyfinServer, @Relation( 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 e5bcafcb..edfc797a 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 @@ -18,6 +18,11 @@ import org.jellyfin.sdk.model.api.SortOrder import org.jellyfin.sdk.model.serializer.UUIDSerializer import java.util.UUID +/** + * Stores the filter, sort, and view options a user changes for a library + * + * This allows for restoring these settings whenever the user navigates to the library + */ @Entity( foreignKeys = [ ForeignKey( diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/ServerPreferences.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/NavDrawerPinnedItem.kt similarity index 85% rename from app/src/main/java/com/github/damontecres/wholphin/data/model/ServerPreferences.kt rename to app/src/main/java/com/github/damontecres/wholphin/data/model/NavDrawerPinnedItem.kt index 2b503023..e312a2b1 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/ServerPreferences.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/NavDrawerPinnedItem.kt @@ -9,6 +9,9 @@ enum class NavPinType { UNPINNED, } +/** + * Stores preference information about nav drawer items such as its order and whether to show or put in More + */ @Entity( foreignKeys = [ ForeignKey( diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/Person.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/Person.kt index ac4a4710..4e639009 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/Person.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/Person.kt @@ -12,6 +12,9 @@ import org.jellyfin.sdk.model.api.BaseItemPerson import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.api.PersonKind +/** + * Represents a person in some media such as an actor or director + */ @Stable data class Person( val id: UUID, diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/PlaybackEffect.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/PlaybackEffect.kt index 3c5cf6a0..0e7bcd37 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/PlaybackEffect.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/PlaybackEffect.kt @@ -5,6 +5,9 @@ import androidx.room.Entity import org.jellyfin.sdk.model.api.BaseItemKind import java.util.UUID +/** + * Store effects applied to some media such as color or hue adjustments + */ @Entity(tableName = "playback_effects", primaryKeys = ["jellyfinUserRowId", "itemId", "type"]) data class PlaybackEffect( val jellyfinUserRowId: Int, diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/PlaybackLanguageChoice.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/PlaybackLanguageChoice.kt index f452e2f1..fb54dfcd 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/PlaybackLanguageChoice.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/PlaybackLanguageChoice.kt @@ -9,6 +9,10 @@ import kotlinx.serialization.UseSerializers import org.jellyfin.sdk.model.serializer.UUIDSerializer import java.util.UUID +/** + * Stores the language choices for a series so they can be applied automatically to other episodes + * without the user needing to explicitly choose the tracks + */ @Entity( foreignKeys = [ ForeignKey( diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/Playlist.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/Playlist.kt index c4cd3792..698a5f34 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/Playlist.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/Playlist.kt @@ -6,6 +6,11 @@ import androidx.compose.runtime.setValue import org.jellyfin.sdk.model.api.MediaType import java.util.UUID +/** + * Tracks playback of multiple items. Points to the current media with function to advance or go to previous ones. + * + * This is not the same thing as a Jellyfin server playlist + */ class Playlist( items: List, startIndex: Int = 0, diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrPermission.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrPermission.kt index 1e9f963d..a6886cf2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrPermission.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrPermission.kt @@ -2,6 +2,9 @@ package com.github.damontecres.wholphin.data.model import com.github.damontecres.wholphin.services.SeerrUserConfig +/** + * Permission levels for a user on a Seerr server + */ enum class SeerrPermission( private val flag: Int, ) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrServer.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrServer.kt index e29305d2..311379e4 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrServer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrServer.kt @@ -13,6 +13,9 @@ import kotlinx.serialization.Serializable import kotlinx.serialization.UseSerializers import org.jellyfin.sdk.model.serializer.UUIDSerializer +/** + * Represents a Seerr server instance + */ @Entity( tableName = "seerr_servers", indices = [Index("url", unique = true)], @@ -26,6 +29,9 @@ data class SeerrServer( val version: String? = null, ) +/** + * Represents a user on a [SeerrServer] + */ @Entity( tableName = "seerr_users", foreignKeys = [ @@ -56,12 +62,18 @@ data class SeerrUser( "SeerrUser(jellyfinUserRowId=$jellyfinUserRowId, serverId=$serverId, authMethod=$authMethod, username=$username, password?=${password.isNotNullOrBlank()}, credential?=${credential.isNotNullOrBlank()})" } +/** + * The method used to authenticate a user to the server + */ enum class SeerrAuthMethod { LOCAL, JELLYFIN, API_KEY, } +/** + * Represents the relationship between a [SeerrServer] and its [SeerrUser]s + */ data class SeerrServerUsers( @Embedded val server: SeerrServer, @Relation( diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/Trailer.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/Trailer.kt index 943789db..01e473b2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/Trailer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/Trailer.kt @@ -1,9 +1,15 @@ package com.github.damontecres.wholphin.data.model +/** + * Represents a trailer for media + */ sealed interface Trailer { val name: String } +/** + * A [Trailer] stored on the Jellyfin server + */ data class LocalTrailer( val baseItem: BaseItem, ) : Trailer { @@ -11,6 +17,9 @@ data class LocalTrailer( get() = baseItem.name ?: "" } +/** + * A [Trailer] available via a remote URL, such as YouTube + */ data class RemoteTrailer( override val name: String, val url: String, diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/VideoFilter.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/VideoFilter.kt index e71db95c..93543e23 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/VideoFilter.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/VideoFilter.kt @@ -14,7 +14,7 @@ import androidx.media3.effect.ScaleAndRotateTransformation import androidx.room.Ignore /** - * Modifications to a video playback + * Modifications to an image or video playback */ data class VideoFilter( val rotation: Int = 0, 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 07b5fc7e..c1507d9f 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 @@ -32,6 +32,9 @@ import java.io.File import javax.inject.Inject import javax.inject.Singleton +/** + * Handles any changes needed when the app in upgraded on the device such as setting new preferences + */ @Singleton class AppUpgradeHandler @Inject @@ -61,6 +64,7 @@ class AppUpgradeHandler Timber.i( "App updated: $previousVersion=>$newVersion, $previousVersionCode=>$newVersionCode", ) + // Store the previous and new version info prefs.edit(true) { putString(VERSION_NAME_PREVIOUS_KEY, previousVersion) putLong(VERSION_CODE_PREVIOUS_KEY, previousVersionCode) @@ -83,6 +87,9 @@ class AppUpgradeHandler } } + /** + * Copies the font file used by MPV subtitles to the app's files directory + */ fun copySubfont(overwrite: Boolean) { try { val fontFileName = "subfont.ttf" @@ -111,6 +118,9 @@ class AppUpgradeHandler const val VERSION_CODE_CURRENT_KEY = "version.current.code" } + /** + * Perform any needed upgrades + */ suspend fun upgradeApp( previous: Version, current: Version, 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 832d4137..1bf34929 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 @@ -32,6 +32,11 @@ import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton +/** + * Stores state for the backdrop of the app shown on non-full screen pages + * + * This is usually the backdrop of the currently focused media + */ @Singleton @OptIn(FlowPreview::class) class BackdropService @@ -46,6 +51,9 @@ class BackdropService private val _backdropFlow = MutableStateFlow(BackdropResult.NONE) val backdropFlow = _backdropFlow + /** + * Update the backdrop to use the specified item + */ suspend fun submit(item: BaseItem) = withContext(Dispatchers.IO) { val imageUrl = @@ -57,8 +65,14 @@ class BackdropService submit(item.id.toString(), imageUrl) } + /** + * Update the backdrop to use the specified discovered item + */ suspend fun submit(item: DiscoverItem) = submit("discover_${item.id}", item.backDropUrl) + /** + * Update the backdrop to use the specified URL + */ suspend fun submit( itemId: String, imageUrl: String?, @@ -74,6 +88,9 @@ class BackdropService } } + /** + * Remove the backdrop, such as when switching pages + */ suspend fun clearBackdrop() { _backdropFlow.update { BackdropResult.NONE @@ -224,6 +241,9 @@ class BackdropService } } +/** + * The result from determining the backdrop URL and extracted colors for the dynamic backdrop + */ data class BackdropResult( val itemId: String?, val imageUrl: String?, @@ -245,6 +265,9 @@ data class BackdropResult( } } +/** + * The colors extracted from an image + */ data class ExtractedColors( val primary: Color, val secondary: Color, diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/DatePlayedService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/DatePlayedService.kt index 205e12bf..9c4cfa5c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/DatePlayedService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/DatePlayedService.kt @@ -27,6 +27,9 @@ import java.util.concurrent.TimeUnit import javax.inject.Inject import javax.inject.Singleton +/** + * Caches when media was lasted played. This is mostly used by the combined Continue Watching & Next Up home row. + */ @Singleton class DatePlayedService @Inject @@ -80,6 +83,12 @@ class DatePlayedService } } + /** + * Get the logical timestamp when the item was last played. + * + * This is calculated using lastest of the actual last played timestamp of the item, + * the previous episode's last played timestamp, and the episode's premiere date + */ suspend fun getLastPlayed(item: BaseItem): LocalDateTime? = withContext(Dispatchers.IO) { val seriesId = item.data.seriesId @@ -93,6 +102,11 @@ class DatePlayedService } } + /** + * Remove the cached last date played for the given item's series + * + * Used for when a user plays this item + */ fun invalidate(item: BaseItem) { item.data.seriesId?.let { seriesId -> Timber.d("Invalidating %s", seriesId) @@ -100,6 +114,9 @@ class DatePlayedService } } + /** + * Remove the cached last data played for the given item or its series + */ suspend fun invalidate(itemId: UUID) { val seriesId = api.userLibraryApi.getItem(itemId = itemId).content.let { @@ -121,6 +138,9 @@ class DatePlayedService } } +/** + * Invalidates the date played cached when the current user changes + */ @ActivityScoped class DatePlayedInvalidationService @Inject diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/DeviceProfileService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/DeviceProfileService.kt index 976fb2c8..a001eb6a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/DeviceProfileService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/DeviceProfileService.kt @@ -14,6 +14,9 @@ import org.jellyfin.sdk.model.api.DeviceProfile import javax.inject.Inject import javax.inject.Singleton +/** + * Creates and caches the device direct play/transcoding profile sent to the server for ExoPlayer + */ @Singleton class DeviceProfileService @Inject @@ -21,7 +24,7 @@ class DeviceProfileService @param:ApplicationContext private val context: Context, ) { val mediaCodecCapabilitiesTest by lazy { - // Created lazily below on the IO thread since it cn take time + // Created lazily below on another thread since it cn take time MediaCodecCapabilitiesTest(context) } private val mutex = Mutex() @@ -33,7 +36,7 @@ class DeviceProfileService prefs: PlaybackPreferences, serverVersion: ServerVersion?, ): DeviceProfile = - withContext(Dispatchers.IO) { + withContext(Dispatchers.Default) { mutex.withLock { val newConfig = DeviceProfileConfiguration( diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/ExtrasService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/ExtrasService.kt index e952177b..65b74083 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/ExtrasService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/ExtrasService.kt @@ -9,12 +9,20 @@ import java.util.UUID import javax.inject.Inject import javax.inject.Singleton +/** + * Get extras for media + * + * @see [ExtrasItem] + */ @Singleton class ExtrasService @Inject constructor( private val api: ApiClient, ) { + /** + * Get the [ExtrasItem]s for the given item + */ suspend fun getExtras(itemId: UUID): List { val extrasMap = api.userLibraryApi @@ -42,6 +50,9 @@ class ExtrasService } } +/** + * The order which extras should be shown + */ private val ExtraType.sortOrder: Int get() = when (this) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/FavoriteWatchManager.kt b/app/src/main/java/com/github/damontecres/wholphin/services/FavoriteWatchManager.kt index a68a6fe5..cdaa0db4 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/FavoriteWatchManager.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/FavoriteWatchManager.kt @@ -8,6 +8,9 @@ import java.util.UUID import javax.inject.Inject import javax.inject.Singleton +/** + * Handles toggling media as favorited or watched + */ @Singleton class FavoriteWatchManager @Inject 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 0b3b0887..2abe6a1e 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 @@ -63,6 +63,9 @@ import java.io.File import javax.inject.Inject import javax.inject.Singleton +/** + * Handles getting home page settings and data + */ @Singleton class HomeSettingsService @Inject @@ -84,6 +87,9 @@ class HomeSettingsService allowTrailingComma = true } + /** + * The current home page settings + */ val currentSettings = MutableStateFlow(HomePageResolvedSettings.EMPTY) /** @@ -245,6 +251,9 @@ class HomeSettingsService currentSettings.update { resolvedSettings } } + /** + * Resolve the settings and set them to be the current settings + */ suspend fun updateCurrent(settings: HomePageSettings) { val resolvedRows = settings.rows.mapIndexed { index, config -> @@ -317,6 +326,9 @@ class HomeSettingsService return HomePageResolvedSettings(rowConfig) } + /** + * Create home page settings from the user's web UI home page settings + */ suspend fun parseFromWebConfig(userId: UUID): HomePageResolvedSettings? { val customPrefs = api.displayPreferencesApi diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt index f6e02df2..ce945b69 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt @@ -1,10 +1,8 @@ package com.github.damontecres.wholphin.services import android.content.Context -import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.ui.SlimItemFields -import com.github.damontecres.wholphin.util.HomeRowLoadingState import com.github.damontecres.wholphin.util.supportItemKinds import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers @@ -16,12 +14,7 @@ import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.itemsApi import org.jellyfin.sdk.api.client.extensions.tvShowsApi -import org.jellyfin.sdk.api.client.extensions.userLibraryApi -import org.jellyfin.sdk.api.client.extensions.userViewsApi import org.jellyfin.sdk.model.api.BaseItemKind -import org.jellyfin.sdk.model.api.CollectionType -import org.jellyfin.sdk.model.api.UserDto -import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest import org.jellyfin.sdk.model.api.request.GetNextUpRequest import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest import timber.log.Timber @@ -31,6 +24,9 @@ import javax.inject.Inject import javax.inject.Singleton import kotlin.time.Duration.Companion.milliseconds +/** + * Get continue watching and next up items for users + */ @Singleton class LatestNextUpService @Inject @@ -39,6 +35,9 @@ class LatestNextUpService private val api: ApiClient, private val datePlayedService: DatePlayedService, ) { + /** + * Get resume (continue watching) items for a user + */ suspend fun getResume( userId: UUID, limit: Int, @@ -60,13 +59,6 @@ class LatestNextUpService remove(BaseItemKind.EPISODE) } }, -// enableImageTypes = -// listOf( -// ImageType.PRIMARY, -// ImageType.THUMB, -// ImageType.BACKDROP, -// ImageType.LOGO, -// ), ) val items = api.itemsApi @@ -77,6 +69,9 @@ class LatestNextUpService return items } + /** + * Get next up items for a user + */ suspend fun getNextUp( userId: UUID, limit: Int, @@ -98,13 +93,6 @@ class LatestNextUpService enableUserData = true, enableRewatching = enableRewatching, nextUpDateCutoff = nextUpDateCutoff, -// enableImageTypes = -// listOf( -// ImageType.PRIMARY, -// ImageType.THUMB, -// ImageType.BACKDROP, -// ImageType.LOGO, -// ), ) val nextUp = api.tvShowsApi @@ -115,65 +103,11 @@ class LatestNextUpService return nextUp } - suspend fun getLatest( - user: UserDto, - limit: Int, - includedIds: List, - ): List { - val excluded = user.configuration?.latestItemsExcludes.orEmpty() - val views by api.userViewsApi.getUserViews() - val latestData = - views.items - .filter { - it.id in includedIds && it.id !in excluded && - it.collectionType in supportedLatestCollectionTypes - }.map { view -> - val title = - view.name?.let { context.getString(R.string.recently_added_in, it) } - ?: context.getString(R.string.recently_added) - val request = - GetLatestMediaRequest( - fields = SlimItemFields, - imageTypeLimit = 1, - parentId = view.id, - groupItems = true, - limit = limit, - isPlayed = null, // Server will handle user's preference - ) - LatestData(title, request) - } - - return latestData - } - - suspend fun loadLatest(latestData: List): List { - val rows = - latestData.mapNotNull { (title, request) -> - try { - val latest = - api.userLibraryApi - .getLatestMedia(request) - .content - .map { BaseItem.from(it, api, true) } - if (latest.isNotEmpty()) { - HomeRowLoadingState.Success( - title = title, - items = latest, - ) - } else { - null - } - } catch (ex: Exception) { - Timber.e(ex, "Exception fetching %s", title) - HomeRowLoadingState.Error( - title = title, - exception = ex, - ) - } - } - return rows - } - + /** + * Create the combined Continue Watching & Next Up items + * + * @see [DatePlayedService] + */ suspend fun buildCombined( resume: List, nextUp: List, @@ -207,17 +141,3 @@ class LatestNextUpService return@withContext result } } - -val supportedLatestCollectionTypes = - setOf( - CollectionType.MOVIES, - CollectionType.TVSHOWS, - CollectionType.HOMEVIDEOS, - // Exclude Live TV because a recording folder view will be used instead - null, // Recordings & mixed collection types - ) - -data class LatestData( - val title: String, - val request: GetLatestMediaRequest, -) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/MediaManagementService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/MediaManagementService.kt index a13881a9..beab6e64 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/MediaManagementService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/MediaManagementService.kt @@ -19,6 +19,9 @@ import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton +/** + * Service to manage media such as deletions + */ @Singleton class MediaManagementService @Inject @@ -45,6 +48,9 @@ class MediaManagementService return canDelete(item, appPreferences) } + /** + * Check if the item can be deleted. This means the app setting is enabled and the user has permission. + */ fun canDelete( item: BaseItem, appPreferences: AppPreferences, @@ -62,10 +68,14 @@ class MediaManagementService } } + /** + * Delete the item. + * + * This item will be sent through [deletedItemFlow] for other services or view models to react. + */ suspend fun deleteItem(item: BaseItem): DeleteResult { try { Timber.i("Deleting %s", item.id) - // TODO enable api.libraryApi.deleteItem(item.id) _deletedItemFlow.emit(DeletedItem(item)) return DeleteResult.Success @@ -88,6 +98,9 @@ sealed interface DeleteResult { ) : DeleteResult } +/** + * Convenience function to delete an item and show a Toast based on success or error + */ fun ViewModel.deleteItem( context: Context, mediaManagementService: MediaManagementService, diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/MediaReportService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/MediaReportService.kt index 14a93ba2..d1a1543a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/MediaReportService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/MediaReportService.kt @@ -21,6 +21,9 @@ import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton +/** + * Send media info to the server + */ @Singleton class MediaReportService @Inject @@ -39,6 +42,9 @@ class MediaReportService encodeDefaults = false } + /** + * Fetch the media info and send it to the server + */ fun sendReportFor(itemId: UUID) { ioScope.launchIO(ExceptionHandler(autoToast = true)) { val item = api.userLibraryApi.getItem(itemId = itemId).content @@ -46,6 +52,9 @@ class MediaReportService } } + /** + * Send the media report for the given item + */ suspend fun sendReportFor(item: BaseItemDto) { val sources = item.mediaSources ?: api.userLibraryApi 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 index be3ceac7..002be20e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt @@ -63,6 +63,11 @@ import javax.inject.Inject import javax.inject.Singleton import kotlin.time.Duration.Companion.seconds +/** + * Manage the global state for playing music + * + * Has functions for modifying the queue + */ @OptIn(UnstableApi::class) @Singleton class MusicService @@ -104,6 +109,11 @@ class MusicService private var activityTracker: TrackActivityPlaybackListener? = null private var websocketJob: Job? = null + /** + * Start music playback + * + * Sets up the media session, activity tracking, and actual playback + */ suspend fun start() { if (mediaSession == null) { mutex.withLock { @@ -130,6 +140,9 @@ class MusicService } } + /** + * Stop music playback + */ suspend fun stop() { mutex.withLock { Timber.i("Stopping music") @@ -191,6 +204,9 @@ class MusicService addAllToQueue(items, startIndex) } + /** + * Replace the queue with the given items + */ suspend fun setQueue( items: List, shuffled: Boolean, @@ -208,6 +224,9 @@ class MusicService } } + /** + * Add an item to the specified index of the queue. If no index specified, it will be added to the end. + */ suspend fun addToQueue( item: BaseItem, index: Int? = null, @@ -229,6 +248,11 @@ class MusicService } } + /** + * Add all the items in teh list to end of the queue + * + * @param startIndex The index to start from within the source list + */ suspend fun addAllToQueue( list: BlockingList, startIndex: Int, @@ -255,6 +279,9 @@ class MusicService updateQueueSize() } + /** + * Converts a [BaseItem] into a [MediaItem] setting an [AudioItem] as its tag + */ private fun convert(audio: BaseItem): MediaItem { val url = api.universalAudioApi.getUniversalAudioStreamUrl( @@ -282,6 +309,9 @@ class MusicService .build() } + /** + * Updates the state for when the queue changes + */ private suspend fun updateQueueSize() { // val ids = // withContext(Dispatchers.Default) { @@ -306,6 +336,9 @@ class MusicService } } + /** + * Move an item within the queue + */ suspend fun moveQueue( index: Int, direction: MoveDirection, @@ -314,6 +347,9 @@ class MusicService updateQueueSize() } + /** + * Move an item within the queue + */ suspend fun moveQueue( index: Int, newIndex: Int, @@ -322,6 +358,9 @@ class MusicService updateQueueSize() } + /** + * Start playback at the given index of the queue + */ suspend fun playIndex(index: Int) { onMain { player.seekTo(index, 0L) @@ -330,6 +369,9 @@ class MusicService // MusicPlayerListener will update state } + /** + * Play this item next after the current, ie add the item as the next index in the queue + */ suspend fun playNext(song: BaseItem) { val mediaItem = convert(song) onMain { @@ -341,6 +383,9 @@ class MusicService updateQueueSize() } + /** + * From the item at the given index from the queue + */ suspend fun removeFromQueue(index: Int) { onMain { player.removeMediaItem(index) } updateQueueSize() @@ -353,7 +398,10 @@ class MusicService return result } - fun subscribe(): Job = + /** + * Subscribes to the server websocket to receive playback commands + */ + private fun subscribe(): Job = api.webSocket .subscribe() .onEach { message -> @@ -503,6 +551,11 @@ private class MusicPlayerListener( } } +/** + * Remember the queue currently playing + * + * @see MusicServiceState + */ @Composable fun rememberQueue( player: Player, 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 8dbf49ee..68486430 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 @@ -38,6 +38,9 @@ import javax.inject.Inject import javax.inject.Singleton import kotlin.time.Duration.Companion.hours +/** + * Gets the items to show in the nav drawer + */ @Singleton class NavDrawerService @Inject @@ -54,6 +57,7 @@ class NavDrawerService val state: StateFlow = _state init { + // Handle updating the nav drawer when the user changes serverRepository.currentUser .asFlow() .combine(serverRepository.currentUserDto.asFlow()) { user, userDto -> @@ -74,10 +78,13 @@ class NavDrawerService showToast(context, "Error fetching user's views") }.launchIn(coroutineScope) + // Handle when the user has logged into a Seerr server seerrServerRepository.active .onEach { discoverActive -> _state.update { it.copy(discoverEnabled = discoverActive) } }.launchIn(coroutineScope) + + // Handle when music is actively playing or not coroutineScope.launchDefault { musicService.state.collectLatest { music -> Timber.v("MusicService updated") @@ -114,6 +121,9 @@ class NavDrawerService } } + /** + * Get all the libraries the user has access to + */ suspend fun getAllUserLibraries( userId: UUID, tvAccess: Boolean, @@ -147,6 +157,9 @@ class NavDrawerService return libraries } + /** + * Get the libraries that the user has not "pinned". These will show in the More section. + */ suspend fun getFilteredUserLibraries( user: JellyfinUser, tvAccess: Boolean, @@ -161,6 +174,9 @@ class NavDrawerService return libraries } + /** + * Update the current state of the nav drawer items + */ suspend fun updateNavDrawer( user: JellyfinUser, userDto: UserDto, diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/PeopleFavorites.kt b/app/src/main/java/com/github/damontecres/wholphin/services/PeopleFavorites.kt index 6a35dda5..1df73d18 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/PeopleFavorites.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/PeopleFavorites.kt @@ -10,6 +10,9 @@ import org.jellyfin.sdk.api.client.extensions.itemsApi import javax.inject.Inject import javax.inject.Singleton +/** + * Gets people in media, specifically to check if they are favorited or not + */ @Singleton class PeopleFavorites @Inject diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/PlaylistCreator.kt b/app/src/main/java/com/github/damontecres/wholphin/services/PlaylistCreator.kt index b47ae33f..fbbc9b70 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/PlaylistCreator.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/PlaylistCreator.kt @@ -38,6 +38,11 @@ import java.util.UUID import javax.inject.Inject import javax.inject.Singleton +/** + * Create [Playlist]s (not Jellyfin server playlist) for playback + * + * Used to create a queue of episodes or from Play All button + */ @Singleton class PlaylistCreator @Inject @@ -74,6 +79,9 @@ class PlaylistCreator return Playlist(episodes, startIndex) } + /** + * Create from a server playlist ID + */ suspend fun createFromPlaylistId( playlistId: UUID, startIndex: Int?, @@ -138,6 +146,11 @@ class PlaylistCreator return Playlist(items, 0) } + /** + * Create a [Playlist] contextually based on the given item. + * + * For example, an episode creates a queue of next up episodes in the series, or a movie creates one for its parts if needed + */ suspend fun createFrom( item: BaseItemDto, startIndex: Int = 0, @@ -275,6 +288,9 @@ class PlaylistCreator } } + /** + * Get the playlists on the server for a given media type + */ suspend fun getServerPlaylists( mediaType: MediaType?, scope: CoroutineScope, diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/RefreshRateService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/RefreshRateService.kt index 06d116a4..91df9518 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/RefreshRateService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/RefreshRateService.kt @@ -22,6 +22,9 @@ import javax.inject.Singleton import kotlin.math.roundToInt import kotlin.time.Duration.Companion.seconds +/** + * Handles determining whether the refresh rate and/or resolution of the display need to changed when playing media + */ @Singleton class RefreshRateService @Inject @@ -119,6 +122,9 @@ class RefreshRateService MainActivity.instance.changeDisplayMode(0) } + /** + * Listens for the display to change so we known the refresh rate or resolution is updated + */ private class Listener( val displayId: Int, ) : DisplayManager.DisplayListener { @@ -213,6 +219,9 @@ class RefreshRateService } } +/** + * Wrapper for a [Display.Mode] + */ data class DisplayMode( val modeId: Int, val physicalWidth: Int, diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/ScreensaverService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/ScreensaverService.kt index c81893b3..6252a2a7 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/ScreensaverService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/ScreensaverService.kt @@ -38,6 +38,9 @@ import javax.inject.Inject import javax.inject.Singleton import kotlin.time.Duration.Companion.milliseconds +/** + * Handles the queue of items to show on the screensaver, both in-app or OS + */ @Singleton class ScreensaverService @Inject @@ -67,6 +70,9 @@ class ScreensaverService }.launchIn(scope) } + /** + * Reset the timer before showing the in-app screensaver + */ fun pulse() { waitJob?.cancel() if (_state.value.enabled) { @@ -95,6 +101,9 @@ class ScreensaverService } } + /** + * Immediately start the in-app screensaver + */ fun start() { _state.update { it.copy( @@ -104,6 +113,9 @@ class ScreensaverService } } + /** + * Immediately stop the in-app screensaver + */ fun stop(cancelJob: Boolean) { _state.update { it.copy( @@ -114,6 +126,9 @@ class ScreensaverService if (cancelJob) waitJob?.cancel() } + /** + * Signal to the OS for keeping the screen on such as during playback or when the in-app screensaver is active + */ fun keepScreenOn(keep: Boolean) { scope.launchDefault { val screensaverEnabled = _state.value.enabled @@ -136,6 +151,9 @@ class ScreensaverService keepScreenOn.update { keep } } + /** + * Create a flow of items to show on the screensaver + */ fun createItemFlow(scope: CoroutineScope): Flow = flow { val prefs = diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/ServerEventListener.kt b/app/src/main/java/com/github/damontecres/wholphin/services/ServerEventListener.kt index f71c902a..979c90bd 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/ServerEventListener.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/ServerEventListener.kt @@ -25,6 +25,9 @@ import org.jellyfin.sdk.model.api.MediaType import timber.log.Timber import javax.inject.Inject +/** + * Listens for basic messages from the server such as messages + */ @ActivityScoped class ServerEventListener @Inject diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/StreamChoiceService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/StreamChoiceService.kt index 6de45f50..05eac34e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/StreamChoiceService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/StreamChoiceService.kt @@ -20,6 +20,9 @@ import java.util.UUID import javax.inject.Inject import javax.inject.Singleton +/** + * Manage the track choices for media + */ @Singleton class StreamChoiceService @Inject @@ -94,6 +97,9 @@ class StreamChoiceService result } + /** + * Returns the audio stream that should play + */ suspend fun chooseAudioStream( source: MediaSourceInfo, seriesId: UUID?, @@ -108,6 +114,9 @@ class StreamChoiceService } } + /** + * Returns the audio stream that should play + */ fun chooseAudioStream( candidates: List, itemPlayback: ItemPlayback?, @@ -135,6 +144,9 @@ class StreamChoiceService } } + /** + * Returns the subtitle stream that should play + */ suspend fun chooseSubtitleStream( source: MediaSourceInfo, audioStream: MediaStream?, @@ -196,6 +208,9 @@ class StreamChoiceService )?.index } + /** + * Returns the subtitle stream that should play + */ fun chooseSubtitleStream( audioStreamLang: String?, candidates: List, diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/TrailerService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/TrailerService.kt index 3f676cba..6be37807 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/TrailerService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/TrailerService.kt @@ -15,6 +15,9 @@ import org.jellyfin.sdk.api.client.extensions.userLibraryApi import javax.inject.Inject import javax.inject.Singleton +/** + * Gets trailers for media + */ @Singleton class TrailerService @Inject diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt b/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt index 709549cf..9e0665dd 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt @@ -45,6 +45,9 @@ import javax.inject.Singleton import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.milliseconds +/** + * Checks if an app update is available + */ @Singleton class UpdateChecker @Inject @@ -65,6 +68,11 @@ class UpdateChecker val ACTIVE = true } + /** + * If the app hasn't recently checked, check for any updates and if there is one, show a toast message + * + * This is safe to call many times because it will only show the toast at most once every 12 hours + */ suspend fun maybeShowUpdateToast( updateUrl: String, showNegativeToast: Boolean = false, @@ -112,11 +120,17 @@ class UpdateChecker } } + /** + * Get the currently installed version + */ fun getInstalledVersion(): Version { val pkgInfo = context.packageManager.getPackageInfo(context.packageName, 0) - return Version.Companion.fromString(pkgInfo.versionName!!) + return Version.fromString(pkgInfo.versionName!!) } + /** + * Get the latest released version + */ suspend fun getLatestRelease(updateUrl: String): Release? { return withContext(Dispatchers.IO) { val request = @@ -161,6 +175,9 @@ class UpdateChecker } } + /** + * Download and install an update + */ suspend fun installRelease( release: Release, callback: DownloadCallback, @@ -263,6 +280,9 @@ class UpdateChecker return targetFile } + /** + * Check if the app has permission to write the download + */ fun hasPermissions(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q || ( diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/UserPreferencesService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/UserPreferencesService.kt index 67e940b5..7c7fce11 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/UserPreferencesService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/UserPreferencesService.kt @@ -9,6 +9,9 @@ import kotlinx.coroutines.flow.map import javax.inject.Inject import javax.inject.Singleton +/** + * Get the current user's [UserPreferences] + */ @Singleton class UserPreferencesService @Inject diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt index a44ab65b..38071cac 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt @@ -34,26 +34,48 @@ import org.jellyfin.sdk.model.DeviceInfo import javax.inject.Qualifier import javax.inject.Singleton +/** + * An [OkHttpClient] that includes the user's access token when making requests + */ @Qualifier @Retention(AnnotationRetention.BINARY) annotation class AuthOkHttpClient +/** + * A basic [OkHttpClient] that does not include auth + */ @Qualifier @Retention(AnnotationRetention.BINARY) annotation class StandardOkHttpClient +/** + * A [CoroutineScope] with [Dispatchers.IO] + */ @Qualifier @Retention(AnnotationRetention.BINARY) annotation class IoCoroutineScope +/** + * A [CoroutineScope] with [Dispatchers.Default] + */ @Qualifier @Retention(AnnotationRetention.BINARY) annotation class DefaultCoroutineScope +/** + * [Dispatchers.IO] + * + * @see IoCoroutineScope + */ @Qualifier @Retention(AnnotationRetention.BINARY) annotation class IoDispatcher +/** + * [Dispatchers.Default] + * + * @see DefaultCoroutineScope + */ @Qualifier @Retention(AnnotationRetention.BINARY) annotation class DefaultDispatcher diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderSchedulerService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderSchedulerService.kt index 297cbd64..fb40c008 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderSchedulerService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderSchedulerService.kt @@ -24,6 +24,9 @@ import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds import kotlin.time.toJavaDuration +/** + * Schedules the [TvProviderWorker] to update the OS resume watching row + */ @ActivityScoped class TvProviderSchedulerService @Inject @@ -44,6 +47,7 @@ class TvProviderSchedulerService workManager.cancelUniqueWork(TvProviderWorker.WORK_NAME) if (supportsTvProvider) { if (user != null) { + // Schedule a new worker whenever the user changes activity.lifecycleScope.launchIO(ExceptionHandler()) { Timber.i("Scheduling TvProviderWorker for ${user.user}") workManager @@ -70,6 +74,9 @@ class TvProviderSchedulerService } } + /** + * Run the [TvProviderWorker] as a one-off instead of scheduled + */ fun launchOneTimeRefresh() { if (supportsTvProvider) { activity.lifecycleScope.launchIO(ExceptionHandler()) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderWorker.kt b/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderWorker.kt index 03ea0478..5a21d0d2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderWorker.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderWorker.kt @@ -43,6 +43,11 @@ import java.util.Date import java.util.UUID import kotlin.time.Duration.Companion.minutes +/** + * Updates the Android OS resume watching row for a given Jellyfin user + * + * @see TvProviderSchedulerService + */ @HiltWorker class TvProviderWorker @AssistedInject diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/Button.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/Button.kt index c65069bd..ff85886d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/Button.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/Button.kt @@ -35,6 +35,11 @@ import androidx.tv.material3.ProvideTextStyle import androidx.tv.material3.Surface import androidx.tv.material3.Text +/** + * This is a re-implementation of [androidx.tv.material3.Button] with altered sizing, padding, colors, etc + * + * This allows for creating smaller and fully circular Buttons. + */ @Composable fun Button( onClick: () -> Unit, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt index 82aab6ba..37de91ce 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt @@ -90,6 +90,9 @@ sealed interface DialogItemEntry data object DialogItemDivider : DialogItemEntry +/** + * An item within a [DialogPopup] + */ data class DialogItem( val headlineContent: @Composable () -> Unit, val onClick: () -> Unit, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/EditTextBox.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/EditTextBox.kt index a85d9fc9..47b4ebc9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/EditTextBox.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/EditTextBox.kt @@ -34,7 +34,6 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction @@ -49,8 +48,10 @@ import androidx.tv.material3.MaterialTheme import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.ui.PreviewTvSpec import com.github.damontecres.wholphin.ui.theme.WholphinTheme -import com.google.protobuf.value +/** + * An input field for text customized for TV entry + */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun EditTextBox( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/FilterByButton.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/FilterByButton.kt index a218b9c7..95926eed 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/FilterByButton.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/FilterByButton.kt @@ -55,6 +55,14 @@ import com.github.damontecres.wholphin.ui.theme.WholphinTheme import com.github.damontecres.wholphin.ui.tryRequestFocus import java.util.UUID +/** + * Button for filtering data. + * + * Clicking on it will show a drop-down menu of filterable options. Many of these show a second drop-down menu when clicked. + * + * @see GetItemsFilter + * @see ItemFilterBy + */ @Composable fun FilterByButton( filterOptions: List>, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt index d42eea5c..e85b1d02 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt @@ -156,6 +156,9 @@ private val genreCache by lazy { } } +/** + * Create a mapping from genre IDs to image URLs using random items within each genre + */ suspend fun getGenreImageMap( api: ApiClient, userId: UUID?, @@ -230,6 +233,9 @@ data class Genre( override val sortName: String get() = name } +/** + * Show an optimized grid of genres for a library + */ @Composable fun GenreCardGrid( itemId: UUID, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreText.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreText.kt index 3598fb93..c697b667 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreText.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreText.kt @@ -11,6 +11,11 @@ import androidx.tv.material3.Text private const val MAX_TO_SHOW = 4 +/** + * Display a comma separated list of genres + * + * Only the first few will be shown + */ @Composable fun GenreText( genres: List, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ItemGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ItemGrid.kt index 38591a67..13ce42a7 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ItemGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ItemGrid.kt @@ -82,6 +82,9 @@ class ItemGridViewModel } } +/** + * Display a grid of a list of arbitrary item IDs such as for [com.github.damontecres.wholphin.data.ExtrasItem] + */ @Composable fun ItemGrid( destination: Destination.ItemGrid, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/LicenseInfo.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/LicenseInfo.kt index 67c56f12..6a2300c2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/LicenseInfo.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/LicenseInfo.kt @@ -6,6 +6,9 @@ import androidx.compose.ui.Modifier import com.mikepenz.aboutlibraries.ui.compose.android.produceLibraries import com.mikepenz.aboutlibraries.ui.compose.m3.LibrariesContainer +/** + * Displays dependencies' license information to comply with attribution + */ @Composable fun LicenseInfo(modifier: Modifier = Modifier) { val libraries by produceLibraries() diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/OverviewText.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/OverviewText.kt index 7417028f..874585de 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/OverviewText.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/OverviewText.kt @@ -23,6 +23,9 @@ import androidx.tv.material3.Text import com.github.damontecres.wholphin.ui.playOnClickSound import com.github.damontecres.wholphin.ui.playSoundOnFocus +/** + * Show the overview text for an item. Uses a fixed size and allows for clicking. + */ @Composable fun OverviewText( overview: String, 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 db40ff0e..5eb1a210 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 @@ -43,6 +43,7 @@ import com.github.damontecres.wholphin.ui.rememberPosition import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.HomeRowLoadingState import com.github.damontecres.wholphin.util.LoadingState +import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Deferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async @@ -50,8 +51,11 @@ import kotlinx.coroutines.flow.MutableStateFlow import org.jellyfin.sdk.model.api.MediaType import java.util.UUID +/** + * Abstract [ViewModel] for the "Recommended" tab for a library + */ abstract class RecommendedViewModel( - val context: Context, + @param:ApplicationContext val context: Context, val navigationManager: NavigationManager, val favoriteWatchManager: FavoriteWatchManager, val mediaReportService: MediaReportService, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/SelectedLeadingContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/SelectedLeadingContent.kt index efc2ddac..d06fd652 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/SelectedLeadingContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/SelectedLeadingContent.kt @@ -13,6 +13,11 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.unit.dp import androidx.tv.material3.LocalContentColor +/** + * Shows a dot indicator + * + * Intended for using within the `leadingContent` of a [androidx.tv.material3.ListItem] + */ @Composable fun BoxScope.SelectedLeadingContent( selected: Boolean, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/SliderBar.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/SliderBar.kt index 64cfc78b..f87f5c98 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/SliderBar.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/SliderBar.kt @@ -126,16 +126,19 @@ data class SliderColors( @Composable fun default() = SliderColors( - activeFocused = SliderActiveColor(true), - activeUnfocused = SliderActiveColor(false), - inactiveFocused = SliderInactiveColor(true), - inactiveUnfocused = SliderInactiveColor(false), + activeFocused = sliderActiveColor(true), + activeUnfocused = sliderActiveColor(false), + inactiveFocused = sliderInactiveColor(true), + inactiveUnfocused = sliderInactiveColor(false), ) } } +/** + * Determines the active color for the slider. This is the "filled" left side of the slider + */ @Composable -fun SliderActiveColor(focused: Boolean): Color { +fun sliderActiveColor(focused: Boolean): Color { val theme = LocalTheme.current return when (theme) { AppThemeColors.UNRECOGNIZED, @@ -165,8 +168,11 @@ fun SliderActiveColor(focused: Boolean): Color { } } +/** + * Determines the inactive color for the slider. This is background of "unfilled" right side of the slider. + */ @Composable -fun SliderInactiveColor(focused: Boolean): Color { +fun sliderInactiveColor(focused: Boolean): Color { val theme = LocalTheme.current return when (theme) { AppThemeColors.UNRECOGNIZED, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/TimeDisplay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/TimeDisplay.kt index f5791e04..f70a4f6a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/TimeDisplay.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/TimeDisplay.kt @@ -12,6 +12,9 @@ import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import com.github.damontecres.wholphin.ui.util.LocalClock +/** + * Displays the [LocalClock] in the upper right corner of the parent [androidx.compose.foundation.layout.Box] + */ @Composable fun BoxScope.TimeDisplay(modifier: Modifier = Modifier) { val timeString by LocalClock.current.timeString diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ViewOptionsDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ViewOptionsDialog.kt index e4e6d350..74c00328 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ViewOptionsDialog.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ViewOptionsDialog.kt @@ -39,6 +39,11 @@ import com.github.damontecres.wholphin.ui.tryRequestFocus import kotlinx.serialization.Serializable import org.jellyfin.sdk.model.api.ImageType +/** + * A dialog that shows the controls for making changes to a [ViewOptions] object. The caller must manage the state of the [ViewOptions]. + * + * It displays the [AppPreference] objects from [ViewOptions.OPTIONS] + */ @Composable fun ViewOptionsDialog( viewOptions: ViewOptions, @@ -105,6 +110,11 @@ fun ViewOptionsDialog( } } +/** + * Stores the customizable view changes from the user + * + * This is used to determine how the UI looks such as card size and shape + */ @Serializable data class ViewOptions( val columns: Int = 6, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/data/AddPlaylistViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/data/AddPlaylistViewModel.kt index 9e2141b5..5c4721c5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/data/AddPlaylistViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/data/AddPlaylistViewModel.kt @@ -19,6 +19,10 @@ import timber.log.Timber import java.util.UUID import javax.inject.Inject +/** + * A supplementary [ViewModel] for adding items to a server playlist + * @see com.github.damontecres.wholphin.ui.detail.PlaylistDialog + */ @HiltViewModel class AddPlaylistViewModel @Inject 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 072de4e1..5cf0789e 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 @@ -42,6 +42,9 @@ data class ItemDetailsDialogInfo( val files: List, ) +/** + * Dialog showing metadata about an item + */ @Composable fun ItemDetailsDialog( info: ItemDetailsDialogInfo, 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 a2b9e923..41f5e534 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 @@ -85,6 +85,9 @@ interface CardGridItem { val sortName: String } +/** + * Shows a vertical grid of [CardGridItem]s + */ @OptIn(ExperimentalFoundationApi::class) @Composable fun CardGrid( 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 b4618eda..080c1c4e 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 @@ -49,9 +49,8 @@ enum class ClearChosenStreams { * If there are any (ie one or more) subtitle tracks, adds an option to disable or pick one * * @param item the media item to build for, typically an Episode or Movie - * @param series the item's series or null if not a TV episode; a non-null value will include a "Go to Series" option + * @param seriesId the item's series or null if not a TV episode; a non-null value will include a "Go to Series" option * @param sourceId the item's media source UUID - * @param navigateTo a function to trigger a navigation * @param onChooseVersion callback to pick a version of the item * @param onChooseTracks callback to pick a track for the given type of the item * @param onShowOverview callback to show overview dialog with media information 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 bd50ce59..d50b7af5 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 @@ -256,7 +256,7 @@ class PlaylistViewModel /** * This method tries to determine the [MediaType] of a playlist * - * In theory, the server will set the type, but sometime it doesn't + * In theory, the server will set the type, but sometimes it doesn't */ private suspend fun determineMediaType() { // Use the type the server says diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewModel.kt index 4276330d..ce3fbd5b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewModel.kt @@ -172,6 +172,9 @@ class LiveTvViewModel } } + /** + * Creates a list of [LocalDateTime] for each hour from now + */ private fun buildGuideTimes() = buildList { val start = LocalDateTime.now().roundDownToHalfHour() @@ -194,15 +197,22 @@ class LiveTvViewModel loading.setValueOnMain(LoadingState.Success) } + /** + * Get live TV programs for a subset of channels + * + * @param guideStart The start timestamp to fetch from + * @param channels The full list of channels + * @param channelIndices The indices of which channels to fetch programs for + */ private suspend fun fetchPrograms( guideStart: LocalDateTime, channels: List, - range: IntRange, + channelIndices: IntRange, ) = mutex.withLock { val maxStartDate = guideStart.plusHours(MAX_HOURS).minusMinutes(1) val minEndDate = guideStart.plusMinutes(1L) - val channelsToFetch = channels.subList(range.first, range.last + 1) - Timber.v("Fetching programs for $range channels ${channelsToFetch.size}") + val channelsToFetch = channels.subList(channelIndices.first, channelIndices.last + 1) + Timber.v("Fetching programs for $channelIndices channels ${channelsToFetch.size}") val request = GetProgramsDto( maxStartDate = maxStartDate, @@ -390,7 +400,7 @@ class LiveTvViewModel Timber.d("Got ${fetchedPrograms.size} programs & ${finalProgramList.size} total programs") withContext(Dispatchers.Main) { this@LiveTvViewModel.programs.value = - FetchedPrograms(range, finalProgramList, programsByChannel) + FetchedPrograms(channelIndices, finalProgramList, programsByChannel) } } @@ -472,6 +482,11 @@ class LiveTvViewModel private var focusLoadingJob: Job? = null + /** + * Callback when focusing on the EPG grid + * + * This determines if more programs/channels should be fetched based on the current position + */ fun onFocusChannel(position: RowColumn) { channels.value?.let { channels -> val fetchedRange = programs.value!!.range 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 index 21520447..81028501 100644 --- 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 @@ -37,6 +37,9 @@ import com.github.damontecres.wholphin.ui.CrossFadeFactory import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds +/** + * Shows the current backdrop images provided by [com.github.damontecres.wholphin.services.BackdropService] + */ @Composable fun Backdrop( drawerIsOpen: Boolean, @@ -59,6 +62,9 @@ fun Backdrop( ) } +/** + * Shows the current backdrop images provided by the [BackdropResult] + */ @Composable fun Backdrop( backdrop: BackdropResult, 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 6e2a2260..26068182 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 @@ -157,6 +157,9 @@ class NavDrawerViewModel fun getUserImage(user: JellyfinUser): String = api.imageApi.getUserImageUrl(user.id) + /** + * Determine which nav drawer item should be highlighted as currently selected + */ fun updateSelectedIndex() { viewModelScope.launchDefault { val asDestinations = @@ -215,6 +218,11 @@ class NavDrawerViewModel } } +/** + * An item that can be shown in the nav drawer + * + * Some are built-in such as Favorites. Others are created dynamically for libraries. + */ sealed interface NavDrawerItem { val id: String @@ -242,6 +250,9 @@ sealed interface NavDrawerItem { } } +/** + * A server provided nav drawer item, typically a library + */ data class ServerNavDrawerItem( val itemId: UUID, val name: String, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavigationDrawerAndroid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavigationDrawerAndroid.kt index 30b9d1d6..0cca40c8 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavigationDrawerAndroid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavigationDrawerAndroid.kt @@ -57,6 +57,11 @@ import androidx.tv.material3.NavigationDrawerItemDefaults import androidx.tv.material3.NavigationDrawerScope import androidx.tv.material3.rememberDrawerState +/** + * This is a re-implementation of [androidx.tv.material3.ModalNavigationDrawer]. + * + * It changes padding, sizing, and animations that couldn't be changed in the Androidx implementation. + */ @Composable fun ModalNavigationDrawer( drawerContent: @Composable NavigationDrawerScope.(DrawerValue) -> Unit, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/CurrentMediaInfo.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/CurrentMediaInfo.kt index e8dc7d1d..40b5e89b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/CurrentMediaInfo.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/CurrentMediaInfo.kt @@ -8,6 +8,11 @@ import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.TrickplayInfo import org.jellyfin.sdk.model.extensions.ticks +/** + * Metadata about the currently playing media + * + * @see CurrentPlayback + */ data class CurrentMediaInfo( val sourceId: String?, val videoStream: SimpleVideoStream?, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/CurrentPlayback.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/CurrentPlayback.kt index 9c62f17b..b0f51e56 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/CurrentPlayback.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/CurrentPlayback.kt @@ -8,6 +8,11 @@ import org.jellyfin.sdk.model.api.PlayMethod import org.jellyfin.sdk.model.api.TranscodingInfo import kotlin.time.Duration +/** + * Information about how the current media is being played such transcoding and decoder info + * + * @see CurrentMediaInfo + */ data class CurrentPlayback( val item: BaseItem, val tracks: List, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/DownloadSubtitlesDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/DownloadSubtitlesDialog.kt index e1a291e1..d4039f72 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/DownloadSubtitlesDialog.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/DownloadSubtitlesDialog.kt @@ -51,7 +51,7 @@ import org.jellyfin.sdk.model.api.RemoteSubtitleInfo @Composable fun DownloadSubtitlesContent( - state: SubtitleSearch, + state: SubtitleSearchStatus, language: String, onSearch: (String) -> Unit, onClickDownload: (RemoteSubtitleInfo) -> Unit, @@ -59,7 +59,7 @@ fun DownloadSubtitlesContent( modifier: Modifier = Modifier, ) { when (val s = state) { - SubtitleSearch.Searching -> { + SubtitleSearchStatus.Searching -> { Wrapper { Text( text = stringResource(R.string.searching), @@ -69,7 +69,7 @@ fun DownloadSubtitlesContent( } } - SubtitleSearch.Downloading -> { + SubtitleSearchStatus.Downloading -> { Wrapper { Text( text = stringResource(R.string.downloading), @@ -79,11 +79,11 @@ fun DownloadSubtitlesContent( } } - is SubtitleSearch.Error -> { + is SubtitleSearchStatus.Error -> { Wrapper { ErrorMessage(null, s.ex, modifier) } } - is SubtitleSearch.Success -> { + is SubtitleSearchStatus.Success -> { val dialogItems = convertRemoteSubtitles(s.options, onClickDownload) val focusRequester = remember { FocusRequester() } LaunchedEffect(Unit) { 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 28f9b9d7..d43b6c27 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 @@ -84,6 +84,9 @@ import org.jellyfin.sdk.model.extensions.ticks import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds +/** + * Possible actions the user can take during playback + */ sealed interface PlaybackAction { data object ShowDebug : PlaybackAction @@ -114,6 +117,9 @@ sealed interface PlaybackAction { data object Next : PlaybackAction } +/** + * UI for actual playback controls such as seek bar, play/pause and seek buttons + */ @OptIn(UnstableApi::class) @Composable fun PlaybackControls( @@ -515,6 +521,11 @@ fun PlaybackFaButton( } } +/** + * Show a dialog aligned to the bottom of the screen instead of centered. + * + * @param gravity The [Gravity] to align the dialog to left or right + */ @Composable fun BottomDialog( choices: List>, @@ -594,10 +605,6 @@ fun BottomDialog( } } -data class MoreButtonOptions( - val options: Map, -) - data class BottomDialogItem( val data: T, val headline: String, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt index a305023c..1968fac3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt @@ -62,6 +62,14 @@ data class PlaybackSettings( val playbackSpeedEnabled: Boolean, ) +/** + * Centralized UI component for displaying dialogs during playback + * + * Typically, the user will click something generating a [PlaybackAction] which translates into the + * [PlaybackDialogType] determining which dialog is shown by this component. + * + * @see PlaybackAction + */ @Composable fun PlaybackDialog( enableSubtitleDelay: Boolean, 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 3bc4ddb0..2b0247e7 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 @@ -575,6 +575,8 @@ enum class OverlayViewState { /** * A wrapper for the playback controls to show title and other information, plus the actual controls + * + * @see PlaybackControls */ @Composable fun Controller( 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 cd39dbc2..3159c4dd 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 @@ -168,7 +168,7 @@ fun PlaybackPageContent( val nextUp by viewModel.nextUp.observeAsState(null) val playlist by viewModel.playlist.observeAsState(Playlist(listOf())) - val subtitleSearch by viewModel.subtitleSearch.observeAsState(null) + val subtitleSearch by viewModel.subtitleSearchStatus.observeAsState(null) val subtitleSearchLanguage by viewModel.subtitleSearchLanguage.observeAsState(Locale.current.language) var playbackDialog by remember { mutableStateOf(null) } 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 f1be78dc..e9af15f1 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 @@ -187,7 +187,7 @@ class PlaybackViewModel private var isPlaylist = false val playlist = MutableLiveData(Playlist(listOf())) - val subtitleSearch = MutableLiveData(null) + val subtitleSearchStatus = MutableLiveData(null) val subtitleSearchLanguage = MutableLiveData(Locale.current.language) val currentUserDto = serverRepository.currentUserDto diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlayerLoadingState.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlayerLoadingState.kt deleted file mode 100644 index c966a9aa..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlayerLoadingState.kt +++ /dev/null @@ -1,39 +0,0 @@ -package com.github.damontecres.wholphin.ui.playback - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.State -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.media3.common.Player -import androidx.media3.common.listen -import androidx.media3.common.util.UnstableApi - -@UnstableApi -@Composable -fun rememberPlayerLoadingState(player: Player): PlayerLoadingState { - val state = remember(player) { PlayerLoadingState(player) } - LaunchedEffect(player) { - state.observe() - } - return state -} - -@UnstableApi -class PlayerLoadingState( - private val player: Player, -) : State { - override var value by mutableStateOf(player.isLoading) - private set - - suspend fun observe() { - value = player.isLoading - player.listen { - if (it.contains(Player.EVENT_IS_LOADING_CHANGED)) { - value = player.isLoading - } - } - } -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekBar.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekBar.kt index 92c6114b..37cb86f7 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekBar.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekBar.kt @@ -49,6 +49,11 @@ import androidx.tv.material3.MaterialTheme import kotlinx.coroutines.FlowPreview import kotlin.time.Duration +/** + * This is a seek bar which seeks by a percentage of the duration instead of a fixed amount of time + * + * For example, if [intervals] is 10, then each seek will be 10% of total media duration + */ @Composable fun SteppedSeekBarImpl( progress: Float, @@ -97,6 +102,9 @@ fun SteppedSeekBarImpl( ) } +/** + * A seek (or scrubber) bar which seeks forward or back a fixed amount of time per move + */ @OptIn(FlowPreview::class) @Composable fun IntervalSeekBarImpl( @@ -147,8 +155,14 @@ fun IntervalSeekBarImpl( ) } +/** + * Actually renders the seek bar. It has callbacks for when the user moves to the left or right + * + * @see IntervalSeekBarImpl + * @see SteppedSeekBarImpl + */ @Composable -fun SeekBarDisplay( +private fun SeekBarDisplay( progress: Float, bufferedProgress: Float, durationMs: Long, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekPreviewImage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekPreviewImage.kt index 08fb7685..8be0b509 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekPreviewImage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekPreviewImage.kt @@ -1,5 +1,6 @@ package com.github.damontecres.wholphin.ui.playback +import androidx.annotation.FloatRange import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -52,20 +53,21 @@ fun Modifier.offsetByPercent( * * @param xPercentage percent offset between 0 inclusive and 1 inclusive */ -fun Modifier.offsetByPercent(xPercentage: Float) = - this.then( - Modifier.layout { measurable, constraints -> - val placeable = measurable.measure(constraints) - layout(placeable.width, placeable.height) { - placeable.placeRelative( - x = - ((constraints.maxWidth * xPercentage).toInt() - placeable.width / 2) - .coerceIn(0, constraints.maxWidth - placeable.width), - y = 0, - ) - } - }, - ) +fun Modifier.offsetByPercent( + @FloatRange(0.0, 1.0) xPercentage: Float, +) = this.then( + Modifier.layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + layout(placeable.width, placeable.height) { + placeable.placeRelative( + x = + ((constraints.maxWidth * xPercentage).toInt() - placeable.width / 2) + .coerceIn(0, constraints.maxWidth - placeable.width), + y = 0, + ) + } + }, +) /** * Show trickplay preview image. This composable assumes the provided URL is for the correct index. diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SimpleMediaStream.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SimpleMediaStream.kt index 6444a268..d430258a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SimpleMediaStream.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SimpleMediaStream.kt @@ -5,6 +5,11 @@ import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.util.StreamFormatting.mediaStreamDisplayTitle import org.jellyfin.sdk.model.api.MediaStream +/** + * A slimmer [MediaStream] with minimal data for UI purposes + * + * @see SimpleVideoStream + */ data class SimpleMediaStream( val index: Int, val streamTitle: String?, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SkipIndicator.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SkipIndicator.kt index 3e417a91..c2344f5f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SkipIndicator.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SkipIndicator.kt @@ -25,6 +25,9 @@ import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.ui.ifElse import kotlin.math.abs +/** + * Shows a rotating indicator when seeking. Displays the amount of seconds in the [durationMs]. + */ @Composable fun SkipIndicator( durationMs: Long, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SubtitleDelay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SubtitleDelay.kt index 17792899..8c645c9b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SubtitleDelay.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SubtitleDelay.kt @@ -30,6 +30,9 @@ import kotlin.time.Duration.Companion.seconds private val delayIncrements = listOf(50.milliseconds, 250.milliseconds, 1.seconds) +/** + * Shows buttons for adding to or subtracting from the given [Duration], ie subtitle delay + */ @Composable fun SubtitleDelay( delay: Duration, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SubtitleSearchUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SubtitleSearchUtils.kt index 021f4290..27188f57 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SubtitleSearchUtils.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SubtitleSearchUtils.kt @@ -20,23 +20,26 @@ import org.jellyfin.sdk.model.api.MediaStreamType import org.jellyfin.sdk.model.api.RemoteSubtitleInfo import timber.log.Timber -sealed interface SubtitleSearch { - data object Searching : SubtitleSearch +sealed interface SubtitleSearchStatus { + data object Searching : SubtitleSearchStatus - data object Downloading : SubtitleSearch + data object Downloading : SubtitleSearchStatus data class Success( val options: List, - ) : SubtitleSearch + ) : SubtitleSearchStatus data class Error( val message: String?, val ex: Exception?, - ) : SubtitleSearch + ) : SubtitleSearchStatus } +/** + * Trigger a search for subtitles in the given language for the currently playing media + */ fun PlaybackViewModel.searchForSubtitles(language: String = Locale.current.language) { - subtitleSearch.value = SubtitleSearch.Searching + subtitleSearchStatus.value = SubtitleSearchStatus.Searching subtitleSearchLanguage.value = language viewModelScope.launchIO { try { @@ -52,23 +55,26 @@ fun PlaybackViewModel.searchForSubtitles(language: String = Locale.current.langu compareByDescending { it.communityRating } .thenByDescending { it.downloadCount }, ) - subtitleSearch.setValueOnMain(SubtitleSearch.Success(results)) + subtitleSearchStatus.setValueOnMain(SubtitleSearchStatus.Success(results)) } } catch (ex: Exception) { Timber.e(ex, "Exception while searching for subtitles") - subtitleSearch.setValueOnMain(SubtitleSearch.Error(null, ex)) + subtitleSearchStatus.setValueOnMain(SubtitleSearchStatus.Error(null, ex)) } } } +/** + * Download the remote subtitles and attempt to activate them once complete + */ fun PlaybackViewModel.downloadAndSwitchSubtitles( subtitleId: String?, wasPlaying: Boolean, ) { if (subtitleId == null) { - subtitleSearch.value = SubtitleSearch.Error("Subtitle has no ID", null) + subtitleSearchStatus.value = SubtitleSearchStatus.Error("Subtitle has no ID", null) } else { - subtitleSearch.value = SubtitleSearch.Downloading + subtitleSearchStatus.value = SubtitleSearchStatus.Downloading viewModelScope.launchIO { try { currentItemPlayback.value?.let { @@ -161,7 +167,7 @@ fun PlaybackViewModel.downloadAndSwitchSubtitles( ) } } - subtitleSearch.setValueOnMain(null) + subtitleSearchStatus.setValueOnMain(null) withContext(Dispatchers.Main) { if (wasPlaying) { player.play() @@ -170,12 +176,12 @@ fun PlaybackViewModel.downloadAndSwitchSubtitles( } } catch (ex: Exception) { Timber.e(ex, "Exception while downloading subtitles: $subtitleId") - subtitleSearch.setValueOnMain(SubtitleSearch.Error(null, ex)) + subtitleSearchStatus.setValueOnMain(SubtitleSearchStatus.Error(null, ex)) } } } } fun PlaybackViewModel.cancelSubtitleSearch() { - subtitleSearch.value = null + subtitleSearchStatus.value = null } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/TrackSelectionUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/TrackSelectionUtils.kt index 201b5982..d056e6f3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/TrackSelectionUtils.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/TrackSelectionUtils.kt @@ -15,6 +15,9 @@ import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod import timber.log.Timber import kotlin.math.max +/** + * Functions for selecting which audio & subtitle tracks to activate in the [androidx.media3.common.Player] + */ object TrackSelectionUtils { @OptIn(UnstableApi::class) fun createTrackSelections( @@ -258,6 +261,9 @@ fun List.findExternalSubtitle(subtitleIndex: Int?): MediaStream? = } } +/** + * The result of [TrackSelectionUtils.createTrackSelections] + */ data class TrackSelectionResult( val trackSelectionParameters: TrackSelectionParameters, val audioSelected: Boolean, 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 8a6fc939..08207e53 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 @@ -3,12 +3,24 @@ package com.github.damontecres.wholphin.util import java.util.function.IntFunction import java.util.function.Predicate +/** + * A [List] which has function that will wait for a result + */ interface BlockingList : List { + /** + * Get the specified index, possibly blocking until it is available + */ suspend fun getBlocking(index: Int): T + /** + * Get first index that matches the given predicate, possibly blocking while searching + */ suspend fun indexOfBlocking(predicate: Predicate): Int companion object { + /** + * Create a [BlockingList] over a regular [List] + */ fun of(list: List): BlockingList = BlockingListWrapper(list) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/CrashReportSenderFactory.kt b/app/src/main/java/com/github/damontecres/wholphin/util/CrashReportSenderFactory.kt index c1630900..c334967f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/CrashReportSenderFactory.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/CrashReportSenderFactory.kt @@ -20,6 +20,9 @@ import org.json.JSONObject import timber.log.Timber import java.util.Date +/** + * Sends a crash report to the current server + */ @AutoService(ReportSenderFactory::class) class CrashReportSenderFactory : ReportSenderFactory { override fun create( diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/DebugLogTree.kt b/app/src/main/java/com/github/damontecres/wholphin/util/DebugLogTree.kt index 46b54466..b7f2e2c6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/DebugLogTree.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/DebugLogTree.kt @@ -4,6 +4,9 @@ import android.util.Log import com.github.damontecres.wholphin.BuildConfig import timber.log.Timber +/** + * Enable debug logging via [Timber] if enabled in the app settings + */ class DebugLogTree private constructor() : Timber.Tree() { // Only add logging for below INFO, production logger in WholphinApplication logs >=INFO override fun isLoggable( diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/FocusPair.kt b/app/src/main/java/com/github/damontecres/wholphin/util/FocusPair.kt deleted file mode 100644 index e44d531a..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/util/FocusPair.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.github.damontecres.wholphin.util - -import androidx.compose.ui.focus.FocusRequester - -data class FocusPair( - val row: Int, - val column: Int, - val focusRequester: FocusRequester, -) diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/RememberTabManager.kt b/app/src/main/java/com/github/damontecres/wholphin/util/RememberTabManager.kt index 1c69d947..46ec3606 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/RememberTabManager.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/RememberTabManager.kt @@ -3,6 +3,9 @@ package com.github.damontecres.wholphin.util import com.github.damontecres.wholphin.preferences.UserPreferences import java.util.UUID +/** + * Functions to remember tabs choices for a library + */ interface RememberTabManager { /** * If enabled, get the remembered tab index for the given item diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/TransformList.kt b/app/src/main/java/com/github/damontecres/wholphin/util/TransformList.kt index 61946d60..a0bfc08b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/TransformList.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/TransformList.kt @@ -1,5 +1,8 @@ package com.github.damontecres.wholphin.util +/** + * A utility class to lazily transforms items from the source list + */ class TransformList( private val source: List, private val transform: (S) -> T, diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/Version.kt b/app/src/main/java/com/github/damontecres/wholphin/util/Version.kt index 03303ac8..b670326b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/Version.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/Version.kt @@ -2,6 +2,9 @@ package com.github.damontecres.wholphin.util import kotlinx.serialization.Serializable +/** + * Represents a version in the format of `..--g` as output by `git describe` + */ @Serializable data class Version( val major: Int, diff --git a/app/src/patches/play_store.patch b/app/src/patches/play_store.patch index ef5fb96d..c1214f0e 100644 --- a/app/src/patches/play_store.patch +++ b/app/src/patches/play_store.patch @@ -1,14 +1,10 @@ -commit f82fb1a2d8ff9917a7bdb0bc7101a0474359ccdc -Author: Damontecres -Date: Sat Nov 22 13:00:55 2025 -0500 +# This patches Wholphin for releasing on app stores where self updating is not permitted - Setup for play store - -diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml -index 6d84299..12576af 100644 ---- a/app/src/main/AndroidManifest.xml -+++ b/app/src/main/AndroidManifest.xml -@@ -4,7 +4,6 @@ +diff --git i/app/src/main/AndroidManifest.xml w/app/src/main/AndroidManifest.xml +index 4479ae86..80596698 100644 +--- i/app/src/main/AndroidManifest.xml ++++ w/app/src/main/AndroidManifest.xml +@@ -6,7 +6,6 @@ @@ -16,7 +12,7 @@ index 6d84299..12576af 100644 -@@ -17,7 +16,7 @@ +@@ -20,7 +19,7 @@ android:required="false" /> -diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt b/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt -index c7ac435..fa42fe1 100644 ---- a/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt -+++ b/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt -@@ -62,7 +62,7 @@ class UpdateChecker +diff --git i/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt w/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt +index 9e0665dd..bc1e1c16 100644 +--- i/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt ++++ w/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt +@@ -65,7 +65,7 @@ class UpdateChecker private val NOTE_REGEX = Regex("") @@ -37,4 +33,4 @@ index c7ac435..fa42fe1 100644 + val ACTIVE = false } - suspend fun maybeShowUpdateToast( + /** From 1334bb3f039b72c34846c0f117cf734c0957551f Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Mon, 30 Mar 2026 14:11:25 -0400 Subject: [PATCH 019/148] Don't recursively query collections on home page (#1160) ## Description Don't recursively query collections for the home page rows. This prevents episodes within tv shows from being shown. ### Related issues Fixes https://github.com/damontecres/Wholphin/issues/1148 Fixes #1147 ### Testing Emulator & API ## Screenshots N/A ## AI or LLM usage None --- .../com/github/damontecres/wholphin/data/model/HomeRowConfig.kt | 2 +- .../wholphin/ui/main/settings/HomeSettingsViewModel.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt index 09622733..1398bc0b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt @@ -162,7 +162,7 @@ sealed interface HomeRowConfig { @SerialName("ByParent") data class ByParent( val parentId: UUID, - val recursive: Boolean, + val recursive: Boolean = false, val sort: SortAndDirection? = null, override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(), ) : HomeRowConfig { 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 50f34513..b3997bf5 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 @@ -414,7 +414,7 @@ class HomeSettingsViewModel config = HomeRowConfig.ByParent( parentId = parent.id, - recursive = true, + recursive = false, ), ) updateState { From 70a4f35b2f89267359e38411de764b6531acf480 Mon Sep 17 00:00:00 2001 From: North-DaCoder Date: Sat, 7 Mar 2026 06:32:08 +0000 Subject: [PATCH 020/148] Translated using Weblate (German) Currently translated at 100.0% (502 of 502 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/de/ --- app/src/main/res/values-de/strings.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 628c465a..30707860 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -227,8 +227,8 @@ Kurzvideos Trifft nur auf Serien zu - Libass für ASS Untertitel verwenden - Direktwiedergabe PGS Untertitel + Direktes abspielen von ASS Untertitel + Direktes abspielen von PGS Untertitel Immer zu Stereo heruntermischen FFmpeg decoder Modul verwenden Standard Skalierung From 5abc5ee0d70d694f5dda66aa947cc03a8196e70e Mon Sep 17 00:00:00 2001 From: Outbreak2096 Date: Sat, 7 Mar 2026 02:56:13 +0000 Subject: [PATCH 021/148] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (502 of 502 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hans/ --- app/src/main/res/values-zh-rCN/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index b78e3322..aad256b7 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -208,7 +208,7 @@ 检查更新 仅适用于电视剧 - 使用 libass 处理 ASS 字幕 + 直接播放 ASS 字幕 直接播放 PGS 字幕 始终降混为立体声 使用 FFmpeg 解码模块 From 02b4d90a1e707cfaca3b83740d5aa9c4bb61b495 Mon Sep 17 00:00:00 2001 From: idezentas Date: Sat, 7 Mar 2026 21:46:06 +0000 Subject: [PATCH 022/148] Translated using Weblate (Turkish) Currently translated at 100.0% (502 of 502 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/tr/ --- app/src/main/res/values-tr/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 4e4c1838..c3ffa676 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -256,7 +256,7 @@ Güncellemeleri kontrol et Yalnızca diziler için geçerlidir - ASS altyazıları için libass kullan + ASS altyazıları doğrudan oynat PGS altyazıları doğrudan oynat Her zaman Stereo\'ya dönüştür FFmpeg kod çözücü kullan From 5575a071e3fd46809c1e43e6b0597c26e243b5ef Mon Sep 17 00:00:00 2001 From: North-DaCoder Date: Sun, 8 Mar 2026 14:56:51 +0000 Subject: [PATCH 023/148] Translated using Weblate (German) Currently translated at 100.0% (502 of 502 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/de/ --- app/src/main/res/values-de/strings.xml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 30707860..4801d137 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -183,7 +183,7 @@ Live TV Outro Wiedergabe Anzahl - Zeige Wiedergabe Debug infos + Wiedergabe Debug Infos anzeigen Wiedergabeliste Wiedergabelisten Zeige als nächstes @@ -250,10 +250,10 @@ Aktualisierungs-URL Schriftgröße Schriftfarbe - Schriftart Deckkraft + Deckkraft der Schrift Stil der Umrandung Farbe der Umrandung - Hintergrund Deckktraft + Deckkraft des Hintergrundes Hintergrund Stil Untertitel Stil Hintergrundfarbe @@ -406,7 +406,7 @@ Auflösungswechsel Gaststars Ab Anfang - keine Fotos + Keine Fotos Links Rotieren Rechts Rotieren Rot @@ -548,7 +548,7 @@ Ersteller - Gestreckt - Zentriert + Textmarker + Kasten Mischer From dfdfc76b7144f197962976003fa7e2d991629b80 Mon Sep 17 00:00:00 2001 From: American_Jesus Date: Mon, 9 Mar 2026 22:18:46 +0000 Subject: [PATCH 024/148] Translated using Weblate (Portuguese) Currently translated at 100.0% (502 of 502 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/pt/ --- app/src/main/res/values-pt/strings.xml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index b4f44186..0866f814 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -566,4 +566,7 @@ Técnico de mixagem Criador Artista + Tem a certeza de que deseja eliminar este elemento? + Mostrar opções de gestão de multimédia + Pesquisar %s From 79959a442128400ad6369d42f7e626349da85be2 Mon Sep 17 00:00:00 2001 From: n8llcaster Date: Tue, 10 Mar 2026 15:31:19 +0000 Subject: [PATCH 025/148] Translated using Weblate (Slovak) Currently translated at 80.0% (402 of 502 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/sk/ --- app/src/main/res/values-sk/strings.xml | 185 ++++++++++++++++++++++++- 1 file changed, 184 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-sk/strings.xml b/app/src/main/res/values-sk/strings.xml index dfb498ba..768b4672 100644 --- a/app/src/main/res/values-sk/strings.xml +++ b/app/src/main/res/values-sk/strings.xml @@ -1,6 +1,6 @@ - O Wholphine + O aplikácii Aktívne nahrávky Obľúbené Pridať server @@ -309,4 +309,187 @@ Pokus o odoslanie na posledný pripojený server Odoslať správy o zlyhaní Nastavenia + + %s minúta + %s minúty + %s minút + %s minút + + Oneskorenie pred prehrávaním nasledujúceho + Automatické prehrávanie nasledujúceho + + Prepísanie prehrávania + Povoliť opätovné prehrávanie v nasledujúcich + Pri obnovení prehrávania preskočiť späť + Preskočiť späť + Správanie preskočenia reklamy + Preskočiť dopredu + Správanie preskočenia intra + Správanie preskočenia outra + Správanie preskočenia náhľadov + Správanie preskočenia rekapitulácie + Aktualizácia k dispozícii + URL používaná na kontrolu aktualizácií aplikácie + Aktualizovať URL + Veľkosť písma + Farba písma + Priehľadnosť písma + Štýl okraja + Farba okraja + Prehľadnosť pozadia + Štýl pozadia + Štýl titulkov + Farba pozadia + Reset + Tučné písmo + Backend prehrávania + MPV: Používa hardvérové dekódovanie + Zakážte, ak dochádza k pádom + Možnosti MPV + Možnosti ExoPlayer + Preskočiť segment + Preskočiť reklamy + Preskočiť náhľad + Preskočiť rekapituláciu + Preskočiť outro + Preskočiť intro + Prehrané + Filter + Rok + Desaťročie + Odstrániť + Dolby Vision + Dolby Atmos + MPV: Použiť gpu-next + Upraviť mpv.conf + Zrušiť zmeny? + Okraj + Podrobné logovanie + Zadajte PIN + Automatické prihlásenie + Prihlásenie cez server + Stlačte stredové tlačidlo na potvrdenie + Vyžadovať PIN pre profil + Potvrdiť PIN + Nesprávny + PIN musí mať minimálne 4 znaky + Odstráni PIN + Veľkosť pamäte obrázkov (MB) + Možnosti zobrazenia + Stĺpce + Rozostup + Pomer strán + Zobraziť podrobnosti + Typ obrázku + + Hosťujúce hviezdy + Veľkosť okraja + Prepínanie obnovovacej frekvencie + Automaticky + Použite používateľské meno/heslo + Prihlásenie + Všeobecné + Kontajner + Titul + Kodek + Profil + Úroveň + Rozlíšenie + Anamorfný + Prekladané + Snímková frekvencia + Bitová hĺbka + Rozsah videa + Typ rozsahu videa + Farebný priestor + Prenos farieb + Základné farby + Formát pixelov + Referenčné snímky + NAL + Jazyk + Rozloženie + Kanály + Vzorkovacia frekvencia + AVC + Áno + Nie + SDR + HDR + HDR10 + HDR10+ + HLG + bit + Hz + Zobraziť názvy + Opakovať + Zobraziť obľúbené kanály ako prvé + Zoradiť kanály podľa nedávno pozretých + Farebné rozlíšenie programov + Oneskorenie titulkov + Štýl backdropu + Prepínanie rozlíšenia + Lokálne + Prehrať trailer + Žiadne trailery + Vyčistiť výber stôp + DTS:X + DTS:HD + TrueHD + DD + DD+ + DTS + Priame prehrávanie Dolby Vision Profile 7 + Ignorovať kontrolu kompatibility zariadenia + Žiadosť + Čaká + Integrácia Seerr + Odstrániť Seerr server + Seerr server pridaný + Rýchle pripojenie + Scenár + Hosťujúca hviezda + Producent + Dirigent + Textár + Aranžér + Inžinier + Zvukový mixér + Tvorca + Umelec + Hľadať %s + Zadajte kód Rýchleho pripojenia + Kód musí mať 6 číslic + Zariadenie bolo úspešne autorizované + Heslo + Používateľské meno + URL + Trendujúce + Nadchádzajúce filmy + Nadchádzajúce seriály + Vyžiadať v 4K + Softvérové dekódovanie AV1 + MPV je teraz predvolený prehrávač okrem HDR.\nToto môžete zmeniť v nastaveniach. + Štýl HDR titulkov + Priehľadnosť obrázkových titulkov + Jas + Kontrast + Sýtosť + Odtieň + Červená + Zelená + Modrá + Rozmazanie + Uložiť do albumu + Prehrať prezentáciu + Zastaviť prezentáciu + Na začiatok + Žiadne ďalšie fotky + Otočiť doľava + Otočiť doprava + Zväčšiť + Zmenšiť + Trvanie prezentácie + Prehrávať videá počas prezentácie + Bez obmedzenia From 768af6958dff882c89a209f3ca02a2502faa762c Mon Sep 17 00:00:00 2001 From: ymir Date: Mon, 9 Mar 2026 16:23:11 +0000 Subject: [PATCH 026/148] Translated using Weblate (French) Currently translated at 100.0% (502 of 502 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/fr/ --- app/src/main/res/values-fr/strings.xml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 312caa8e..208fcf48 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -566,4 +566,7 @@ Mélangeur Créateur Artiste + Êtes-vous sûr de vouloir supprimer cet élément ? + Afficher les options de gestion des médias + Recherche %s From 9ae23d6725642dbfc9622ad03ce77aad7d0831dc Mon Sep 17 00:00:00 2001 From: RabSsS Date: Mon, 9 Mar 2026 16:24:38 +0000 Subject: [PATCH 027/148] Translated using Weblate (French) Currently translated at 100.0% (502 of 502 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/fr/ --- app/src/main/res/values-fr/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 208fcf48..6d61bd9d 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -197,7 +197,7 @@ Échelle de contenu par défaut Installer la mise à jour Version installée - Nombre maximal d\'éléments par ligne sur la page d\'accueil + Eléments sur les lignes de la page d\'accueil Choisissez les éléments à afficher par défaut, les autres seront masqués Personnaliser les éléments du menu de navigation Cliquez pour changer de page From 2fe3799785827d5a9ee701bf18b49eebf2d0b1eb Mon Sep 17 00:00:00 2001 From: RafaelSoaresP Date: Mon, 9 Mar 2026 18:45:56 +0000 Subject: [PATCH 028/148] Translated using Weblate (Portuguese (Brazil)) Currently translated at 100.0% (502 of 502 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/pt_BR/ --- app/src/main/res/values-pt-rBR/strings.xml | 200 ++++++++++++++++----- 1 file changed, 160 insertions(+), 40 deletions(-) diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 94da1f4d..16500357 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -64,9 +64,9 @@ Mais como isso Mais - Filmes - - + Filme + Filmes + Filmes Nome A Seguir @@ -80,9 +80,9 @@ Finalização Caminho - Pessoas - - + Pessoa + Pessoas + Pessoas Contagem de Reproduções Reproduzir a partir daqui @@ -151,18 +151,18 @@ Não assistidos com melhor avaliação Trailer - Trailers - - + Trailer + Trailers + Trailers Agenda do DVR Guia Temporada Temporadas - Programas de TV - - + Programa de TV + Programas de TV + Programas de TV Interface Desconhecido @@ -190,53 +190,53 @@ Extras Outro - - + Outros + Outros Por Trás das Cenas - - + Por Trás das Cenas + Por Trás das Cenas - Músicas Tema - - + Música Tema + Músicas Tema + Músicas Tema - Vídeos Tema - - + Vídeo Tema + Vídeos Tema + Vídeos Tema - Clipes - - + Clipe + Clipes + Clipes - Cenas Deletadas - - + Cena Deletada + Cenas Deletadas + Cenas Deletadas - Entrevistas - - + Entrevista + Entrevistas + Entrevistas - Cenas - - + Cena + Cenas + Cenas - Amostras - - + Amostra + Amostras + Amostras - Curtas - - + Curta + Curtas + Curtas Favoritar %s @@ -461,4 +461,124 @@ Altura Aplicar a todas as linhas Personalizar página inicial + Pular para trás ao resumir a reprodução + Pular para trás + Pular para frente + NAL + AVC + Salvar para álbum + Linhas iniciais + Carregar a partir do perfil de usuário no servidor + Salvar para o perfil de usuário no servidor + Carregar a partir do cliente web + Usar imagem da série + Adicionar linha para %1$s + Sobrescrever configurações no servidor? + Sobrescrever configurações locais? + Para episódios + Sugestões para %1$s + Aumentar tamanho para todos os cartões + Diminuir tamanho para todos os cartões + Usar imagens de prévia + Configurações salvas + Predefinições de exibição + Predefinições embutidas para estilizar rapidamente todas as linhas + Escolher linhas e imagens na página inicial + Iniciar após + Animar + Classificação etária máxima + Sem máximo + Para todas as idades + Até a idade %s + Protetor de tela + Configurações do protetor de tela + Iniciar o protetor de tela + Duração + Fotos + Incluir tipos + Para quais tipos de itens mostrar imagens + Ajustar + Cortar + Preencher + Preencher largura + Preencher altura + Padrão do Wholphin + Wholphin Compacto + Prévia das imagens das séries + Prévias das imagens do episódio + Mais baixo + Baixo + Médio + Alto + Máximo + Roxo + Laranja + Azul intenso + Preto + Ignorar + Pular automaticamente + Perguntar se pular + Apenas usar FFmpeg se não existir nenhum decodificador embarcado + Preferir usar FFmpeg aos decodificadores embarcados + Nunca usar decodificadores FFmpeg + Ao fim da reprodução + Durante os créditos/finalização + Branco + Cinza claro + Cinza escuro + Amarelo + Ciano + Magenta + Contorno + Sombra + Envolver + Encaixotar + Preferir MPV + Usar ExoPlayer para reprodução HDR + Pôster (2:3) + 16:9 + 4:3 + Quadrado (1:1) + Primária + Prévia + Imagem com cor dinâmica + Apenas imagem + Chave de API + Autenticar no servidor Seerr + Usuário Jellyfin + Usuário local + Nenhuma legenda remota encontrada + Presente + Usar protetor de tela do aplicativo + Tem certeza que quer apagar este item? + Mostrar opções de gerenciamento de mídia + Ator + Compositor + Escritor + Estrela convidada + Produtor + Regente + Letrista + Arranjador + Engenheiro + Criador + Artista + Buscar %s + Mixagem + + Bastidores + + + + + %s minuto + %s minutos + %s minutos + + Insira a URL e a chave de API + Procurar e baixar legendas + Baixar e atualizar + Procurar e baixar + Combinar \"Continue Assistindo\" e \"A Seguir\" + Elenco e equipe From 5eb3ba86c85047dd18c9174835e813b581245628 Mon Sep 17 00:00:00 2001 From: North-DaCoder Date: Tue, 10 Mar 2026 16:42:17 +0000 Subject: [PATCH 029/148] Translated using Weblate (German) Currently translated at 100.0% (503 of 503 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/de/ --- app/src/main/res/values-de/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 4801d137..75405328 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -551,4 +551,5 @@ Textmarker Kasten Mischer + Alle auswählen From c7b7f67e0cd7e3182bfa0ade6ca13ddb36d274c5 Mon Sep 17 00:00:00 2001 From: danpergal84 Date: Wed, 11 Mar 2026 15:25:59 +0000 Subject: [PATCH 030/148] Translated using Weblate (Spanish) Currently translated at 100.0% (504 of 504 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/es/ --- app/src/main/res/values-es/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index e228ac4d..7f4a3dd9 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -569,4 +569,5 @@ ¿Estás seguro de que quieres eliminar este elemento? Mostrar opciones de gestión de medios Buscar %s + Seleccionar todo From 3821ec7046bab9595905efd713d34a4403a5954d Mon Sep 17 00:00:00 2001 From: n8llcaster Date: Wed, 11 Mar 2026 13:40:05 +0000 Subject: [PATCH 031/148] Translated using Weblate (Slovak) Currently translated at 100.0% (504 of 504 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/sk/ --- app/src/main/res/values-sk/strings.xml | 105 ++++++++++++++++++++++++- 1 file changed, 103 insertions(+), 2 deletions(-) diff --git a/app/src/main/res/values-sk/strings.xml b/app/src/main/res/values-sk/strings.xml index 768b4672..6b933215 100644 --- a/app/src/main/res/values-sk/strings.xml +++ b/app/src/main/res/values-sk/strings.xml @@ -182,7 +182,7 @@ Pozadie Úspech Rodičovské hodnotenie - Trvanie + Dĺžka Bonusový materiál Ostatné @@ -381,7 +381,7 @@ Pomer strán Zobraziť podrobnosti Typ obrázku - + Hosťujúce hviezdy Veľkosť okraja Prepínanie obnovovacej frekvencie @@ -492,4 +492,105 @@ Trvanie prezentácie Prehrávať videá počas prezentácie Bez obmedzenia + DTS:HD MA + Prepísať nastavenia na serveri? + Prepísať lokálne nastavenia? + Pre epizódy + Návrhy pre %1$s + Zväčšiť veľkosť všetkých kariet + Zmenšiť veľkosť všetkých kariet + Použiť náhľadové obrázky + Nastavenia uložené + Predvoľby displeja + Vstavané predvoľby na rýchlu úpravu všetkých riadkov + Vyberte riadky a obrázky na domovskej stránke + Začať po + Animovať + Maximálne vekové hodnotenie + Bez vekového obmedzenia + Pre všetky vekové kategórie + Do veku %s + Šetrič obrazovky + Nastavenia šetriča obrazovky + Spustiť šetrič obrazovky + Trvanie + Fotografie + Zahrnúť typy + Pre ktoré typy položiek zobrazovať obrázky + Prispôsobiť + Orezať + Vyplniť + Vyplniť na šírku + Vyplniť na výšku + Wholphin predvolené + Wholphin kompaktné + Náhľadové obrázky seriálov + Náhľadové obrázky epizód + Náhľad + Pridať riadok + Žánre v %1$s + Nedávno vydané v %1$s + Výška + Použiť na všetky riadky + Prispôsobiť domovskú stránku + Riadky na domovskej stránke + Načítať zo serverového profilu používateľa + Uložiť do serverového profilu používateľa + Načítať z webového klienta + Použiť obrázok seriálu + Pridať riadok pre %1$s + Najnižšia + Nízka + Stredná + Vysoká + Plná + Fialová + Oranžová + Výrazná modrá + Čierna + Ignorovať + Preskočiť automaticky + Opýtať sa na preskočenie + Použiť FFmpeg len v prípade, ak neexistuje žiadny vstavaný dekodér + Preferovať použitie FFmpeg pred vstavanými dekodérmi + Nikdy nepoužívať FFmpeg dekodéry + Na konci prehrávania + Počas záverečných titulkov/outro + Biela + Svetlo šedá + Tmavo šedá + Žltá + Azúrová + Magenta + Obrys + Tieň + Prispôsobiť pozadie textu + Text v rámčeku + Uprednostniť MPV + Použiť ExoPlayer na prehrávanie HDR + Plagát (2:3) + 16:9 + 4:3 + Štvorec (1:1) + Primárny + Obrázok s dynamickou farbou + Iba obrázok + + API kľúč + Prihlásenie na Seerr server + Jellyfin používateľ + Lokálny používateľ + + Nenašli sa žiadne vzdialené titulky + Prebieha + Použiť šetrič obrazovky v aplikácii + Naozaj chcete odstrániť túto položku? + Zobraziť možnosti správy médií + Herec + Skladateľ + Vybrať všetko + Vyhľadať + Autorizujte iné zariadenie na prihlásenie do vášho účtu + Odoslať log informácií o médiách na server + Max. počet dní v sekcií Nasleduje From e91a29bce6643c016b0b292255ea5c9c93b77783 Mon Sep 17 00:00:00 2001 From: Outbreak2096 Date: Wed, 11 Mar 2026 10:49:05 +0000 Subject: [PATCH 032/148] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (504 of 504 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hans/ --- app/src/main/res/values-zh-rCN/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index aad256b7..0ee7e9fc 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -533,4 +533,5 @@ 显示媒体管理选项 是否确定要删除此项目? 搜索 %s + 全选 From 6420345e78e5abbd1f7bb7cdf0bb6ac4cd0e06c5 Mon Sep 17 00:00:00 2001 From: idezentas Date: Wed, 11 Mar 2026 11:01:24 +0000 Subject: [PATCH 033/148] Translated using Weblate (Turkish) Currently translated at 100.0% (504 of 504 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/tr/ --- app/src/main/res/values-tr/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index c3ffa676..2922f2df 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -560,4 +560,5 @@ Bu öğeyi silmek istediğinizden emin misiniz? Medya yönetim seçeneklerini göster Ara %s + Hepsinin seç From 8549265203953f89e8c7578ca6fb9877515a9c25 Mon Sep 17 00:00:00 2001 From: jatag41526 Date: Thu, 12 Mar 2026 16:44:11 +0000 Subject: [PATCH 034/148] Translated using Weblate (Russian) Currently translated at 64.4% (325 of 504 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/ru/ --- app/src/main/res/values-ru/strings.xml | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 04204a19..c69f25cb 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -393,4 +393,28 @@ Опорные кадры Да Нет + + %s день + %s дня + %s дней + %s дней + + + %s минута + %s минуты + %s минут + %s минут + + Каналы + Частота дискретизации + Контраст + Насыщенность + Оттенок + Красный + Зелёный + Синий + Яркость + Интеграция с Seerr + Найти %s + Выбрать всё From cbf8f5226f9a374ce1b96feaf71465e70aafe953 Mon Sep 17 00:00:00 2001 From: opakholis Date: Fri, 13 Mar 2026 01:00:04 +0000 Subject: [PATCH 035/148] Translated using Weblate (Indonesian) Currently translated at 100.0% (504 of 504 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/id/ --- app/src/main/res/values-in/strings.xml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index dc94ff96..ae739f25 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -522,4 +522,16 @@ Teknisi Mixer Kreator + Pilih semua + Cari %s + Tampilkan opsi kelola media + Apakah kamu yakin ingin menghapus item ini? + Bayangan + Pembungkus + Kotak + Sesuaikan + Pangkas + Layar penuh + Lebar penuh + Tinggi penuh From a6cc4b5146796b7d7694fc4bde41335a6a250cba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Priit=20J=C3=B5er=C3=BC=C3=BCt?= Date: Fri, 13 Mar 2026 14:20:13 +0000 Subject: [PATCH 036/148] Translated using Weblate (Estonian) Currently translated at 98.0% (494 of 504 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/et/ --- app/src/main/res/values-et/strings.xml | 78 ++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index 85a803a8..cb7d2119 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -464,4 +464,82 @@ Rakenduses kaasasolevad eelseadistused kõikide ridade kiireks muutmiseks Vali kodulehe/avalehe read ja pildid Märgi %s lemmikuks + + %s minut + %s minutit + + Purpurpunane + Oranž + Tugev sinine + Must + Eira + Jäta automaatselt vahele + Vahelejätmiseks küsi + Kui ühtegi kaasnevat dekoodrit pole, siis kasuta vaid FFmpeg-i + Eelista kaasnevatele dekoodritele FFmpeg-i + Ära iialgi kasuta dekoderimiseks FFmpeg-i + Taasesituse lõpus + Valge + Helehall + Tumehall + Kollane + Rohekassinine + Fuksiapunane + Äärisjoon + Vari + Reamurdmine + Eelista MPV-d + HDR-i taasesituseks kasuta ExoPlayerit + Plakat (2:3) + 16:9 + 4:3 + Ruut (1:1) + Sõrmejälg + Esmane + Pilt dünaamiliste värvidega + Ainult pilt + + API võti + Logi sisse Seerri serverisse + Jellyfini kasutaja + Kohalik kasutaja + + Kaugseadmest ei leidnunud subtiitreid + Kasuta rakendusesisest ekraanisäästjat + Kas sa oled kindel, et soovid selle objekti kustutada? + Näida meediumi haldamise valikuid + Näitleja + Helilooja + Kirjanik + Külalisesineja + Tootja + Dirigent + Laulusõnade autor + Seade autor + Heliinsener + Helimiksija + Looja + Kunstnik + Otsi: %s + Vali kõik + Ruudustatud + Ekraanisäästja seadistused + Käivita ekraanisäästja + Kestus + Fotod + Kaasa tüübid + Sobita + Kadreeri + Täida + Täida laiuses + Täida kõrguses + Wholphini vaikeseadistus + Wholphini kompaktne vaade + Sarja pisipildid + Osa pisipildid + Vaikseim + Vaikne + Keskmine + Vali + Täisvaljus From 3c40a98f4cd8fab4d1ba9768e156322ef24a5f94 Mon Sep 17 00:00:00 2001 From: unclenet Date: Fri, 13 Mar 2026 09:07:59 +0000 Subject: [PATCH 037/148] Translated using Weblate (Italian) Currently translated at 100.0% (504 of 504 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/it/ --- app/src/main/res/values-it/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 596d898f..604a9ed5 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -569,4 +569,5 @@ Cerca %s Sei sicuro di voler cancellare questo elemento? Mostra opzioni di gestione dei media + Seleziona tutti From 94ff03236b95651e64f0651ecd1ac8e868340738 Mon Sep 17 00:00:00 2001 From: disguisedpsycho Date: Sat, 14 Mar 2026 03:06:12 +0000 Subject: [PATCH 038/148] Translated using Weblate (Hebrew) Currently translated at 21.2% (107 of 504 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/he/ --- app/src/main/res/values-iw/strings.xml | 115 ++++++++++++++++++++++++- 1 file changed, 113 insertions(+), 2 deletions(-) diff --git a/app/src/main/res/values-iw/strings.xml b/app/src/main/res/values-iw/strings.xml index 51d5978a..de80b56d 100644 --- a/app/src/main/res/values-iw/strings.xml +++ b/app/src/main/res/values-iw/strings.xml @@ -18,10 +18,10 @@ מסחרי דירוג קהילתי אירעה שגיאה! לחץ על הכפתור על מנת לשלוח יומני אירועים לשרת שלך. - לאשר + אשר המשך צפייה דירוג מבקרים - %.1f שניות + %.2f שניות ברירת מחדל מחק מת @@ -30,4 +30,115 @@ מושבת נולד בחר %1$s + בחר שרת + שרתים שנמצאו + מחפש… + הזן כתובת שרת + בחר משתמש + החלף שרתים + חיפוש + דף הבית + מועדפים + + סרטים + + + + + סדרות + + + + עונות + עונה + טריילר + + טריילרים + + + + כתובית + כתוביות + ממשק + התחבר אוטומטית + זכור כרטיסיות שנבחרו + מופעל + נמוך ביותר + נמוך + בינוני + גבוה + מלא + סגול + כתום + שחור + לבן + התחבר + שם משתמש + סיסמה + מוריד… + מסתיים ב-%1$s + הזן כתובת IP או URL + פרקים + חיצוני + גודל + ז\'אנרים + עבור לסדרה + עבור אל + הסתר + שגיאה בטעינת האוסף %1$s + פתיח + תכנים + עוד + שם + הבא בתור + אין נתונים + אין תוצאות + לא נמצאו שרתים + סיום + ללא + + אנשים + + + + נגן מכאן + נגן + מהירות ניגון + ניגון + פלייליסט + פלייליסטים + + הורדה %s + %s הורדות + %s הורדות + + + שעה %d + שעתיים + %d שעות + + + יום %s + יומיים + %s ימים + + + פריט %d + %d פריטים + %d פריטים + + + שניה %d + %d שניות + %d שניות + + + דקה %s + %s דקות + %s דקות + + סמן כלא נצפה + סמן כנצפה + מקסימום קצב נתונים + עוד דומים From cff89b418a1b9d372f776fbec827f622ccadf96f Mon Sep 17 00:00:00 2001 From: streamyfinchris Date: Sun, 15 Mar 2026 21:47:14 +0000 Subject: [PATCH 039/148] Translated using Weblate (Swedish) Currently translated at 75.1% (379 of 504 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/sv/ --- app/src/main/res/values-sv/strings.xml | 105 +++++++++++++++++++++++++ 1 file changed, 105 insertions(+) diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index cef8bb90..c84a46c3 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -322,4 +322,109 @@ Ingen gräns Lägg till rad Tryck bakåt för att avbryta + Längd + Orange + Blå + Bild med dynamisk färg + Bakgrundsstil + Bakgrundsstil + Kantstorlek + Koden måste vara 6 siffror + Ange Quick Connect-kod + Enheten har auktoriserats + Auktorisera en annan enhet att logga in på ditt konto + Nyligen släppt i %1$s + Grön + Profil + Snabbanslutning + Ta bort Seerr-server + Begär i 4K + Begär + Kom ihåg vald flik + Växling av uppdateringsfrekvens + Referensbilder + Röd + Växling av uppdateringsfrekvens + Försök igen + Tillåt omvisning i “Nästa upp” + API nyckel + Anpassa navigationsmenyn + Välj vilka objekt som ska visas som standard, övriga döljs + Växla sidor i navigationsmenyn vid fokus + Klicka för att växla sida + Vid slutet av uppspelningen + Under eftertexter/slutsekvens + Pixelformat + PIN måste vara minst 4 tecken + Skriv över inställningar på servern? + Skriv över lokala inställningar? + Seerr-integration + Skicka apploggar till aktuell server + Markera alla + Seerr-server tillagd + Logga in på Seerr-server + Lokal användare + Jellyfin användare + Steg för sökreglage + Sök %s + Skicka medieinformationslogg till server + Försöker skicka till senast anslutna server + Pågående + Visa alternativ för mediahantering + Visa titlar + Fråga om att hoppa över + Hoppa över automatiskt + Användbart för felsökning + + + Mättnad + Spara till album + Spara till serverns användarprofil + Samplingsfrekvens + Hz + Serverfel + Inget tal upptäcktes + Kunde inte starta röstigenkänning + Röstigenkänning tog för lång tid + Beteende för att hoppa över reklam + Hoppa tillbaka + Hoppa tillbaka vid återupptagen uppspelning + Ignorera + Beteende för att hoppa över intro + Beteende för att hoppa över outro + Beteende för att hoppa över förhandsvisningar + Beteende för att hoppa över repris + URL + Utförlig loggning + Kommande filmer + Kommande TV-serier + Webbadress som används för att kontrollera appuppdateringar + MPV är nu standardspelaren förutom för HDR. Du kan ändra detta i inställningarna. + Använd seriebild + Använd miniatyrbilder + Rader på startsidan + Allmänt + Gästskådespelare + HDR-undertextstil + Höjd + Opacitet för bildundertexter + Nyans + Miniatyr + Primär + Dirigent + Container + Beskär + Fyll höjd + Anpassa + Fyll bredd + Fyll + Anpassa startsidan + Välj rader och bilder på startsidan + Är du säker på att du vill ta bort detta objekt? + Upptäck + Wholphin Kompakt + Wholphin Standard + Visningsförinställningar + Inbyggda förinställningar för att snabbt styla alla rader + Avsnittsminiatyrer From b4f2258c5ed59a2000173223d3f37173db1c270b Mon Sep 17 00:00:00 2001 From: danyrd92 Date: Mon, 16 Mar 2026 02:29:06 +0000 Subject: [PATCH 040/148] Translated using Weblate (Spanish) Currently translated at 100.0% (504 of 504 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/es/ --- app/src/main/res/values-es/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 7f4a3dd9..d7841ebc 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -406,7 +406,7 @@ DD DD+ DTS - Reproducción directa Dolby Vision Profile 7 + Direct play Dolby Vision Profile 7 Ignora las comprobaciones de compatibilidad del dispositivo Descubrir Solicitar From 666aa3432f77d44831c7b0740326d3b38df37b12 Mon Sep 17 00:00:00 2001 From: ymir Date: Mon, 16 Mar 2026 08:10:52 +0000 Subject: [PATCH 041/148] Translated using Weblate (French) Currently translated at 100.0% (504 of 504 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/fr/ --- app/src/main/res/values-fr/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 6d61bd9d..05968332 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -569,4 +569,5 @@ Êtes-vous sûr de vouloir supprimer cet élément ? Afficher les options de gestion des médias Recherche %s + Tout sélectionner From c2ee2149911fc8fc3c4ac0e4702b840e5fea8011 Mon Sep 17 00:00:00 2001 From: oyvhov Date: Mon, 16 Mar 2026 17:05:11 +0000 Subject: [PATCH 042/148] Translated using Weblate (Norwegian Nynorsk) Currently translated at 66.2% (334 of 504 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/nn/ --- app/src/main/res/values-nn/strings.xml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/app/src/main/res/values-nn/strings.xml b/app/src/main/res/values-nn/strings.xml index b4826c5b..874236ff 100644 --- a/app/src/main/res/values-nn/strings.xml +++ b/app/src/main/res/values-nn/strings.xml @@ -359,4 +359,22 @@ Vel standardelementa som skal visast, andre vert skjulde Sluttar %1$s + Berre påtvinga undertekstar + Stemmesøk + Startar + Snakk for å søkje + Jobbar + Trykk tilbake for å avbryta + Lydopptaksfeil + Feil med talegjenkjenning + Treng mikrofontilgang + Nettverksfeil + Tidsavbrot på nettverket + Fann inga tale + Talegjenkjenning er oppteken + Tenarfeil + Inga tale funne + Ukjend feil + Klarte ikkje starta talegjenkjenning + Tidsavbrot for talegjenkjenning From 86b0228b960b138554eed0a2ae2f1fe8746267e0 Mon Sep 17 00:00:00 2001 From: oyvhov Date: Mon, 16 Mar 2026 17:10:52 +0000 Subject: [PATCH 043/148] Translated using Weblate (Norwegian Nynorsk) Currently translated at 66.2% (334 of 504 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/nn/ --- app/src/main/res/values-nn/strings.xml | 166 +++++++++++++++++++++++++ 1 file changed, 166 insertions(+) diff --git a/app/src/main/res/values-nn/strings.xml b/app/src/main/res/values-nn/strings.xml index 874236ff..12154c8a 100644 --- a/app/src/main/res/values-nn/strings.xml +++ b/app/src/main/res/values-nn/strings.xml @@ -377,4 +377,170 @@ Ukjend feil Klarte ikkje starta talegjenkjenning Tidsavbrot for talegjenkjenning + Prøv på nytt + Favoritt %s + Oppløysingsbyte + Lokal + Spel trailer + Ingen trailerar + Tøm sporval + DTS:X + DTS:HD + DTS:HD MA + TrueHD + DD + DD+ + DTS + Direkteavspeling av Dolby Vision-profil 7 + Ignorerer kompatibilitetssjekk på eininga + Oppdag + Be om + Ventar + Seerr-integrasjon + Fjern Seerr-tenar + Seerr-tenar lagt til + Hurtigkopling + Gi ei anna eining tilgang til å logge inn på kontoen din + Skriv inn hurtigkoplingskode + Koden må vere 6 siffer + Eininga fekk tilgang + Passord + Brukarnamn + URL + Populært no + Kommande filmar + Kommande TV-seriar + Be om i 4K + AV1-programvaredekoding + HDR-tekststil + Gjennomsiktigheit for bilettekst + Lysstyrke + Kontrast + Metning + Fargetone + Raud + Grøn + Blå + Sløring + Lagre til album + Spel lysbiletevising + Stopp lysbiletevising + Ved byrjinga + Ingen fleire bilete + Roter til venstre + Roter til høgre + Zoom inn + Zoom ut + Lengde på lysbiletevising + Spel videoar i lysbiletevisinga + Send logg med medieinfo til tenaren + Ingen grense + Maks dagar i Neste + Legg til rad + Sjangrar i %1$s + Nylig utgitt i %1$s + Høgde + Bruk på alle radene + Tilpass heim-sida + Rader på heim-sida + Last frå brukarprofil på tenaren + Lagre til brukarprofil på tenaren + Last frå nettklient + Bruk bilete av serien + Legg til rad for %1$s + Overskrive innstillingane på tenaren? + Overskrive lokale innstillingar? + For episodar + Forslag for %1$s + Auk storleiken på alle korta + Mink storleiken på alle korta + Bruk miniatyrbilete + Innstillingar lagra + Visingsval + Innebygde val for å raskt endre stilen på alle radene + Vel rader og bilete på heim-sida + Start etter + Animer + Maks aldersgrense + Ingen maksgrense + For alle aldrar + Opp til %s år + Skjermsparar + Innstillingar for skjermsparar + Start skjermsparar + Varigheit + Bilete + Inkluder typar + Kva typar element det skal visast bilete for + Tilpass + Beskjer + Fyll + Fyll breidde + Fyll høgde + Wholphin-standard + Wholphin-kompakt + Miniatyrbilete for seriar + Miniatyrbilete for episodar + Lågast + Låg + Middels + Høg + Full + Lilla + Oransje + Kraftig blå + Svart + Ignorer + Hopp over automatisk + Spør om å hoppe over + Berre bruk FFmpeg om det ikkje finst innebygd dekodar + Føretrekk FFmpeg framfor innebygde dekodarar + Aldri bruk FFmpeg-dekodarar + Ved slutten av avspelinga + Under rulletekst/outro + Kvit + Lysegrå + Mørkegrå + Gul + Cyan + Magenta + Omriss + Skugge + Omkransa + Rektangulær + Føretrekk MPV + Bruk ExoPlayer for HDR-avspeling + Plakat (2:3) + 16:9 + 4:3 + Kvadrat (1:1) + Primær + Miniatyr + Bilete med dynamisk farge + Berre bilete + + API-nøkkel + Logg inn på Seerr-tenar + Jellyfin-brukar + Lokal brukar + + Fann ingen eksterne undertekstar + No + Bruk skjermsparar i appen + Er du sikker på at du vil sletta dette elementet? + Vis alternativ for mediehandsaming + Skodespelar + Komponist + Forfattar + Gjestestjerne + Produsent + Dirigent + Tekstforfattar + Arrangør + Teknikar + Miksar + Skapar + Artist + Søk %s + Vel alle From 841b1bc71344a0b315da3372bad52dcb83b00668 Mon Sep 17 00:00:00 2001 From: American_Jesus Date: Mon, 16 Mar 2026 21:25:15 +0000 Subject: [PATCH 044/148] Translated using Weblate (Portuguese) Currently translated at 100.0% (504 of 504 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/pt/ --- app/src/main/res/values-pt/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 0866f814..89b399fa 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -569,4 +569,5 @@ Tem a certeza de que deseja eliminar este elemento? Mostrar opções de gestão de multimédia Pesquisar %s + Selecionar tudo From 6aba1c1561b05c90bf85882966eebf6047a642ec Mon Sep 17 00:00:00 2001 From: EmadAljohani Date: Tue, 17 Mar 2026 01:11:03 +0000 Subject: [PATCH 045/148] Translated using Weblate (Arabic) Currently translated at 28.1% (142 of 504 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/ar/ --- app/src/main/res/values-ar/strings.xml | 42 ++++++++++++++++++++------ 1 file changed, 32 insertions(+), 10 deletions(-) diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 327f969b..bab3e3d7 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -66,11 +66,11 @@ المزيد الأفلام - - - - - + + + + + الاسم التالي @@ -85,11 +85,11 @@ المسار الأشخاص - - - - - + + + + + عدد مرات التشغيل تشغيل من هنا @@ -128,4 +128,26 @@ خطأ في الشبكة انتهت مهلة الشبكة لم يتم التعرف على الكلام + التعرف على الصوت مشغول حالياً + خطأ في الخادم + لم يتم اكتشاف صوت + خطأ غير معروف + فشل بدء التعرف على الصوت + انتهت مهلة التعرف على الصوت + إعادة المحاولة + اختر الخادم + اختر المستخدم + إظهار معلومات تصحيح الأخطاء + إظهار التالي + إظهار + تشغيل عشوائي + تخطي + تاريخ الإضافة + تاريخ إضافة الحلقة + تاريخ التشغيل + تاريخ الإصدار + الاسم + عشوائي + استوديوهات + إرسال From 6b72746ee3fb6604eb94d36681d1b4f3bb1ee146 Mon Sep 17 00:00:00 2001 From: SimonHung Date: Sun, 22 Mar 2026 05:56:53 +0000 Subject: [PATCH 046/148] Translated using Weblate (Chinese (Traditional Han script)) Currently translated at 100.0% (504 of 504 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hant/ --- app/src/main/res/values-zh-rTW/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index c96c987f..bbcba3b2 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -533,4 +533,5 @@ 確定要刪除此項目嗎? 顯示媒體管理選項 搜尋 %s + 全選 From a05980a4a88ce79814474ed9194aa72ab0032e8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Priit=20J=C3=B5er=C3=BC=C3=BCt?= Date: Tue, 24 Mar 2026 09:40:46 +0000 Subject: [PATCH 047/148] Translated using Weblate (Estonian) Currently translated at 100.0% (504 of 504 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/et/ --- app/src/main/res/values-et/strings.xml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index cb7d2119..eab60eff 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -542,4 +542,14 @@ Keskmine Vali Täisvaljus + Käivita peale + Animeeri + Ülemine vanusepiirang + Puudub + Sobilik kõikidele vanustele + Kuni vanuseni %s + Ekraanisäästja + Lõputiitrite ja lõpuklipi ajal + Jätkuv sari + Mis tüüpi objekte peaks piltide kontekstis näitama From 0b0b93abf78234ca2dd05f2eb7e7fdc939e3039a Mon Sep 17 00:00:00 2001 From: danpergal84 Date: Tue, 24 Mar 2026 20:01:15 +0000 Subject: [PATCH 048/148] Translated using Weblate (Spanish) Currently translated at 100.0% (528 of 528 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/es/ --- app/src/main/res/values-es/strings.xml | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index d7841ebc..557d7966 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -570,4 +570,28 @@ Mostrar opciones de gestión de medios Buscar %s Seleccionar todo + Mostrar portada del álbum + Mostrar visualizador + Mostrar imagen de fondo + Reproducir en + Para visualizar el audio en reproducción, se requiere permiso para grabar audio. + Continuar + Álbumes + Artistas + Canciones + Ir al artista + Reproduciendo + Mezcla instantánea + Ir al álbum + Añadir a la cola + Artista del álbum + Álbum + Más escuchadas + Reproducir a continuación + Videoclips + Letras + Ocultar letras + Mostrar letras + Canción con letra + Eliminar de la cola From 259bb2bbf1a3092c8c0e0076f877745dd73df699 Mon Sep 17 00:00:00 2001 From: lednurb Date: Wed, 25 Mar 2026 14:59:55 +0000 Subject: [PATCH 049/148] Translated using Weblate (Dutch) Currently translated at 68.5% (362 of 528 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/nl/ --- app/src/main/res/values-nl/strings.xml | 68 +++++++++++++++++++++++--- 1 file changed, 60 insertions(+), 8 deletions(-) diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 7068d379..a60dcc91 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -18,11 +18,11 @@ Collectie Collecties Reclame - Gemeenschapsbeoordeling + Waardering publiek Er is een fout opgetreden. Druk op de knop om de logboeken naar je server te versturen. Bevestigen - Hervatten - Critici-beoordeling + Verder kijken + Waardering critici %.1f seconden Standaard Verwijderen @@ -40,7 +40,7 @@ Extern Favorieten Grootte - Afgedwongen + Geforceerd Genres Ga naar serie Ga naar @@ -64,7 +64,7 @@ Meer Films Naam - Hierna + Volgende Geen informatie Geen zoekresultaten Geen ingeplande opnames @@ -85,7 +85,7 @@ Profielinstellingen Wachtrij Samenvatten - Onlangs toegevoegd aan ‘%1$s’ + Onlangs toegevoegd aan %1$s Onlangs toegevoegd Onlangs opgenomen Onlangs uitgebracht @@ -130,7 +130,7 @@ Gids Seizoen Seizoenen - Tv-shows + Afleveringen Vormgeving Onbekend Updates @@ -151,7 +151,7 @@ Lettertype Achtergrond Voltooid - Kijkwijzerbeoordeling + Kijkwijzer Duur Extra\'s @@ -354,4 +354,56 @@ Favoriete kanalen eerst tonen Kanalen sorteren op onlangs bekeken Programma\'s voorzien van kleurcode + Eindigt om %1$s + Voer server-adres in + Ontdekken + Draai naar links + Draai naar rechts + Zoom uit + Negeren + Vraag om overslaan + Favoriete %s + Duur + Rij toevoegen + Alle leeftijden + Gebruikersnaam + Leeftijd tot %s + Verzadiging + Gastrol + Verwijder uit rij + Login Seerr-server + Zoek %s + Acteur + Afspeel voorkeuren wissen + Aflevering thumbnails + Auteur + Voor afleveringen + Start na + Toevoegen aan wachtrij + Componist + Serie thumbnails + Weet u zeker dat u dit item wilt verwijderen? + Instellingen opgeslagen + Ga door + Muziekvideos + Max. aantal dagen in Volgende + Wordt nu afgespeeld + Verzoek + Trailer afspelen + Geen servers gevonden + Alleen geforceerde ondertiteling + Opnieuw proberen + Vertraging ondertiteling + Geen trailers + In behandeling + Wachtwoord + Verwachte films + Verwachte series + Verzoek in 4K + Helderheid + Stuur media-info log naar server + Resolutie schakelen + Lokaal + Achtergrondstijl + Negeer compatibiliteitscontrole apparaat From a9f09888b99f38b1833be9203d5778501c31ccc9 Mon Sep 17 00:00:00 2001 From: Outbreak2096 Date: Wed, 25 Mar 2026 13:18:07 +0000 Subject: [PATCH 050/148] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (528 of 528 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hans/ --- app/src/main/res/values-zh-rCN/strings.xml | 26 +++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 0ee7e9fc..c6d7f903 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -83,7 +83,7 @@ 播放列表 预览 用户配置设置 - 队列 + 待播列表 前情提要 最近添加至 %1$s 最近添加 @@ -534,4 +534,28 @@ 是否确定要删除此项目? 搜索 %s 全选 + 专辑 + 音乐人 + 歌曲 + 前往音乐人 + 正在播放 + 即时混音 + 前往专辑 + 添加到待播列表 + 从待播列表中移除 + 专辑音乐人 + 专辑 + 热门歌曲 + 接下来播放 + 音乐视频 + 歌词 + 隐藏歌词 + 显示歌词 + 歌曲有歌词 + 显示专辑封面 + 显示可视化效果 + 显示背景幕 + 播放方式? + 可视化当前播放的音频需要录音权限。 + 继续 From 9a18750a056ba463d9c26a690e73fdb47cd7b341 Mon Sep 17 00:00:00 2001 From: idezentas Date: Tue, 24 Mar 2026 20:41:01 +0000 Subject: [PATCH 051/148] Translated using Weblate (Turkish) Currently translated at 100.0% (528 of 528 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/tr/ --- app/src/main/res/values-tr/strings.xml | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 2922f2df..ede526c0 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -561,4 +561,28 @@ Medya yönetim seçeneklerini göster Ara %s Hepsinin seç + Albümler + Sanatçılar + Şarkılar + Sanatçıya git + Şimdi oynatılıyor + Anlık mix + Albüme git + Kuyruğa ekle + Albüm sanatçısı + Albüm + Popüler şarkılar + Sonrakini oynat + Müzik Videoları + Şarkı sözü + Şarkı sözünü gizle + Şarkı sözünü göster + Şarkının sözleri var mı + Kuyruktan sil + Albüm kapağını göster + Görselleştiriciyi göster + Arka Planı göster + Nasıl çalınsın? + Çalan sesi görselleştirmek için ses kaydetme izni gerekiyor. + Devam et From cc8bf112c58041484e4e076b16e9fa08c837254c Mon Sep 17 00:00:00 2001 From: lednurb Date: Wed, 25 Mar 2026 20:16:55 +0000 Subject: [PATCH 052/148] Translated using Weblate (Dutch) Currently translated at 74.4% (393 of 528 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/nl/ --- app/src/main/res/values-nl/strings.xml | 57 ++++++++++++++++++++------ 1 file changed, 44 insertions(+), 13 deletions(-) diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index a60dcc91..0996afdf 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -2,7 +2,7 @@ Over Actieve opnames - Toev. aan favorieten + Aan favorieten toevoegen Server toevoegen Gebruiker toevoegen Audio @@ -18,7 +18,7 @@ Collectie Collecties Reclame - Waardering publiek + Waardering (publiek) Er is een fout opgetreden. Druk op de knop om de logboeken naar je server te versturen. Bevestigen Verder kijken @@ -47,7 +47,7 @@ Afspeelbediening verbergen Foutopsporingsinformatie verbergen Verbergen - Overzicht + Home Onmiddellijk Intro #ABCDEFGHIJKLMNOPQRSTUVWXYZ @@ -84,7 +84,7 @@ Voorvertoning Profielinstellingen Wachtrij - Samenvatten + Samenvatting Onlangs toegevoegd aan %1$s Onlangs toegevoegd Onlangs opgenomen @@ -92,13 +92,13 @@ Aanbevolen Programma opnemen Serie opnemen - Verw. uit favorieten + Uit favorieten verwijderen Herstarten Hervatten Opslaan Zoeken - Bezig met zoeken… + Aan het zoeken… Kies een server Kies een gebruiker Foutopsporingsinformatie tonen @@ -106,10 +106,10 @@ Tonen Willekeurig Overslaan - Toegevoegd op - Toegevoegd op (aflevering) - Bekeken op - Uitgebracht op + Toegevoegd + Toegevoegd (aflevering) + Bekeken + Uitgebracht Naam Willekeurig Studio\'s @@ -369,9 +369,9 @@ Gebruikersnaam Leeftijd tot %s Verzadiging - Gastrol - Verwijder uit rij - Login Seerr-server + Gast hoofdrol + Verwijder uit wachtrij + Login bij Seerr server Zoek %s Acteur Afspeel voorkeuren wissen @@ -406,4 +406,35 @@ Lokaal Achtergrondstijl Negeer compatibiliteitscontrole apparaat + Gesproken zoekopdracht + Spreek om te zoeken + Starten + Verwerken + Terugknop om te annuleren + Audio opnamefout + Spraakherkenningsfout + Toestemming microfoon vereist + Netwerkfout + Netwerk timeout + Geen spraak herkent + Spraakherkenning bezig + Serverfout + Geen spraak gedetecteerd + Onbekende fout + Fout bij starten spraakherkenning + Spraakherkenning timed out + Seerr integratie + Verwijder Seerr server + Seerr server toegevoegd + HDR ondertitelstijl + Dekking ondertitelbeeld + Zoom in + Geen limiet + Startscherm aanpassen + Startrijen + "Aanbevelingen voor %1$s" + Laagst + Automatisch overslaan + Dirigent + Producent From 52fbebb41f0a4d60cca05722a678a516d9985619 Mon Sep 17 00:00:00 2001 From: lednurb Date: Thu, 26 Mar 2026 19:33:32 +0000 Subject: [PATCH 053/148] Translated using Weblate (Dutch) Currently translated at 74.4% (393 of 528 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/nl/ --- app/src/main/res/values-nl/strings.xml | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 0996afdf..ee6c1bc4 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -18,11 +18,11 @@ Collectie Collecties Reclame - Waardering (publiek) + Beoordeling (publiek) Er is een fout opgetreden. Druk op de knop om de logboeken naar je server te versturen. Bevestigen Verder kijken - Waardering critici + Beoordeling (recensie) %.1f seconden Standaard Verwijderen @@ -30,13 +30,13 @@ Geregisseerd door %1$s Regisseur Uitgeschakeld - Aangetroffen servers + Gevonden servers Bezig met downloaden… Ingeschakeld - Voer het IP-adres of de url van de server in + Voer IP-adres of url van server in Afleveringen - ‘%1$s’ kan niet worden geladen + Fout bij laden %1$s collectie Extern Favorieten Grootte @@ -55,8 +55,8 @@ Licentie-informatie Live-tv Bezig met laden… - Wil je de gehele serie als bekeken markeren? - Wil je de gehele serie als niet-bekeken markeren? + Serie als bekeken markeren? + Serie als niet-bekeken markeren? Markeren als niet-bekeken Markeren als bekeken Max. bitsnelheid @@ -74,7 +74,7 @@ Locatie Personen Aantal keer bekeken - Hervatten vanaf hier + Vanaf hier afspelen Afspelen Foutopsporingsinformatie tonen Afspeelsnelheid @@ -144,7 +144,7 @@ Klok tonen Volgorde van uitgezonden afleveringen Afspeellijst maken - Toevoegen aan afspeellijst + Aan afspeellijst toevoegen Pauzeren met één druk op de knop Druk op de middelste knop van het d-pad om af te spelen/te pauzeren Cursief lettertype gebruiken @@ -388,7 +388,7 @@ Muziekvideos Max. aantal dagen in Volgende Wordt nu afgespeeld - Verzoek + Aanvragen Trailer afspelen Geen servers gevonden Alleen geforceerde ondertiteling From ba71f3df204bf8b7a03bf19589d2183965f8e0b9 Mon Sep 17 00:00:00 2001 From: North-DaCoder Date: Fri, 27 Mar 2026 10:06:22 +0000 Subject: [PATCH 054/148] Translated using Weblate (German) Currently translated at 100.0% (528 of 528 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/de/ --- app/src/main/res/values-de/strings.xml | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 75405328..2f975f33 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -552,4 +552,28 @@ Kasten Mischer Alle auswählen + Alben + Interpreten + Lieder + Zu Interpret wechseln + Läuft gerade + Sofortmix + Zu Album wechseln + Zur Warteschlange hinzufügen + Album Interpret + Album + Beliebte Lieder + Als nächstes abspielen + Musikvideos + Liedtexte + Liedtexte ausblenden + Liedtexte einblenden + Lied hat Liedtext + Aus Warteschlange entfernen + Album Cover anzeigen + Visualizer anzeigen + Hintergrundbild anzeigen + Abspielen als? + Die Visualisierung der aktuellen Wiedergabe erfordert die Berechtigung zur Audioaufzeichnung. + Fortfahren From 8daf91466049dc564bf0f3ace5b53b66dd89e5bc Mon Sep 17 00:00:00 2001 From: SimonHung Date: Fri, 27 Mar 2026 03:47:27 +0000 Subject: [PATCH 055/148] Translated using Weblate (Chinese (Traditional Han script)) Currently translated at 100.0% (528 of 528 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hant/ --- app/src/main/res/values-zh-rTW/strings.xml | 26 +++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index bbcba3b2..9680b274 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -25,7 +25,7 @@ 播放清單 預覽 個人化設定 - 佇列 + 待播佇列 最近新增 最近錄製 最近發行 @@ -534,4 +534,28 @@ 顯示媒體管理選項 搜尋 %s 全選 + 專輯 + 藝人 + 歌曲 + 前往藝人 + 正在播放 + 前往專輯 + 加入待播佇列 + 移出待播佇列 + 專輯藝人 + 專輯 + 熱門歌曲 + 下一首播放 + 音樂影片 + 歌詞 + 隱藏歌詞 + 顯示歌詞 + 歌曲有歌詞 + 顯示專輯封面 + 顯示視覺化效果 + 顯示背景 + 播放方式? + 啟用音樂視覺化需要錄音權限。 + 繼續 + 即時混播 From fd6d52c7ba5746a14b008437aac983ccc553c0a3 Mon Sep 17 00:00:00 2001 From: lednurb Date: Fri, 27 Mar 2026 10:27:58 +0000 Subject: [PATCH 056/148] Translated using Weblate (Dutch) Currently translated at 74.6% (394 of 528 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/nl/ --- app/src/main/res/values-nl/strings.xml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index ee6c1bc4..1afae518 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -60,7 +60,7 @@ Markeren als niet-bekeken Markeren als bekeken Max. bitsnelheid - Gerelateerd + Soortgelijk Meer Films Naam @@ -437,4 +437,8 @@ Automatisch overslaan Dirigent Producent + + %s dag + %s dagen + From 91f864ae53ba39e67fbcdfbfda3b915a494305fe Mon Sep 17 00:00:00 2001 From: gatisr Date: Fri, 27 Mar 2026 14:35:18 +0000 Subject: [PATCH 057/148] Added translation using Weblate (Latvian) --- app/src/main/res/values-lv/strings.xml | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 app/src/main/res/values-lv/strings.xml diff --git a/app/src/main/res/values-lv/strings.xml b/app/src/main/res/values-lv/strings.xml new file mode 100644 index 00000000..55344e51 --- /dev/null +++ b/app/src/main/res/values-lv/strings.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file From 65a9f4fdc16ae135622dc6ded1a61f7740535e86 Mon Sep 17 00:00:00 2001 From: gatisr Date: Fri, 27 Mar 2026 15:23:50 +0000 Subject: [PATCH 058/148] Translated using Weblate (Latvian) Currently translated at 37.3% (197 of 528 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/lv/ --- app/src/main/res/values-lv/strings.xml | 277 ++++++++++++++++++++++++- 1 file changed, 276 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-lv/strings.xml b/app/src/main/res/values-lv/strings.xml index 55344e51..df6225af 100644 --- a/app/src/main/res/values-lv/strings.xml +++ b/app/src/main/res/values-lv/strings.xml @@ -1,3 +1,278 @@ - \ No newline at end of file + Par + Aktīvie ieraksti + Izlase + Pievienot Serveri + Pievienot Lietotāju + Audio + Dzimšanas vieta + Bitu ātrums + Dzimis + Atcelt Ierakstu + Atcelt Seriālu Ierakstu + Atcelt + Nodaļas + Izvēlēties %1$s + Dzēst attēlu kešatmiņu + Kolekcija + Kolekcijas + Reklāma + Skatītāju vērtējums + Radās kļūda! Nospiediet pogu, lai sūtītu žurnālus uz savu serveri. + Apstiprināt + Turpināt skatīties + Kritiķu vērtējums + %.2f sekundes + Noklusējums + Dzēst + Miris + Režisējis %1$s + Režisors + Atslēgts + Atrastie serveri + + Lejupielādē… + Ieslēgts + Beigsies %1$s + Ievadi Servera IP vai URL + Ievadi servera adresi + Sērijas + Kļūda, ielādējot kolekciju %1$s + Ārējais + Izlases + Izmērs + Piespiedu + Žanri + Iet uz sērijām + Iet Uz + Paslēpt atskaņošanas kontroles + Paslēpt atkļūdošanas info + Paslēpt + Sākums + Tūlītēja + Ievads + Bibliotēka + Licences informācija + Tiešraides + Ielādē… + Atzīmēt visas sērijas kā redzētas? + Atzīmēt visas sērijas kā neredzētas? + Atzīmēt kā neredzētu + Atzīmēt kā redzētu + Maksimālais bitu ātrums + Līdzīgs saturs + Vairāk + + Filmas + + + + Nosaukums + Rindā + Nav datu + Nav rezultātu + Serveri netika atrasti + Nav ieplānoti ieraksti + Nav pieejami atjauninājumi + Neviens + Tikai Piespiedu Subtitri + Nobeigums + Ceļš + + Cilvēki + + + + Skatījumu skaits + Atskaņot no šejienes + Atskaņot + Rādīt atkļūdošanas informāciju + Atskaņošanas ātrums + Atskaņošana + Saraksts + Saraksti + Priekšskatījums + Lietotāja Profila Iestatījumi + Rinda + Atskats + Nesen pievienots sadaļā %1$s + Nesen pievienots + Nesenie Ieraksti + Nesen Iznācis + Ieteikumi + Ierakstīt Raidījumu + Ierakstīt Sērijas + Noņemt no izlases + Restartēt + Turpināt + Saglabāt + + Meklēt + Meklē… + Balss meklēšana + Uzsāk + Runā lai meklētu + Apstrādā + Spied atpakaļ lai atceltu + Audio ieraksta kļūda + Balss atpazīšanas kļūda + Mikrofona tiesību kļūda + Tīkla kļūda + Tīkla noilgums + Balss netika atpazīta + Balss atpazīšana aizņemta + Servera kļūda + Neviena balss netika atklāta + Nezināma kļūda + Kļūda sākot balss atpazīšanu + Balss atpazīšanai iestājies noilgums + Mēģināt vēlreiz + Izvēlies Serveri + Izvēlies Lietotāju + Rādīt atkļūdošanas info + Rādīt \"Skaties tālāk\" + Rādīt + Sajaukt + Izlaist + Pievienošanas Datums + Sērijas pievienošanas datums + Atskaņošanas Datums + Izdošanas Datums + Nosaukums + Nejaušs + Studija + Iesniegt + Lejupielāde ir lēna, iespējams jāmēģina restartēt video + Subtitri + Subtitri + Ieteikumi + Mainīt serverus + Pārslēgt + Augstāk novērtētie un neredzētie + Treileris + + Treileri + + + + Ierakstu plāns + Programma + Sezona + Sezonas + + TV Šovi + + + + Saskarne + Nezināms + Atjauninājumi + Versija + Video Mērogs + Video + Video + Skatīties tiešraidi + %1$d gadus vecs + Atskaņot ar transkodēšanu + Informācija par failu + Rādīt Pulksteni + Iznākušo Sēriju Secība + Izveidot jaunu izlasi + Pievienot izlasei + Apturēt ar vienu klikšķi + Nospiediet pults vidu, lai pauzētu/atskaņotu + Slīpraksts + Teksts + Fons + Veiksmīgi + Vecuma ierobežojums + Ilgums + Ekstras + + Cits + + + + + Aizkadri + + + + + Tituldziesmas + + + + + Titulvideo + + + + + Klipi + + + + + Izgrieztās ainas + + + + + Intervijas + + + + + Ainas + + + + + Paraugi + + + + + Īssižeti + + + + + Īsie rullīši + + + + Izlase %s + + %s lejupielāžu + %s lejupielāde + %s lejupielādes + + + %d stundas + %d stunda + %d stundas + + + %s dienas + %s diena + %s dienas + + + %d elementi + %d elements + %d elementi + + + %d sekundes + %d sekunde + %d sekundes + + + %s minūtes + %s minūte + %s minūtes + + From 8e2d4778cb71d336e0a287ca987ed99a2676c2ed Mon Sep 17 00:00:00 2001 From: danpergal84 Date: Sat, 28 Mar 2026 15:11:49 +0000 Subject: [PATCH 059/148] Translated using Weblate (Spanish) Currently translated at 100.0% (529 of 529 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/es/ --- app/src/main/res/values-es/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 557d7966..692546c9 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -594,4 +594,5 @@ Mostrar letras Canción con letra Eliminar de la cola + Separar por tipos From 4f519958ca975245c4e7d02a10a51c138788c3fe Mon Sep 17 00:00:00 2001 From: North-DaCoder Date: Sun, 29 Mar 2026 13:11:31 +0000 Subject: [PATCH 060/148] Translated using Weblate (German) Currently translated at 100.0% (529 of 529 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/de/ --- app/src/main/res/values-de/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 2f975f33..12c2f8ac 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -576,4 +576,5 @@ Abspielen als? Die Visualisierung der aktuellen Wiedergabe erfordert die Berechtigung zur Audioaufzeichnung. Fortfahren + Verschiedene Arten From e70beebaa0806982a364028aeea393930993dd07 Mon Sep 17 00:00:00 2001 From: Outbreak2096 Date: Sun, 29 Mar 2026 05:21:02 +0000 Subject: [PATCH 061/148] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (529 of 529 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hans/ --- app/src/main/res/values-zh-rCN/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index c6d7f903..de4c04c7 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -558,4 +558,5 @@ 播放方式? 可视化当前播放的音频需要录音权限。 继续 + 分开类型 From ce4408f30511ad73f17af55fb1f541fa7858bb99 Mon Sep 17 00:00:00 2001 From: alexq83 Date: Sun, 29 Mar 2026 18:18:54 +0000 Subject: [PATCH 062/148] Translated using Weblate (German) Currently translated at 100.0% (530 of 530 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/de/ --- app/src/main/res/values-de/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 12c2f8ac..707e119f 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -84,7 +84,7 @@ %1$s Wählen Bilder Cache leeren Community Bewertung - Es ist ein Fehler aufgetreten! Drücke den Knopf um Logs zu deinem Server zu senden. + Ein Fehler ist aufgetreten! Drücke die Taste um ein Logo an deinen Server zu senden. Regie von %1$s Deaktiviert Gefundene Server From 72fd1a4cb4798942e10cef27d1dbf8388326b73d Mon Sep 17 00:00:00 2001 From: Sk4hnt42 Date: Sun, 29 Mar 2026 18:36:19 +0000 Subject: [PATCH 063/148] Translated using Weblate (German) Currently translated at 100.0% (530 of 530 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/de/ --- app/src/main/res/values-de/strings.xml | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 707e119f..ba8f5cff 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -32,7 +32,7 @@ Keine geplanten Aufnahmen Kein Update verfügbar Pfad - Personen + Darsteller Zuletzt hinzugefügt in %1$s Zuletzt hinzugefügt Zuletzt aufgenommen @@ -132,21 +132,21 @@ Debug Infos anzeigen - Zufall + Zufällig Überspringen - Datum hinzugefügt - Datum Episode hinzugefügt - Datum abgespielt + Hinzugefügt am + Episode hinzugefügt am + Wiedergegeben am Erscheinungsdatum Zufällig - Absenden - Der Download benötigt eine längere Zeit, evtl. musst du die Wiedergabe erneut starten + Übermitteln + Herunterladen dauert sehr lange, vielleicht hilft ein neustart der Wiedergabe Untertitel Vorschläge Wechseln - Top Bewertet Ungesehen + Am besten bewertet ungesehen Fernsehprogramm - Mit Transkodierung abspielen + Mit Transcoding wiedergeben Uhr anzeigen Neue Playlist erstellen Zur Playlist hinzufügen @@ -190,8 +190,8 @@ Zeigen Name Studios - DVR Zeitplan - Videogröße + Aufnahmeplan + Video Skalierung Video Videos Nach Sendedatum sortiert From 4d510e772fe17478876b6997ef71fe70ac690f34 Mon Sep 17 00:00:00 2001 From: North-DaCoder Date: Sun, 29 Mar 2026 18:36:32 +0000 Subject: [PATCH 064/148] Translated using Weblate (German) Currently translated at 100.0% (530 of 530 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/de/ --- app/src/main/res/values-de/strings.xml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index ba8f5cff..26d7e23d 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -84,7 +84,7 @@ %1$s Wählen Bilder Cache leeren Community Bewertung - Ein Fehler ist aufgetreten! Drücke die Taste um ein Logo an deinen Server zu senden. + Ein Fehler ist aufgetreten! Drücke die Taste um Logs an deinen Server zu senden. Regie von %1$s Deaktiviert Gefundene Server @@ -140,7 +140,7 @@ Erscheinungsdatum Zufällig Übermitteln - Herunterladen dauert sehr lange, vielleicht hilft ein neustart der Wiedergabe + Herunterladen dauert sehr lange, vielleicht hilft ein Neustart der Wiedergabe Untertitel Vorschläge Wechseln @@ -223,7 +223,7 @@ - Shorts + Kurzvideo Kurzvideos Trifft nur auf Serien zu @@ -577,4 +577,5 @@ Die Visualisierung der aktuellen Wiedergabe erfordert die Berechtigung zur Audioaufzeichnung. Fortfahren Verschiedene Arten + Logos anstatt Titelnamen anzeigen From 058aed1f53bae4edc8445e6d96bf318d030b857a Mon Sep 17 00:00:00 2001 From: Lorixx Date: Sun, 29 Mar 2026 18:20:27 +0000 Subject: [PATCH 065/148] Translated using Weblate (German) Currently translated at 100.0% (530 of 530 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/de/ --- app/src/main/res/values-de/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 26d7e23d..fc7721d8 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -118,7 +118,7 @@ Als nächstes Keine Von hier wiedergeben - Wiedergeben + Abspielen Wiedergabegeschwindigkeit Wiedergabe Vorschau From 64ad27aabf758b7803c5f9ce259b4ff31e21e961 Mon Sep 17 00:00:00 2001 From: zyplex Date: Sun, 29 Mar 2026 18:35:29 +0000 Subject: [PATCH 066/148] Translated using Weblate (German) Currently translated at 100.0% (530 of 530 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/de/ --- app/src/main/res/values-de/strings.xml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index fc7721d8..cdda2507 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -439,7 +439,7 @@ Interlaced NAL - Gewähre anderen Geräten Zugriff auf deinen Account + Gewähre anderen Geräten Zugang zu deinem Account Eingabe Quick Connect Code Keine Beschränkung Reihe hinzufügen @@ -447,13 +447,13 @@ kürzlich veröffentlicht in %1$s Höhe Auf alle Reihen anwenden - Reihe hinzufügen für %1$s - Überschreibe Einstellungen auf dem Server? - Überschreibe lokale Einstellungen? + Füge Reihe für %1$s hinzu + Einstellungen auf dem Server übverschreiben? + Lokale Einstellungen überschreiben? Vorschläge für %1$s Sende Media Info Log zum Server Anpassung Homepage - Für Episoden + Für Folgen Vorlagen um schnell alle Reihen zu gestalten Wähle Reihen und Bilder auf der Homepage Starte anschließend From 9495cb4d014b10cd6448ffaa5f37a73ef4b35603 Mon Sep 17 00:00:00 2001 From: flipip Date: Sun, 29 Mar 2026 18:37:02 +0000 Subject: [PATCH 067/148] Translated using Weblate (German) Currently translated at 100.0% (530 of 530 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/de/ --- app/src/main/res/values-de/strings.xml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index cdda2507..c5b5ae89 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -223,8 +223,8 @@ - Kurzvideo - Kurzvideos + Short + Shorts Trifft nur auf Serien zu Direktes abspielen von ASS Untertitel @@ -440,8 +440,8 @@ Interlaced NAL Gewähre anderen Geräten Zugang zu deinem Account - Eingabe Quick Connect Code - Keine Beschränkung + Quick Connect-Code eingeben + Keine Begrenzung Reihe hinzufügen Genres in %1$s kürzlich veröffentlicht in %1$s @@ -542,7 +542,7 @@ Arrangeur Ingenieur Dunkelblau - Untertitel-Transparenz + Transparenz des Bilduntertitels Vorschaubild Serien Vorschaubilder verwenden From 51a8777b3085e61babeb8901755477665ffdd9a6 Mon Sep 17 00:00:00 2001 From: Sk4hnt42 Date: Sun, 29 Mar 2026 18:36:51 +0000 Subject: [PATCH 068/148] Translated using Weblate (German) Currently translated at 100.0% (530 of 530 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/de/ --- app/src/main/res/values-de/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index c5b5ae89..70aa278d 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -145,7 +145,7 @@ Vorschläge Wechseln Am besten bewertet ungesehen - Fernsehprogramm + Guide Mit Transcoding wiedergeben Uhr anzeigen Neue Playlist erstellen From 98e4ec08684d05d5810187b66dcb2fbfccb18147 Mon Sep 17 00:00:00 2001 From: danpergal84 Date: Mon, 30 Mar 2026 12:05:57 +0000 Subject: [PATCH 069/148] Translated using Weblate (Spanish) Currently translated at 100.0% (530 of 530 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/es/ --- app/src/main/res/values-es/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 692546c9..13147e59 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -595,4 +595,5 @@ Canción con letra Eliminar de la cola Separar por tipos + Preferir logotipos para los títulos From e0309fb38bf9712af4fbcd3b573d1547248c1eda Mon Sep 17 00:00:00 2001 From: American_Jesus Date: Mon, 30 Mar 2026 11:21:09 +0000 Subject: [PATCH 070/148] Translated using Weblate (Portuguese) Currently translated at 100.0% (530 of 530 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/pt/ --- app/src/main/res/values-pt/strings.xml | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 89b399fa..7e7fa408 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -570,4 +570,30 @@ Mostrar opções de gestão de multimédia Pesquisar %s Selecionar tudo + Álbuns + Artistas + Músicas + Ir para o artista + A reproduzir + Mistura instantânea + Ir para o álbum + Adicionar à fila + Artista do álbum + Álbum + Mais ouvidas + Reproduzir a seguir + Videoclips + Letras + Esconder letras + Mostrar letras + Música contêm letras + Remover da fila + Mostrar capa do álbum + Mostrar visualizador + Mostrar imagem de fundo + Reproduzir como? + Para visualizar o áudio que está a ser reproduzido, é necessária autorização para gravar áudio. + Continuar + Separar por tipos + Prefiro mostrar logótipos nos títulos From 84435f713b87a347dde64612de98727b56ad6061 Mon Sep 17 00:00:00 2001 From: n8llcaster Date: Sun, 29 Mar 2026 17:57:07 +0000 Subject: [PATCH 071/148] Translated using Weblate (Slovak) Currently translated at 95.2% (505 of 530 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/sk/ --- app/src/main/res/values-sk/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/values-sk/strings.xml b/app/src/main/res/values-sk/strings.xml index 6b933215..fb441889 100644 --- a/app/src/main/res/values-sk/strings.xml +++ b/app/src/main/res/values-sk/strings.xml @@ -593,4 +593,5 @@ Autorizujte iné zariadenie na prihlásenie do vášho účtu Odoslať log informácií o médiách na server Max. počet dní v sekcií Nasleduje + Albumy From 6a3b45371bf9536da17f19691108d3e435289aab Mon Sep 17 00:00:00 2001 From: SimonHung Date: Sun, 29 Mar 2026 18:40:25 +0000 Subject: [PATCH 072/148] Translated using Weblate (Chinese (Traditional Han script)) Currently translated at 100.0% (530 of 530 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hant/ --- app/src/main/res/values-zh-rTW/strings.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 9680b274..ecb15f67 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -558,4 +558,6 @@ 啟用音樂視覺化需要錄音權限。 繼續 即時混播 + 依類型分開顯示 + 優先顯示圖像標題 From 2cabc0cdbafe3556f10aaa41d4c608df5212bf6f Mon Sep 17 00:00:00 2001 From: gatisr Date: Mon, 30 Mar 2026 06:54:57 +0000 Subject: [PATCH 073/148] Translated using Weblate (Latvian) Currently translated at 37.1% (197 of 530 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/lv/ --- app/src/main/res/values-lv/strings.xml | 62 +++++++++++++------------- 1 file changed, 31 insertions(+), 31 deletions(-) diff --git a/app/src/main/res/values-lv/strings.xml b/app/src/main/res/values-lv/strings.xml index df6225af..92cefdbe 100644 --- a/app/src/main/res/values-lv/strings.xml +++ b/app/src/main/res/values-lv/strings.xml @@ -65,8 +65,8 @@ Vairāk Filmas - - + + Nosaukums Rindā @@ -81,8 +81,8 @@ Ceļš Cilvēki - - + + Skatījumu skaits Atskaņot no šejienes @@ -100,7 +100,7 @@ Nesen pievienots Nesenie Ieraksti Nesen Iznācis - Ieteikumi + Ieteicamie Ierakstīt Raidījumu Ierakstīt Sērijas Noņemt no izlases @@ -153,8 +153,8 @@ Treileris Treileri - - + + Ierakstu plāns Programma @@ -162,8 +162,8 @@ Sezonas TV Šovi - - + + Saskarne Nezināms @@ -191,58 +191,58 @@ Ekstras Cits - - + + Aizkadri - - + + Tituldziesmas - - + + Titulvideo - - + + Klipi - - + + Izgrieztās ainas - - + + Intervijas - - + + Ainas - - + + Paraugi - - + + Īssižeti - - + + Īsie rullīši - - + + Izlase %s From 5158675d2be0a419365366ef25e3935a73dd5fd7 Mon Sep 17 00:00:00 2001 From: Pwnisher Date: Mon, 30 Mar 2026 14:21:39 +0000 Subject: [PATCH 074/148] Added translation using Weblate (Romanian) --- app/src/main/res/values-ro/strings.xml | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 app/src/main/res/values-ro/strings.xml diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml new file mode 100644 index 00000000..55344e51 --- /dev/null +++ b/app/src/main/res/values-ro/strings.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file From 6f855fe4a31538fcc0d0453c64993f9ccba53c52 Mon Sep 17 00:00:00 2001 From: Pwnisher Date: Mon, 30 Mar 2026 15:35:15 +0000 Subject: [PATCH 075/148] Translated using Weblate (Romanian) Currently translated at 51.3% (272 of 530 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/ro/ --- app/src/main/res/values-ro/strings.xml | 308 ++++++++++++++++++++++++- 1 file changed, 307 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index 55344e51..b99bf40b 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -1,3 +1,309 @@ - \ No newline at end of file + Despre + Favorite + Adaugă server + Adaugă utilizator + Audio + Locul nașterii + Născut + Anulează înregistrarea + Anulează + Capitole + Alege %1$s + Colecție + Colecții + Se descarcă… + Regizat de %1$s + Golește memoria cache a imaginilor + Confirmă + Regizor + Favorite + Se încheie la %1$s + Introdu adresa IP sau URL-ul + Introdu adresa serverului + Episoade + Eroare la încărcarea colecției %1$s + Extern + Dimensiune + Activat + Șterge + Continuă vizionarea + %.2f secunde + Dezactivat + Genuri + Introducere + #ABCDEFGHIJKLMNOPQRSTUVWXYZ + Nume + În continuare + Nu există rezultate + Nu s-au găsit servere + Nu sunt înregistrări programate + Nu este disponibilă nicio actualizare + + Filme + + + + Librărie + Mergi la + Mergi la seria + Se încarcă… + Marchez întreaga serie ca vizionată? + Marchez întreaga serie ca nevizionată? + Marchează ca nevizionat + Marchează ca vizionat + + Persoane + + + + Număr de redări + Mai mult + Acasă + Nu există date + Încheiere + Cale + Mai multe articole similare + Ascunde + Limbă + Rezoluție + Titlu + Coloane + Confirmă PIN + Dolby Vision + Dolby Atmos + Introdu PIN + Conectare automată + PIN-ul trebuie sa aibă 4 sau mai multe caractere + Anulezi modificările? + An + Decadă + Filtru + Redat + Opțiuni MPV + Opțiuni ExoPlayer + Modifică mpv.conf + Spațiere + Rată de aspect + Înregistrare detaliată + Tip imagine + + Vedete invitate + Nivel + Canale + Da + Nu + SDR + HDR + HDR10 + HDR10+ + HLG + Arată titlurile + Repetă + Afișează mai întâi canalele favorite + Sortează canalele după vizionarea recentă + Decalare subitrare + Descoperă + Redă trailer + Fără trailere + Integrare Seerr + Elimină serverul Seerr + Serverul Seer adăugat + Conectare rapidă + Autorizează un alt dispozitiv să se conecteze la contul tău + Introdu codul de conectare rapidă + Codul trebuie să aibă 6 cifre + Dispozitiv autorizat cu success + Parolă + Nume utilizator + Adresă URL + În tendințe + Filme viitoare + Seriale viitoare + Solicită în format 4K + Luminozitate + Contrast + Saturație + Nuanță + Roșu + Verde + Albastru + Estompare + Salvează pentru album + Albume + Artiști + Cântece + Mergi la artist + Se redă acum + Mix instant + Mergi la album + Adaugă în coadă + Adaugă rând + Genuri în %1$s + Lansate recent în %1$s + Înălțime + Aplică la toate rândurile + Personalizează pagina de pornire + Pentru episoade + Sugestii pentru %1$s + Mărește dimensiunea tuturor cardurilor + Micșorează dimensiunea tuturor cardurilor + Setări salvate + Durată + Fotografii + Cel mai scăzut + Scăzut + Mediu + Ridicat + Maxim + Violet + Portocaliu + Alb + Gri deschis + Gri închis + Galben + Cian + Magenta + Contur + Umbră + Preferă MPV + Folosește ExoPlayer pentru redare HDR + Pătrat (1:1) + Cheie API + Conectează-te la serverul Seerr + Servere descoperite + + Reclamă + Evaluarea comunității + A apărut o eroare! Apasă butonul pentru a trimite jurnalele către serverul tău. + Implicit + Decedat + Înregistrări active + Evaluarea criticilor + Ascunde comenzile de redare + TV în direct + Redă + Recapitulare + Adăugat recent + Înregistrat recent + Lansat recent + Recomandat + Liste de redare + Previzualizare + Setări profil utilizator + Coadă + Adăugat recent în %1$s + Caută + Se caută… + Căutare vocală + Se procesează + Alege server + Alege utilizator + Sări peste + Reîncearcă + Arată + Studiouri + Trimite + Descărcarea durează mult; s-ar putea să fie necesar să repornești redarea + Subtitrare + Subtitrări + Sugestii + Schimbă serverele + Schimbă + Sezon + Sezoane + + Seriale TV + + + + Interfață + Necunoscut + Actualizări + Versiune + Arată ceasul + %1$d ani vechime + Ghid + Pauză cu un click + + Interviuri + + + + Fundal + + %s descărcare + %s descărcări + %s descărcări + + + %d oră + %d ore + %d ore + + + %s zi + %s zile + %s zile + + + %d articol + %d articole + %d articole + + + %d secundă + %d secunde + %d secunde + + + %s minut + %s minute + %s minute + + Verifică dacă sunt actualizări + Instalează actualizarea + Versiune instalată + Setări + Dimensiune font + Culoare font + Opacitate font + Stil margine + Culoare fundal + Resetare + Actualizare disponibilă + URL folosit pentru a căuta actualizări + URL actualizare + Trimite rapoarte de eroare + Stil subtitrare + Stil fundal + Sări peste introducere + Sări peste reclame + Sări peste previzualizare + Sări peste recapitulare + Incorect + Folosește utilizator/parolă + Solicitare + În așteptare + MPV este acum playerul implicit, cu excepția conținutului HDR.\nPoți modifica aceasta din setări. + Stilul subtitrărilor HDR + Rotește la dreapta + Pentru toate vârstele + Până la vârsta de %s + Economizor de ecran + Setări economizor de ecran + Sări automat + Cere să sari peste + Utilizator Jellyfin + Utilizator local + + Compozitor + Scriitor + Versuri + Ascunde versurile + Afișează versurile + Cântecul are versuri + Selectează tot + Redă ca? + From ca43baa5310cfaa27d5a01510f272d7dca592d99 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Mon, 30 Mar 2026 17:08:30 -0400 Subject: [PATCH 076/148] Release v0.6.0 From 4ea21be4307abeac459ee5a45269b7175ac4e42c Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Tue, 31 Mar 2026 18:10:40 -0400 Subject: [PATCH 077/148] Move native builds to wholphin-extensions (#1172) ## Description This PR moves the native components (ffmpeg decoder, av1 decoder, & `libmpv`) to https://github.com/damontecres/wholphin-extensions. They can be retrieved as gradle dependencies now. This allows for contributors to more easily include these extensions when building Wholphin locally. There are no user facing changes in this PR. Note: the kotlin code for interfacing with `libmpv`, ie `MPVLib` & `MpvPlayer`, is still in this repo for now. ### Related issues Related to #828 ### Testing Emulator ## Screenshots N/A ## AI or LLM usage None --- .github/actions/native-build/action.yml | 82 ----------- .github/workflows/main.yml | 7 +- .github/workflows/pr.yml | 4 - .github/workflows/release.yml | 8 +- DEVELOPMENT.md | 16 +-- app/build.gradle.kts | 28 +++- app/src/main/jni/Android.mk | 82 ----------- app/src/main/jni/Application.mk | 17 --- app/src/main/jni/README.md | 16 --- app/src/main/jni/event.cpp | 121 ---------------- app/src/main/jni/event.h | 3 - app/src/main/jni/globals.h | 7 - app/src/main/jni/jni_utils.cpp | 52 ------- app/src/main/jni/jni_utils.h | 30 ---- app/src/main/jni/log.cpp | 9 -- app/src/main/jni/log.h | 20 --- app/src/main/jni/main.cpp | 108 -------------- app/src/main/jni/property.cpp | 125 ---------------- app/src/main/jni/render.cpp | 38 ----- app/src/main/jni/thumbnail.cpp | 133 ----------------- gradle/libs.versions.toml | 4 + scripts/ffmpeg/README.md | 14 -- scripts/ffmpeg/build_ffmpeg_decoder.sh | 97 ------------- scripts/mpv/README.md | 22 --- scripts/mpv/buildall.sh | 180 ------------------------ scripts/mpv/get_dependencies.sh | 75 ---------- scripts/mpv/include/ci.sh | 91 ------------ scripts/mpv/include/depinfo.sh | 43 ------ scripts/mpv/include/path.sh | 32 ----- scripts/mpv/scripts/dav1d.sh | 22 --- scripts/mpv/scripts/ffmpeg.sh | 48 ------- scripts/mpv/scripts/freetype2.sh | 21 --- scripts/mpv/scripts/fribidi.sh | 22 --- scripts/mpv/scripts/harfbuzz.sh | 22 --- scripts/mpv/scripts/libass.sh | 25 ---- scripts/mpv/scripts/libplacebo.sh | 25 ---- scripts/mpv/scripts/lua.sh | 44 ------ scripts/mpv/scripts/mbedtls.sh | 22 --- scripts/mpv/scripts/mpv-android.sh | 77 ---------- scripts/mpv/scripts/mpv.sh | 30 ---- scripts/mpv/scripts/unibreak.sh | 24 ---- settings.gradle.kts | 8 ++ 42 files changed, 48 insertions(+), 1806 deletions(-) delete mode 100644 .github/actions/native-build/action.yml delete mode 100644 app/src/main/jni/Android.mk delete mode 100644 app/src/main/jni/Application.mk delete mode 100644 app/src/main/jni/README.md delete mode 100644 app/src/main/jni/event.cpp delete mode 100644 app/src/main/jni/event.h delete mode 100644 app/src/main/jni/globals.h delete mode 100644 app/src/main/jni/jni_utils.cpp delete mode 100644 app/src/main/jni/jni_utils.h delete mode 100644 app/src/main/jni/log.cpp delete mode 100644 app/src/main/jni/log.h delete mode 100644 app/src/main/jni/main.cpp delete mode 100644 app/src/main/jni/property.cpp delete mode 100644 app/src/main/jni/render.cpp delete mode 100644 app/src/main/jni/thumbnail.cpp delete mode 100644 scripts/ffmpeg/README.md delete mode 100755 scripts/ffmpeg/build_ffmpeg_decoder.sh delete mode 100644 scripts/mpv/README.md delete mode 100755 scripts/mpv/buildall.sh delete mode 100755 scripts/mpv/get_dependencies.sh delete mode 100755 scripts/mpv/include/ci.sh delete mode 100755 scripts/mpv/include/depinfo.sh delete mode 100755 scripts/mpv/include/path.sh delete mode 100755 scripts/mpv/scripts/dav1d.sh delete mode 100755 scripts/mpv/scripts/ffmpeg.sh delete mode 100755 scripts/mpv/scripts/freetype2.sh delete mode 100755 scripts/mpv/scripts/fribidi.sh delete mode 100755 scripts/mpv/scripts/harfbuzz.sh delete mode 100755 scripts/mpv/scripts/libass.sh delete mode 100755 scripts/mpv/scripts/libplacebo.sh delete mode 100755 scripts/mpv/scripts/lua.sh delete mode 100755 scripts/mpv/scripts/mbedtls.sh delete mode 100755 scripts/mpv/scripts/mpv-android.sh delete mode 100755 scripts/mpv/scripts/mpv.sh delete mode 100755 scripts/mpv/scripts/unibreak.sh diff --git a/.github/actions/native-build/action.yml b/.github/actions/native-build/action.yml deleted file mode 100644 index 770e552a..00000000 --- a/.github/actions/native-build/action.yml +++ /dev/null @@ -1,82 +0,0 @@ -name: "Native build" -description: "Performs native builds" -inputs: - cache: - description: "Whether to use the cache or not" - required: true -runs: - using: "composite" - steps: - - name: Load ffmpeg module cache - id: cache_ffmpeg_module - if: ${{ inputs.cache }} - uses: actions/cache/restore@v5 - with: - path: | - app/libs - key: ${{ runner.os }}-ffmpeg-${{ hashFiles('**/libs.versions.toml', '**/scripts/ffmpeg/build_ffmpeg_decoder.sh') }} - - name: Load libmpv module cache - id: cache_libmpv_module - if: ${{ inputs.cache }} - uses: actions/cache/restore@v5 - with: - path: | - app/src/main/libs - key: ${{ runner.os }}-libmpv-${{ hashFiles('**/scripts/mpv/*.sh', '**/scripts/mpv/include/*', '**/scripts/mpv/scripts/*', 'app/src/main/jni/*') }} - - name: Install dependencies - if: steps.cache_ffmpeg_module.outputs.cache-hit != 'true' || steps.cache_libmpv_module.outputs.cache-hit != 'true' - shell: bash - run: | - python -m pip install --upgrade pip - pip install jsonschema jinja2 - sudo apt update - sudo apt install -y build-essential autoconf pkg-config libtool ninja-build unzip wget meson nasm - - -# ffmpeg - - name: Build ffmpeg decoder - id: ffmpeg-decoder - if: steps.cache_ffmpeg_module.outputs.cache-hit != 'true' - shell: bash - run: | - cd scripts/ffmpeg - ./build_ffmpeg_decoder.sh "${{ env.ANDROID_SDK_ROOT }}/ndk/${{ env.NDK_VERSION }}" - - name: Save ffmpeg module cache - id: cache_ffmpeg_module_save - uses: actions/cache/save@v5 - with: - path: | - app/libs - key: ${{ steps.cache_ffmpeg_module.outputs.cache-primary-key }} - -# libmpv - - name: Get libmpv dependencies - if: steps.cache_libmpv_module.outputs.cache-hit != 'true' - shell: bash - run: | - cd scripts/mpv - ./get_dependencies.sh - - name: Build libmpv dependencies - if: steps.cache_libmpv_module.outputs.cache-hit != 'true' - shell: bash - run: | - cd scripts/mpv - ./buildall.sh --clean --arch arm64 mpv - ./buildall.sh mpv - cd ../.. - env PREFIX32="$(realpath scripts/mpv/prefix/armv7l)" PREFIX64="$(realpath scripts/mpv/prefix/arm64)" ndk-build -C app/src/main -j - - - name: Setup jniLibs - shell: bash - run: | - cd app/src/main - mkdir -p jniLibs - cp -r libs/* jniLibs - #ln -s libs jniLibs - - name: Save libmpv module cache - id: cache_libmpv_module_save - uses: actions/cache/save@v5 - with: - path: | - app/src/main/libs - key: ${{ steps.cache_libmpv_module.outputs.cache-primary-key }} diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 9583525d..390b1232 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -30,10 +30,6 @@ jobs: fetch-depth: 0 # Need the tags to build - name: Setup uses: ./.github/actions/setup - - name: Native build - uses: ./.github/actions/native-build - with: - cache: true - name: Get version names run: | @@ -55,8 +51,11 @@ jobs: KEY_PASSWORD: "${{ secrets.KEY_PASSWORD }}" KEY_STORE_PASSWORD: "${{ secrets.KEY_STORE_PASSWORD }}" SIGNING_KEY: "${{ secrets.SIGNING_KEY }}" + ORG_GRADLE_PROJECT_WholphinExtensionsUsername: "${{ secrets.EXTENSIONS_USERNAME }}" + ORG_GRADLE_PROJECT_WholphinExtensionsPassword: "${{ secrets.EXTENSIONS_PASSWORD }}" run: | ./gradlew clean assembleRelease assembleDebug --no-daemon + - name: Verify signatures run: | echo "Verify APK signatures" diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 0b0f2204..dc518c87 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -36,10 +36,6 @@ jobs: fetch-depth: 0 # Need the tags to build - name: Setup uses: ./.github/actions/setup - - name: Native build - uses: ./.github/actions/native-build - with: - cache: true - name: Build app id: buildapp run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9611c810..466aa8b1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,10 +26,6 @@ jobs: fetch-depth: 0 # Need the tags to build - name: Setup uses: ./.github/actions/setup - - name: Native build - uses: ./.github/actions/native-build - with: - cache: false - name: Build app id: buildapp env: @@ -37,6 +33,8 @@ jobs: KEY_PASSWORD: "${{ secrets.KEY_PASSWORD }}" KEY_STORE_PASSWORD: "${{ secrets.KEY_STORE_PASSWORD }}" SIGNING_KEY: "${{ secrets.SIGNING_KEY }}" + ORG_GRADLE_PROJECT_WholphinExtensionsUsername: "${{ secrets.EXTENSIONS_USERNAME }}" + ORG_GRADLE_PROJECT_WholphinExtensionsPassword: "${{ secrets.EXTENSIONS_PASSWORD }}" run: | ./gradlew clean assembleRelease --no-daemon @@ -47,6 +45,8 @@ jobs: KEY_PASSWORD: "${{ secrets.KEY_PASSWORD }}" KEY_STORE_PASSWORD: "${{ secrets.KEY_STORE_PASSWORD }}" SIGNING_KEY: "${{ secrets.SIGNING_KEY }}" + ORG_GRADLE_PROJECT_WholphinExtensionsUsername: "${{ secrets.EXTENSIONS_USERNAME }}" + ORG_GRADLE_PROJECT_WholphinExtensionsPassword: "${{ secrets.EXTENSIONS_PASSWORD }}" run: | git apply app/src/patches/play_store.patch ./gradlew bundleRelease --no-daemon diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 390b2d63..21d41e3e 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -43,21 +43,11 @@ Code is split into several packages: - `ui` - User interface code and ViewModels - `util` - Utility classes and functions -### Native components +## Extensions -#### FFmpeg decoder module +Wholphin uses several native components for extra playback compatibility. This includes Media3 ffmpeg/av1 decoders and `libmpv`. These extensions are not required to build the app, but without them some functionality will not work. -Wholphin ships with [media3 ffmpeg decoder module](https://github.com/androidx/media/blob/release/libraries/decoder_ffmpeg/README.md). - -It is not required to build the extension in order to build the app locally. - -You can build the module on MacOS or Linux with the [`build_ffmpeg_decoder.sh`](./scripts/ffmpeg/build_ffmpeg_decoder.sh) script. - -#### MPV player backend - -Wholphin has a playback engine that uses [`libmpv`](https://github.com/mpv-player/mpv). The app uses JNI code from [`mpv-android`](https://github.com/mpv-android/mpv-android) and has an implementation of `androidx.media3.common.Player` to swap out for `ExoPlayer`. - -See the [build scripts](scripts/mpv/) for details on building this component. +If you want to include these in a local build, see the [instructions here](https://github.com/damontecres/wholphin-extensions?tab=readme-ov-file#usage) for configuring the repository. ### App settings diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ea0b0a9f..cbda6b36 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -21,6 +21,8 @@ val isCI = if (System.getenv("CI") != null) System.getenv("CI").toBoolean() else val shouldSign = isCI && System.getenv("KEY_ALIAS") != null val ffmpegModuleExists = project.file("libs/lib-decoder-ffmpeg-release.aar").exists() val av1ModuleExists = project.file("libs/lib-decoder-av1-release.aar").exists() +val mpvModuleExists = project.file("libs/wholphin-mpv-release.aar").exists() +val extensionsRepoActive = project.hasProperty("WholphinExtensionsUsername") val gitTags = providers @@ -288,11 +290,33 @@ dependencies { debugImplementation(libs.androidx.compose.ui.tooling) debugImplementation(libs.androidx.compose.ui.test.manifest) coreLibraryDesugaring(libs.desugar.jdk.libs) - if (ffmpegModuleExists || isCI) { + + if (ffmpegModuleExists) { + logger.info("Using local ffmpeg decoder") implementation(files("libs/lib-decoder-ffmpeg-release.aar")) + } else if (extensionsRepoActive) { + logger.info("Using prebuilt ffmpeg decoder") + implementation(libs.wholphin.extensions.ffmpeg) + } else { + logger.warn("Media3 ffmpeg decoder was NOT found") } - if (av1ModuleExists || isCI) { + if (av1ModuleExists) { + logger.info("Using local av1 decoder") implementation(files("libs/lib-decoder-av1-release.aar")) + } else if (extensionsRepoActive) { + logger.info("Using prebuilt av1 decoder") + implementation(libs.wholphin.extensions.av1) + } else { + logger.warn("Media3 av1 decoder was NOT found") + } + if (mpvModuleExists) { + logger.info("Using local libMPV build") + implementation(files("libs/wholphin-mpv-release.aar")) + } else if (extensionsRepoActive) { + logger.info("Using prebuilt libMPV") + implementation(libs.wholphin.extensions.mpv) + } else { + logger.warn("libMPV was NOT found") } testImplementation(libs.mockk.android) diff --git a/app/src/main/jni/Android.mk b/app/src/main/jni/Android.mk deleted file mode 100644 index 798ae9a2..00000000 --- a/app/src/main/jni/Android.mk +++ /dev/null @@ -1,82 +0,0 @@ -LOCAL_PATH:= $(call my-dir) - -ifeq ($(TARGET_ARCH_ABI),armeabi-v7a) -PREFIX = $(PREFIX32) -endif -ifeq ($(TARGET_ARCH_ABI),arm64-v8a) -PREFIX = $(PREFIX64) -endif -ifeq ($(TARGET_ARCH_ABI),x86_64) -PREFIX = $(PREFIX_X64) -endif -ifeq ($(TARGET_ARCH_ABI),x86) -PREFIX = $(PREFIX_X86) -endif - -include $(CLEAR_VARS) -LOCAL_MODULE := libswresample -LOCAL_SRC_FILES := $(PREFIX)/lib/$(LOCAL_MODULE).so -include $(PREBUILT_SHARED_LIBRARY) - -include $(CLEAR_VARS) -LOCAL_MODULE := libpostproc -LOCAL_SRC_FILES := $(PREFIX)/lib/$(LOCAL_MODULE).so -# only include if library file exists -ifneq (,$(wildcard $(LOCAL_SRC_FILES))) -include $(PREBUILT_SHARED_LIBRARY) -endif - -include $(CLEAR_VARS) -LOCAL_MODULE := libavutil -LOCAL_SRC_FILES := $(PREFIX)/lib/$(LOCAL_MODULE).so -include $(PREBUILT_SHARED_LIBRARY) - -include $(CLEAR_VARS) -LOCAL_MODULE := libavcodec -LOCAL_SRC_FILES := $(PREFIX)/lib/$(LOCAL_MODULE).so -include $(PREBUILT_SHARED_LIBRARY) - -include $(CLEAR_VARS) -LOCAL_MODULE := libavformat -LOCAL_SRC_FILES := $(PREFIX)/lib/$(LOCAL_MODULE).so -include $(PREBUILT_SHARED_LIBRARY) - -include $(CLEAR_VARS) -LOCAL_MODULE := libswscale -LOCAL_SRC_FILES := $(PREFIX)/lib/$(LOCAL_MODULE).so -include $(PREBUILT_SHARED_LIBRARY) - -include $(CLEAR_VARS) -LOCAL_MODULE := libavfilter -LOCAL_SRC_FILES := $(PREFIX)/lib/$(LOCAL_MODULE).so -include $(PREBUILT_SHARED_LIBRARY) - -include $(CLEAR_VARS) -LOCAL_MODULE := libavdevice -LOCAL_SRC_FILES := $(PREFIX)/lib/$(LOCAL_MODULE).so -LOCAL_EXPORT_C_INCLUDES := $(PREFIX)/include -include $(PREBUILT_SHARED_LIBRARY) - -include $(CLEAR_VARS) -LOCAL_MODULE := libmpv -LOCAL_SRC_FILES := $(PREFIX)/lib/libmpv.so -LOCAL_EXPORT_C_INCLUDES := $(PREFIX)/include -include $(PREBUILT_SHARED_LIBRARY) - -include $(CLEAR_VARS) - -LOCAL_MODULE := libplayer -LOCAL_CFLAGS := -Werror -LOCAL_CPPFLAGS += -std=c++11 -LOCAL_SRC_FILES := \ - main.cpp \ - render.cpp \ - log.cpp \ - jni_utils.cpp \ - property.cpp \ - event.cpp \ - thumbnail.cpp -LOCAL_LDLIBS := -llog -lGLESv3 -lEGL -latomic -LOCAL_SHARED_LIBRARIES := swscale avcodec mpv - -include $(BUILD_SHARED_LIBRARY) diff --git a/app/src/main/jni/Application.mk b/app/src/main/jni/Application.mk deleted file mode 100644 index 53418cb4..00000000 --- a/app/src/main/jni/Application.mk +++ /dev/null @@ -1,17 +0,0 @@ -APP_ABI := -ifneq ($(PREFIX32),) -APP_ABI += armeabi-v7a -endif -ifneq ($(PREFIX64),) -APP_ABI += arm64-v8a -endif -ifneq ($(PREFIX_X64),) -APP_ABI += x86_64 -endif -ifneq ($(PREFIX_X86),) -APP_ABI += x86 -endif - -APP_PLATFORM := android-21 -APP_STL := c++_shared -APP_SUPPORT_FLEXIBLE_PAGE_SIZES := true diff --git a/app/src/main/jni/README.md b/app/src/main/jni/README.md deleted file mode 100644 index 15d82972..00000000 --- a/app/src/main/jni/README.md +++ /dev/null @@ -1,16 +0,0 @@ -# JNI bindings for libmpv - -The files in this directory are copied from https://github.com/mpv-android/mpv-android/tree/master/app/src/main/jni - -## License & copyright - -The files are copyrighted and used under MIT license below: - -Copyright (c) 2016 Ilya Zhuravlev -Copyright (c) 2016 sfan5 - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/app/src/main/jni/event.cpp b/app/src/main/jni/event.cpp deleted file mode 100644 index 26c69300..00000000 --- a/app/src/main/jni/event.cpp +++ /dev/null @@ -1,121 +0,0 @@ -#include - -#include - -#include "globals.h" -#include "jni_utils.h" -#include "log.h" - -static void sendPropertyUpdateToJava(JNIEnv *env, mpv_event_property *prop) -{ - jstring jprop = env->NewStringUTF(prop->name); - jstring jvalue = NULL; - switch (prop->format) { - case MPV_FORMAT_NONE: - env->CallStaticVoidMethod(mpv_MPVLib, mpv_MPVLib_eventProperty_S, jprop); - break; - case MPV_FORMAT_FLAG: - env->CallStaticVoidMethod(mpv_MPVLib, mpv_MPVLib_eventProperty_Sb, jprop, - (jboolean) (*(int*)prop->data != 0)); - break; - case MPV_FORMAT_INT64: - env->CallStaticVoidMethod(mpv_MPVLib, mpv_MPVLib_eventProperty_Sl, jprop, - (jlong) *(int64_t*)prop->data); - break; - case MPV_FORMAT_DOUBLE: - env->CallStaticVoidMethod(mpv_MPVLib, mpv_MPVLib_eventProperty_Sd, jprop, - (jdouble) *(double*)prop->data); - break; - case MPV_FORMAT_STRING: - jvalue = env->NewStringUTF(*(const char**)prop->data); - env->CallStaticVoidMethod(mpv_MPVLib, mpv_MPVLib_eventProperty_SS, jprop, jvalue); - break; - default: - ALOGV("sendPropertyUpdateToJava: Unknown property update format received in callback: %d!", prop->format); - break; - } - if (jprop) - env->DeleteLocalRef(jprop); - if (jvalue) - env->DeleteLocalRef(jvalue); -} - -static void sendEventToJava(JNIEnv *env, int event) -{ - env->CallStaticVoidMethod(mpv_MPVLib, mpv_MPVLib_event, event); -} - -static void sendEndFileEventToJava(JNIEnv *env, mpv_event_end_file *event) -{ - env->CallStaticVoidMethod(mpv_MPVLib, mpv_MPVLib_end_file_event, event->reason, event->error); -} - -static void sendLogMessageToJava(JNIEnv *env, mpv_event_log_message *msg) -{ - // filter the most obvious cases of invalid utf-8, since Java would choke on it - const auto invalid_utf8 = [] (unsigned char c) { - return c == 0xc0 || c == 0xc1 || c >= 0xf5; - }; - for (int i = 0; msg->text[i]; i++) { - if (invalid_utf8(static_cast(msg->text[i]))) - return; - } - - jstring jprefix = env->NewStringUTF(msg->prefix); - jstring jtext = env->NewStringUTF(msg->text); - - env->CallStaticVoidMethod(mpv_MPVLib, mpv_MPVLib_logMessage_SiS, - jprefix, (jint) msg->log_level, jtext); - - if (jprefix) - env->DeleteLocalRef(jprefix); - if (jtext) - env->DeleteLocalRef(jtext); -} - -void *event_thread(void *arg) -{ - JNIEnv *env = NULL; - acquire_jni_env(g_vm, &env); - if (!env) - die("failed to acquire java env"); - - while (1) { - mpv_event *mp_event; - mpv_event_property *mp_property = NULL; - mpv_event_log_message *msg = NULL; - mpv_event_end_file *mp_end_file = NULL; - - mp_event = mpv_wait_event(g_mpv, -1.0); - - if (g_event_thread_request_exit) - break; - - if (mp_event->event_id == MPV_EVENT_NONE) - continue; - - switch (mp_event->event_id) { - case MPV_EVENT_LOG_MESSAGE: - msg = (mpv_event_log_message*)mp_event->data; - ALOGV("[%s:%s] %s", msg->prefix, msg->level, msg->text); - sendLogMessageToJava(env, msg); - break; - case MPV_EVENT_PROPERTY_CHANGE: - mp_property = (mpv_event_property*)mp_event->data; - sendPropertyUpdateToJava(env, mp_property); - break; - case MPV_EVENT_END_FILE: - mp_end_file = (mpv_event_end_file*)mp_event->data; - sendEndFileEventToJava(env, mp_end_file); - break; - default: - ALOGV("event: %s\n", mpv_event_name(mp_event->event_id)); - sendEventToJava(env, mp_event->event_id); - break; - } - } - - g_vm->DetachCurrentThread(); - - return NULL; -} diff --git a/app/src/main/jni/event.h b/app/src/main/jni/event.h deleted file mode 100644 index ebb20d2d..00000000 --- a/app/src/main/jni/event.h +++ /dev/null @@ -1,3 +0,0 @@ -#pragma once - -void *event_thread(void *arg); diff --git a/app/src/main/jni/globals.h b/app/src/main/jni/globals.h deleted file mode 100644 index e2427b8b..00000000 --- a/app/src/main/jni/globals.h +++ /dev/null @@ -1,7 +0,0 @@ -#pragma once - -#include - -extern JavaVM *g_vm; -extern mpv_handle *g_mpv; -extern std::atomic g_event_thread_request_exit; diff --git a/app/src/main/jni/jni_utils.cpp b/app/src/main/jni/jni_utils.cpp deleted file mode 100644 index f878fdff..00000000 --- a/app/src/main/jni/jni_utils.cpp +++ /dev/null @@ -1,52 +0,0 @@ -#define UTIL_EXTERN -#include "jni_utils.h" - -#include -#include - -bool acquire_jni_env(JavaVM *vm, JNIEnv **env) -{ - int ret = vm->GetEnv((void**) env, JNI_VERSION_1_6); - if (ret == JNI_EDETACHED) - return vm->AttachCurrentThread(env, NULL) == 0; - else - return ret == JNI_OK; -} - -// Apparently it's considered slow to FindClass and GetMethodID every time we need them, -// so let's have a nice cache here. - -void init_methods_cache(JNIEnv *env) -{ - static bool methods_initialized = false; - if (methods_initialized) - return; - - #define FIND_CLASS(name) reinterpret_cast(env->NewGlobalRef(env->FindClass(name))) - java_Integer = FIND_CLASS("java/lang/Integer"); - java_Integer_init = env->GetMethodID(java_Integer, "", "(I)V"); - java_Double = FIND_CLASS("java/lang/Double"); - java_Double_init = env->GetMethodID(java_Double, "", "(D)V"); - java_Boolean = FIND_CLASS("java/lang/Boolean"); - java_Boolean_init = env->GetMethodID(java_Boolean, "", "(Z)V"); - - android_graphics_Bitmap = FIND_CLASS("android/graphics/Bitmap"); - // createBitmap(int[], int, int, android.graphics.Bitmap$Config) - android_graphics_Bitmap_createBitmap = env->GetStaticMethodID(android_graphics_Bitmap, "createBitmap", "([IIILandroid/graphics/Bitmap$Config;)Landroid/graphics/Bitmap;"); - android_graphics_Bitmap_Config = FIND_CLASS("android/graphics/Bitmap$Config"); - // static final android.graphics.Bitmap$Config ARGB_8888 - android_graphics_Bitmap_Config_ARGB_8888 = env->GetStaticFieldID(android_graphics_Bitmap_Config, "ARGB_8888", "Landroid/graphics/Bitmap$Config;"); - - mpv_MPVLib = FIND_CLASS("com/github/damontecres/wholphin/util/mpv/MPVLib"); - mpv_MPVLib_eventProperty_S = env->GetStaticMethodID(mpv_MPVLib, "eventProperty", "(Ljava/lang/String;)V"); // eventProperty(String) - mpv_MPVLib_eventProperty_Sb = env->GetStaticMethodID(mpv_MPVLib, "eventProperty", "(Ljava/lang/String;Z)V"); // eventProperty(String, boolean) - mpv_MPVLib_eventProperty_Sl = env->GetStaticMethodID(mpv_MPVLib, "eventProperty", "(Ljava/lang/String;J)V"); // eventProperty(String, long) - mpv_MPVLib_eventProperty_Sd = env->GetStaticMethodID(mpv_MPVLib, "eventProperty", "(Ljava/lang/String;D)V"); // eventProperty(String, double) - mpv_MPVLib_eventProperty_SS = env->GetStaticMethodID(mpv_MPVLib, "eventProperty", "(Ljava/lang/String;Ljava/lang/String;)V"); // eventProperty(String, String) - mpv_MPVLib_event = env->GetStaticMethodID(mpv_MPVLib, "event", "(I)V"); // event(int) - mpv_MPVLib_end_file_event = env->GetStaticMethodID(mpv_MPVLib, "eventEndFile", "(II)V"); // eventEndFile(int, int) - mpv_MPVLib_logMessage_SiS = env->GetStaticMethodID(mpv_MPVLib, "logMessage", "(Ljava/lang/String;ILjava/lang/String;)V"); // logMessage(String, int, String) - #undef FIND_CLASS - - methods_initialized = true; -} diff --git a/app/src/main/jni/jni_utils.h b/app/src/main/jni/jni_utils.h deleted file mode 100644 index ed9d025d..00000000 --- a/app/src/main/jni/jni_utils.h +++ /dev/null @@ -1,30 +0,0 @@ -#pragma once - -#include - -#define jni_func_name(name) Java_com_github_damontecres_wholphin_util_mpv_MPVLib_##name -#define jni_func(return_type, name, ...) JNIEXPORT return_type JNICALL jni_func_name(name) (JNIEnv *env, jobject obj, ##__VA_ARGS__) - -bool acquire_jni_env(JavaVM *vm, JNIEnv **env); -void init_methods_cache(JNIEnv *env); - -#ifndef UTIL_EXTERN -#define UTIL_EXTERN extern -#endif - -UTIL_EXTERN jclass java_Integer, java_Double, java_Boolean; -UTIL_EXTERN jmethodID java_Integer_init, java_Double_init, java_Boolean_init; - -UTIL_EXTERN jclass android_graphics_Bitmap, android_graphics_Bitmap_Config; -UTIL_EXTERN jmethodID android_graphics_Bitmap_createBitmap; -UTIL_EXTERN jfieldID android_graphics_Bitmap_Config_ARGB_8888; - -UTIL_EXTERN jclass mpv_MPVLib; -UTIL_EXTERN jmethodID mpv_MPVLib_eventProperty_S, - mpv_MPVLib_eventProperty_Sb, - mpv_MPVLib_eventProperty_Sl, - mpv_MPVLib_eventProperty_Sd, - mpv_MPVLib_eventProperty_SS, - mpv_MPVLib_event, - mpv_MPVLib_end_file_event, - mpv_MPVLib_logMessage_SiS; diff --git a/app/src/main/jni/log.cpp b/app/src/main/jni/log.cpp deleted file mode 100644 index 240af1bc..00000000 --- a/app/src/main/jni/log.cpp +++ /dev/null @@ -1,9 +0,0 @@ -#include "log.h" - -#include - -void die(const char *msg) -{ - ALOGE("%s", msg); - exit(1); -} diff --git a/app/src/main/jni/log.h b/app/src/main/jni/log.h deleted file mode 100644 index bef941b2..00000000 --- a/app/src/main/jni/log.h +++ /dev/null @@ -1,20 +0,0 @@ -#pragma once - -#include - -#define DEBUG 0 - -#define LOG_TAG "mpv" -#define ALOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__) -#if DEBUG -#define ALOGV(...) __android_log_print(ANDROID_LOG_VERBOSE, LOG_TAG, __VA_ARGS__) -#else -#define ALOGV(...) (void)0 -#endif - -__attribute__((noreturn)) void die(const char *msg); - -#define CHECK_MPV_INIT() do { \ - if (__builtin_expect(!g_mpv, 0)) \ - die("libmpv is not initialized"); \ - } while (0) diff --git a/app/src/main/jni/main.cpp b/app/src/main/jni/main.cpp deleted file mode 100644 index 5f3faedc..00000000 --- a/app/src/main/jni/main.cpp +++ /dev/null @@ -1,108 +0,0 @@ -#include -#include -#include -#include -#include -#include - -#include - -#include - -extern "C" { - #include -} - -#include "log.h" -#include "jni_utils.h" -#include "event.h" - -#define ARRAYLEN(a) (sizeof(a)/sizeof(a[0])) - -extern "C" { - jni_func(void, create, jobject appctx); - jni_func(void, init); - jni_func(void, destroy); - - jni_func(void, command, jobjectArray jarray); -}; - -JavaVM *g_vm; -mpv_handle *g_mpv; -std::atomic g_event_thread_request_exit(false); - -static pthread_t event_thread_id; - -static void prepare_environment(JNIEnv *env, jobject appctx) { - setlocale(LC_NUMERIC, "C"); - - if (!env->GetJavaVM(&g_vm) && g_vm) - av_jni_set_java_vm(g_vm, NULL); - - jobject global_appctx = env->NewGlobalRef(appctx); - if (global_appctx) - av_jni_set_android_app_ctx(global_appctx, NULL); - - init_methods_cache(env); -} - -jni_func(void, create, jobject appctx) { - prepare_environment(env, appctx); - - if (g_mpv) - die("mpv is already initialized"); - - g_mpv = mpv_create(); - if (!g_mpv) - die("context init failed"); - - // use terminal log level but request verbose messages - // this way --msg-level can be used to adjust later - mpv_request_log_messages(g_mpv, "terminal-default"); - mpv_set_option_string(g_mpv, "msg-level", "all=v"); -} - -jni_func(void, init) { - if (!g_mpv) - die("mpv is not created"); - - if (mpv_initialize(g_mpv) < 0) - die("mpv init failed"); - - g_event_thread_request_exit = false; - if (pthread_create(&event_thread_id, NULL, event_thread, NULL) != 0) - die("thread create failed"); - pthread_setname_np(event_thread_id, "event_thread"); -} - -jni_func(void, destroy) { - if (!g_mpv) { - ALOGV("mpv destroy called but it's already destroyed"); - return; - } - - // poke event thread and wait for it to exit - g_event_thread_request_exit = true; - mpv_wakeup(g_mpv); - pthread_join(event_thread_id, NULL); - - mpv_terminate_destroy(g_mpv); - g_mpv = NULL; -} - -jni_func(void, command, jobjectArray jarray) { - CHECK_MPV_INIT(); - - const char *arguments[128] = {0}; - int len = env->GetArrayLength(jarray); - if (len >= ARRAYLEN(arguments)) - die("too many command arguments"); - - for (int i = 0; i < len; ++i) - arguments[i] = env->GetStringUTFChars((jstring)env->GetObjectArrayElement(jarray, i), NULL); - - mpv_command(g_mpv, arguments); - - for (int i = 0; i < len; ++i) - env->ReleaseStringUTFChars((jstring)env->GetObjectArrayElement(jarray, i), arguments[i]); -} diff --git a/app/src/main/jni/property.cpp b/app/src/main/jni/property.cpp deleted file mode 100644 index a9558ab6..00000000 --- a/app/src/main/jni/property.cpp +++ /dev/null @@ -1,125 +0,0 @@ -#include -#include - -#include - -#include "jni_utils.h" -#include "log.h" -#include "globals.h" - -extern "C" { - jni_func(jint, setOptionString, jstring option, jstring value); - - jni_func(jobject, getPropertyInt, jstring property); - jni_func(void, setPropertyInt, jstring property, jint value); - jni_func(jobject, getPropertyDouble, jstring property); - jni_func(void, setPropertyDouble, jstring property, jdouble value); - jni_func(jobject, getPropertyBoolean, jstring property); - jni_func(void, setPropertyBoolean, jstring property, jboolean value); - jni_func(jstring, getPropertyString, jstring jproperty); - jni_func(void, setPropertyString, jstring jproperty, jstring jvalue); - - jni_func(void, observeProperty, jstring property, jint format); -} - -jni_func(jint, setOptionString, jstring joption, jstring jvalue) { - CHECK_MPV_INIT(); - - const char *option = env->GetStringUTFChars(joption, NULL); - const char *value = env->GetStringUTFChars(jvalue, NULL); - - int result = mpv_set_option_string(g_mpv, option, value); - - env->ReleaseStringUTFChars(joption, option); - env->ReleaseStringUTFChars(jvalue, value); - - return result; -} - -static int common_get_property(JNIEnv *env, jstring jproperty, mpv_format format, void *output) -{ - CHECK_MPV_INIT(); - - const char *prop = env->GetStringUTFChars(jproperty, NULL); - int result = mpv_get_property(g_mpv, prop, format, output); - if (result == MPV_ERROR_PROPERTY_UNAVAILABLE) - ALOGV("mpv_get_property(%s) format %d was unavailable", prop, format); - else if (result < 0) - ALOGE("mpv_get_property(%s) format %d returned error %s", prop, format, mpv_error_string(result)); - env->ReleaseStringUTFChars(jproperty, prop); - - return result; -} - -static int common_set_property(JNIEnv *env, jstring jproperty, mpv_format format, void *value) -{ - CHECK_MPV_INIT(); - - const char *prop = env->GetStringUTFChars(jproperty, NULL); - int result = mpv_set_property(g_mpv, prop, format, value); - if (result < 0) - ALOGE("mpv_set_property(%s, %p) format %d returned error %s", prop, value, format, mpv_error_string(result)); - env->ReleaseStringUTFChars(jproperty, prop); - - return result; -} - -jni_func(jobject, getPropertyInt, jstring jproperty) { - int64_t value = 0; - if (common_get_property(env, jproperty, MPV_FORMAT_INT64, &value) < 0) - return NULL; - return env->NewObject(java_Integer, java_Integer_init, (jint)value); -} - -jni_func(jobject, getPropertyDouble, jstring jproperty) { - double value = 0; - if (common_get_property(env, jproperty, MPV_FORMAT_DOUBLE, &value) < 0) - return NULL; - return env->NewObject(java_Double, java_Double_init, (jdouble)value); -} - -jni_func(jobject, getPropertyBoolean, jstring jproperty) { - int value = 0; - if (common_get_property(env, jproperty, MPV_FORMAT_FLAG, &value) < 0) - return NULL; - return env->NewObject(java_Boolean, java_Boolean_init, (jboolean)value); -} - -jni_func(jstring, getPropertyString, jstring jproperty) { - char *value; - if (common_get_property(env, jproperty, MPV_FORMAT_STRING, &value) < 0) - return NULL; - jstring jvalue = env->NewStringUTF(value); - mpv_free(value); - return jvalue; -} - -jni_func(void, setPropertyInt, jstring jproperty, jint jvalue) { - int64_t value = static_cast(jvalue); - common_set_property(env, jproperty, MPV_FORMAT_INT64, &value); -} - -jni_func(void, setPropertyDouble, jstring jproperty, jdouble jvalue) { - double value = static_cast(jvalue); - common_set_property(env, jproperty, MPV_FORMAT_DOUBLE, &value); -} - -jni_func(void, setPropertyBoolean, jstring jproperty, jboolean jvalue) { - int value = jvalue == JNI_TRUE ? 1 : 0; - common_set_property(env, jproperty, MPV_FORMAT_FLAG, &value); -} - -jni_func(void, setPropertyString, jstring jproperty, jstring jvalue) { - const char *value = env->GetStringUTFChars(jvalue, NULL); - common_set_property(env, jproperty, MPV_FORMAT_STRING, &value); - env->ReleaseStringUTFChars(jvalue, value); -} - -jni_func(void, observeProperty, jstring property, jint format) { - CHECK_MPV_INIT(); - const char *prop = env->GetStringUTFChars(property, NULL); - int result = mpv_observe_property(g_mpv, 0, prop, (mpv_format)format); - if (result < 0) - ALOGE("mpv_observe_property(%s) format %d returned error %s", prop, format, mpv_error_string(result)); - env->ReleaseStringUTFChars(property, prop); -} diff --git a/app/src/main/jni/render.cpp b/app/src/main/jni/render.cpp deleted file mode 100644 index 1df2bb5a..00000000 --- a/app/src/main/jni/render.cpp +++ /dev/null @@ -1,38 +0,0 @@ -#include - -#include - -#include "jni_utils.h" -#include "log.h" -#include "globals.h" - -extern "C" { - jni_func(void, attachSurface, jobject surface_); - jni_func(void, detachSurface); -}; - -static jobject surface; - -jni_func(void, attachSurface, jobject surface_) { - CHECK_MPV_INIT(); - - surface = env->NewGlobalRef(surface_); - if (!surface) - die("invalid surface provided"); - int64_t wid = reinterpret_cast(surface); - int result = mpv_set_option(g_mpv, "wid", MPV_FORMAT_INT64, &wid); - if (result < 0) - ALOGE("mpv_set_option(wid) returned error %s", mpv_error_string(result)); -} - -jni_func(void, detachSurface) { - CHECK_MPV_INIT(); - - int64_t wid = 0; - int result = mpv_set_option(g_mpv, "wid", MPV_FORMAT_INT64, &wid); - if (result < 0) - ALOGE("mpv_set_option(wid) returned error %s", mpv_error_string(result)); - - env->DeleteGlobalRef(surface); - surface = NULL; -} diff --git a/app/src/main/jni/thumbnail.cpp b/app/src/main/jni/thumbnail.cpp deleted file mode 100644 index d1156f8e..00000000 --- a/app/src/main/jni/thumbnail.cpp +++ /dev/null @@ -1,133 +0,0 @@ -#include -#include - -#include -#include -#include - -extern "C" { - #include -}; - -#include "jni_utils.h" -#include "globals.h" -#include "log.h" - -extern "C" { - jni_func(jobject, grabThumbnail, jint dimension); -}; - -static inline mpv_node make_node_str(const char *s) -{ - mpv_node r{}; - r.format = MPV_FORMAT_STRING; - r.u.string = const_cast(s); - return r; -} - -jni_func(jobject, grabThumbnail, jint dimension) { - CHECK_MPV_INIT(); - - mpv_node result{}; - { - mpv_node c{}, c_args[2]; - mpv_node_list c_array{}; - c_args[0] = make_node_str("screenshot-raw"); - c_args[1] = make_node_str("video"); - c_array.num = 2; - c_array.values = c_args; - c.format = MPV_FORMAT_NODE_ARRAY; - c.u.list = &c_array; - if (mpv_command_node(g_mpv, &c, &result) < 0) { - ALOGE("screenshot-raw command failed"); - return NULL; - } - } - - // extract relevant property data from the node map mpv returns - int w = 0, h = 0, stride = 0; - bool format_ok = false; - struct mpv_byte_array *data = NULL; - do { - if (result.format != MPV_FORMAT_NODE_MAP) - break; - for (int i = 0; i < result.u.list->num; i++) { - std::string key(result.u.list->keys[i]); - const mpv_node *val = &result.u.list->values[i]; - if (key == "w" || key == "h" || key == "stride") { - if (val->format != MPV_FORMAT_INT64) - break; - if (key == "w") - w = val->u.int64; - else if (key == "h") - h = val->u.int64; - else - stride = val->u.int64; - } else if (key == "format") { - if (val->format != MPV_FORMAT_STRING) - break; - format_ok = !strcmp(val->u.string, "bgr0"); - } else if (key == "data") { - if (val->format != MPV_FORMAT_BYTE_ARRAY) - break; - data = val->u.ba; - } - } - } while (0); - if (!w || !h || !stride || !format_ok || !data) { - ALOGE("extracting data failed"); - mpv_free_node_contents(&result); - return NULL; - } - ALOGV("screenshot w:%d h:%d stride:%d", w, h, stride); - - // crop to square - int crop_left = 0, crop_top = 0; - int new_w = w, new_h = h; - if (w > h) { - crop_left = (w - h) / 2; - new_w = h; - } else { - crop_top = (h - w) / 2; - new_h = w; - } - ALOGV("cropped w:%u h:%u", new_w, new_h); - - uint8_t *new_data = reinterpret_cast(data->data); - new_data += crop_left * sizeof(uint32_t); // move begin rightwards - new_data += stride * crop_top; // move begin downwards - - // convert & scale to appropriate size - struct SwsContext *ctx = sws_getContext( - new_w, new_h, AV_PIX_FMT_BGR0, - dimension, dimension, AV_PIX_FMT_RGB32, - SWS_BICUBIC, NULL, NULL, NULL); - if (!ctx) { - mpv_free_node_contents(&result); - return NULL; - } - - jintArray arr = env->NewIntArray(dimension * dimension); - jint *scaled = env->GetIntArrayElements(arr, NULL); - - uint8_t *src_p[4] = { new_data }, *dst_p[4] = { (uint8_t*) scaled }; - int src_stride[4] = { stride }, - dst_stride[4] = { (int) sizeof(jint) * dimension }; - sws_scale(ctx, src_p, src_stride, 0, new_h, dst_p, dst_stride); - sws_freeContext(ctx); - - mpv_free_node_contents(&result); // frees data->data - - // create android.graphics.Bitmap - env->ReleaseIntArrayElements(arr, scaled, 0); - - jobject bitmap_config = - env->GetStaticObjectField(android_graphics_Bitmap_Config, android_graphics_Bitmap_Config_ARGB_8888); - jobject bitmap = - env->CallStaticObjectMethod(android_graphics_Bitmap, android_graphics_Bitmap_createBitmap, - arr, dimension, dimension, bitmap_config); - env->DeleteLocalRef(arr); - env->DeleteLocalRef(bitmap_config); - - return bitmap; -} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c462981e..49b8b45a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -44,8 +44,12 @@ kotlinxCoroutinesTest = "1.10.2" coreTesting = "2.2.0" openapi-generator = "7.21.0" runner = "1.7.0" +wholphin-extensions = "0.1.0" [libraries] +wholphin-extensions-mpv = { module = "com.github.damontecres.wholphin.mpv:wholphin-mpv", version.ref = "wholphin-extensions" } +wholphin-extensions-ffmpeg = { module = "com.github.damontecres.wholphin.media3:decoder-ffmpeg", version.ref = "wholphin-extensions" } +wholphin-extensions-av1 = { module = "com.github.damontecres.wholphin.media3:decoder-av1", version.ref = "wholphin-extensions" } aboutlibraries-core = { module = "com.mikepenz:aboutlibraries-core", version.ref = "aboutLibraries" } aboutlibraries-compose-m3 = { module = "com.mikepenz:aboutlibraries-compose-m3", version.ref = "aboutLibraries" } acra-dialog = { module = "ch.acra:acra-dialog", version.ref = "acra" } diff --git a/scripts/ffmpeg/README.md b/scripts/ffmpeg/README.md deleted file mode 100644 index 36a9ca9a..00000000 --- a/scripts/ffmpeg/README.md +++ /dev/null @@ -1,14 +0,0 @@ -# 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 deleted file mode 100755 index 338b850e..00000000 --- a/scripts/ffmpeg/build_ffmpeg_decoder.sh +++ /dev/null @@ -1,97 +0,0 @@ -#!/usr/bin/env bash -set -ex - -if [ -z "$1" ]; then - echo "Error: Must provide NDK path" - exit 1 -fi -NDK_PATH="$1" - -SCRIPT_PATH="$(realpath "${BASH_SOURCE[0]}")" -SCRIPT_DIR="$(dirname "${SCRIPT_PATH}")" -PROJECT_ROOT="$(realpath "${SCRIPT_DIR}/../../")" - -# Config -ANDROID_ABI=21 -ENABLED_DECODERS=(dca ac3 eac3 mlp truehd flac alac pcm_mulaw pcm_alaw mp3) -FFMPEG_BRANCH="release/6.0" -DAV1D_BRANCH="1.5.3" - -# Path configs -DIR_PATH="$(pwd)" -TARGET_PATH="$PROJECT_ROOT/app/libs" -MEDIA_PATH="$DIR_PATH/ffmpeg_decoder/media" -FFMPEG_MODULE_PATH="$MEDIA_PATH/libraries/decoder_ffmpeg/src/main" -FFMPEG_PATH="$DIR_PATH/ffmpeg_decoder/ffmpeg" -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 - -echo "$PROJECT_ROOT/gradle/libs.versions.toml" - -media_version="$(grep "androidx-media3 = " "$PROJECT_ROOT/gradle/libs.versions.toml" | awk -F'"' '{print $2}')" - -pushd ffmpeg_decoder || exit - -if [[ -d media ]]; then - pushd media || exit - 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 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 - -[[ ! -d "${FFMPEG_MODULE_PATH}/jni/ffmpeg" ]] && ln -s "$FFMPEG_PATH" "${FFMPEG_MODULE_PATH}/jni/ffmpeg" - -pushd "${FFMPEG_MODULE_PATH}/jni" || exit - -./build_ffmpeg.sh "${FFMPEG_MODULE_PATH}" "${NDK_PATH}" "${HOST_PLATFORM}" "${ANDROID_ABI}" "${ENABLED_DECODERS[@]}" - -# av1 module - -pushd "$AV1_MODULE_PATH/jni" || exit - -if [[ ! -d cpu_features ]]; then - git clone https://github.com/google/cpu_features --depth 1 --single-branch cpu_features -fi - -pushd "$AV1_MODULE_PATH/jni" || exit - -if [[ -d dav1d ]]; then - pushd dav1d || exit - 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 - -pushd "$AV1_MODULE_PATH/jni" || exit - -/usr/bin/env bash ./build_dav1d.sh "${AV1_MODULE_PATH}" "${NDK_PATH}" "${HOST_PLATFORM}" - - -pushd "$MEDIA_PATH" || exit -./gradlew :lib-decoder-ffmpeg:assemble :lib-decoder-av1:assemble -popd || exit - -popd || exit -cp "$MEDIA_PATH/libraries/decoder_ffmpeg/buildout/outputs/aar/lib-decoder-ffmpeg-release.aar" "$TARGET_PATH/" -cp "$MEDIA_PATH/libraries/decoder_av1/buildout/outputs/aar/lib-decoder-av1-release.aar" "$TARGET_PATH/" -popd || exit diff --git a/scripts/mpv/README.md b/scripts/mpv/README.md deleted file mode 100644 index 106907bb..00000000 --- a/scripts/mpv/README.md +++ /dev/null @@ -1,22 +0,0 @@ -# MPV build scripts - -This scripts are adapted from https://github.com/mpv-android/mpv-android/tree/ae0d956c5a98ab8bf25af7e2c73bcb59e19c15b7/buildscripts licensed MIT. - -## Instructions - -```bash -cd scripts/mpv -./get_dependencies.sh - -# Install build dependencies -pip install meson jsonschema - -export NDK_PATH=... # Such as ~/Library/Android/sdk/ndk/29.0.14206865 -# Build arm64 -PATH="$PATH:$NDK_PATH/toolchains/llvm/prebuilt/darwin-x86_64/bin" ./buildall.sh --clean --arch arm64 mpv -# Build arm32 -PATH="$PATH:$NDK_PATH/toolchains/llvm/prebuilt/darwin-x86_64/bin" ./buildall.sh mpv - -cd ../.. -env PREFIX32="$(realpath scripts/mpv/prefix/armv7l)" PREFIX64="$(realpath scripts/mpv/prefix/arm64)" "$NDK_PATH/ndk-build" -C app/src/main -j && cp -fr app/src/main/libs/ app/src/main/jnilibs/ -``` diff --git a/scripts/mpv/buildall.sh b/scripts/mpv/buildall.sh deleted file mode 100755 index 56208015..00000000 --- a/scripts/mpv/buildall.sh +++ /dev/null @@ -1,180 +0,0 @@ -#!/bin/bash -e - -cd "$( dirname "${BASH_SOURCE[0]}" )" -. ./include/depinfo.sh - -cleanbuild=0 -nodeps=0 -clang=1 -target=mpv-android -arch=armv7l - -getdeps () { - varname="dep_${1//-/_}[*]" - echo ${!varname} -} - -loadarch () { - unset CC CXX CPATH LIBRARY_PATH C_INCLUDE_PATH CPLUS_INCLUDE_PATH - unset CFLAGS CXXFLAGS CPPFLAGS LDFLAGS - - local apilvl=21 - # ndk_triple: what the toolchain actually is - # cc_triple: what Google pretends the toolchain is - if [ "$1" == "armv7l" ]; then - export ndk_suffix= - export ndk_triple=arm-linux-androideabi - cc_triple=armv7a-linux-androideabi$apilvl - prefix_name=armv7l - elif [ "$1" == "arm64" ]; then - export ndk_suffix=-arm64 - export ndk_triple=aarch64-linux-android - cc_triple=$ndk_triple$apilvl - prefix_name=arm64 - elif [ "$1" == "x86" ]; then - export ndk_suffix=-x86 - export ndk_triple=i686-linux-android - cc_triple=$ndk_triple$apilvl - prefix_name=x86 - elif [ "$1" == "x86_64" ]; then - export ndk_suffix=-x64 - export ndk_triple=x86_64-linux-android - cc_triple=$ndk_triple$apilvl - prefix_name=x86_64 - else - echo "Invalid architecture" >&2 - exit 1 - fi - export prefix_dir="$PWD/prefix/$prefix_name" - if [ $clang -eq 1 ]; then - export CC=$cc_triple-clang - export CXX=$cc_triple-clang++ - else - export CC=$cc_triple-gcc - export CXX=$cc_triple-g++ - fi - export LDFLAGS="-Wl,-O1,--icf=safe -Wl,-z,max-page-size=16384" - export AR=llvm-ar - export RANLIB=llvm-ranlib -} - -setup_prefix () { - if [ ! -d "$prefix_dir" ]; then - mkdir -p "$prefix_dir" - # enforce flat structure (/usr/local -> /) - ln -s . "$prefix_dir/usr" - ln -s . "$prefix_dir/local" - fi - - local cpu_family=${ndk_triple%%-*} - [ "$cpu_family" == "i686" ] && cpu_family=x86 - - if ! command -v pkg-config >/dev/null; then - echo "pkg-config not provided!" - return 1 - fi - - # meson wants to be spoonfed this file, so create it ahead of time - # also define: release build, static libs and no source downloads at runtime(!!!) - cat >"$prefix_dir/crossfile.tmp" <&2 '\e[1;31m%s\e[m\n' "Target $1 not found" - return 1 - fi - if [ $nodeps -eq 0 ]; then - printf >&2 '\e[1;34m%s\e[m\n' "Preparing $1..." - local deps=$(getdeps $1) - echo >&2 "Dependencies: $deps" - for dep in $deps; do - build $dep - done - fi - printf >&2 '\e[1;34m%s\e[m\n' "Building $1..." - if [ "$1" == "mpv-android" ]; then - pushd .. - BUILDSCRIPT=buildscripts/scripts/$1.sh - else - pushd deps/$1 - BUILDSCRIPT=../../scripts/$1.sh - fi - [ $cleanbuild -eq 1 ] && $BUILDSCRIPT clean - $BUILDSCRIPT build - popd -} - -usage () { - printf '%s\n' \ - "Usage: buildall.sh [options] [target]" \ - "Builds the specified target (default: $target)" \ - "-n Do not build dependencies" \ - "--clean Clean build dirs before compiling" \ - "--gcc Use gcc compiler (unsupported!)" \ - "--arch Build for specified architecture (default: $arch; supported: armv7l, arm64, x86, x86_64)" - exit 0 -} - -while [ $# -gt 0 ]; do - case "$1" in - --clean) - cleanbuild=1 - ;; - -n|--no-deps) - nodeps=1 - ;; - --gcc) - clang=0 - ;; - --arch) - shift - arch=$1 - ;; - -h|--help) - usage - ;; - -*) - echo "Unknown flag $1" >&2 - exit 1 - ;; - *) - target=$1 - ;; - esac - shift -done - -loadarch $arch -setup_prefix -build $target - -[ "$target" == "mpv-android" ] && \ - ls -lh ../app/build/outputs/apk/{default,api29}/*/*.apk - -exit 0 diff --git a/scripts/mpv/get_dependencies.sh b/scripts/mpv/get_dependencies.sh deleted file mode 100755 index 829b2a06..00000000 --- a/scripts/mpv/get_dependencies.sh +++ /dev/null @@ -1,75 +0,0 @@ -#!/usr/bin/env bash - -set -exou pipefail - -BUILD_PATH="./deps" - -mkdir -p "$BUILD_PATH" -pushd "$BUILD_PATH" || exit - -function clone(){ - repo=$1 - branch=$2 - dir=$3 - shift 3 - - if [[ -d "$dir" ]]; then - pushd "$dir" || exit - git fetch origin "$branch" --depth 1 - git checkout --force FETCH_HEAD - popd || exit - else - git clone "$repo" --depth 1 --single-branch -b "$branch" "$dir" "$@" - fi -} - -clone "https://github.com/videolan/dav1d" "1.5.3" dav1d - -clone "https://github.com/FFmpeg/FFmpeg" "n8.0" ffmpeg - -clone "https://gitlab.freedesktop.org/freetype/freetype.git" "VER-2-14-1" freetype2 --recurse-submodules - -clone "https://github.com/libass/libass" "0.17.4" libass - -rm -rf libplacebo -git clone "https://github.com/haasn/libplacebo" --single-branch -b "master" --recurse-submodules libplacebo -pushd libplacebo || exit -git checkout 1bd8d2d6a715bc870bdffa6759e62c419000dd51 -popd || exit - -clone "https://github.com/mpv-player/mpv" "v0.41.0" mpv - -if [[ ! -d mbedtls ]]; then - mkdir mbedtls - wget https://github.com/Mbed-TLS/mbedtls/releases/download/mbedtls-3.6.4/mbedtls-3.6.4.tar.bz2 -O - | \ - tar -xj -C mbedtls --strip-components=1 -fi - -if [[ ! -d fribidi ]]; then - mkdir fribidi - wget https://github.com/fribidi/fribidi/releases/download/v1.0.16/fribidi-1.0.16.tar.xz -O - | \ - tar -xJ -C fribidi --strip-components=1 -fi - -if [[ ! -d harfbuzz ]]; then - mkdir harfbuzz - wget https://github.com/harfbuzz/harfbuzz/releases/download/12.1.0/harfbuzz-12.1.0.tar.xz -O - | \ - tar -xJ -C harfbuzz --strip-components=1 -fi - -version_unibreak="6.1" -if [[ ! -d unibreak ]]; then - mkdir unibreak - wget https://github.com/adah1972/libunibreak/releases/download/libunibreak_${version_unibreak//./_}/libunibreak-${version_unibreak}.tar.gz -O - | \ - tar -xz -C unibreak --strip-components=1 -fi - -if [[ ! -d lua ]]; then - mkdir lua - wget https://www.lua.org/ftp/lua-5.2.4.tar.gz -O - | \ - tar -xz -C lua --strip-components=1 -fi - -# python packages: jsonschema jinja2 meson - -popd || exit diff --git a/scripts/mpv/include/ci.sh b/scripts/mpv/include/ci.sh deleted file mode 100755 index 19b44a0b..00000000 --- a/scripts/mpv/include/ci.sh +++ /dev/null @@ -1,91 +0,0 @@ -#!/bin/bash -e - -# go to buildscripts root folder -cd "$( dirname "${BASH_SOURCE[0]}" )/.." - -. ./include/depinfo.sh - -msg() { - printf '==> %s\n' "$1" -} - -fetch_prefix() { - if [[ "$CACHE_MODE" == folder ]]; then - local text= - if [ -f "$CACHE_FOLDER/id.txt" ]; then - text=$(cat "$CACHE_FOLDER/id.txt") - else - echo "Cache seems to be empty" - fi - printf 'Expecting "%s",\nfound "%s".\n' "$ci_tarball" "$text" - if [[ "$text" == "$ci_tarball" ]]; then - tar -xzf "$CACHE_FOLDER/data.tgz" -C prefix && return 0 - fi - fi - return 1 -} - -build_prefix() { - msg "Building the prefix ($ci_tarball)..." - - msg "Fetching deps" - IN_CI=1 ./include/download-deps.sh - - # build everything mpv depends on (but not mpv itself) - for x in ${dep_mpv[@]}; do - msg "Building $x" - ./buildall.sh $x - done - - if [[ "$CACHE_MODE" == folder && -w "$CACHE_FOLDER" ]]; then - msg "Compressing the prefix" - tar -cvzf "$CACHE_FOLDER/data.tgz" -C prefix . - echo "$ci_tarball" >"$CACHE_FOLDER/id.txt" - fi -} - -export WGET="wget --progress=bar:force" - -if [ "$1" = "export" ]; then - # export variable with unique cache identifier - echo "CACHE_IDENTIFIER=$ci_tarball" - exit 0 -elif [ "$1" = "install" ]; then - # install deps - if [[ -n "$ANDROID_HOME" && -d "$ANDROID_HOME" ]]; then - msg "Linking existing SDK" - mkdir -p sdk - ln -sv "$ANDROID_HOME" sdk/android-sdk-linux - fi - - msg "Fetching SDK + NDK" - IN_CI=1 ./include/download-sdk.sh - - msg "Fetching mpv" - mkdir -p deps/mpv - $WGET https://github.com/mpv-player/mpv/archive/master.tar.gz -O master.tgz - tar -xzf master.tgz -C deps/mpv --strip-components=1 - rm master.tgz - - msg "Trying to fetch existing prefix" - mkdir -p prefix - fetch_prefix || build_prefix - exit 0 -elif [ "$1" = "build" ]; then - # run build - : -else - exit 1 -fi - -msg "Building mpv" -./buildall.sh -n mpv || { - # show logfile if configure failed - [ ! -f deps/mpv/_build/config.h ] && cat deps/mpv/_build/meson-logs/meson-log.txt - exit 1 -} - -msg "Building mpv-android" -./buildall.sh -n - -exit 0 diff --git a/scripts/mpv/include/depinfo.sh b/scripts/mpv/include/depinfo.sh deleted file mode 100755 index 875e3687..00000000 --- a/scripts/mpv/include/depinfo.sh +++ /dev/null @@ -1,43 +0,0 @@ -#!/bin/bash -e - -## Dependency versions -# Make sure to keep v_ndk and v_ndk_n in sync, both are listed on the NDK download page - -v_sdk=11076708_latest -v_ndk=r29 -v_ndk_n=29.0.14206865 -v_sdk_platform=35 -v_sdk_build_tools=35.0.0 - -v_lua=5.2.4 -v_unibreak=6.1 -v_harfbuzz=12.1.0 -v_fribidi=1.0.16 -v_freetype=2.14.1 -v_mbedtls=3.6.4 - - -## Dependency tree -# I would've used a dict but putting arrays in a dict is not a thing - -dep_mbedtls=() -dep_dav1d=() -dep_ffmpeg=(mbedtls dav1d) -dep_freetype2=() -dep_fribidi=() -dep_harfbuzz=() -dep_unibreak=() -dep_libass=(freetype2 fribidi harfbuzz unibreak) -dep_lua=() -dep_libplacebo=() -dep_mpv=(ffmpeg libass lua libplacebo) -dep_mpv_android=(mpv) - - -## for CI workflow - -# pinned ffmpeg revision -v_ci_ffmpeg=n8.0 - -# filename used to uniquely identify a build prefix -ci_tarball="prefix-ndk-${v_ndk}-lua-${v_lua}-unibreak-${v_unibreak}-harfbuzz-${v_harfbuzz}-fribidi-${v_fribidi}-freetype-${v_freetype}-mbedtls-${v_mbedtls}-ffmpeg-${v_ci_ffmpeg}.tgz" diff --git a/scripts/mpv/include/path.sh b/scripts/mpv/include/path.sh deleted file mode 100755 index 4ab1073c..00000000 --- a/scripts/mpv/include/path.sh +++ /dev/null @@ -1,32 +0,0 @@ -#!/bin/bash - -DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && cd .. && pwd )" - -. "$DIR/include/depinfo.sh" - -os=linux -[[ "$OSTYPE" == "darwin"* ]] && os=mac -export os - -if [ "$os" == "mac" ]; then - [ -z "$cores" ] && cores=$(sysctl -n hw.ncpu) - # various things rely on GNU behaviour - export INSTALL=`which ginstall` - export SED=gsed -else - [ -z "$cores" ] && cores=$(grep -c ^processor /proc/cpuinfo) -fi -cores=${cores:-4} - -# configure pkg-config paths if inside buildscripts -if [ -n "$ndk_triple" ]; then - export PKG_CONFIG_SYSROOT_DIR="$prefix_dir" - export PKG_CONFIG_LIBDIR="$PKG_CONFIG_SYSROOT_DIR/lib/pkgconfig" - unset PKG_CONFIG_PATH -fi - -toolchain=$(echo "$DIR/sdk/android-ndk-${v_ndk}/toolchains/llvm/prebuilt/"*) -[ -d "$toolchain" ] && \ - export PATH="$toolchain/bin:$DIR/sdk/android-ndk-${v_ndk}:$DIR/sdk/bin:$PATH" -export ANDROID_HOME="$DIR/sdk/android-sdk-$os" -unset ANDROID_SDK_ROOT ANDROID_NDK_ROOT diff --git a/scripts/mpv/scripts/dav1d.sh b/scripts/mpv/scripts/dav1d.sh deleted file mode 100755 index aef89de7..00000000 --- a/scripts/mpv/scripts/dav1d.sh +++ /dev/null @@ -1,22 +0,0 @@ -#!/bin/bash -e - -. ../../include/path.sh - -build=_build$ndk_suffix - -if [ "$1" == "build" ]; then - true -elif [ "$1" == "clean" ]; then - rm -rf $build - exit 0 -else - exit 255 -fi - -unset CC CXX # meson wants these unset - -meson setup $build --cross-file "$prefix_dir"/crossfile.txt \ - -Denable_tests=false -Db_lto=true -Dstack_alignment=16 - -ninja -C $build -j$cores -DESTDIR="$prefix_dir" ninja -C $build install diff --git a/scripts/mpv/scripts/ffmpeg.sh b/scripts/mpv/scripts/ffmpeg.sh deleted file mode 100755 index c9c73a74..00000000 --- a/scripts/mpv/scripts/ffmpeg.sh +++ /dev/null @@ -1,48 +0,0 @@ -#!/bin/bash -e - -. ../../include/path.sh - -if [ "$1" == "build" ]; then - true -elif [ "$1" == "clean" ]; then - rm -rf _build$ndk_suffix - exit 0 -else - exit 255 -fi - -mkdir -p _build$ndk_suffix -cd _build$ndk_suffix - -cpu=armv7-a -[[ "$ndk_triple" == "aarch64"* ]] && cpu=armv8-a -[[ "$ndk_triple" == "x86_64"* ]] && cpu=generic -[[ "$ndk_triple" == "i686"* ]] && cpu="i686 --disable-asm" - -cpuflags= -[[ "$ndk_triple" == "arm"* ]] && cpuflags="$cpuflags -mfpu=neon -mcpu=cortex-a8" - -args=( - --target-os=android --enable-cross-compile - --cross-prefix=$ndk_triple- --cc=$CC --pkg-config=pkg-config --nm=llvm-nm - --arch=${ndk_triple%%-*} --cpu=$cpu - --extra-cflags="-I$prefix_dir/include $cpuflags" --extra-ldflags="-L$prefix_dir/lib" - - --enable-{jni,mediacodec,mbedtls,libdav1d} --disable-vulkan - --disable-static --enable-shared --enable-{gpl,version3} - - # disable unneeded parts - --disable-{stripping,doc,programs} - # to keep the build lean we disable some feature quite aggressively: - # - muxers, encoders: mpv-android does not have any way to use these - # - devices: no practical use on Android - --disable-{muxers,encoders,devices} - # useful to taking screenshots - --enable-encoder=mjpeg,png - # useful for the `dump-cache` command - --enable-muxer=mov,matroska,mpegts -) -../configure "${args[@]}" - -make -j$cores -make DESTDIR="$prefix_dir" install diff --git a/scripts/mpv/scripts/freetype2.sh b/scripts/mpv/scripts/freetype2.sh deleted file mode 100755 index deceb0b4..00000000 --- a/scripts/mpv/scripts/freetype2.sh +++ /dev/null @@ -1,21 +0,0 @@ -#!/bin/bash -e - -. ../../include/path.sh - -build=_build$ndk_suffix - -if [ "$1" == "build" ]; then - true -elif [ "$1" == "clean" ]; then - rm -rf $build - exit 0 -else - exit 255 -fi - -unset CC CXX # meson wants these unset - -meson setup $build --cross-file "$prefix_dir"/crossfile.txt - -ninja -C $build -j$cores -DESTDIR="$prefix_dir" ninja -C $build install diff --git a/scripts/mpv/scripts/fribidi.sh b/scripts/mpv/scripts/fribidi.sh deleted file mode 100755 index a9e22aa5..00000000 --- a/scripts/mpv/scripts/fribidi.sh +++ /dev/null @@ -1,22 +0,0 @@ -#!/bin/bash -e - -. ../../include/path.sh - -build=_build$ndk_suffix - -if [ "$1" == "build" ]; then - true -elif [ "$1" == "clean" ]; then - rm -rf $build - exit 0 -else - exit 255 -fi - -unset CC CXX # meson wants these unset - -meson setup $build --cross-file "$prefix_dir"/crossfile.txt \ - -D{tests,docs}=false - -ninja -C $build -j$cores -DESTDIR="$prefix_dir" ninja -C $build install diff --git a/scripts/mpv/scripts/harfbuzz.sh b/scripts/mpv/scripts/harfbuzz.sh deleted file mode 100755 index c1d074f6..00000000 --- a/scripts/mpv/scripts/harfbuzz.sh +++ /dev/null @@ -1,22 +0,0 @@ -#!/bin/bash -e - -. ../../include/path.sh - -build=_build$ndk_suffix - -if [ "$1" == "build" ]; then - true -elif [ "$1" == "clean" ]; then - rm -rf $build - exit 0 -else - exit 255 -fi - -unset CC CXX # meson wants these unset - -meson setup $build --cross-file "$prefix_dir"/crossfile.txt \ - -Dtests=disabled -Ddocs=disabled - -ninja -C $build -j$cores -DESTDIR="$prefix_dir" ninja -C $build install diff --git a/scripts/mpv/scripts/libass.sh b/scripts/mpv/scripts/libass.sh deleted file mode 100755 index 739916de..00000000 --- a/scripts/mpv/scripts/libass.sh +++ /dev/null @@ -1,25 +0,0 @@ -#!/bin/bash -e - -. ../../include/path.sh - -if [ "$1" == "build" ]; then - true -elif [ "$1" == "clean" ]; then - rm -rf _build$ndk_suffix - exit 0 -else - exit 255 -fi - -[ -f configure ] || ./autogen.sh - -mkdir -p _build$ndk_suffix -cd _build$ndk_suffix - -../configure \ - --host=$ndk_triple --with-pic \ - --enable-static --disable-shared \ - --enable-libunibreak --disable-require-system-font-provider - -make -j$cores -make DESTDIR="$prefix_dir" install diff --git a/scripts/mpv/scripts/libplacebo.sh b/scripts/mpv/scripts/libplacebo.sh deleted file mode 100755 index fac33d94..00000000 --- a/scripts/mpv/scripts/libplacebo.sh +++ /dev/null @@ -1,25 +0,0 @@ -#!/bin/bash -e - -. ../../include/path.sh - -build=_build$ndk_suffix - -if [ "$1" == "build" ]; then - true -elif [ "$1" == "clean" ]; then - rm -rf $build - exit 0 -else - exit 255 -fi - -unset CC CXX -meson setup $build --cross-file "$prefix_dir"/crossfile.txt \ - -Dvulkan=disabled -Ddemos=false - -ninja -C $build -j$cores -DESTDIR="$prefix_dir" ninja -C $build install - -# add missing library for static linking -# this isn't "-lstdc++" due to a meson bug: https://github.com/mesonbuild/meson/issues/11300 -${SED:-sed} '/^Libs:/ s|$| -lc++|' "$prefix_dir/lib/pkgconfig/libplacebo.pc" -i diff --git a/scripts/mpv/scripts/lua.sh b/scripts/mpv/scripts/lua.sh deleted file mode 100755 index cc3c52ec..00000000 --- a/scripts/mpv/scripts/lua.sh +++ /dev/null @@ -1,44 +0,0 @@ -#!/bin/bash -e - -. ../../include/path.sh - -if [ "$1" == "build" ]; then - true -elif [ "$1" == "clean" ]; then - make clean - exit 0 -else - exit 255 -fi - -# Building seperately from source tree is not supported, this means we are forced to always clean -$0 clean - -mycflags=( - # ensures correct linking into libmpv.so - -fPIC - # bionic is missing decimal_point in localeconv [src/llex.c] - -Dgetlocaledecpoint\\\(\\\)=\\\(46\\\) - # force fallback as ftello/fseeko are not defined [src/liolib.c] - -Dlua_fseek -) - -# LUA_T= and LUAC_T= to disable building lua & luac -# -Dgetlocaledecpoint()=('.') fixes bionic missing decimal_point in localeconv -make CC="$CC" AR="$AR rc" RANLIB="$RANLIB" \ - MYCFLAGS="${mycflags[*]}" \ - PLAT=linux LUA_T= LUAC_T= -j$cores - -# TO_BIN=/dev/null disables installing lua & luac -make INSTALL=${INSTALL:-install} INSTALL_TOP="$prefix_dir" TO_BIN=/dev/null install - -# make pc only generates a partial pkg-config file because ???? -mkdir -p $prefix_dir/lib/pkgconfig -make pc >$prefix_dir/lib/pkgconfig/lua.pc -cat >>$prefix_dir/lib/pkgconfig/lua.pc <<'EOF' -Name: Lua -Description: -Version: ${version} -Libs: -L${libdir} -llua -Cflags: -I${includedir} -EOF diff --git a/scripts/mpv/scripts/mbedtls.sh b/scripts/mpv/scripts/mbedtls.sh deleted file mode 100755 index 32ae32f9..00000000 --- a/scripts/mpv/scripts/mbedtls.sh +++ /dev/null @@ -1,22 +0,0 @@ -#!/bin/bash -e - -. ../../include/path.sh - -if [ "$1" == "build" ]; then - true -elif [ "$1" == "clean" ]; then - make clean - exit 0 -else - exit 255 -fi - -$0 clean # separate building not supported, always clean -if [[ "$ndk_triple" == "i686"* ]]; then - ./scripts/config.py unset MBEDTLS_AESNI_C -else - ./scripts/config.py set MBEDTLS_AESNI_C -fi - -make -j$cores no_test -make DESTDIR="$prefix_dir" install diff --git a/scripts/mpv/scripts/mpv-android.sh b/scripts/mpv/scripts/mpv-android.sh deleted file mode 100755 index af152810..00000000 --- a/scripts/mpv/scripts/mpv-android.sh +++ /dev/null @@ -1,77 +0,0 @@ -#!/bin/bash -e - -DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" -BUILD="$DIR/.." -MPV_ANDROID="$DIR/../.." - -. $BUILD/include/path.sh -. $BUILD/include/depinfo.sh - -if [ "$1" == "build" ]; then - true -elif [ "$1" == "clean" ]; then - rm -rf $MPV_ANDROID/{app,.}/build $MPV_ANDROID/app/src/main/{libs,obj} - exit 0 -else - exit 255 -fi - -[ -n "$ANDROID_SIGNING_KEY" ] && BUNDLE=1 - -nativeprefix () { - if [ -f $BUILD/prefix/$1/lib/libmpv.so ]; then - echo $BUILD/prefix/$1 - else - echo >&2 "Warning: libmpv.so not found in native prefix for $1, support will be omitted" - fi -} - -prefix32=$(nativeprefix "armv7l") -prefix64=$(nativeprefix "arm64") -prefix_x64=$(nativeprefix "x86_64") -prefix_x86=$(nativeprefix "x86") - -if [[ -z "$prefix32" && -z "$prefix64" && -z "$prefix_x64" && -z "$prefix_x86" ]]; then - echo >&2 "Error: no mpv library detected." - exit 255 -fi - -PREFIX32=$prefix32 PREFIX64=$prefix64 PREFIX_X64=$prefix_x64 PREFIX_X86=$prefix_x86 \ -ndk-build -C app/src/main -j$cores - -targets=(assembleDebug) -if [ -z "$DONT_BUILD_RELEASE" ]; then - targets+=(assembleRelease) - [ -n "$BUNDLE" ] && targets+=(bundleRelease) -fi -./gradlew "${targets[@]}" - -if [ -n "$ANDROID_SIGNING_KEY" ]; then - cd "${MPV_ANDROID}/app/build/outputs/apk" - apksigner=${ANDROID_HOME}/build-tools/${v_sdk_build_tools}/apksigner - for v in default api29; do - pushd $v - # sign the universal debug APK - "$apksigner" sign --ks "${ANDROID_SIGNING_KEY}" \ - --in debug/app-$v-universal-debug.apk --out debug/app-$v-universal-debug-signed.apk - # but all of the release APKs - for apk in release/*-unsigned.apk; do - "$apksigner" sign --ks "${ANDROID_SIGNING_KEY}" \ - --in $apk --out ${apk/-unsigned/-signed} - done - popd - done - # and the bundle - cd ../bundle - if [ -n "$BUNDLE" ]; then - if [ -z "$ANDROID_SIGNING_ALIAS" ]; then - echo >&2 "Error: ANDROID_SIGNING_ALIAS must be set to use jarsigner" - exit 1 - fi - pushd defaultRelease - jarsigner -keystore "${ANDROID_SIGNING_KEY}" -signedjar \ - app-default-release-signed.aab app-default-release.aab \ - "${ANDROID_SIGNING_ALIAS}" - popd - fi -fi diff --git a/scripts/mpv/scripts/mpv.sh b/scripts/mpv/scripts/mpv.sh deleted file mode 100755 index feb85c95..00000000 --- a/scripts/mpv/scripts/mpv.sh +++ /dev/null @@ -1,30 +0,0 @@ -#!/bin/bash -e - -. ../../include/path.sh - -build=_build$ndk_suffix - -if [ "$1" == "build" ]; then - true -elif [ "$1" == "clean" ]; then - rm -rf $build - exit 0 -else - exit 255 -fi - -unset CC CXX # meson wants these unset - -meson setup $build --cross-file "$prefix_dir"/crossfile.txt \ - --default-library shared \ - -Diconv=disabled -Dlua=enabled \ - -Dlibmpv=true -Dcplayer=false \ - -Dmanpage-build=disabled - -ninja -C $build -j$cores -if [ -f $build/libmpv.a ]; then - echo >&2 "Meson fucked up, forcing rebuild." - $0 clean - exec $0 build -fi -DESTDIR="$prefix_dir" ninja -C $build install diff --git a/scripts/mpv/scripts/unibreak.sh b/scripts/mpv/scripts/unibreak.sh deleted file mode 100755 index d18dbea5..00000000 --- a/scripts/mpv/scripts/unibreak.sh +++ /dev/null @@ -1,24 +0,0 @@ -#!/bin/bash -e - -. ../../include/path.sh - -build=_build$ndk_suffix - -if [ "$1" == "build" ]; then - true -elif [ "$1" == "clean" ]; then - rm -rf $build - exit 0 -else - exit 255 -fi - -mkdir -p $build -cd $build - -../configure \ - --host=$ndk_triple --with-pic \ - --enable-static --disable-shared - -make -j$cores -make DESTDIR="$prefix_dir" install diff --git a/settings.gradle.kts b/settings.gradle.kts index 4b95efe8..a9195399 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -16,6 +16,14 @@ dependencyResolutionManagement { repositories { google() mavenCentral() + @Suppress("ktlint:standard:property-naming") + val WholphinExtensionsUsername: String? by settings + if (!WholphinExtensionsUsername.isNullOrBlank()) { + maven("https://maven.pkg.github.com/damontecres/wholphin-extensions") { + name = "WholphinExtensions" + credentials(PasswordCredentials::class) + } + } } } From 762a5e6dcc3bdef329feff97e020fc2ba8156dc5 Mon Sep 17 00:00:00 2001 From: Leon Omelan Date: Wed, 1 Apr 2026 00:39:02 +0200 Subject: [PATCH 078/148] Optimize recompositions on focus change (#1138) ## Description I've look at the app using Layout inspector to see if there are any recompositions that shouldn't be happening while navigating normally in the app. I've noticed two issues 1. Every BannerCard was being recomposed on every cursor move, which added a lot of overhead to home screen navigation 2. Tab row in the Series View was being recomposed while navigating across episodes in given season. ### Related issues Performance on the home screen is lacking, especially on low end devices, while this isn't a full fix, it will improve the navigation, as the app will be making significantly less work. Probably will fix https://github.com/damontecres/Wholphin/issues/1043 ### Testing Using Layout Inspector built-in recomposition counter and highlights it's easy to see components which recompose while they shouldn't. I've tested it on a 4K TV emulator on my M1 Macbook Pro ## Screenshots main: image my branch: image same navigation, on the same content. (down, right x4 ## AI or LLM usage Gemini built in to Android Studio helped me identify non-stable parts of the code. It also helped me understand and implement fixes in the HomePage code. I've took the time to check manually that it didn't alter any behavior, and verified the fixes using Layout Inspector and recomposition counter --- .../wholphin/ui/cards/BannerCard.kt | 12 +- .../damontecres/wholphin/ui/cards/ItemRow.kt | 42 ++-- .../wholphin/ui/components/TabRow.kt | 30 ++- .../ui/detail/series/SeriesOverviewContent.kt | 18 +- .../damontecres/wholphin/ui/main/HomePage.kt | 196 ++++++++++-------- 5 files changed, 185 insertions(+), 113 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/BannerCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/BannerCard.kt index a05af761..8d00edfd 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/BannerCard.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/BannerCard.kt @@ -19,6 +19,7 @@ import androidx.compose.runtime.Composable 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 @@ -98,10 +99,15 @@ fun BannerCard( } } var imageError by remember(imageUrl) { mutableStateOf(false) } + + // Stabilize callbacks to prevent AsyncImage from recomposing + val currentOnClick by rememberUpdatedState(onClick) + val currentOnLongClick by rememberUpdatedState(onLongClick) + Card( modifier = modifier.size(cardHeight * aspectRatio, cardHeight), - onClick = onClick, - onLongClick = onLongClick, + onClick = { currentOnClick() }, + onLongClick = { currentOnLongClick() }, interactionSource = interactionSource, colors = CardDefaults.colors( @@ -119,7 +125,7 @@ fun BannerCard( model = imageUrl, contentDescription = null, contentScale = imageContentScale, - onError = { imageError = true }, + onError = remember { { imageError = true } }, modifier = Modifier.fillMaxSize(), ) } else { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt index 314a7e1a..5b929a9b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt @@ -12,6 +12,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester @@ -45,6 +46,10 @@ fun ItemRow( val firstFocus = remember { FocusRequester() } val focusRequester = remember { FocusRequester() } var position by rememberInt() + + val currentOnClickItem by rememberUpdatedState(onClickItem) + val currentOnLongClickItem by rememberUpdatedState(onLongClickItem) + Column( verticalArrangement = Arrangement.spacedBy(8.dp), modifier = @@ -73,23 +78,36 @@ fun ItemRow( ) { itemsIndexed(items) { index, item -> val cardModifier = - if (index == position) { - Modifier.focusRequester(firstFocus) - } else { - Modifier + remember(index, position) { + if (index == position) { + Modifier.focusRequester(firstFocus) + } else { + Modifier + } } + + val onClick = + remember(index, item) { + { + position = index + if (item != null) currentOnClickItem(index, item) + } + } + + val onLongClick = + remember(index, item) { + { + position = index + if (item != null) currentOnLongClickItem(index, item) + } + } + cardContent.invoke( index, item, cardModifier, - { - position = index - if (item != null) onClickItem.invoke(index, item) - }, - { - position = index - if (item != null) onLongClickItem.invoke(index, item) - }, + onClick, + onLongClick, ) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/TabRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/TabRow.kt index 25b51698..f54fda5b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/TabRow.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/TabRow.kt @@ -19,9 +19,9 @@ import androidx.compose.foundation.lazy.rememberLazyListState 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.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -40,7 +40,6 @@ import androidx.compose.ui.unit.sp import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import com.github.damontecres.wholphin.ui.PreviewTvSpec -import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.theme.WholphinTheme import com.github.damontecres.wholphin.ui.tryRequestFocus import timber.log.Timber @@ -60,6 +59,11 @@ fun TabRow( } } var rowHasFocus by remember { mutableStateOf(false) } + + val currentSelectedTabIndex by rememberUpdatedState(selectedTabIndex) + val currentFocusRequesters by rememberUpdatedState(focusRequesters) + val currentOnClick by rememberUpdatedState(onClick) + LazyRow( state = state, modifier = @@ -71,14 +75,16 @@ fun TabRow( onEnter = { // If entering from left or right, use last or first tab // Otherwise use the selected tab - Timber.v("onEnter requestedFocusDirection=$requestedFocusDirection, selectedTabIndex=$selectedTabIndex") + val index = currentSelectedTabIndex + val requesters = currentFocusRequesters + Timber.v("onEnter requestedFocusDirection=$requestedFocusDirection, selectedTabIndex=$index") val focusRequester = if (requestedFocusDirection == FocusDirection.Left) { - focusRequesters.lastOrNull() + requesters.lastOrNull() } else if (requestedFocusDirection == FocusDirection.Right) { - focusRequesters.firstOrNull() + requesters.firstOrNull() } else { - focusRequesters.getOrNull(selectedTabIndex) + requesters.getOrNull(index) } (focusRequester ?: FocusRequester.Default).tryRequestFocus() } @@ -86,15 +92,19 @@ fun TabRow( ) { itemsIndexed(tabs) { index, tabTitle -> val interactionSource = remember { MutableInteractionSource() } + val onTabClick = + remember(index) { + { + currentOnClick(index) + } + } Tab( title = tabTitle, selected = index == selectedTabIndex, rowActive = rowHasFocus, interactionSource = interactionSource, - onClick = { - onClick.invoke(index) - }, - modifier = Modifier.focusRequester(focusRequesters[index]), + onClick = onTabClick, + modifier = Modifier.focusRequester(focusRequesters.getOrElse(index) { FocusRequester() }), ) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt index b32bbd30..eeb1607c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt @@ -30,6 +30,7 @@ import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier @@ -95,7 +96,7 @@ fun SeriesOverviewContent( ) { val scope = rememberCoroutineScope() val bringIntoViewRequester = remember { BringIntoViewRequester() } - var selectedTabIndex by rememberSaveable(position) { mutableIntStateOf(position.seasonTabIndex) } + var selectedTabIndex by rememberSaveable(position.seasonTabIndex) { mutableIntStateOf(position.seasonTabIndex) } LaunchedEffect(selectedTabIndex) { logTab("series_overview", selectedTabIndex) } @@ -120,6 +121,8 @@ fun SeriesOverviewContent( } val focusRequesters = remember(seasons) { List(seasons.size) { FocusRequester() } } + val currentOnChangeSeason by rememberUpdatedState(onChangeSeason) + Box( modifier = modifier @@ -154,11 +157,14 @@ fun SeriesOverviewContent( TabRow( selectedTabIndex = selectedTabIndex, tabs = tabs, - onClick = { - selectedTabIndex = it - onChangeSeason.invoke(it) - requestFocusAfterSeason = true - }, + onClick = + remember { + { + selectedTabIndex = it + currentOnChangeSeason(it) + requestFocusAfterSeason = true + } + }, focusRequesters = focusRequesters, modifier = Modifier 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 94281b78..9addbb0e 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 @@ -22,10 +22,12 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider 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.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -122,54 +124,69 @@ fun HomePage( var showDeleteDialog by remember { mutableStateOf(null) } val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) var position by rememberPosition() + + val onFocusPosition = remember { { it: RowColumn -> position = it } } + val onClickItem = + remember { + { clickedPosition: RowColumn, item: BaseItem -> + position = clickedPosition + viewModel.navigationManager.navigateTo(item.destination()) + } + } + val onLongClickItem = + remember { + { clickedPosition: RowColumn, item: BaseItem -> + position = clickedPosition + 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.navigationManager::navigateTo, + onClickWatch = { itemId, played -> + viewModel.setWatched(itemId, played) + }, + onClickFavorite = { itemId, favorite -> + viewModel.setFavorite(itemId, favorite) + }, + onClickAddPlaylist = { itemId -> + playlistViewModel.loadPlaylists(MediaType.VIDEO) + showPlaylistDialog = itemId + }, + onSendMediaInfo = viewModel.mediaReportService::sendReportFor, + onClickDelete = { + showDeleteDialog = RowColumnItem(position, item) + }, + ), + ) + dialog = + DialogParams( + title = item.title ?: "", + fromLongClick = true, + items = dialogItems, + ) + } + } + val onClickPlay = + remember { + { _: RowColumn, item: BaseItem -> + viewModel.navigationManager.navigateTo(Destination.Playback(item)) + } + } + HomePageContent( homeRows = homeRows, position = position, - onFocusPosition = { position = it }, - onClickItem = { clickedPosition, item -> - position = clickedPosition - viewModel.navigationManager.navigateTo(item.destination()) - }, - onLongClickItem = { clickedPosition, item -> - position = clickedPosition - 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.navigationManager::navigateTo, - onClickWatch = { itemId, played -> - viewModel.setWatched(itemId, played) - }, - onClickFavorite = { itemId, favorite -> - viewModel.setFavorite(itemId, favorite) - }, - onClickAddPlaylist = { itemId -> - playlistViewModel.loadPlaylists(MediaType.VIDEO) - showPlaylistDialog = itemId - }, - onSendMediaInfo = viewModel.mediaReportService::sendReportFor, - onClickDelete = { - showDeleteDialog = RowColumnItem(position, item) - }, - ), - ) - dialog = - DialogParams( - title = item.title ?: "", - fromLongClick = true, - items = dialogItems, - ) - }, - onClickPlay = { _, item -> - viewModel.navigationManager.navigateTo(Destination.Playback(item)) - }, + onFocusPosition = onFocusPosition, + onClickItem = onClickItem, + onLongClickItem = onLongClickItem, + onClickPlay = onClickPlay, loadingState = refreshing, showClock = preferences.appPreferences.interfacePreferences.showClock, onUpdateBackdrop = viewModel::updateBackdrop, @@ -210,6 +227,8 @@ fun HomePage( ) } } + + else -> {} } } @@ -239,12 +258,17 @@ fun HomePageContent( }, ) { val focusedItem = - position.let { - (homeRows.getOrNull(it.row) as? HomeRowLoadingState.Success)?.items?.getOrNull(it.column) + remember(homeRows, position) { + (homeRows.getOrNull(position.row) as? HomeRowLoadingState.Success)?.items?.getOrNull(position.column) } - val rowFocusRequesters = remember(homeRows) { List(homeRows.size) { FocusRequester() } } + val rowFocusRequesters = remember(homeRows.size) { List(homeRows.size) { FocusRequester() } } var firstFocused by remember { mutableStateOf(false) } + + val currentPosition by rememberUpdatedState(position) + val currentOnFocusPosition by rememberUpdatedState(onFocusPosition) + val currentOnClickPlay by rememberUpdatedState(onClickPlay) + if (takeFocus) { LaunchedEffect(homeRows) { if (!firstFocused && homeRows.isNotEmpty()) { @@ -266,11 +290,6 @@ fun HomePageContent( } } } - LaunchedEffect(position) { - if (position.row >= 0) { - listState.animateScrollToItem(position.row) - } - } LaunchedEffect(onUpdateBackdrop, focusedItem) { focusedItem?.let { onUpdateBackdrop.invoke(it) } } @@ -280,9 +299,11 @@ fun HomePageContent( val density = LocalDensity.current val spaceAbovePx = - with(density) { - // The size of the row titles & spacing - 50.dp.toPx() + remember(density) { + with(density) { + // The size of the row titles & spacing + 50.dp.toPx() + } } val defaultBringIntoViewSpec = LocalBringIntoViewSpec.current CompositionLocalProvider( @@ -329,15 +350,21 @@ fun HomePageContent( ItemRow( title = row.title, items = row.items, - onClickItem = { index, item -> - onClickItem.invoke(RowColumn(rowIndex, index), item) - }, - onLongClickItem = { index, item -> - onLongClickItem.invoke( - RowColumn(rowIndex, index), - item, - ) - }, + onClickItem = + remember(rowIndex, onClickItem) { + { index, item -> + onClickItem.invoke(RowColumn(rowIndex, index), item) + } + }, + onLongClickItem = + remember(rowIndex, onLongClickItem) { + { index, item -> + onLongClickItem.invoke( + RowColumn(rowIndex, index), + item, + ) + } + }, modifier = Modifier .fillMaxWidth() @@ -346,6 +373,26 @@ fun HomePageContent( .animateItem(), horizontalPadding = viewOptions.spacing.dp, cardContent = { index, item, cardModifier, onClick, onLongClick -> + val onFocus = + remember(rowIndex, index) { + { isFocused: Boolean -> + if (isFocused) { + currentOnFocusPosition(RowColumn(rowIndex, index)) + } + } + } + val onKey = + remember(item) { + { event: androidx.compose.ui.input.key.KeyEvent -> + if (isPlayKeyUp(event) && item?.type?.playable == true) { + Timber.v("Clicked play on ${item.id}") + currentOnClickPlay(currentPosition, item) + true + } else { + false + } + } + } HomePageCardContent( index = index, item = item, @@ -354,23 +401,8 @@ fun HomePageContent( viewOptions = viewOptions, modifier = cardModifier - .onFocusChanged { - if (it.isFocused) { - onFocusPosition?.invoke( - RowColumn(rowIndex, index), - ) - } - }.onKeyEvent { - if (isPlayKeyUp(it) && item?.type?.playable == true) { - Timber.v("Clicked play on ${item.id}") - onClickPlay.invoke( - position, - item, - ) - return@onKeyEvent true - } - return@onKeyEvent false - }, + .onFocusChanged { onFocus(it.isFocused) } + .onKeyEvent { onKey(it) }, ) }, ) From 7ccef6f802e56ddf22f2bd7ac94a7121b5d30487 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Tue, 31 Mar 2026 23:45:00 -0400 Subject: [PATCH 079/148] Fix card overlays showing, fix home page refresh indicator (#1178) ## Description - Always show card overlays (ie Movie vs TV Show) for discover/Seerr cards - Show card overlays (ie favorite and watched status) for search result cards - Restore showing home page refresh indicator ### Related issues Closes #1141 Closes https://github.com/damontecres/Wholphin/issues/702#issuecomment-4114775539 ### Testing Emulator ## Screenshots N/A, no new UIs, just adding existing ones ## AI or LLM usage None --- .../wholphin/ui/cards/DiscoverItemCard.kt | 4 +- .../wholphin/ui/cards/EpisodeCard.kt | 3 +- .../detail/discover/DiscoverMovieDetails.kt | 2 +- .../detail/discover/DiscoverSeriesDetails.kt | 2 +- .../wholphin/ui/discover/SeerrDiscoverPage.kt | 2 +- .../wholphin/ui/main/HomeViewModel.kt | 8 +++- .../wholphin/ui/main/SearchPage.kt | 38 ++++++++++--------- 7 files changed, 34 insertions(+), 25 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/DiscoverItemCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/DiscoverItemCard.kt index a7df0cb3..7f9129d0 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/DiscoverItemCard.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/DiscoverItemCard.kt @@ -16,7 +16,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.NonRestartableComposable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -50,13 +49,12 @@ import com.github.damontecres.wholphin.ui.theme.WholphinTheme import kotlinx.coroutines.delay @Composable -@NonRestartableComposable fun DiscoverItemCard( item: DiscoverItem?, onClick: () -> Unit, onLongClick: () -> Unit, - showOverlay: Boolean, modifier: Modifier = Modifier, + showOverlay: Boolean = true, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, ) { val focused by interactionSource.collectIsFocusedAsState() diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/EpisodeCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/EpisodeCard.kt index 279ca7b5..ab6263ae 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/EpisodeCard.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/EpisodeCard.kt @@ -45,6 +45,7 @@ fun EpisodeCard( modifier: Modifier = Modifier, imageHeight: Dp = Dp.Unspecified, imageWidth: Dp = Dp.Unspecified, + showImageOverlay: Boolean = false, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, ) { val dto = item?.data @@ -94,7 +95,7 @@ fun EpisodeCard( ItemCardImage( item = item, name = item?.name, - showOverlay = false, + showOverlay = showImageOverlay, favorite = dto?.userData?.isFavorite ?: false, watched = dto?.userData?.played ?: false, unwatchedCount = dto?.userData?.unplayedItemCount ?: -1, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetails.kt index a366e788..85743e3d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetails.kt @@ -341,7 +341,7 @@ fun DiscoverMovieDetailsContent( item = item, onClick = onClick, onLongClick = onLongClick, - showOverlay = false, + showOverlay = true, modifier = mod, ) }, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt index 12acfd37..42d4ce05 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt @@ -378,7 +378,7 @@ fun DiscoverSeriesDetailsContent( item = item, onClick = onClick, onLongClick = onLongClick, - showOverlay = false, + showOverlay = true, modifier = mod, ) }, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt index e30be9b3..4f09ee9d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt @@ -342,7 +342,7 @@ fun DiscoverRow( item = item, onClick = onClick, onLongClick = onLongClick, - showOverlay = false, + showOverlay = true, modifier = mod.onFocusChanged { if (it.isFocused) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt index 53417863..f53f80f7 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt @@ -85,7 +85,13 @@ class HomeViewModel val refresh = state.loadingState == LoadingState.Success && state.settings == settings Timber.v("refresh=$refresh, state.loadingState=${state.loadingState}") - _state.update { it.copy(settings = settings) } + _state.update { + it.copy( + loadingState = if (refresh) LoadingState.Success else LoadingState.Loading, + refreshState = LoadingState.Loading, + settings = settings, + ) + } val semaphore = Semaphore(4) 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 fde8e3aa..2c4f729e 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 @@ -1,6 +1,7 @@ package com.github.damontecres.wholphin.ui.main import androidx.activity.compose.BackHandler +import androidx.annotation.StringRes import androidx.compose.foundation.focusGroup import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -32,7 +33,6 @@ import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource @@ -232,7 +232,6 @@ fun SearchPage( modifier: Modifier = Modifier, viewModel: SearchViewModel = hiltViewModel(), ) { - val context = LocalContext.current val focusManager = LocalFocusManager.current val keyboardController = LocalSoftwareKeyboardController.current val movies by viewModel.movies.observeAsState(SearchResult.NoQuery) @@ -377,7 +376,7 @@ fun SearchPage( } } searchResultRow( - title = context.getString(R.string.movies), + title = R.string.movies, result = movies, rowIndex = MOVIE_ROW, position = position, @@ -387,7 +386,7 @@ fun SearchPage( modifier = Modifier.fillMaxWidth(), ) searchResultRow( - title = context.getString(R.string.tv_shows), + title = R.string.tv_shows, result = series, rowIndex = SERIES_ROW, position = position, @@ -397,7 +396,7 @@ fun SearchPage( modifier = Modifier.fillMaxWidth(), ) searchResultRow( - title = context.getString(R.string.episodes), + title = R.string.episodes, result = episodes, rowIndex = EPISODE_ROW, position = position, @@ -414,12 +413,13 @@ fun SearchPage( }, onLongClick = onLongClick, imageHeight = 140.dp, + showImageOverlay = true, modifier = mod.padding(horizontal = 8.dp), ) }, ) searchResultRow( - title = context.getString(R.string.albums), + title = R.string.albums, result = albums, rowIndex = ALBUM_ROW, position = position, @@ -437,12 +437,13 @@ fun SearchPage( onLongClick = onLongClick, imageHeight = Cards.heightEpisode, aspectRatio = AspectRatios.SQUARE, + showImageOverlay = true, modifier = mod, ) }, ) searchResultRow( - title = context.getString(R.string.artists), + title = R.string.artists, result = artists, rowIndex = COLLECTION_ROW, position = position, @@ -460,12 +461,13 @@ fun SearchPage( onLongClick = onLongClick, imageHeight = Cards.heightEpisode, aspectRatio = AspectRatios.SQUARE, + showImageOverlay = true, modifier = mod, ) }, ) searchResultRow( - title = context.getString(R.string.songs), + title = R.string.songs, result = songs, rowIndex = SONG_ROW, position = position, @@ -483,12 +485,13 @@ fun SearchPage( onLongClick = onLongClick, imageHeight = Cards.heightEpisode, aspectRatio = AspectRatios.SQUARE, + showImageOverlay = true, modifier = mod, ) }, ) searchResultRow( - title = context.getString(R.string.collections), + title = R.string.collections, result = collections, rowIndex = COLLECTION_ROW, position = position, @@ -498,7 +501,7 @@ fun SearchPage( modifier = Modifier.fillMaxWidth(), ) searchResultRow( - title = context.getString(R.string.discover), + title = R.string.discover, result = seerrResults, rowIndex = SEERR_ROW, position = position, @@ -525,7 +528,7 @@ fun SearchPage( } fun LazyListScope.searchResultRow( - title: String, + @StringRes title: Int, result: SearchResult, rowIndex: Int, position: RowColumn, @@ -549,6 +552,7 @@ fun LazyListScope.searchResultRow( }, onLongClick = onLongClick, imageHeight = Cards.height2x3, + showImageOverlay = true, modifier = mod, ) }, @@ -557,7 +561,7 @@ fun LazyListScope.searchResultRow( when (val r = result) { is SearchResult.Error -> { SearchResultPlaceholder( - title = title, + title = stringResource(title), message = r.ex.localizedMessage ?: "Error occurred during search", messageColor = MaterialTheme.colorScheme.error, modifier = Modifier, @@ -570,7 +574,7 @@ fun LazyListScope.searchResultRow( SearchResult.Searching -> { SearchResultPlaceholder( - title = title, + title = stringResource(title), message = stringResource(R.string.searching), modifier = modifier, ) @@ -579,13 +583,13 @@ fun LazyListScope.searchResultRow( is SearchResult.Success -> { if (r.items.isEmpty()) { SearchResultPlaceholder( - title = title, + title = stringResource(title), message = stringResource(R.string.no_results), modifier = modifier, ) } else { ItemRow( - title = title, + title = stringResource(title), items = r.items, onClickItem = onClickItem, onLongClickItem = { _, _ -> }, @@ -598,13 +602,13 @@ fun LazyListScope.searchResultRow( is SearchResult.SuccessSeerr -> { if (r.items.isEmpty()) { SearchResultPlaceholder( - title = title, + title = stringResource(title), message = stringResource(R.string.no_results), modifier = modifier, ) } else { ItemRow( - title = title, + title = stringResource(title), items = r.items, onClickItem = { index, item -> onClickPosition.invoke(RowColumn(rowIndex, index)) From 46beee00bb12c707fb74cd2b529d3d38b70f94df Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Wed, 1 Apr 2026 01:05:24 -0400 Subject: [PATCH 080/148] Fix supported audio types (#1173) ## Description Fixes the list of supported audio types when playing music. This fixes allows for playback of audio formats like ALAC, PCM/WAV, Vorbis, etc. ### Related issues Fixes #1168 ### Testing Emulator ## Screenshots N/A ## AI or LLM usage None --- .../damontecres/wholphin/services/MusicService.kt | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) 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 index 002be20e..0fb0c774 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt @@ -30,7 +30,7 @@ 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 com.github.damontecres.wholphin.util.profile.supportedAudioCodecs import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -83,6 +83,8 @@ class MusicService private val _state = MutableStateFlow(MusicServiceState.EMPTY) val state: StateFlow = _state + private val audioFormats by lazy { listOf(*supportedAudioCodecs) } + val player: Player by lazy { ExoPlayer .Builder(context) @@ -286,14 +288,9 @@ class MusicService val url = api.universalAudioApi.getUniversalAudioStreamUrl( itemId = audio.id, - container = - listOf( - Codec.Audio.OPUS, - Codec.Audio.MP3, - Codec.Audio.AAC, - Codec.Audio.FLAC, - ), + container = audioFormats, ) + Timber.i("url=%s", url) val imageUrl = audio.data.albumId?.let { albumId -> imageUrlService.getItemImageUrl( From feb8c3179107322a520351be5d122746fe588aa2 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Fri, 3 Apr 2026 12:44:39 -0400 Subject: [PATCH 081/148] Show toast & add option to fetch release notes when app updates (#1182) ## Description When Wholphin is updated, it will show a short toast message. This works for both app store and side loaded installs. You can now click on the "Installed version" in settings to fetch the release notes for the current version. ### Related issues Closes #1058 ### Testing Emulator ## Screenshots ![release_notes Large](https://github.com/user-attachments/assets/2c03eb74-abb2-4b45-8d6f-01e8ef72116c) ## AI or LLM usage None --- .../damontecres/wholphin/MainActivity.kt | 21 +++- .../wholphin/services/AppUpgradeHandler.kt | 70 ++++++++------ .../wholphin/services/UpdateChecker.kt | 95 ++++++++++++------- .../ui/preferences/PreferencesContent.kt | 45 +++++++-- .../ui/preferences/PreferencesViewModel.kt | 25 +++++ .../wholphin/ui/setup/InstallUpdatePage.kt | 35 ++++--- app/src/main/res/values/strings.xml | 1 + 7 files changed, 207 insertions(+), 85 deletions(-) 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 e8791bde..44b0bcb9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -1,5 +1,6 @@ package com.github.damontecres.wholphin +import android.content.Context import android.content.Intent import android.content.res.Configuration import android.os.Bundle @@ -51,14 +52,15 @@ import com.github.damontecres.wholphin.ui.LocalImageUrlService import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds 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.showToast import com.github.damontecres.wholphin.ui.theme.WholphinTheme import com.github.damontecres.wholphin.ui.util.ProvideLocalClock import com.github.damontecres.wholphin.util.DebugLogTree import com.github.damontecres.wholphin.util.ExceptionHandler import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.catch @@ -394,6 +396,7 @@ class MainActivity : AppCompatActivity() { class MainActivityViewModel @Inject constructor( + @param:ApplicationContext private val context: Context, private val preferences: DataStore, val serverRepository: ServerRepository, private val navigationManager: SetupNavigationManager, @@ -402,9 +405,19 @@ class MainActivityViewModel private val appUpgradeHandler: AppUpgradeHandler, ) : ViewModel() { fun appStart() { - viewModelScope.launchIO { + viewModelScope.launchDefault { try { - appUpgradeHandler.run() + val needUpgrade = appUpgradeHandler.needUpgrade() + if (needUpgrade) { + showToast( + context, + context.getString( + R.string.updated_toast, + appUpgradeHandler.currentVersion.toString(), + ), + ) + appUpgradeHandler.run() + } appUpgradeHandler.copySubfont(false) val prefs = preferences.data.firstOrNull() ?: AppPreferences.getDefaultInstance() @@ -447,7 +460,7 @@ class MainActivityViewModel navigationManager.navigateTo(SetupDestination.ServerList) } } - viewModelScope.launchIO { + viewModelScope.launchDefault { // Create the mediaCodecCapabilitiesTest if needed deviceProfileService.mediaCodecCapabilitiesTest.supportsAVC() } 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 c1507d9f..23093cfd 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 @@ -1,11 +1,11 @@ package com.github.damontecres.wholphin.services import android.content.Context +import android.content.pm.PackageInfo import android.os.Build import androidx.core.content.edit import androidx.datastore.core.DataStore import androidx.preference.PreferenceManager -import com.github.damontecres.wholphin.WholphinApplication import com.github.damontecres.wholphin.data.SeerrServerDao import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreferences @@ -43,12 +43,10 @@ class AppUpgradeHandler private val appPreferences: DataStore, private val seerrServerDao: SeerrServerDao, ) { - suspend fun run() { - val pkgInfo = - WholphinApplication.instance.packageManager.getPackageInfo( - WholphinApplication.instance.packageName, - 0, - ) + val pkgInfo: PackageInfo get() = context.packageManager.getPackageInfo(context.packageName, 0) + val currentVersion: Version get() = Version.fromString(pkgInfo.versionName!!) + + fun needUpgrade(): Boolean { val prefs = PreferenceManager.getDefaultSharedPreferences(context) val previousVersion = prefs.getString(VERSION_NAME_CURRENT_KEY, null) val previousVersionCode = prefs.getLong(VERSION_CODE_CURRENT_KEY, -1) @@ -60,31 +58,43 @@ class AppUpgradeHandler } else { pkgInfo.versionCode.toLong() } - if (newVersion != previousVersion || newVersionCode != previousVersionCode) { - Timber.i( - "App updated: $previousVersion=>$newVersion, $previousVersionCode=>$newVersionCode", - ) - // Store the previous and new version info - prefs.edit(true) { - putString(VERSION_NAME_PREVIOUS_KEY, previousVersion) - putLong(VERSION_CODE_PREVIOUS_KEY, previousVersionCode) - putString(VERSION_NAME_CURRENT_KEY, newVersion) - putLong(VERSION_CODE_CURRENT_KEY, newVersionCode) + Timber.i( + "App versions: $previousVersion=>$newVersion, $previousVersionCode=>$newVersionCode", + ) + return newVersion != previousVersion || newVersionCode != previousVersionCode + } + + suspend fun run() { + Timber.i("App upgrade started") + val prefs = PreferenceManager.getDefaultSharedPreferences(context) + val previousVersion = prefs.getString(VERSION_NAME_CURRENT_KEY, null) + val previousVersionCode = prefs.getLong(VERSION_CODE_CURRENT_KEY, -1) + + val newVersion = pkgInfo.versionName!! + val newVersionCode = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + pkgInfo.longVersionCode + } else { + pkgInfo.versionCode.toLong() } - try { - copySubfont(true) - upgradeApp( - Version.fromString(previousVersion ?: "0.0.0"), - Version.fromString(newVersion), - appPreferences, - ) - } catch (ex: Exception) { - Timber.e(ex, "Exception during app upgrade") - } - Timber.i("App upgrade complete") - } else { - Timber.d("No app update needed") + // Store the previous and new version info + prefs.edit(true) { + putString(VERSION_NAME_PREVIOUS_KEY, previousVersion) + putLong(VERSION_CODE_PREVIOUS_KEY, previousVersionCode) + putString(VERSION_NAME_CURRENT_KEY, newVersion) + putLong(VERSION_CODE_CURRENT_KEY, newVersionCode) } + try { + copySubfont(true) + upgradeApp( + Version.fromString(previousVersion ?: "0.0.0"), + Version.fromString(newVersion), + appPreferences, + ) + } catch (ex: Exception) { + Timber.e(ex, "Exception during app upgrade") + } + Timber.i("App upgrade complete") } /** diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt b/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt index 9e0665dd..f1701416 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt @@ -128,50 +128,67 @@ class UpdateChecker return Version.fromString(pkgInfo.versionName!!) } + suspend fun getRelease(version: Version): Release? { + val url = + "https://api.github.com/repos/damontecres/Wholphin/releases/tags/v${version.major}.${version.minor}.${version.patch}" + return withContext(Dispatchers.IO) { + val request = + Request + .Builder() + .url(url) + .get() + .build() + getRelease(request) + } + } + /** * Get the latest released version */ - suspend fun getLatestRelease(updateUrl: String): Release? { - return withContext(Dispatchers.IO) { + suspend fun getLatestRelease(updateUrl: String): Release? = + withContext(Dispatchers.IO) { val request = Request .Builder() .url(updateUrl) .get() .build() - okHttpClient.newCall(request).execute().use { - if (it.isSuccessful && it.body != null) { - val result = Json.parseToJsonElement(it.body!!.string()) - val name = result.jsonObject["name"]?.jsonPrimitive?.contentOrNull - val version = Version.tryFromString(name) - val publishedAt = - result.jsonObject["published_at"]?.jsonPrimitive?.contentOrNull - val body = result.jsonObject["body"]?.jsonPrimitive?.contentOrNull - val downloadUrl = - result.jsonObject["assets"] - ?.jsonArray - ?.let { assets -> getDownloadUrl(assets, BuildConfig.DEBUG) } - Timber.v("version=$version, downloadUrl=$downloadUrl") - if (version != null) { - val notes = - if (body.isNotNullOrBlank()) { - NOTE_REGEX - .findAll(body) - .map { m -> - m.groupValues[1] - }.toList() - } else { - listOf() - } - return@use Release(version, downloadUrl, publishedAt, body, notes) - } else { - Timber.w("Update version parsing failed. name=$name") - } + getRelease(request) + } + + private fun getRelease(request: Request): Release? { + return okHttpClient.newCall(request).execute().use { + if (it.isSuccessful && it.body != null) { + val result = Json.parseToJsonElement(it.body!!.string()) + val name = result.jsonObject["name"]?.jsonPrimitive?.contentOrNull + val version = Version.tryFromString(name) + val publishedAt = + result.jsonObject["published_at"]?.jsonPrimitive?.contentOrNull + val body = result.jsonObject["body"]?.jsonPrimitive?.contentOrNull + val downloadUrl = + result.jsonObject["assets"] + ?.jsonArray + ?.let { assets -> getDownloadUrl(assets, BuildConfig.DEBUG) } + Timber.v("version=$version, downloadUrl=$downloadUrl") + if (version != null) { + val notes = + if (body.isNotNullOrBlank()) { + NOTE_REGEX + .findAll(body) + .map { m -> + m.groupValues[1] + }.toList() + } else { + emptyList() + } + return@use Release(version, downloadUrl, publishedAt, body, notes) } else { - Timber.w("Update check failed: ${it.message}") + Timber.w("Update version parsing failed. name=$name") } - return@use null + } else { + Timber.w("Update check failed ${it.code}: ${it.message}") } + return@use null } } @@ -347,7 +364,19 @@ data class Release( val publishedAt: String?, val body: String?, val notes: List, -) +) { + val content = + "# ${version}\n" + + ( + (notes.joinToString("\n").takeIf { it.isNotNullOrBlank() } ?: "") + + (body ?: "") + ).replace( + Regex("https://github.com/\\w*/\\w+/pull/(\\d+)"), + "#$1", + ) + // Remove the last line for full changelog since its just a link + .replace(Regex("\\*\\*Full Changelog\\*\\*.*"), "") +} interface DownloadCallback { fun contentLength(contentLength: Long) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt index c214d3cb..18b630f6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt @@ -24,7 +24,6 @@ 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.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -55,20 +54,26 @@ import com.github.damontecres.wholphin.preferences.advancedPreferences import com.github.damontecres.wholphin.preferences.basicPreferences import com.github.damontecres.wholphin.preferences.screensaverPreferences import com.github.damontecres.wholphin.preferences.updatePlaybackPreferences +import com.github.damontecres.wholphin.services.Release import com.github.damontecres.wholphin.services.SeerrConnectionStatus import com.github.damontecres.wholphin.services.UpdateChecker import com.github.damontecres.wholphin.ui.components.ConfirmDialog +import com.github.damontecres.wholphin.ui.components.ErrorMessage +import com.github.damontecres.wholphin.ui.components.LoadingPage +import com.github.damontecres.wholphin.ui.components.ScrollableDialog import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.playOnClickSound import com.github.damontecres.wholphin.ui.playSoundOnFocus import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings +import com.github.damontecres.wholphin.ui.setup.ReleaseNotes import com.github.damontecres.wholphin.ui.setup.UpdateViewModel import com.github.damontecres.wholphin.ui.setup.seerr.AddSeerServerDialog import com.github.damontecres.wholphin.ui.setup.seerr.SwitchSeerrViewModel import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.LoadingState import kotlinx.coroutines.launch @@ -93,6 +98,7 @@ fun PreferencesContent( val currentUser by viewModel.currentUser.observeAsState() val currentServer by seerrVm.currentSeerrServer.collectAsState(null) var showPinFlow by remember { mutableStateOf(false) } + var showVersionDialog by remember { mutableStateOf(false) } var cacheUsage by remember { mutableStateOf(CacheUsage(0, 0, 0)) } val seerrConnection by viewModel.seerrConnection.collectAsState() @@ -252,15 +258,13 @@ fun PreferencesContent( } when (pref) { AppPreference.InstalledVersion -> { - var clickCount by remember { mutableIntStateOf(0) } ClickPreference( title = stringResource(R.string.installed_version), onClick = { - if (movementSounds) playOnClickSound(context) - if (clickCount++ >= 2) { - clickCount = 0 - viewModel.navigationManager.navigateTo(Destination.Debug) - } + showVersionDialog = true + }, + onLongClick = { + viewModel.navigationManager.navigateTo(Destination.Debug) }, summary = installedVersion.toString(), interactionSource = interactionSource, @@ -615,6 +619,33 @@ fun PreferencesContent( }, ) } + if (showVersionDialog) { + LaunchedEffect(Unit) { + viewModel.fetchReleaseNotes() + } + val release by viewModel.releaseNotes.collectAsState() + ScrollableDialog( + onDismissRequest = { showVersionDialog = false }, + ) { + item { + when (val r = release) { + is DataLoadingState.Error -> { + ErrorMessage(message = "Error", exception = r.exception) + } + + DataLoadingState.Pending, + DataLoadingState.Loading, + -> { + LoadingPage() + } + + is DataLoadingState.Success -> { + ReleaseNotes(r.data) + } + } + } + } + } } @Composable diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt index 58f9a5e3..80dd7934 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt @@ -11,10 +11,13 @@ import com.github.damontecres.wholphin.preferences.resetSubtitles import com.github.damontecres.wholphin.preferences.updateSubtitlePreferences import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.Release import com.github.damontecres.wholphin.services.ScreensaverService import com.github.damontecres.wholphin.services.SeerrServerRepository +import com.github.damontecres.wholphin.services.UpdateChecker import com.github.damontecres.wholphin.ui.detail.DebugViewModel.Companion.sendAppLogs import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.RememberTabManager @@ -22,9 +25,11 @@ import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.model.ClientInfo import org.jellyfin.sdk.model.DeviceInfo +import timber.log.Timber import javax.inject.Inject @HiltViewModel @@ -42,6 +47,7 @@ class PreferencesViewModel private val seerrServerRepository: SeerrServerRepository, private val deviceInfo: DeviceInfo, private val clientInfo: ClientInfo, + private val updateChecker: UpdateChecker, ) : ViewModel(), RememberTabManager by rememberTabManager { val currentUser get() = serverRepository.currentUser @@ -51,6 +57,8 @@ class PreferencesViewModel private val _quickConnectStatus = MutableStateFlow(LoadingState.Pending) val quickConnectStatus: StateFlow = _quickConnectStatus + val releaseNotes = MutableStateFlow>(DataLoadingState.Pending) + init { viewModelScope.launchIO { serverRepository.currentUser.value?.let { user -> @@ -99,6 +107,23 @@ class PreferencesViewModel } } + fun fetchReleaseNotes() { + viewModelScope.launchIO { + releaseNotes.update { DataLoadingState.Loading } + try { + val release = updateChecker.getRelease(updateChecker.getInstalledVersion()) + if (release != null) { + releaseNotes.update { DataLoadingState.Success(release) } + } else { + releaseNotes.update { DataLoadingState.Error("Release not found") } + } + } catch (ex: Exception) { + Timber.e(ex, "Error fetching release") + releaseNotes.update { DataLoadingState.Error(ex) } + } + } + } + companion object { suspend fun resetSubtitleSettings(appPreferences: DataStore) { appPreferences.updateData { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/InstallUpdatePage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/InstallUpdatePage.kt index cc6f2d3d..7e5a6ec5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/InstallUpdatePage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/InstallUpdatePage.kt @@ -53,7 +53,6 @@ import com.github.damontecres.wholphin.services.Release import com.github.damontecres.wholphin.services.UpdateChecker import com.github.damontecres.wholphin.ui.PreviewTvSpec import com.github.damontecres.wholphin.ui.components.BasicDialog -import com.github.damontecres.wholphin.ui.components.Button import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.components.TextButton @@ -66,6 +65,7 @@ import com.github.damontecres.wholphin.util.LoadingExceptionHandler import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.Version import com.mikepenz.markdown.m3.Markdown +import com.mikepenz.markdown.m3.markdownTypography import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -272,15 +272,7 @@ fun InstallUpdatePageContent( }, ) { item { - Markdown( - (release.notes.joinToString("\n\n") + (release.body ?: "")) - .replace( - Regex("https://github.com/damontecres/\\w+/pull/(\\d+)"), - "#$1", - ) - // Remove the last line for full changelog since its just a link - .replace(Regex("\\*\\*Full Changelog\\*\\*.*"), ""), - ) + ReleaseNotes(release) } } Column( @@ -317,6 +309,25 @@ fun InstallUpdatePageContent( } } +@Composable +fun ReleaseNotes( + release: Release, + modifier: Modifier = Modifier, +) { + Markdown( + content = release.content, + typography = + markdownTypography( + h1 = MaterialTheme.typography.headlineLarge, + h2 = MaterialTheme.typography.headlineMedium, + h3 = MaterialTheme.typography.headlineSmall, + text = MaterialTheme.typography.bodySmall, + code = MaterialTheme.typography.bodySmall, + ), + modifier = modifier, + ) +} + @Composable fun DownloadDialog( contentLength: Long, @@ -392,13 +403,15 @@ private fun InstallUpdatePageContentPreview() { downloadUrl = "https://url", publishedAt = null, body = - "Lorem ipsum dolor sit amet consectetur adipiscing elit. Quisque faucibus " + + "## Header 2\n" + + "Lorem ipsum dolor sit amet consectetur adipiscing elit. Quisque faucibus " + "ex sapien vitae pellentesque sem placerat. In id cursus mi pretium " + "tellus duis convallis. Tempus leo eu aenean sed diam urna tempor. " + "Pulvinar vivamus fringilla lacus nec metus bibendum egestas. " + "Iaculis massa nisl malesuada lacinia integer nunc posuere. " + "Ut hendrerit semper vel class aptent taciti sociosqu. Ad litora " + "torquent per conubia nostra inceptos himenaeos.\n\n" + + "### Header 3\n" + "Lorem ipsum dolor sit amet consectetur adipiscing elit. Quisque faucibus " + "ex sapien vitae pellentesque sem placerat. In id cursus mi pretium " + "tellus duis convallis. Tempus leo eu aenean sed diam urna tempor. " + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b7dbbf3b..ce4bf2b6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -748,5 +748,6 @@ Select all Separate types Prefer showing logos for titles + Wholphin updated to %s From 44d1ac484f10f6e7077c18ee3286772e8960821d Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Fri, 3 Apr 2026 12:44:59 -0400 Subject: [PATCH 082/148] Refactor movie page to use use state flow (#1183) ## Description Refactors the movie page & view model to use a `StateFlow` instead of many `LiveData` objects. This is in keeping with best practices. There are no user facing changes. ### Related issues None ### Testing Emulator ## Screenshots N/A ## AI or LLM usage None --- .../wholphin/ui/components/ErrorMessage.kt | 11 + .../wholphin/ui/detail/movie/MovieDetails.kt | 394 ++++++++---------- .../ui/detail/movie/MovieViewModel.kt | 218 +++++----- 3 files changed, 316 insertions(+), 307 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ErrorMessage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ErrorMessage.kt index 76c9e097..59bb456f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ErrorMessage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ErrorMessage.kt @@ -14,6 +14,7 @@ import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.ui.detail.DebugViewModel.Companion.sendAppLogs +import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.LoadingState import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext @@ -98,3 +99,13 @@ fun ErrorMessage( exception = error.exception, modifier = modifier, ) + +@Composable +fun ErrorMessage( + error: DataLoadingState.Error, + modifier: Modifier = Modifier, +) = ErrorMessage( + message = error.message, + exception = error.exception, + modifier = modifier, +) 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 47a80e49..7538fdd2 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 @@ -17,6 +17,7 @@ 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.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester @@ -28,10 +29,8 @@ import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.LifecycleResumeEffect import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.data.ChosenStreams import com.github.damontecres.wholphin.data.ExtrasItem import com.github.damontecres.wholphin.data.model.BaseItem -import com.github.damontecres.wholphin.data.model.Chapter import com.github.damontecres.wholphin.data.model.DiscoverItem import com.github.damontecres.wholphin.data.model.Person import com.github.damontecres.wholphin.data.model.Trailer @@ -67,11 +66,11 @@ import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForPerson import com.github.damontecres.wholphin.ui.discover.DiscoverRow import com.github.damontecres.wholphin.ui.discover.DiscoverRowData +import com.github.damontecres.wholphin.ui.letNotEmpty import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.rememberInt import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.ExceptionHandler -import com.github.damontecres.wholphin.util.LoadingState import kotlinx.coroutines.launch import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.MediaStreamType @@ -98,16 +97,7 @@ fun MovieDetails( viewModel.init() onPauseOrDispose { } } - val item by viewModel.item.observeAsState() - val people by viewModel.people.observeAsState(listOf()) - val chapters by viewModel.chapters.observeAsState(listOf()) - val trailers by viewModel.trailers.observeAsState(listOf()) - val extras by viewModel.extras.observeAsState(listOf()) - val similar by viewModel.similar.observeAsState(listOf()) - 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() + val state by viewModel.state.collectAsState() var overviewDialog by remember { mutableStateOf(null) } var moreDialog by remember { mutableStateOf(null) } @@ -140,196 +130,188 @@ fun MovieDetails( onClickDelete = { showDeleteDialog = it }, ) - when (val state = loading) { - is LoadingState.Error -> { - ErrorMessage(state, modifier) + when (val s = state.loading) { + is DataLoadingState.Error -> { + ErrorMessage(s, modifier) } - LoadingState.Loading, - LoadingState.Pending, + DataLoadingState.Loading, + DataLoadingState.Pending, -> { LoadingPage(modifier) } - LoadingState.Success -> { - item?.let { movie -> - LifecycleResumeEffect(destination.itemId) { - viewModel.maybePlayThemeSong( - destination.itemId, - preferences.appPreferences.interfacePreferences.playThemeSongs, - ) - onPauseOrDispose { - viewModel.release() - } - } - MovieDetailsContent( - preferences = preferences, - movie = movie, - chosenStreams = chosenStreams, - people = people, - chapters = chapters, - extras = extras, - trailers = trailers, - similar = similar, - onClickItem = { index, item -> - viewModel.navigateTo(item.destination()) - }, - onClickPerson = { - viewModel.navigateTo( - Destination.MediaItem( - it.id, - BaseItemKind.PERSON, - ), - ) - }, - playOnClick = { - viewModel.navigateTo( - Destination.Playback( - movie.id, - it.inWholeMilliseconds, - ), - ) - }, - overviewOnClick = { - overviewDialog = - ItemDetailsDialogInfo( - title = movie.name ?: context.getString(R.string.unknown), - overview = movie.data.overview, - genres = movie.data.genres.orEmpty(), - files = movie.data.mediaSources.orEmpty(), - ) - }, - moreOnClick = { - moreDialog = - DialogParams( - fromLongClick = false, - title = movie.name + " (${movie.data.productionYear ?: ""})", - items = - buildMoreDialogItems( - context = context, - item = movie, - watched = movie.data.userData?.played ?: false, - favorite = movie.data.userData?.isFavorite ?: false, - seriesId = null, - sourceId = chosenStreams?.source?.id?.toUUIDOrNull(), - canClearChosenStreams = chosenStreams?.itemPlayback != null || chosenStreams?.plc != null, - canDelete = canDelete, - actions = moreActions, - onChooseVersion = { - chooseVersion = - chooseVersionParams( - context, - movie.data.mediaSources!!, - ) { idx -> - val source = movie.data.mediaSources!![idx] - viewModel.savePlayVersion( - movie, - source.id!!.toUUID(), - ) - } - moreDialog = null - }, - onChooseTracks = { type -> - - viewModel.streamChoiceService - .chooseSource( - movie.data, - chosenStreams?.itemPlayback, - )?.let { source -> - chooseVersion = - chooseStream( - context = context, - streams = source.mediaStreams.orEmpty(), - type = type, - currentIndex = - if (type == MediaStreamType.AUDIO) { - chosenStreams?.audioStream?.index - } else { - chosenStreams?.subtitleStream?.index - }, - onClick = { trackIndex -> - viewModel.saveTrackSelection( - movie, - chosenStreams?.itemPlayback, - trackIndex, - type, - ) - }, - preferredSubtitleLanguage = preferredSubtitleLanguage, - ) - } - }, - onShowOverview = { - overviewDialog = - ItemDetailsDialogInfo( - title = - movie.name - ?: context.getString(R.string.unknown), - overview = movie.data.overview, - genres = movie.data.genres.orEmpty(), - files = movie.data.mediaSources.orEmpty(), - ) - }, - onClearChosenStreams = { - viewModel.clearChosenStreams(chosenStreams) - }, - ), - ) - }, - watchOnClick = { - viewModel.setWatched(movie.id, !movie.played) - }, - favoriteOnClick = { - viewModel.setFavorite(movie.id, !movie.favorite) - }, - onLongClickPerson = { index, person -> - val items = - buildMoreDialogItemsForPerson( - context = context, - person = person, - actions = moreActions, - ) - moreDialog = - DialogParams( - fromLongClick = true, - title = person.name ?: "", - items = items, - ) - }, - onLongClickSimilar = { index, similar -> - val items = - buildMoreDialogItemsForHome( - context = context, - item = similar, - seriesId = null, - playbackPosition = similar.playbackPosition, - watched = similar.played, - favorite = similar.favorite, - canDelete = false, - actions = moreActions, - ) - moreDialog = - DialogParams( - fromLongClick = true, - title = similar.title ?: "", - items = items, - ) - }, - trailerOnClick = { - TrailerService.onClick(context, it, viewModel::navigateTo) - }, - onClickExtra = { index, extra -> - viewModel.navigateTo(extra.destination) - }, - discovered = discovered, - onClickDiscover = { index, item -> - viewModel.navigateTo(item.destination) - }, - canDelete = canDelete, - deleteOnClick = { showDeleteDialog = movie }, - modifier = modifier, + is DataLoadingState.Success -> { + val unknownStr = stringResource(R.string.unknown) + val movie by rememberUpdatedState(s.data) + val chosenStreams by rememberUpdatedState(state.chosenStreams) + LifecycleResumeEffect(destination.itemId) { + viewModel.maybePlayThemeSong( + destination.itemId, + preferences.appPreferences.interfacePreferences.playThemeSongs, ) + onPauseOrDispose { + viewModel.release() + } } + MovieDetailsContent( + preferences = preferences, + movie = movie, + state = state, + onClickItem = { index, item -> + viewModel.navigateTo(item.destination()) + }, + onClickPerson = { + viewModel.navigateTo( + Destination.MediaItem( + it.id, + BaseItemKind.PERSON, + ), + ) + }, + playOnClick = { + viewModel.navigateTo( + Destination.Playback( + movie.id, + it.inWholeMilliseconds, + ), + ) + }, + overviewOnClick = { + overviewDialog = + ItemDetailsDialogInfo( + title = movie.name ?: unknownStr, + overview = movie.data.overview, + genres = movie.data.genres.orEmpty(), + files = movie.data.mediaSources.orEmpty(), + ) + }, + moreOnClick = { + moreDialog = + DialogParams( + fromLongClick = false, + title = movie.name + " (${movie.data.productionYear ?: ""})", + items = + buildMoreDialogItems( + context = context, + item = movie, + watched = movie.data.userData?.played ?: false, + favorite = movie.data.userData?.isFavorite ?: false, + seriesId = null, + sourceId = chosenStreams?.source?.id?.toUUIDOrNull(), + canClearChosenStreams = chosenStreams.let { it?.itemPlayback != null || it?.plc != null }, + canDelete = state.canDelete, + actions = moreActions, + onChooseVersion = { + chooseVersion = + chooseVersionParams( + context, + movie.data.mediaSources!!, + ) { idx -> + val source = movie.data.mediaSources!![idx] + viewModel.savePlayVersion( + movie, + source.id!!.toUUID(), + ) + } + moreDialog = null + }, + onChooseTracks = { type -> + viewModel.streamChoiceService + .chooseSource( + movie.data, + chosenStreams?.itemPlayback, + )?.let { source -> + chooseVersion = + chooseStream( + context = context, + streams = source.mediaStreams.orEmpty(), + type = type, + currentIndex = + if (type == MediaStreamType.AUDIO) { + chosenStreams?.audioStream?.index + } else { + chosenStreams?.subtitleStream?.index + }, + onClick = { trackIndex -> + viewModel.saveTrackSelection( + movie, + chosenStreams?.itemPlayback, + trackIndex, + type, + ) + }, + preferredSubtitleLanguage = preferredSubtitleLanguage, + ) + } + }, + onShowOverview = { + overviewDialog = + ItemDetailsDialogInfo( + title = movie.name ?: unknownStr, + overview = movie.data.overview, + genres = movie.data.genres.orEmpty(), + files = movie.data.mediaSources.orEmpty(), + ) + }, + onClearChosenStreams = { + viewModel.clearChosenStreams(chosenStreams) + }, + ), + ) + }, + watchOnClick = { + viewModel.setWatched(movie.id, !movie.played) + }, + favoriteOnClick = { + viewModel.setFavorite(movie.id, !movie.favorite) + }, + onLongClickPerson = { index, person -> + val items = + buildMoreDialogItemsForPerson( + context = context, + person = person, + actions = moreActions, + ) + moreDialog = + DialogParams( + fromLongClick = true, + title = person.name ?: "", + items = items, + ) + }, + onLongClickSimilar = { index, similar -> + val items = + buildMoreDialogItemsForHome( + context = context, + item = similar, + seriesId = null, + playbackPosition = similar.playbackPosition, + watched = similar.played, + favorite = similar.favorite, + canDelete = false, + actions = moreActions, + ) + moreDialog = + DialogParams( + fromLongClick = true, + title = similar.title ?: "", + items = items, + ) + }, + trailerOnClick = { + TrailerService.onClick(context, it, viewModel::navigateTo) + }, + onClickExtra = { index, extra -> + viewModel.navigateTo(extra.destination) + }, + onClickDiscover = { index, item -> + viewModel.navigateTo(item.destination) + }, + canDelete = state.canDelete, + deleteOnClick = { showDeleteDialog = state.movie }, + modifier = modifier, + ) } } overviewDialog?.let { info -> @@ -403,13 +385,7 @@ private const val DISCOVER_ROW = SIMILAR_ROW + 1 fun MovieDetailsContent( preferences: UserPreferences, movie: BaseItem, - chosenStreams: ChosenStreams?, - people: List, - chapters: List, - trailers: List, - extras: List, - similar: List, - discovered: List, + state: MovieState, playOnClick: (Duration) -> Unit, trailerOnClick: (Trailer) -> Unit, overviewOnClick: () -> Unit, @@ -454,7 +430,7 @@ fun MovieDetailsContent( MovieDetailsHeader( preferences = preferences, movie = movie, - chosenStreams = chosenStreams, + chosenStreams = state.chosenStreams, bringIntoViewRequester = bringIntoViewRequester, overviewOnClick = overviewOnClick, modifier = @@ -481,7 +457,7 @@ fun MovieDetailsContent( } } }, - trailers = trailers, + trailers = state.trailers, trailerOnClick = { position = TRAILER_ROW trailerOnClick.invoke(it) @@ -496,7 +472,7 @@ fun MovieDetailsContent( ) } } - if (people.isNotEmpty()) { + state.people.letNotEmpty { people -> item { PersonRow( people = people, @@ -515,7 +491,7 @@ fun MovieDetailsContent( ) } } - if (chapters.isNotEmpty()) { + state.chapters.letNotEmpty { chapters -> item { ChapterRow( chapters = chapters, @@ -532,7 +508,7 @@ fun MovieDetailsContent( ) } } - if (extras.isNotEmpty()) { + state.extras.letNotEmpty { extras -> item { ExtrasRow( extras = extras, @@ -548,7 +524,7 @@ fun MovieDetailsContent( ) } } - if (similar.isNotEmpty()) { + state.similar.letNotEmpty { similar -> item { val imageHeight = remember(movie.type) { @@ -587,7 +563,7 @@ fun MovieDetailsContent( ) } } - if (discovered.isNotEmpty()) { + state.discovered.letNotEmpty { discovered -> item { DiscoverRow( row = 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 131c0cd6..82518409 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 @@ -1,9 +1,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 @@ -30,36 +28,28 @@ 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 -import com.github.damontecres.wholphin.ui.setValueOnMain -import com.github.damontecres.wholphin.util.ExceptionHandler -import com.github.damontecres.wholphin.util.LoadingExceptionHandler -import com.github.damontecres.wholphin.util.LoadingState +import com.github.damontecres.wholphin.ui.showToast +import com.github.damontecres.wholphin.util.DataLoadingState 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.Job -import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow 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 import org.jellyfin.sdk.api.client.extensions.libraryApi import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.model.api.MediaStreamType import org.jellyfin.sdk.model.api.request.GetSimilarItemsRequest +import timber.log.Timber import java.util.UUID @HiltViewModel(assistedFactory = MovieViewModel.Factory::class) @@ -89,94 +79,99 @@ class MovieViewModel fun create(itemId: UUID): MovieViewModel } - val loading = MutableLiveData(LoadingState.Pending) - val item = MutableLiveData(null) - val trailers = MutableLiveData>(listOf()) - val people = MutableLiveData>(listOf()) - val chapters = MutableLiveData>(listOf()) - val extras = MutableLiveData>(listOf()) - val similar = MutableLiveData>() - val chosenStreams = MutableLiveData(null) - val discovered = MutableStateFlow>(listOf()) - - val canDelete = MutableStateFlow(false) + private val _state = MutableStateFlow(MovieState()) + val state: StateFlow = _state init { init() viewModelScope.launchDefault { - item - .asFlow() - .filterNotNull() - .combinePair(userPreferencesService.flow.map { it.appPreferences }) - .collectLatest { (item, preferences) -> - canDelete.update { mediaManagementService.canDelete(item, preferences) } + userPreferencesService.flow.collectLatest { preferences -> + _state.update { + val canDelete = + it.movie?.let { + mediaManagementService.canDelete( + it, + preferences.appPreferences, + ) + } + it.copy( + canDelete = canDelete ?: false, + ) } + } } } - private fun fetchAndSetItem(): Deferred = - viewModelScope.async( - Dispatchers.IO + - LoadingExceptionHandler( - loading, - "Error fetching movie", - ), - ) { - val item = - api.userLibraryApi.getItem(itemId).content.let { - BaseItem.from(it, api) - } - this@MovieViewModel.item.setValueOnMain(item) - item - } + private suspend fun getMovie(): BaseItem { + val item = + api.userLibraryApi.getItem(itemId).content.let { + BaseItem(it) + } + return item + } fun init(): Job = - viewModelScope.launch( - Dispatchers.IO + - LoadingExceptionHandler( - loading, - "Error fetching movie", - ), - ) { - val item = fetchAndSetItem().await() - val result = + viewModelScope.launchDefault { + val movie = + try { + getMovie() + } catch (ex: Exception) { + Timber.e(ex, "Failed to fetch movie %s", itemId) + _state.update { it.copy(loading = DataLoadingState.Error(ex)) } + return@launchDefault + } + val chosenStreams = itemPlaybackRepository.getSelectedTracks( - item.id, - item, + itemId, + movie, userPreferencesService.getCurrent(), ) - val remoteTrailers = trailerService.getRemoteTrailers(item) - withContext(Dispatchers.Main) { - this@MovieViewModel.item.value = item - chosenStreams.value = result - this@MovieViewModel.trailers.value = remoteTrailers - loading.value = LoadingState.Success - backdropService.submit(item) + val remoteTrailers = trailerService.getRemoteTrailers(movie) + val chapters = Chapter.fromDto(movie.data, api) + _state.update { + it.copy( + loading = DataLoadingState.Success(movie), + chosenStreams = chosenStreams, + trailers = remoteTrailers, + chapters = chapters, + ) } + backdropService.submit(movie) viewModelScope.launchIO { - trailerService.getLocalTrailers(item).letNotEmpty { localTrailers -> - withContext(Dispatchers.Main) { - this@MovieViewModel.trailers.value = localTrailers + remoteTrailers + trailerService.getLocalTrailers(movie).letNotEmpty { localTrailers -> + _state.update { + it.copy( + trailers = localTrailers + remoteTrailers, + ) } } } viewModelScope.launchIO { - val people = peopleFavorites.getPeopleFor(item) - this@MovieViewModel.people.setValueOnMain(people) + val people = peopleFavorites.getPeopleFor(movie) + _state.update { + it.copy( + people = people, + ) + } } viewModelScope.launchIO { - val extras = extrasService.getExtras(item.id) - this@MovieViewModel.extras.setValueOnMain(extras) + val extras = extrasService.getExtras(itemId) + _state.update { + it.copy( + extras = extras, + ) + } } viewModelScope.launchIO { - val results = seerrService.similar(item).orEmpty() - discovered.update { results } + val results = seerrService.similar(movie).orEmpty() + _state.update { + it.copy( + discovered = results, + ) + } } - withContext(Dispatchers.Main) { - chapters.value = Chapter.fromDto(item.data, api) - } - if (!similar.isInitialized) { + if (state.value.similar.isEmpty()) { val similar = api.libraryApi .getSimilarItems( @@ -187,31 +182,48 @@ class MovieViewModel limit = 25, ), ).content.items - .map { BaseItem.Companion.from(it, api) } - this@MovieViewModel.similar.setValueOnMain(similar) + .map { BaseItem(it) } + + _state.update { it.copy(similar = similar) } } } fun setWatched( itemId: UUID, played: Boolean, - ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { - favoriteWatchManager.setWatched(itemId, played) - fetchAndSetItem() + ) = viewModelScope.launchDefault { + try { + favoriteWatchManager.setWatched(itemId, played) + getMovie().let { movie -> + _state.update { + it.copy(loading = DataLoadingState.Success(movie)) + } + } + } catch (ex: Exception) { + Timber.e(ex, "Error updating watch status for movie %s", itemId) + showToast(context, "Something went wrong...") + } } fun setFavorite( itemId: UUID, favorite: Boolean, - ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { - favoriteWatchManager.setFavorite(itemId, favorite) - val item = item.value - fetchAndSetItem() - if (item != null && itemId != item.id) { - viewModelScope.launchIO { - val people = peopleFavorites.getPeopleFor(item) - this@MovieViewModel.people.setValueOnMain(people) + ) = viewModelScope.launchDefault { + try { + favoriteWatchManager.setFavorite(itemId, favorite) + val movie = getMovie() + _state.update { + it.copy(loading = DataLoadingState.Success(movie)) } + if (itemId != movie.id) { + viewModelScope.launchIO { + val people = peopleFavorites.getPeopleFor(movie) + _state.update { it.copy(people = people) } + } + } + } catch (ex: Exception) { + Timber.e(ex, "Error updating favorite %s", itemId) + showToast(context, "Something went wrong...") } } @@ -227,9 +239,7 @@ class MovieViewModel result?.let { itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs) } - withContext(Dispatchers.Main) { - chosenStreams.value = chosen - } + _state.update { it.copy(chosenStreams = chosen) } } } @@ -253,9 +263,7 @@ class MovieViewModel result?.let { itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs) } - withContext(Dispatchers.Main) { - chosenStreams.value = chosen - } + _state.update { it.copy(chosenStreams = chosen) } } } @@ -283,14 +291,14 @@ class MovieViewModel fun clearChosenStreams(chosenStreams: ChosenStreams?) { viewModelScope.launchIO { itemPlaybackRepository.deleteChosenStreams(chosenStreams) - item.value?.let { item -> + state.value.movie?.let { item -> val result = itemPlaybackRepository.getSelectedTracks( itemId, item, userPreferencesService.getCurrent(), ) - this@MovieViewModel.chosenStreams.setValueOnMain(result) + _state.update { it.copy(chosenStreams = result) } } } } @@ -301,3 +309,17 @@ class MovieViewModel } } } + +data class MovieState( + val loading: DataLoadingState = DataLoadingState.Pending, + val trailers: List = emptyList(), + val people: List = emptyList(), + val chapters: List = emptyList(), + val extras: List = emptyList(), + val similar: List = emptyList(), + val discovered: List = emptyList(), + val chosenStreams: ChosenStreams? = null, + val canDelete: Boolean = false, +) { + val movie: BaseItem? = (loading as? DataLoadingState.Success)?.data +} From 5ab4bb712bebbced124abb69c6b6e471ab313179 Mon Sep 17 00:00:00 2001 From: Leon Omelan Date: Mon, 6 Apr 2026 16:11:01 +0200 Subject: [PATCH 083/148] performance: reduce recompositons in PersonCard and SeasonCard (#1184) ## Description related docs: https://developer.android.com/develop/ui/compose/performance/bestpractices#defer-reads Removed setting padding with animated value, instead moved to lambda offset and layout modifiers to increase performance and reduce recompositions when focusing those cards Split from #1152 ### Related issues ### Testing Tested on a emulato ## Screenshots |Component | Pre | Post | |---|--------|--------| |Person Card|Screenshot 2026-04-02 at
08 19 18 | Screenshot 2026-04-02 at 08 22
06 | |Season Card| Screenshot 2026-04-02
at 08 18 31 |Screenshot 2026-04-02 at 08 24
06 | ## AI or LLM usage Gemini in AS was used for repetitive code completion --- .../wholphin/ui/cards/PersonCard.kt | 26 ++++++++++++++----- .../wholphin/ui/cards/SeasonCard.kt | 26 ++++++++++++++----- 2 files changed, 40 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/PersonCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/PersonCard.kt index 2ce33e09..4e146134 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/PersonCard.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/PersonCard.kt @@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape @@ -25,9 +26,11 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.layout import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.tv.material3.Border @@ -80,7 +83,7 @@ fun PersonCard( ) { val hideOverlayDelay = 1_000L - val focused = interactionSource.collectIsFocusedAsState().value + val focused by interactionSource.collectIsFocusedAsState() var focusedAfterDelay by remember { mutableStateOf(false) } if (focused) { @@ -95,10 +98,13 @@ fun PersonCard( } else { focusedAfterDelay = false } - val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp) - val spaceBelow by animateDpAsState(if (focused) 4.dp else 12.dp) + + // Do not use `by` here, this way we are Defer reads and recompositions to only when modifier calculates + val spaceBetweenState = animateDpAsState(if (focused) 12.dp else 4.dp, label = "spaceBetween") + val spaceBelowState = animateDpAsState(if (focused) 4.dp else 12.dp, label = "spaceBelow") + Column( - verticalArrangement = Arrangement.spacedBy(spaceBetween), + verticalArrangement = Arrangement.spacedBy(4.dp), // Fixed base spacing modifier = modifier, ) { Card( @@ -168,8 +174,16 @@ fun PersonCard( verticalArrangement = Arrangement.spacedBy(0.dp), modifier = Modifier - .padding(bottom = spaceBelow) - .fillMaxWidth(), + // Optimization: move animation reads to layout/draw phase + .offset { + IntOffset(0, (spaceBetweenState.value - 4.dp).roundToPx()) + }.layout { measurable, constraints -> + val paddingPx = spaceBelowState.value.roundToPx() + val placeable = measurable.measure(constraints) + layout(placeable.width, placeable.height + paddingPx) { + placeable.placeRelative(0, 0) + } + }.fillMaxWidth(), ) { Text( text = name ?: "", diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SeasonCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SeasonCard.kt index b066d372..962ab727 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SeasonCard.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SeasonCard.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable @@ -18,10 +19,12 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.layout import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.tv.material3.Card import androidx.tv.material3.CardDefaults @@ -52,7 +55,7 @@ fun SeasonCard( val imageUrlService = LocalImageUrlService.current val density = LocalDensity.current val imageUrl = - remember(item, imageHeight, imageWidth) { + remember(item, imageHeight, imageWidth, density) { if (item != null) { val fillHeight = if (imageHeight != Dp.Unspecified) { @@ -125,14 +128,17 @@ fun SeasonCard( aspectRatio: Float = AspectRatios.TALL, ) { val focused by interactionSource.collectIsFocusedAsState() - val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp) - val spaceBelow by animateDpAsState(if (focused) 4.dp else 12.dp) + // Do not use `by` here, this way we are Defer reads and recompositions to only when modifier calculates + val spaceBetween = animateDpAsState(if (focused) 12.dp else 4.dp, label = "spaceBetween") + val spaceBelow = animateDpAsState(if (focused) 4.dp else 12.dp, label = "spaceBelow") + val focusedAfterDelay by rememberFocusedAfterDelay(interactionSource) val aspectRationToUse = aspectRatio.coerceAtLeast(AspectRatios.MIN) val width = imageHeight * aspectRationToUse val height = imageWidth * (1f / aspectRationToUse) + Column( - verticalArrangement = Arrangement.spacedBy(spaceBetween), + verticalArrangement = Arrangement.spacedBy(4.dp), // Fixed base spacing modifier = modifier.size(width, height), ) { Card( @@ -173,8 +179,16 @@ fun SeasonCard( verticalArrangement = Arrangement.spacedBy(0.dp), modifier = Modifier - .padding(bottom = spaceBelow) - .fillMaxWidth(), + // Optimization: move animation reads to layout/draw phase + .offset { + IntOffset(0, (spaceBetween.value - 4.dp).roundToPx()) + }.layout { measurable, constraints -> + val paddingPx = spaceBelow.value.roundToPx() + val placeable = measurable.measure(constraints) + layout(placeable.width, placeable.height + paddingPx) { + placeable.placeRelative(0, 0) + } + }.fillMaxWidth(), ) { Text( text = title ?: "", From 1366ee744d2a510bc5a9f0e1fecb6e6135713ad8 Mon Sep 17 00:00:00 2001 From: Leon Omelan Date: Mon, 6 Apr 2026 17:23:54 +0200 Subject: [PATCH 084/148] perfomance: Reduce recompositions in SeriesOverviewContent when navigating (#1185) ## Description Make alpha channel and background change in Banner Card more recomposition friendly. Split from #1152 ### Related issues ### Testing Tested on a emulator It's observed when moving from TV series episode card to EpisodeFooter and back ## Screenshots | Pre | Post | |--------|--------| | Screenshot 2026-04-02 at 08 41 40 | Screenshot 2026-04-02 at 08 44
56 | ## AI or LLM usage Gemini in AS was used for repetitive code completion --- .../ui/detail/series/SeriesOverviewContent.kt | 36 +++++++++---------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt index eeb1607c..34a9539a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt @@ -40,6 +40,7 @@ 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.graphics.graphicsLayer import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.res.stringResource @@ -260,14 +261,21 @@ fun SeriesOverviewContent( ).ifElse( episodeIndex == epPosition, Modifier.focusRequester(episodeRowFocusRequester), - ).ifElse( - episodeIndex != position.episodeRowIndex, - Modifier - .background( - Color.Black, - shape = RoundedCornerShape(8.dp), - ).alpha(dimming), - ).onFocusChanged { + ).background( + if (episodeIndex != position.episodeRowIndex) { + Color.Black + } else { + Color.Transparent + }, + shape = RoundedCornerShape(8.dp), + ).graphicsLayer { + alpha = + if (episodeIndex != position.episodeRowIndex) { + dimming + } else { + 1f + } + }.onFocusChanged { if (it.isFocused) { scope.launch { bringIntoViewRequester.bringIntoView() @@ -319,17 +327,9 @@ fun SeriesOverviewContent( } } - val castAndCrew = + val (guestStars, castAndCrew) = remember(peopleInEpisode) { - peopleInEpisode.filterNot { - it.type == PersonKind.GUEST_STAR - } - } - val guestStars = - remember(peopleInEpisode) { - peopleInEpisode.filter { - it.type == PersonKind.GUEST_STAR - } + peopleInEpisode.partition { it.type == PersonKind.GUEST_STAR } } AnimatedVisibility( From 9a3af225a4bb3c4fff0f32d58a9d36703152db54 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Mon, 6 Apr 2026 13:18:55 -0400 Subject: [PATCH 085/148] View more discover results (#1198) ## Description Adds a card at the end of the rows on the discover page/tab to view more results. When clicking this card, it goes to a grid view of more results starting focused on the next one. ### Dev notes This PR modifies `ApiRequestPager` to move most of its functionality into an abstract class, `RequestPager`. And then add a new concrete class, `DiscoverRequestPager`, for Seerr API paging. `ApiRequestPager` remains for Jellyfin paging. ### Related issues Related to #1035 ### Testing Emulator ## Screenshots ![discover_view_more Large](https://github.com/user-attachments/assets/180aa7a5-45c3-4544-b3f8-e3d394859178) ![discover_grid Large](https://github.com/user-attachments/assets/ae5e8fbf-0fa4-4a2e-96ac-629ae688c05d) ## AI or LLM usage None --- .../wholphin/services/SeerrService.kt | 12 ++ .../wholphin/ui/cards/DiscoverItemCard.kt | 99 ++++++++- .../damontecres/wholphin/ui/cards/ItemRow.kt | 21 +- .../ui/components/CollectionFolderGrid.kt | 26 ++- .../wholphin/ui/detail/PersonPage.kt | 2 + .../wholphin/ui/detail/movie/MovieDetails.kt | 2 + .../ui/detail/series/SeriesDetails.kt | 2 + .../ui/discover/DiscoverRequestGrid.kt | 198 ++++++++++++++++++ .../wholphin/ui/discover/DiscoverRow.kt | 194 +++++++++++++++++ .../wholphin/ui/discover/SeerrDiscoverPage.kt | 172 +++++++-------- .../wholphin/ui/nav/Destination.kt | 7 + .../wholphin/ui/nav/DestinationContent.kt | 9 + .../wholphin/util/ApiRequestPager.kt | 166 +++------------ .../wholphin/util/DiscoverRequestPager.kt | 111 ++++++++++ .../damontecres/wholphin/util/RequestPager.kt | 148 +++++++++++++ app/src/main/res/values/strings.xml | 3 + 16 files changed, 926 insertions(+), 246 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverRequestGrid.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverRow.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/util/DiscoverRequestPager.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/util/RequestPager.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrService.kt index 0c1801ac..d9a340c9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrService.kt @@ -191,6 +191,18 @@ class SeerrService return "${base}${prefix}$path" } + suspend fun createDiscoverItem(item: Any): DiscoverItem = + when (item) { + is MovieResult -> createDiscoverItem(item) + is MovieDetails -> createDiscoverItem(item) + is TvResult -> createDiscoverItem(item) + is TvDetails -> createDiscoverItem(item) + is SeerrSearchResult -> createDiscoverItem(item) + is CreditCast -> createDiscoverItem(item) + is CreditCrew -> createDiscoverItem(item) + else -> throw IllegalArgumentException("Unsupported type ${item::class.qualifiedName}") + } + suspend fun createDiscoverItem(movie: MovieResult): DiscoverItem = DiscoverItem( id = movie.id, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/DiscoverItemCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/DiscoverItemCard.kt index 7f9129d0..93e9adcc 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/DiscoverItemCard.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/DiscoverItemCard.kt @@ -14,6 +14,8 @@ import androidx.compose.foundation.layout.fillMaxWidth 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.ArrowForward import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -33,8 +35,10 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.tv.material3.Card import androidx.tv.material3.CardDefaults +import androidx.tv.material3.Icon 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.data.model.DiscoverItem import com.github.damontecres.wholphin.data.model.SeerrAvailability @@ -56,6 +60,7 @@ fun DiscoverItemCard( modifier: Modifier = Modifier, showOverlay: Boolean = true, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + width: Dp = Cards.height2x3 * AspectRatios.TALL, ) { val focused by interactionSource.collectIsFocusedAsState() val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp) @@ -75,16 +80,15 @@ fun DiscoverItemCard( } else { focusedAfterDelay = false } - val width = Cards.height2x3 * AspectRatios.TALL - val height = Dp.Unspecified * (1f / AspectRatios.TALL) Column( + horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(spaceBetween), - modifier = modifier.size(width, height), + modifier = modifier.size(width, Dp.Unspecified), ) { Card( modifier = Modifier - .size(Dp.Unspecified, Cards.height2x3) + .size(width, Dp.Unspecified) .aspectRatio(AspectRatios.TALL), onClick = onClick, onLongClick = onLongClick, @@ -284,6 +288,88 @@ fun PartiallyAvailableIndicator(modifier: Modifier = Modifier) { } } +@Composable +fun DiscoverViewMoreCard( + onClick: () -> Unit, + onLongClick: () -> Unit, + modifier: Modifier = Modifier, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, +) { + val focused by interactionSource.collectIsFocusedAsState() + val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp) + val spaceBelow by animateDpAsState(if (focused) 4.dp else 12.dp) + var focusedAfterDelay by remember { mutableStateOf(false) } + + val hideOverlayDelay = 500L + if (focused) { + LaunchedEffect(Unit) { + delay(hideOverlayDelay) + if (focused) { + focusedAfterDelay = true + } else { + focusedAfterDelay = false + } + } + } else { + focusedAfterDelay = false + } + val width = Cards.height2x3 * AspectRatios.TALL + val height = Dp.Unspecified * (1f / AspectRatios.TALL) + Column( + verticalArrangement = Arrangement.spacedBy(spaceBetween), + modifier = modifier.size(width, height), + ) { + Card( + modifier = + Modifier + .size(Dp.Unspecified, Cards.height2x3) + .aspectRatio(AspectRatios.TALL), + onClick = onClick, + onLongClick = onLongClick, + interactionSource = interactionSource, + colors = + CardDefaults.colors( + containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp), + ), + ) { + Box( + modifier = + Modifier + .fillMaxSize(), + ) { + Icon( + imageVector = Icons.Default.ArrowForward, + tint = MaterialTheme.colorScheme.onSurface, + contentDescription = "View more", + modifier = Modifier.fillMaxSize(), + ) + } + } + + Column( + verticalArrangement = Arrangement.spacedBy(0.dp), + modifier = + Modifier + .padding(bottom = spaceBelow) + .fillMaxWidth(), + ) { + Text( + text = stringResource(R.string.view_more), + maxLines = 1, + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp) + .enableMarquee(focusedAfterDelay), + ) + } + } +} + @PreviewTvSpec @Composable private fun Preview() { @@ -292,6 +378,11 @@ private fun Preview() { PendingIndicator() AvailableIndicator() PartiallyAvailableIndicator() + DiscoverViewMoreCard( + onClick = {}, + onLongClick = {}, + modifier = Modifier, + ) } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt index 5b929a9b..c9f6a142 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable +import androidx.compose.runtime.NonRestartableComposable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState @@ -59,12 +60,8 @@ fun ItemRow( } }, ) { - Text( - text = title, - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onBackground, - modifier = Modifier.padding(start = 8.dp), - ) + ItemRowTitle(title) + LazyRow( state = state, horizontalArrangement = Arrangement.spacedBy(horizontalPadding), @@ -113,3 +110,15 @@ fun ItemRow( } } } + +@Composable +@NonRestartableComposable +fun ItemRowTitle( + title: String, + modifier: Modifier = Modifier, +) = Text( + text = title, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onBackground, + modifier = modifier.padding(start = 8.dp), +) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt index 4c8090e3..ae945726 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt @@ -21,6 +21,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.NonRestartableComposable import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf @@ -882,15 +883,7 @@ fun CollectionFolderGridContent( modifier = Modifier.fillMaxWidth(), ) { if (showTitle) { - Text( - text = title, - style = MaterialTheme.typography.displayMedium, - color = MaterialTheme.colorScheme.onBackground, - textAlign = TextAlign.Center, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.fillMaxWidth(), - ) + GridTitle(title) } val endPadding = 16.dp + if (sortAndDirection.sort == ItemSortBy.SORT_NAME) 24.dp else 0.dp @@ -1146,3 +1139,18 @@ val CollectionType.baseItemKinds: List listOf() } } + +@Composable +@NonRestartableComposable +fun GridTitle( + title: String, + modifier: Modifier = Modifier, +) = Text( + text = title, + style = MaterialTheme.typography.displayMedium, + color = MaterialTheme.colorScheme.onBackground, + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = modifier.fillMaxWidth(), +) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt index 5a01d8a3..a6a77e23 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt @@ -73,6 +73,7 @@ import com.github.damontecres.wholphin.ui.theme.WholphinTheme import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.DataLoadingState +import com.github.damontecres.wholphin.util.DiscoverRequestType import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.LoadingExceptionHandler import com.github.damontecres.wholphin.util.LoadingState @@ -386,6 +387,7 @@ fun PersonPageContent( DiscoverRowData( stringResource(R.string.discover), DataLoadingState.Success(discovered), + DiscoverRequestType.UNKNOWN, ), onClickItem = { index: Int, item: DiscoverItem -> position = RowColumn(DISCOVER_ROW, index) 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 7538fdd2..31e6233c 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 @@ -70,6 +70,7 @@ import com.github.damontecres.wholphin.ui.letNotEmpty import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.rememberInt import com.github.damontecres.wholphin.util.DataLoadingState +import com.github.damontecres.wholphin.util.DiscoverRequestType import com.github.damontecres.wholphin.util.ExceptionHandler import kotlinx.coroutines.launch import org.jellyfin.sdk.model.api.BaseItemKind @@ -570,6 +571,7 @@ fun MovieDetailsContent( DiscoverRowData( stringResource(R.string.discover), DataLoadingState.Success(discovered), + type = DiscoverRequestType.UNKNOWN, ), onClickItem = { index: Int, item: DiscoverItem -> position = DISCOVER_ROW 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 3a95440f..0e1e6a8b 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 @@ -87,6 +87,7 @@ import com.github.damontecres.wholphin.ui.letNotEmpty import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.rememberInt import com.github.damontecres.wholphin.util.DataLoadingState +import com.github.damontecres.wholphin.util.DiscoverRequestType import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.LoadingState import kotlinx.coroutines.launch @@ -653,6 +654,7 @@ fun SeriesDetailsContent( DiscoverRowData( stringResource(R.string.discover), DataLoadingState.Success(discovered), + type = DiscoverRequestType.UNKNOWN, ), onClickItem = { index: Int, item: DiscoverItem -> position = DISCOVER_ROW diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverRequestGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverRequestGrid.kt new file mode 100644 index 00000000..b3460265 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverRequestGrid.kt @@ -0,0 +1,198 @@ +package com.github.damontecres.wholphin.ui.discover + +import android.content.Context +import androidx.compose.foundation.layout.Column +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +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.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.data.model.DiscoverItem +import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.SeerrApi +import com.github.damontecres.wholphin.services.SeerrService +import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard +import com.github.damontecres.wholphin.ui.components.ErrorMessage +import com.github.damontecres.wholphin.ui.components.GridTitle +import com.github.damontecres.wholphin.ui.components.LoadingPage +import com.github.damontecres.wholphin.ui.detail.CardGrid +import com.github.damontecres.wholphin.ui.launchDefault +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.util.DataLoadingState +import com.github.damontecres.wholphin.util.DiscoverMovieRequestHandler +import com.github.damontecres.wholphin.util.DiscoverRequestPager +import com.github.damontecres.wholphin.util.DiscoverRequestType +import com.github.damontecres.wholphin.util.DiscoverTvRequestHandler +import com.github.damontecres.wholphin.util.TrendingRequestHandler +import com.github.damontecres.wholphin.util.UpcomingMovieRequestHandler +import com.github.damontecres.wholphin.util.UpcomingTvRequestHandler +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.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import timber.log.Timber + +@HiltViewModel(assistedFactory = DiscoverRequestViewModel.Factory::class) +class DiscoverRequestViewModel + @AssistedInject + constructor( + @param:ApplicationContext private val context: Context, + private val seerrService: SeerrService, + val navigationManager: NavigationManager, + private val api: SeerrApi, + @Assisted val type: DiscoverRequestType, + @Assisted startIndex: Int, + ) : ViewModel() { + @AssistedFactory + interface Factory { + fun create( + type: DiscoverRequestType, + startIndex: Int, + ): DiscoverRequestViewModel + } + + private val _state = MutableStateFlow(DiscoverRequestState()) + val state: StateFlow = _state + + init { + viewModelScope.launchDefault { + try { + val pager = + when (type) { + DiscoverRequestType.DISCOVER_TV -> { + DiscoverRequestPager( + api, + DiscoverTvRequestHandler, + seerrService::createDiscoverItem, + viewModelScope, + ) + } + + DiscoverRequestType.DISCOVER_MOVIES -> { + DiscoverRequestPager( + api, + DiscoverMovieRequestHandler, + seerrService::createDiscoverItem, + viewModelScope, + ) + } + + DiscoverRequestType.TRENDING -> { + DiscoverRequestPager( + api, + TrendingRequestHandler, + seerrService::createDiscoverItem, + viewModelScope, + ) + } + + DiscoverRequestType.UPCOMING_TV -> { + DiscoverRequestPager( + api, + UpcomingTvRequestHandler, + seerrService::createDiscoverItem, + viewModelScope, + ) + } + + DiscoverRequestType.UPCOMING_MOVIES -> { + DiscoverRequestPager( + api, + UpcomingMovieRequestHandler, + seerrService::createDiscoverItem, + viewModelScope, + ) + } + + DiscoverRequestType.UNKNOWN -> { + throw IllegalArgumentException("Cannot display grid for DiscoverRequestType.UNKNOWN") + } + }.init(startIndex) + _state.update { + it.copy( + loading = DataLoadingState.Success(pager), + ) + } + } catch (ex: Exception) { + Timber.e(ex, "Error initializing %s", type) + _state.update { + it.copy( + loading = DataLoadingState.Error(ex), + ) + } + } + } + } + } + +data class DiscoverRequestState( + val loading: DataLoadingState> = DataLoadingState.Pending, +) + +@Composable +fun DiscoverRequestGrid( + destination: Destination.DiscoverMoreResult, + modifier: Modifier = Modifier, + viewModel: DiscoverRequestViewModel = + hiltViewModel( + creationCallback = { it.create(destination.type, destination.startIndex) }, + ), +) { + val state by viewModel.state.collectAsState() + when (val s = state.loading) { + DataLoadingState.Pending, + DataLoadingState.Loading, + -> { + LoadingPage(modifier) + } + + is DataLoadingState.Error -> { + ErrorMessage(s, modifier) + } + + is DataLoadingState.Success> -> { + val gridFocusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { gridFocusRequester.tryRequestFocus() } + Column( + modifier = modifier, + ) { + GridTitle(stringResource(destination.type.stringRes)) + + CardGrid( + initialPosition = destination.startIndex, + pager = s.data, + onClickItem = { index, item -> + viewModel.navigationManager.navigateTo(Destination.DiscoveredItem(item)) + }, + onLongClickItem = { index, item -> }, + onClickPlay = { _, _ -> }, + letterPosition = { 0 }, + gridFocusRequester = gridFocusRequester, + showJumpButtons = false, + showLetterButtons = false, + cardContent = { item, onClick, onLongClick, mod -> + DiscoverItemCard( + item = item, + onClick = onClick, + onLongClick = onLongClick, + modifier = mod, + width = Dp.Unspecified, + ) + }, + ) + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverRow.kt new file mode 100644 index 00000000..806a816a --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverRow.kt @@ -0,0 +1,194 @@ +package com.github.damontecres.wholphin.ui.discover + +import androidx.compose.foundation.focusGroup +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.lazy.LazyRow +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +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.focusRestorer +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.Dp +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.data.model.DiscoverItem +import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard +import com.github.damontecres.wholphin.ui.cards.DiscoverViewMoreCard +import com.github.damontecres.wholphin.ui.cards.ItemRowTitle +import com.github.damontecres.wholphin.ui.components.ErrorMessage +import com.github.damontecres.wholphin.ui.ifElse +import com.github.damontecres.wholphin.ui.rememberInt +import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.util.DataLoadingState + +@Composable +fun DiscoverRow( + row: DiscoverRowData, + onClickItem: (Int, DiscoverItem) -> Unit, + onLongClickItem: (Int, DiscoverItem) -> Unit, + onCardFocus: (Int) -> Unit, + focusRequester: FocusRequester, + modifier: Modifier = Modifier, + enableViewMore: Boolean = false, + onClickViewMore: () -> Unit = {}, +) { + when (val state = row.items) { + is DataLoadingState.Error -> { + ErrorMessage(state.message, state.exception, modifier) + } + + DataLoadingState.Loading, + DataLoadingState.Pending, + -> { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier, + ) { + Text( + text = row.title, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onBackground, + ) + Text( + text = stringResource(R.string.loading), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onBackground, + ) + } + } + + is DataLoadingState.Success> -> { + DiscoverItemRow( + title = row.title, + items = state.data, + onClickItem = onClickItem, + onLongClickItem = onLongClickItem, + onCardFocus = onCardFocus, + enableViewMore = enableViewMore, + onClickViewMore = onClickViewMore, + modifier = modifier.focusRequester(focusRequester), + ) + } + } +} + +@Composable +fun DiscoverItemRow( + title: String, + items: List, + onClickItem: (Int, DiscoverItem) -> Unit, + onLongClickItem: (Int, DiscoverItem) -> Unit, + onCardFocus: (Int) -> Unit, + enableViewMore: Boolean, + modifier: Modifier = Modifier, + horizontalPadding: Dp = 16.dp, + onClickViewMore: () -> Unit = {}, +) { + val state = rememberLazyListState() + val firstFocus = remember { FocusRequester() } + val focusRequester = remember { FocusRequester() } + var position by rememberInt() + + val currentOnClickItem by rememberUpdatedState(onClickItem) + val currentOnLongClickItem by rememberUpdatedState(onLongClickItem) + val currentOnCardFocus by rememberUpdatedState(onCardFocus) + + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = + modifier.focusProperties { + onEnter = { + focusRequester.tryRequestFocus() + } + }, + ) { + ItemRowTitle(title) + + LazyRow( + state = state, + horizontalArrangement = Arrangement.spacedBy(horizontalPadding), + contentPadding = PaddingValues(horizontal = horizontalPadding, vertical = 8.dp), + modifier = + Modifier + .fillMaxWidth() + .focusGroup() + .focusRestorer(firstFocus) + .focusRequester(focusRequester), + ) { + itemsIndexed(items) { index, item -> + val cardModifier = + remember(index, position) { + if (index == position) { + Modifier.focusRequester(firstFocus) + } else { + Modifier + }.onFocusChanged { + if (it.isFocused) { + currentOnCardFocus.invoke(index) + } + } + } + + val onClick = + remember(index, item) { + { + position = index + if (item != null) currentOnClickItem(index, item) + } + } + + val onLongClick = + remember(index, item) { + { + position = index + if (item != null) currentOnLongClickItem(index, item) + } + } + + DiscoverItemCard( + item = item, + onClick = onClick, + onLongClick = onLongClick, + showOverlay = true, + modifier = cardModifier, + ) + } + if (enableViewMore) { + item { + DiscoverViewMoreCard( + onClick = + remember { + { + position = items.size + onClickViewMore.invoke() + } + }, + onLongClick = {}, + modifier = + Modifier + .ifElse(items.size == position, Modifier.focusRequester(firstFocus)) + .onFocusChanged { + if (it.isFocused) { + currentOnCardFocus.invoke(items.size) + } + }, + ) + } + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt index 4f09ee9d..6c5b299a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt @@ -23,18 +23,14 @@ 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.focus.focusRestorer -import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.platform.LocalDensity -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 androidx.tv.material3.MaterialTheme -import androidx.tv.material3.Text import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.api.seerr.infrastructure.ClientException import com.github.damontecres.wholphin.data.model.DiscoverItem import com.github.damontecres.wholphin.data.model.DiscoverRating import com.github.damontecres.wholphin.data.model.SeerrItemType @@ -42,23 +38,22 @@ 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.services.SeerrService -import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard -import com.github.damontecres.wholphin.ui.cards.ItemRow -import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.data.RowColumn import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.listToDotString import com.github.damontecres.wholphin.ui.main.HomePageHeader +import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.rememberPosition import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.util.ScrollToTopBringIntoViewSpec import com.github.damontecres.wholphin.util.DataLoadingState +import com.github.damontecres.wholphin.util.DiscoverRequestType import com.google.common.cache.CacheBuilder import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update -import org.jellyfin.sdk.api.client.ApiClient +import timber.log.Timber import javax.inject.Inject @HiltViewModel @@ -68,7 +63,6 @@ class SeerrDiscoverViewModel @param:ApplicationContext private val context: Context, private val seerrService: SeerrService, val navigationManager: NavigationManager, - private val api: ApiClient, private val backdropService: BackdropService, ) : ViewModel() { val state = MutableStateFlow(DiscoverState()) @@ -79,24 +73,53 @@ class SeerrDiscoverViewModel backdropService.clearBackdrop() } fetchAndUpdateState(seerrService::discoverMovies) { - this.copy(movies = DiscoverRowData(context.getString(R.string.movies), it)) + this.copy( + movies = + DiscoverRowData( + context.getString(R.string.movies), + it, + DiscoverRequestType.DISCOVER_MOVIES, + ), + ) } fetchAndUpdateState(seerrService::discoverTv) { - this.copy(tv = DiscoverRowData(context.getString(R.string.tv_shows), it)) + this.copy( + tv = + DiscoverRowData( + context.getString(R.string.tv_shows), + it, + DiscoverRequestType.DISCOVER_TV, + ), + ) } fetchAndUpdateState(seerrService::trending) { - this.copy(trending = DiscoverRowData(context.getString(R.string.trending), it)) + this.copy( + trending = + DiscoverRowData( + context.getString(R.string.trending), + it, + DiscoverRequestType.TRENDING, + ), + ) } fetchAndUpdateState(seerrService::upcomingMovies) { this.copy( upcomingMovies = - DiscoverRowData(context.getString(R.string.upcoming_movies), it), + DiscoverRowData( + context.getString(R.string.upcoming_movies), + it, + DiscoverRequestType.UPCOMING_MOVIES, + ), ) } fetchAndUpdateState(seerrService::upcomingTv) { this.copy( upcomingTv = - DiscoverRowData(context.getString(R.string.upcoming_tv), it), + DiscoverRowData( + context.getString(R.string.upcoming_tv), + it, + DiscoverRequestType.UPCOMING_TV, + ), ) } } @@ -127,6 +150,8 @@ class SeerrDiscoverViewModel if (item != null) { backdropService.submit("discover_${item.id}", item.backDropUrl) fetchRating(item) + } else { + backdropService.clearBackdrop() } } } @@ -145,26 +170,39 @@ class SeerrDiscoverViewModel return@launchIO } val result = - when (item.type) { - SeerrItemType.MOVIE -> { - DiscoverRating( - seerrService.api.moviesApi.movieMovieIdRatingsGet( - movieId = item.id, - ), - ) - } + try { + when (item.type) { + SeerrItemType.MOVIE -> { + DiscoverRating( + seerrService.api.moviesApi.movieMovieIdRatingsGet( + movieId = item.id, + ), + ) + } - SeerrItemType.TV -> { - DiscoverRating(seerrService.api.tvApi.tvTvIdRatingsGet(tvId = item.id)) - } + SeerrItemType.TV -> { + DiscoverRating(seerrService.api.tvApi.tvTvIdRatingsGet(tvId = item.id)) + } - SeerrItemType.PERSON -> { - DiscoverRating(null, null) - } - - SeerrItemType.UNKNOWN -> { + SeerrItemType.PERSON -> { + DiscoverRating(null, null) + } + + SeerrItemType.UNKNOWN -> { + DiscoverRating(null, null) + } + } + } catch (ex: ClientException) { + if (ex.statusCode == 404) { + Timber.w("No rating found for %s", item.id) DiscoverRating(null, null) + } else { + Timber.e(ex, "Error getting rating for %s", item.id) + return@launchIO } + } catch (ex: Exception) { + Timber.e(ex, "Error getting rating for %s", item.id) + return@launchIO } ratingCache.put(item.id, result) rating.update { @@ -177,9 +215,10 @@ class SeerrDiscoverViewModel data class DiscoverRowData( val title: String, val items: DataLoadingState>, + val type: DiscoverRequestType, ) { companion object { - val EMPTY = DiscoverRowData("", DataLoadingState.Pending) + val EMPTY = DiscoverRowData("", DataLoadingState.Pending, DiscoverRequestType.UNKNOWN) } } @@ -286,6 +325,15 @@ fun SeerrDiscoverPage( onLongClickItem = { index, item -> }, onCardFocus = { index -> position = RowColumn(rowIndex, index) }, focusRequester = focusRequesters[rowIndex], + enableViewMore = row.type != DiscoverRequestType.UNKNOWN, + onClickViewMore = { + (row.items as? DataLoadingState.Success>)?.data?.size?.let { + position = RowColumn(rowIndex, it) + } + viewModel.navigationManager.navigateTo( + Destination.DiscoverMoreResult(row.type), + ) + }, modifier = Modifier .fillMaxWidth(), @@ -296,63 +344,3 @@ fun SeerrDiscoverPage( } } } - -@Composable -fun DiscoverRow( - row: DiscoverRowData, - onClickItem: (Int, DiscoverItem) -> Unit, - onLongClickItem: (Int, DiscoverItem) -> Unit, - onCardFocus: (Int) -> Unit, - focusRequester: FocusRequester, - modifier: Modifier = Modifier, -) { - when (val state = row.items) { - is DataLoadingState.Error -> { - ErrorMessage(state.message, state.exception, modifier) - } - - DataLoadingState.Loading, - DataLoadingState.Pending, - -> { - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = modifier, - ) { - Text( - text = row.title, - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onBackground, - ) - Text( - text = stringResource(R.string.loading), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onBackground, - ) - } - } - - is DataLoadingState.Success> -> { - ItemRow( - title = row.title, - items = state.data, - onClickItem = onClickItem, - onLongClickItem = onLongClickItem, - cardContent = { index: Int, item: DiscoverItem?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit -> - DiscoverItemCard( - item = item, - onClick = onClick, - onLongClick = onLongClick, - showOverlay = true, - modifier = - mod.onFocusChanged { - if (it.isFocused) { - onCardFocus.invoke(index) - } - }, - ) - }, - modifier = modifier.focusRequester(focusRequester), - ) - } - } -} 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 936ce69f..df6053ad 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 @@ -12,6 +12,8 @@ import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.ui.data.SortAndDirection import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption +import com.github.damontecres.wholphin.util.DiscoverRequestType +import com.github.damontecres.wholphin.util.SEERR_PAGE_SIZE import kotlinx.serialization.Serializable import kotlinx.serialization.UseSerializers import org.jellyfin.sdk.model.api.BaseItemKind @@ -129,6 +131,11 @@ sealed class Destination( val item: DiscoverItem, ) : Destination(false) + data class DiscoverMoreResult( + val type: DiscoverRequestType, + val startIndex: Int = SEERR_PAGE_SIZE, + ) : Destination(false) + @Serializable data object NowPlaying : 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 5591f56d..ca80bb66 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 @@ -35,6 +35,7 @@ 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 +import com.github.damontecres.wholphin.ui.discover.DiscoverRequestGrid import com.github.damontecres.wholphin.ui.main.HomePage import com.github.damontecres.wholphin.ui.main.SearchPage import com.github.damontecres.wholphin.ui.main.settings.HomeSettingsPage @@ -353,6 +354,14 @@ fun DestinationContent( } } } + + is Destination.DiscoverMoreResult -> { + LaunchedEffect(Unit) { onClearBackdrop.invoke() } + DiscoverRequestGrid( + destination = destination, + modifier = modifier, + ) + } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/ApiRequestPager.kt b/app/src/main/java/com/github/damontecres/wholphin/util/ApiRequestPager.kt index 08f9318e..f57dfa46 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/ApiRequestPager.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/ApiRequestPager.kt @@ -1,18 +1,10 @@ package com.github.damontecres.wholphin.util -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.ui.DEFAULT_PAGE_SIZE -import com.google.common.cache.CacheBuilder import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlinx.serialization.Serializable import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.Response import org.jellyfin.sdk.api.client.extensions.genresApi @@ -35,111 +27,36 @@ import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest import org.jellyfin.sdk.model.api.request.GetSuggestionsRequest import timber.log.Timber import java.util.UUID -import java.util.function.Predicate /** - * Handles paging for an API request. You must call [init] prior to use. - * - * Initially, items returned will be null, but requesting the items triggers API calls in the given [CoroutineScope]. - * Since the items are stored in [androidx.compose.runtime.MutableState], it will automatically trigger recompositions when the items are ultimately fetched. - * - * Finally, items are cached allow for backward and forward scrolling. + * A [RequestPager] for Jellyfin server queries */ class ApiRequestPager( val api: ApiClient, val request: T, val requestHandler: RequestHandler, - private val scope: CoroutineScope, - private val pageSize: Int = DEFAULT_PAGE_SIZE, + scope: CoroutineScope, + pageSize: Int = DEFAULT_PAGE_SIZE, cacheSize: Long = 8, private val useSeriesForPrimary: Boolean = false, -) : AbstractList(), - BlockingList { - private var items by mutableStateOf(ItemList(0, pageSize, mapOf())) - private var totalCount by mutableIntStateOf(-1) - private val mutex = Mutex() - private val cachedPages = - CacheBuilder - .newBuilder() - .maximumSize(cacheSize) - .build>() +) : RequestPager(scope, pageSize, cacheSize) { + override suspend fun init(initialPosition: Int): ApiRequestPager = super.init(initialPosition) as ApiRequestPager - suspend fun init(initialPosition: Int = 0): ApiRequestPager { - if (totalCount < 0) { - fetchPageBlocking(initialPosition, true) - } - return this - } - - override operator fun get(index: Int): BaseItem? { - if (index in 0..): Int { - init() - for (i in 0 until totalCount) { - val currentItem = getBlocking(i) - if (currentItem != null && predicate.test(currentItem)) { - return i - } - } - return -1 - } - - override val size: Int - get() = totalCount - - private fun fetchPage(position: Int): Job = - scope.launch(ExceptionHandler() + Dispatchers.IO) { - fetchPageBlocking(position, false) - } - - private suspend fun fetchPageBlocking( - position: Int, - setTotalCount: Boolean, - ) { - mutex.withLock { - val pageNumber = position / pageSize - if (cachedPages.getIfPresent(pageNumber) == null || setTotalCount) { - if (DEBUG) Timber.v("fetchPage: $pageNumber") - val newRequest = - requestHandler.prepare( - request, - pageNumber * pageSize, - pageSize, - setTotalCount, - ) - val result = requestHandler.execute(api, newRequest).content - if (setTotalCount) { - totalCount = result.totalRecordCount.coerceAtLeast(0) - } - val data = mutableListOf() - result.items.forEach { data.add(BaseItem.from(it, api, useSeriesForPrimary)) } - cachedPages.put(pageNumber, data) - items = ItemList(totalCount, pageSize, cachedPages.asMap()) - } - } + override suspend fun fetchPage( + pageNumber: Int, + includeTotalCount: Boolean, + ): QueryResult { + val newRequest = + requestHandler.prepare( + request, + pageNumber * pageSize, + pageSize, + includeTotalCount, + ) + val result = requestHandler.execute(api, newRequest).content + val data = mutableListOf() + result.items.forEach { data.add(BaseItem(it, useSeriesForPrimary)) } + return QueryResult(data, result.totalRecordCount) } suspend fun refreshItem( @@ -161,7 +78,7 @@ class ApiRequestPager( if (page != null && index in page.indices) { page[index] = item cachedPages.put(pageNumber, page) - items = ItemList(totalCount, pageSize, cachedPages.asMap()) + items = ItemList(size, pageSize, cachedPages.asMap()) } } } @@ -181,39 +98,10 @@ class ApiRequestPager( } fetchPageBlocking(position, true) } - - companion object { - private const val DEBUG = false - } - - class ItemList( - val size: Int, - val pageSize: Int, - val pages: Map>, - ) { - operator fun get(position: Int): T? { - val page = position / pageSize - val data = pages[page] - if (data != null) { - val index = position % pageSize - if (index in data.indices) { - return data[index] - } else { - // This can happen when items are removed while scrolling - Timber.w( - "Index $index not in data: position=$position, data.size=${data.size}", - ) - return null - } - } else { - return null - } - } - } } /** - * Specifies how the [ApiRequestPager] should prepare and execute API calls + * Specifies how a [RequestPager] should prepare and execute API calls */ interface RequestHandler { /** @@ -235,6 +123,7 @@ interface RequestHandler { ): Response } +@Serializable val GetItemsRequestHandler = object : RequestHandler { override fun prepare( @@ -255,6 +144,7 @@ val GetItemsRequestHandler = ): Response = api.itemsApi.getItems(request) } +@Serializable val GetEpisodesRequestHandler = object : RequestHandler { override fun prepare( @@ -274,6 +164,7 @@ val GetEpisodesRequestHandler = ): Response = api.tvShowsApi.getEpisodes(request) } +@Serializable val GetResumeItemsRequestHandler = object : RequestHandler { override fun prepare( @@ -294,6 +185,7 @@ val GetResumeItemsRequestHandler = ): Response = api.itemsApi.getResumeItems(request) } +@Serializable val GetNextUpRequestHandler = object : RequestHandler { override fun prepare( @@ -314,6 +206,7 @@ val GetNextUpRequestHandler = ): Response = api.tvShowsApi.getNextUp(request) } +@Serializable val GetSuggestionsRequestHandler = object : RequestHandler { override fun prepare( @@ -334,6 +227,7 @@ val GetSuggestionsRequestHandler = ): Response = api.suggestionsApi.getSuggestions(request) } +@Serializable val GetPlaylistItemsRequestHandler = object : RequestHandler { override fun prepare( @@ -353,6 +247,7 @@ val GetPlaylistItemsRequestHandler = ): Response = api.playlistsApi.getPlaylistItems(request) } +@Serializable val GetGenresRequestHandler = object : RequestHandler { override fun prepare( @@ -392,6 +287,7 @@ val GetProgramsDtoHandler = ): Response = api.liveTvApi.getPrograms(request) } +@Serializable val GetPersonsHandler = object : RequestHandler { override fun prepare( diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/DiscoverRequestPager.kt b/app/src/main/java/com/github/damontecres/wholphin/util/DiscoverRequestPager.kt new file mode 100644 index 00000000..cee890ce --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/util/DiscoverRequestPager.kt @@ -0,0 +1,111 @@ +package com.github.damontecres.wholphin.util + +import androidx.annotation.StringRes +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.api.seerr.model.MovieResult +import com.github.damontecres.wholphin.api.seerr.model.TvResult +import com.github.damontecres.wholphin.data.model.DiscoverItem +import com.github.damontecres.wholphin.services.SeerrApi +import com.github.damontecres.wholphin.services.SeerrSearchResult +import kotlinx.coroutines.CoroutineScope + +/** + * A [RequestPager] for Seerr server queries + */ +class DiscoverRequestPager( + private val api: SeerrApi, + val requestHandler: DiscoverRequestHandler, + val transform: suspend (T) -> DiscoverItem, + scope: CoroutineScope, + pageSize: Int = SEERR_PAGE_SIZE, + cacheSize: Long = 16, +) : RequestPager(scope, pageSize, cacheSize) { + override suspend fun init(initialPosition: Int): DiscoverRequestPager = super.init(initialPosition) as DiscoverRequestPager + + override suspend fun fetchPage( + pageNumber: Int, + includeTotalCount: Boolean, + ): QueryResult { + val result = requestHandler.execute(api, pageNumber + 1) // Seerr pages are 1-indexed + val transformed = result.items.map { transform.invoke(it) } + return QueryResult(transformed, result.totalCount) + } +} + +const val SEERR_PAGE_SIZE = 20 + +enum class DiscoverRequestType( + @param:StringRes val stringRes: Int, +) { + DISCOVER_TV(R.string.discover_tv), + DISCOVER_MOVIES(R.string.discover_movies), + TRENDING(R.string.trending), + UPCOMING_TV(R.string.upcoming_tv), + UPCOMING_MOVIES(R.string.upcoming_movies), + UNKNOWN(R.string.unknown), +} + +/** + * Specifies how a [RequestPager] should prepare and execute API calls + */ +interface DiscoverRequestHandler { + suspend fun execute( + api: SeerrApi, + pageNumber: Int, + ): QueryResult +} + +val DiscoverTvRequestHandler = + object : DiscoverRequestHandler { + override suspend fun execute( + api: SeerrApi, + pageNumber: Int, + ): QueryResult = + api.api.searchApi.discoverTvGet(page = pageNumber).let { + QueryResult(it.results.orEmpty(), it.totalResults ?: 0) + } + } + +val DiscoverMovieRequestHandler = + object : DiscoverRequestHandler { + override suspend fun execute( + api: SeerrApi, + pageNumber: Int, + ): QueryResult = + api.api.searchApi.discoverMoviesGet(page = pageNumber).let { + QueryResult(it.results.orEmpty(), it.totalResults ?: 0) + } + } + +val TrendingRequestHandler = + object : DiscoverRequestHandler { + override suspend fun execute( + api: SeerrApi, + pageNumber: Int, + ): QueryResult = + api.api.searchApi.discoverTrendingGet(page = pageNumber).let { + QueryResult(it.results.orEmpty(), it.totalResults ?: 0) + } + } + +val UpcomingTvRequestHandler = + object : DiscoverRequestHandler { + override suspend fun execute( + api: SeerrApi, + pageNumber: Int, + ): QueryResult = + api.api.searchApi.discoverTvUpcomingGet(page = pageNumber).let { + QueryResult(it.results.orEmpty(), it.totalResults ?: 0) + } + } + +val UpcomingMovieRequestHandler = + object : DiscoverRequestHandler { + override suspend fun execute( + api: SeerrApi, + pageNumber: Int, + ): QueryResult = + api.api.searchApi.discoverMoviesUpcomingGet(page = pageNumber).let { + QueryResult(it.results.orEmpty(), it.totalResults ?: 0) + } + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/RequestPager.kt b/app/src/main/java/com/github/damontecres/wholphin/util/RequestPager.kt new file mode 100644 index 00000000..c9f91534 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/util/RequestPager.kt @@ -0,0 +1,148 @@ +package com.github.damontecres.wholphin.util + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.github.damontecres.wholphin.ui.DEFAULT_PAGE_SIZE +import com.google.common.cache.CacheBuilder +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import timber.log.Timber +import java.util.function.Predicate + +/** + * Handles paging for an API request. You must call [init] prior to use. + * + * Initially, items returned will be null, but requesting the items triggers API calls in the given [CoroutineScope]. + * Since the items are stored in [androidx.compose.runtime.MutableState], it will automatically trigger recompositions when the items are ultimately fetched. + * + * Finally, items are cached allow for backward and forward scrolling. + */ +abstract class RequestPager( + protected val scope: CoroutineScope, + protected val pageSize: Int = DEFAULT_PAGE_SIZE, + cacheSize: Long = 8, +) : AbstractList(), + BlockingList { + protected var totalCount by mutableIntStateOf(-1) + protected var items by mutableStateOf(ItemList(0, pageSize, mapOf())) + protected val mutex = Mutex() + protected val cachedPages = + CacheBuilder + .newBuilder() + .maximumSize(cacheSize) + .build>() + + open suspend fun init(initialPosition: Int = 0): RequestPager { + if (totalCount < 0) { + fetchPageBlocking(initialPosition, true) + } + return this + } + + override operator fun get(index: Int): T? { + if (index in 0..): Int { + init(0) + for (i in 0 until totalCount) { + val currentItem = getBlocking(i) + if (currentItem != null && predicate.test(currentItem)) { + return i + } + } + return -1 + } + + override val size: Int + get() = totalCount + + private fun fetchPage(position: Int): Job = + scope.launch(ExceptionHandler() + Dispatchers.IO) { + fetchPageBlocking(position, false) + } + + protected suspend fun fetchPageBlocking( + position: Int, + setTotalCount: Boolean, + ) { + mutex.withLock { + val pageNumber = position / pageSize + if (cachedPages.getIfPresent(pageNumber) == null || setTotalCount) { + if (DEBUG) Timber.v("fetchPage: $pageNumber") + val result = fetchPage(pageNumber, setTotalCount) + if (setTotalCount && result.totalCount != null) { + totalCount = result.totalCount.coerceAtLeast(0) + } + cachedPages.put(pageNumber, result.items.toMutableList()) + items = ItemList(totalCount, pageSize, cachedPages.asMap()) + } + } + } + + protected abstract suspend fun fetchPage( + pageNumber: Int, + includeTotalCount: Boolean, + ): QueryResult + + companion object { + internal const val DEBUG = false + } + + class ItemList( + val size: Int, + val pageSize: Int, + val pages: Map>, + ) { + operator fun get(position: Int): T? { + val page = position / pageSize + val data = pages[page] + if (data != null) { + val index = position % pageSize + if (index in data.indices) { + return data[index] + } else { + // This can happen when items are removed while scrolling + Timber.w( + "Index $index not in data: position=$position, data.size=${data.size}", + ) + return null + } + } else { + return null + } + } + } +} + +data class QueryResult( + val items: List, + val totalCount: Int?, +) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ce4bf2b6..c41b5511 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -749,5 +749,8 @@ Separate types Prefer showing logos for titles Wholphin updated to %s + View more + Discover TV Shows + Discover Movies From 31465051ffca918f4199ead3df3614a82a98d608 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Mon, 6 Apr 2026 15:24:20 -0400 Subject: [PATCH 086/148] Dev: use build product flavors for app store release (#1205) ## Description Instead of applying a code patch, this PR uses build time [product flavors](https://developer.android.com/build/build-variants#product-flavors) to define the existing default and an app store variant. This is more robust than maintaining a patch file and making sure it is applied before building. The app store variant applies the same code changes (removing install permission, requiring leanback, & setting `UpdateChecker.ACTIVE`), but via manifest placeholders/merging and `BuildConfig` entries. There are no user facing changes. ### Related issues None ### Testing Built the variants and verified manifest changes via aapt ## Screenshots N/A ## AI or LLM usage None --- .github/workflows/main.yml | 4 +- .github/workflows/pr.yml | 15 +-- .github/workflows/release.yml | 23 ++--- app/build.gradle.kts | 16 ++++ app/src/appstore/AndroidManifest.xml | 7 ++ app/src/debug/AndroidManifest.xml | 94 ------------------- app/src/main/AndroidManifest.xml | 2 +- .../wholphin/services/UpdateChecker.kt | 2 +- .../wholphin/ui/detail/DebugPage.kt | 35 +++++++ app/src/patches/play_store.patch | 36 ------- .../damontecres/wholphin/ui/BasicUiTests.kt | 22 +++++ 11 files changed, 91 insertions(+), 165 deletions(-) create mode 100644 app/src/appstore/AndroidManifest.xml delete mode 100644 app/src/debug/AndroidManifest.xml delete mode 100644 app/src/patches/play_store.patch diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 390b1232..7cbde21f 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -54,7 +54,7 @@ jobs: ORG_GRADLE_PROJECT_WholphinExtensionsUsername: "${{ secrets.EXTENSIONS_USERNAME }}" ORG_GRADLE_PROJECT_WholphinExtensionsPassword: "${{ secrets.EXTENSIONS_PASSWORD }}" run: | - ./gradlew clean assembleRelease assembleDebug --no-daemon + ./gradlew clean assembleDefaultRelease assembleDefaultDebug --no-daemon - name: Verify signatures run: | @@ -67,7 +67,7 @@ jobs: find app/build/outputs/apk -name '*.apk' -print0 | while IFS= read -r -d '' line; do # Wholphin-release-0.2.9-62-g5c12e03-16-arm64-v8a.apk => Wholphin-arm64-v8a.apk - short_name="$(echo $line | sed -E 's/-[0-9]+\.[0-9]+\.[0-9]+-[0-9]+-g[a-fA-F0-9]+-[0-9]+//')" + short_name="$(echo $line | sed -E 's/-[0-9]+\.[0-9]+\.[0-9]+-[0-9]+-g[a-fA-F0-9]+-[0-9]+//' | sed -E 's/-default//')" echo "$line => $short_name" cp "$line" "$short_name" done diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index dc518c87..bde4e1ef 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -39,19 +39,6 @@ jobs: - name: Build app id: buildapp run: | - ./gradlew clean assembleDebug testDebugUnitTest --no-daemon + ./gradlew clean assembleDefaultDebug testDefaultDebugUnitTest --no-daemon apks=$(find app/build/outputs \( -name '*.apk' -or -name '*.aab' \) -print0 | tr '\0' ',' | sed 's/,$//') echo "apks=$apks" >> "$GITHUB_OUTPUT" - - test-patch: - runs-on: ubuntu-latest - needs: pre-commit - steps: - - name: Checkout the code - uses: actions/checkout@v6 - with: - fetch-depth: 1 - - name: Test applying patch - run: | - git apply app/src/patches/play_store.patch - git diff diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 466aa8b1..d47134ef 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -36,22 +36,8 @@ jobs: ORG_GRADLE_PROJECT_WholphinExtensionsUsername: "${{ secrets.EXTENSIONS_USERNAME }}" ORG_GRADLE_PROJECT_WholphinExtensionsPassword: "${{ secrets.EXTENSIONS_PASSWORD }}" run: | - ./gradlew clean assembleRelease --no-daemon + ./gradlew clean assembleDefaultRelease bundleAppstoreRelease --no-daemon - - name: Build app - id: buildaab - env: - KEY_ALIAS: "${{ secrets.KEY_ALIAS }}" - KEY_PASSWORD: "${{ secrets.KEY_PASSWORD }}" - KEY_STORE_PASSWORD: "${{ secrets.KEY_STORE_PASSWORD }}" - SIGNING_KEY: "${{ secrets.SIGNING_KEY }}" - ORG_GRADLE_PROJECT_WholphinExtensionsUsername: "${{ secrets.EXTENSIONS_USERNAME }}" - ORG_GRADLE_PROJECT_WholphinExtensionsPassword: "${{ secrets.EXTENSIONS_PASSWORD }}" - run: | - git apply app/src/patches/play_store.patch - ./gradlew bundleRelease --no-daemon - aab=$(find app/build/outputs -name '*.aab') - echo "aab=$aab" >> "$GITHUB_OUTPUT" - name: Verify signatures run: | echo "Verify APK signatures" @@ -62,13 +48,16 @@ jobs: run: | find app/build/outputs/apk -name '*.apk' -print0 | while IFS= read -r -d '' line; do - # Wholphin-release-0.2.9-62-g5c12e03-16-arm64-v8a.apk => Wholphin-arm64-v8a.apk - short_name="$(echo $line | sed -E 's/-release-[0-9]+\.[0-9]+\.[0-9]+-[0-9]+-g[a-fA-F0-9]+-[0-9]+//')" + # Wholphin-default-release-0.2.9-62-g5c12e03-16-arm64-v8a.apk => Wholphin-arm64-v8a.apk + short_name="$(echo $line | sed -E 's/-default-release-[0-9]+\.[0-9]+\.[0-9]+-[0-9]+-g[a-fA-F0-9]+-[0-9]+//')" echo "$line => $short_name" cp "$line" "$short_name" done apks=$(find app/build/outputs/apk -name '*.apk' -print0 | tr '\0' ',' | sed 's/,$//') echo "apks=$apks" >> "$GITHUB_OUTPUT" + + aab=$(find app/build/outputs -name '*.aab') + echo "aab=$aab" >> "$GITHUB_OUTPUT" - name: Checksums run: | echo "SHA256 checksums:" diff --git a/app/build.gradle.kts b/app/build.gradle.kts index cbda6b36..80a6b2e4 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -47,6 +47,8 @@ android { versionCode = gitTags.trim().lines().size versionName = gitDescribe.trim().removePrefix("v").ifBlank { "0.0.0" } testInstrumentationRunner = "com.github.damontecres.wholphin.test.WholphinTestRunner" + + buildConfigField("long", "BUILD_TIME", System.currentTimeMillis().toString()) } signingConfigs { @@ -108,6 +110,20 @@ android { } } } + flavorDimensions += "version" + productFlavors { + create("default") { + dimension = "version" + isDefault = true + manifestPlaceholders += mapOf("leanback" to false) + buildConfigField("boolean", "UPDATING_ENABLED", "true") + } + create("appstore") { + dimension = "version" + manifestPlaceholders += mapOf("leanback" to true) + buildConfigField("boolean", "UPDATING_ENABLED", "false") + } + } compileOptions { sourceCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11 diff --git a/app/src/appstore/AndroidManifest.xml b/app/src/appstore/AndroidManifest.xml new file mode 100644 index 00000000..8a762a28 --- /dev/null +++ b/app/src/appstore/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/app/src/debug/AndroidManifest.xml b/app/src/debug/AndroidManifest.xml deleted file mode 100644 index 29b2eb75..00000000 --- a/app/src/debug/AndroidManifest.xml +++ /dev/null @@ -1,94 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 4479ae86..8dd32ce9 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -20,7 +20,7 @@ android:required="false" /> + android:required="${leanback}" /> diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt b/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt index f1701416..40a04218 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt @@ -65,7 +65,7 @@ class UpdateChecker private val NOTE_REGEX = Regex("") - val ACTIVE = true + const val ACTIVE = BuildConfig.UPDATING_ENABLED } /** diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt index cfae65ba..543d8e2c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt @@ -54,6 +54,7 @@ import org.jellyfin.sdk.model.DeviceInfo import timber.log.Timber import java.io.BufferedReader import java.io.InputStreamReader +import java.util.Date import javax.inject.Inject @HiltViewModel @@ -75,6 +76,34 @@ class DebugViewModel display.supportedModes.orEmpty() } + val av1Included by lazy { + try { + Class.forName("androidx.media3.decoder.av1.Libdav1dVideoRenderer") + true + } catch (_: ClassNotFoundException) { + false + } + } + + val ffmpegIncluded by lazy { + try { + Class.forName("androidx.media3.decoder.ffmpeg.FfmpegAudioRenderer") + true + } catch (_: ClassNotFoundException) { + false + } + } + + val libMpvLoaded by lazy { + try { + System.loadLibrary("player") + System.loadLibrary("mpv") + true + } catch (_: Exception) { + false + } + } + init { viewModelScope.launchIO { serverRepository.currentUser.value?.rowId?.let { @@ -218,6 +247,7 @@ fun DebugPage( style = MaterialTheme.typography.displaySmall, color = MaterialTheme.colorScheme.onSurface, ) + val buildTime = Date(BuildConfig.BUILD_TIME) val pkgInfo = context.packageManager.getPackageInfo(context.packageName, 0) val installInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { @@ -239,6 +269,11 @@ fun DebugPage( "Version Code: ${pkgInfo.versionCodeLong}", "ClientInfo: ${viewModel.clientInfo}", "Build type: ${BuildConfig.BUILD_TYPE}", + "Build flavor: ${BuildConfig.FLAVOR}", + "Build time: $buildTime", + "FFMPEG included: ${viewModel.ffmpegIncluded}", + "AV1 included: ${viewModel.av1Included}", + "libmpv loaded: ${viewModel.libMpvLoaded}", "Debug enabled: ${BuildConfig.DEBUG}", "ABIs: ${Build.SUPPORTED_ABIS.toList()}", ) + installInfo diff --git a/app/src/patches/play_store.patch b/app/src/patches/play_store.patch deleted file mode 100644 index c1214f0e..00000000 --- a/app/src/patches/play_store.patch +++ /dev/null @@ -1,36 +0,0 @@ -# This patches Wholphin for releasing on app stores where self updating is not permitted - -diff --git i/app/src/main/AndroidManifest.xml w/app/src/main/AndroidManifest.xml -index 4479ae86..80596698 100644 ---- i/app/src/main/AndroidManifest.xml -+++ w/app/src/main/AndroidManifest.xml -@@ -6,7 +6,6 @@ - - - -- - -@@ -20,7 +19,7 @@ - android:required="false" /> - -+ android:required="true" /> - -diff --git i/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt w/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt -index 9e0665dd..bc1e1c16 100644 ---- i/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt -+++ w/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt -@@ -65,7 +65,7 @@ class UpdateChecker - - private val NOTE_REGEX = Regex("") - -- val ACTIVE = true -+ val ACTIVE = false - } - - /** diff --git a/app/src/test/java/com/github/damontecres/wholphin/ui/BasicUiTests.kt b/app/src/test/java/com/github/damontecres/wholphin/ui/BasicUiTests.kt index 66178a4e..25121739 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/ui/BasicUiTests.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/ui/BasicUiTests.kt @@ -1,5 +1,7 @@ package com.github.damontecres.wholphin.ui +import android.app.Application +import android.content.pm.ActivityInfo import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.ui.Modifier import androidx.compose.ui.input.key.Key @@ -14,6 +16,7 @@ import androidx.compose.ui.test.performTextInput import androidx.compose.ui.test.pressKey import androidx.compose.ui.test.requestFocus import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.test.core.app.ApplicationProvider import com.github.damontecres.wholphin.services.ScreensaverService import com.github.damontecres.wholphin.services.ScreensaverState import com.github.damontecres.wholphin.services.SetupDestination @@ -49,8 +52,11 @@ import org.junit.Assert import org.junit.Before import org.junit.Rule import org.junit.Test +import org.junit.rules.TestWatcher +import org.junit.runner.Description import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf import org.robolectric.annotation.Config import javax.inject.Inject @@ -61,7 +67,23 @@ class BasicUiTests { @get:Rule(order = 0) var hiltRule = HiltAndroidRule(this) + // From https://github.com/robolectric/robolectric/pull/4736#issuecomment-1831034882 @get:Rule(order = 1) + val addActivityToRobolectricRule = + object : TestWatcher() { + override fun starting(description: Description?) { + super.starting(description) + val appContext: Application = ApplicationProvider.getApplicationContext() + val activityInfo = + ActivityInfo().apply { + name = TestActivity::class.java.name + packageName = appContext.packageName + } + shadowOf(appContext.packageManager).addOrUpdateActivity(activityInfo) + } + } + + @get:Rule(order = 2) val composeTestRule = createAndroidComposeRule() @Inject From bc5bd2f852371bbce5655e46580c7092e8ec3942 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Mon, 6 Apr 2026 17:06:02 -0400 Subject: [PATCH 087/148] Enable globstar --- .github/workflows/main.yml | 2 ++ .github/workflows/release.yml | 2 ++ 2 files changed, 4 insertions(+) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 7cbde21f..7e8b8bfb 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -81,6 +81,8 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | + shopt -s globstar + gh release delete "${{ env.TAG_NAME }}" --cleanup-tag -y || true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d47134ef..87b777ab 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -73,6 +73,8 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | + shopt -s globstar + gh release create "${{ env.TAG_NAME }}" \ --latest --title "${{ env.TAG_NAME }}" --verify-tag -n "" --draft \ "app/build/outputs/apk/**/*.apk" From ac13ce5713c3bcfc8be8f4614a61ffface87e51a Mon Sep 17 00:00:00 2001 From: Damontecres Date: Mon, 6 Apr 2026 18:02:42 -0400 Subject: [PATCH 088/148] Fix globstar quoting --- .github/workflows/main.yml | 2 +- .github/workflows/release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 7e8b8bfb..6f130105 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -99,4 +99,4 @@ jobs: gh release create "${{ env.TAG_NAME }}" \ --latest=false --prerelease --title "${{ env.VERSION_NAME }}" --target "${{ env.BRANCH_NAME }}" -F ci_release_note.txt \ - "app/build/outputs/apk/**/*.apk" + app/build/outputs/apk/**/*.apk diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 87b777ab..5a7de9cb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -77,4 +77,4 @@ jobs: gh release create "${{ env.TAG_NAME }}" \ --latest --title "${{ env.TAG_NAME }}" --verify-tag -n "" --draft \ - "app/build/outputs/apk/**/*.apk" + app/build/outputs/apk/**/*.apk From edaf06afd0faf76c7aab9e3a765a6a9b95a2b9bf Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Mon, 6 Apr 2026 18:17:04 -0400 Subject: [PATCH 089/148] Show music video watch status on artist & album pages (#1209) ## Description Show the watched indicator and favorite icon on music videos on both the artist or album detail pages. ### Related issues Fixes #1206 ### Testing Quick emulator testing ## Screenshots N/A, same indicators as the rest of the app ## AI or LLM usage None --- .../damontecres/wholphin/ui/detail/music/AlbumDetailsPage.kt | 3 +++ .../damontecres/wholphin/ui/detail/music/ArtistDetailsPage.kt | 3 +++ 2 files changed, 6 insertions(+) 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 index dfd0aa0b..53cdb444 100644 --- 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 @@ -539,6 +539,9 @@ fun AlbumDetailsPage( onClick = onClick, onLongClick = onLongClick, aspectRatio = AspectRatios.WIDE, + played = item?.played ?: false, + playPercent = item?.data?.userData?.playedPercentage ?: 0.0, + favorite = item?.favorite ?: false, modifier = mod, ) }, 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 index 050373fb..4a338d03 100644 --- 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 @@ -568,6 +568,9 @@ fun ArtistDetailsPage( onClick = onClick, onLongClick = onLongClick, aspectRatio = AspectRatios.WIDE, + played = item?.played ?: false, + playPercent = item?.data?.userData?.playedPercentage ?: 0.0, + favorite = item?.favorite ?: false, modifier = mod, ) }, From ade564d907179cf364d745b47f2d05a163bdddce Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Tue, 7 Apr 2026 16:03:58 -0400 Subject: [PATCH 090/148] Add studio tab & filtering to TV show libraries (#1211) ## Description Adds a tab for Studios (TV networks) to TV libraries. This displays a grid of all of the studios. Clicking one displays the series produced by the studio. Additionally, adds a filter to the library tab to filter by studio. You can select multiple studios, but its not the best UX since there will likely be many studios (I have almost 200). Finally, you can add a home row for studios in a library. ### Related issues Closes #1002 ### Testing Mostly manual testing via emulator ## Screenshots ### Studios tab for a library ![studios_grid Large](https://github.com/user-attachments/assets/d51e6d20-3768-4bd5-8a09-45cd491064b2) ### Filter drop down for studios ![filter_by_studios Large](https://github.com/user-attachments/assets/7dbd4c13-4194-4674-b272-d42257368ce2) ### Updates series details Shows the studio above the genres ![series_details_studio Large](https://github.com/user-attachments/assets/745caa9b-838c-4e8b-b0f8-69d5f316287a) ## AI or LLM usage None --- .../wholphin/data/filter/ItemFilterBy.kt | 39 ++++ .../wholphin/data/model/BaseItem.kt | 29 +++ .../wholphin/data/model/HomeRowConfig.kt | 12 + .../wholphin/services/HomeSettingsService.kt | 63 +++++ .../wholphin/ui/cards/StudioCard.kt | 150 ++++++++++++ .../wholphin/ui/components/FilterByButton.kt | 9 +- .../wholphin/ui/components/GenreCardGrid.kt | 1 + .../wholphin/ui/components/StudioCardGrid.kt | 220 ++++++++++++++++++ .../wholphin/ui/data/ItemDetailsDialogInfo.kt | 7 + .../wholphin/ui/detail/CollectionFolderTv.kt | 16 ++ .../wholphin/ui/detail/movie/MovieDetails.kt | 2 + .../ui/detail/series/SeriesDetails.kt | 23 +- .../damontecres/wholphin/ui/main/HomePage.kt | 12 + .../main/settings/HomeLibraryRowTypeList.kt | 11 + .../ui/main/settings/HomeSettingsViewModel.kt | 14 ++ .../wholphin/ui/nav/Destination.kt | 1 + .../wholphin/ui/nav/DestinationContent.kt | 8 +- .../wholphin/ui/util/FilterUtils.kt | 13 ++ .../wholphin/util/ApiRequestPager.kt | 22 ++ app/src/main/res/values/strings.xml | 1 + .../wholphin/test/TestHomeRowSamples.kt | 2 + 21 files changed, 651 insertions(+), 4 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/cards/StudioCard.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/components/StudioCardGrid.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/filter/ItemFilterBy.kt b/app/src/main/java/com/github/damontecres/wholphin/data/filter/ItemFilterBy.kt index 1d536f28..37fd9514 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/filter/ItemFilterBy.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/filter/ItemFilterBy.kt @@ -17,6 +17,19 @@ val DefaultFilterOptions = DecadeFilter, ) +val DefaultTvFilterOptions = + listOf( + PlayedFilter, + FavoriteFilter, + GenreFilter, + StudioFilter, + CommunityRatingFilter, + OfficialRatingFilter, + VideoTypeFilter, + YearFilter, + DecadeFilter, + ) + val DefaultForFavoritesFilterOptions = listOf( PlayedFilter, @@ -32,6 +45,19 @@ val DefaultForGenresFilterOptions = listOf( PlayedFilter, FavoriteFilter, + StudioFilter, + CommunityRatingFilter, + OfficialRatingFilter, + VideoTypeFilter, + YearFilter, + DecadeFilter, + ) + +val DefaultForStudiosFilterOptions = + listOf( + PlayedFilter, + FavoriteFilter, + GenreFilter, CommunityRatingFilter, OfficialRatingFilter, VideoTypeFilter, @@ -183,3 +209,16 @@ data object CommunityRatingFilter : ItemFilterBy { filter: GetItemsFilter, ): GetItemsFilter = filter.copy(minCommunityRating = value?.toDouble()) } + +data object StudioFilter : ItemFilterBy> { + override val stringRes: Int = R.string.studios + + override val supportMultiple: Boolean = true + + override fun get(filter: GetItemsFilter): List? = filter.studios + + override fun set( + value: List?, + filter: GetItemsFilter, + ): GetItemsFilter = filter.copy(studios = value) +} 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 cf7461ea..1b9523cc 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 @@ -270,6 +270,7 @@ fun createGenreDestination( includeItemTypes: List?, ) = Destination.FilteredCollection( itemId = parentId, + parentType = BaseItemKind.GENRE, filter = CollectionFolderFilter( nameOverride = @@ -286,3 +287,31 @@ fun createGenreDestination( ), recursive = true, ) + +fun createStudioDestination( + studioId: UUID, + name: String, + parentId: UUID, + parentName: String?, + includeItemTypes: List?, +) = Destination.FilteredCollection( + itemId = parentId, + parentType = BaseItemKind.STUDIO, + filter = + CollectionFolderFilter( + nameOverride = + listOfNotNull( + name, + parentName, + ).joinToString(" "), + filter = + GetItemsFilter( + studios = listOf(studioId), + includeItemTypes = includeItemTypes, + ), + useSavedLibraryDisplayInfo = false, + ), + recursive = true, +) + +val BaseItem.studioNames get() = data.studios?.mapNotNull { it.name }.orEmpty() diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt index 1398bc0b..74a6c6d1 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt @@ -90,6 +90,18 @@ sealed interface HomeRowConfig { override fun updateViewOptions(viewOptions: HomeRowViewOptions): Genres = this.copy(viewOptions = viewOptions) } + /** + * Row of a studios in a library + */ + @Serializable + @SerialName("Studios") + data class Studios( + val parentId: UUID, + override val viewOptions: HomeRowViewOptions = HomeRowViewOptions.genreDefault, + ) : HomeRowConfig { + override fun updateViewOptions(viewOptions: HomeRowViewOptions): Studios = this.copy(viewOptions = viewOptions) + } + /** * Favorites for a specific type */ 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 2abe6a1e..cbad6d32 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 @@ -8,6 +8,7 @@ import com.github.damontecres.wholphin.data.model.HomePageSettings import com.github.damontecres.wholphin.data.model.HomeRowConfig import com.github.damontecres.wholphin.data.model.SUPPORTED_HOME_PAGE_SETTINGS_VERSION import com.github.damontecres.wholphin.data.model.createGenreDestination +import com.github.damontecres.wholphin.data.model.createStudioDestination import com.github.damontecres.wholphin.preferences.DefaultUserConfiguration import com.github.damontecres.wholphin.preferences.HomePagePreferences import com.github.damontecres.wholphin.ui.DefaultItemFields @@ -21,6 +22,7 @@ import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.util.GetGenresRequestHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.GetPersonsHandler +import com.github.damontecres.wholphin.util.GetStudiosRequestHandler import com.github.damontecres.wholphin.util.HomeRowLoadingState import com.github.damontecres.wholphin.util.HomeRowLoadingState.Error import com.github.damontecres.wholphin.util.HomeRowLoadingState.Success @@ -58,6 +60,7 @@ import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest import org.jellyfin.sdk.model.api.request.GetPersonsRequest import org.jellyfin.sdk.model.api.request.GetRecommendedProgramsRequest import org.jellyfin.sdk.model.api.request.GetRecordingsRequest +import org.jellyfin.sdk.model.api.request.GetStudiosRequest import timber.log.Timber import java.io.File import javax.inject.Inject @@ -483,6 +486,15 @@ class HomeSettingsService ) } + is HomeRowConfig.Studios -> { + val name = getItemName(config.parentId) ?: "" + HomeRowConfigDisplay( + id, + context.getString(R.string.studios_in, name), + config, + ) + } + is HomeRowConfig.GetItems -> { HomeRowConfigDisplay(id, config.name, config) } @@ -701,6 +713,57 @@ class HomeSettingsService ) } + is HomeRowConfig.Studios -> { + val request = + GetStudiosRequest( + parentId = row.parentId, + userId = userDto.id, + limit = limit, + includeItemTypes = listOf(BaseItemKind.SERIES), + ) + val items = + GetStudiosRequestHandler + .execute(api, request) + .content.items + val library = + libraries + .firstOrNull { it.itemId == row.parentId } + val title = + library?.name?.let { context.getString(R.string.studios_in, it) } + ?: context.getString(R.string.studios) + val studios = + items.map { + val imageUrl = + imageUrlService.getItemImageUrl( + itemId = it.id, + imageType = ImageType.THUMB, + ) + BaseItem( + it, + false, + imageUrl, + createStudioDestination( + studioId = it.id, + name = it.name ?: "", + parentId = row.parentId, + parentName = library?.name, + includeItemTypes = + library?.collectionType?.let { + getTypeFor(it)?.let { + listOf(it) + } + }, + ), + ) + } + + Success( + title, + studios, + viewOptions = row.viewOptions, + ) + } + is HomeRowConfig.RecentlyAdded -> { val library = libraries diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/StudioCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/StudioCard.kt new file mode 100644 index 00000000..fb89a58b --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/StudioCard.kt @@ -0,0 +1,150 @@ +package com.github.damontecres.wholphin.ui.cards + +import androidx.compose.foundation.background +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +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.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.tv.material3.Card +import androidx.tv.material3.CardDefaults +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import coil3.compose.AsyncImage +import coil3.request.ImageRequest +import coil3.request.crossfade +import com.github.damontecres.wholphin.ui.AspectRatios +import com.github.damontecres.wholphin.ui.PreviewTvSpec +import com.github.damontecres.wholphin.ui.components.Genre +import com.github.damontecres.wholphin.ui.components.Studio +import com.github.damontecres.wholphin.ui.isNotNullOrBlank +import com.github.damontecres.wholphin.ui.setup.rememberIdColor +import com.github.damontecres.wholphin.ui.theme.WholphinTheme +import java.util.UUID + +@Composable +fun StudioCard( + studio: Studio?, + onClick: () -> Unit, + onLongClick: () -> Unit, + modifier: Modifier = Modifier, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, +) = StudioCard( + studioId = studio?.id, + name = studio?.name, + imageUrl = studio?.imageUrl, + onClick = onClick, + onLongClick = onLongClick, + modifier = modifier, + interactionSource = interactionSource, +) + +@Composable +fun StudioCard( + studioId: UUID?, + name: String?, + imageUrl: String?, + onClick: () -> Unit, + onLongClick: () -> Unit, + modifier: Modifier = Modifier, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, +) { + val background = rememberIdColor(studioId).copy(alpha = .4f) + var error by remember { mutableStateOf(false) } + Card( + modifier = modifier, + onClick = onClick, + onLongClick = onLongClick, + interactionSource = interactionSource, + colors = + CardDefaults.colors( + containerColor = Color.Transparent, + ), + ) { + Box( + contentAlignment = Alignment.Center, + modifier = + Modifier + .aspectRatio(AspectRatios.WIDE) + .fillMaxSize() + .clip(RoundedCornerShape(8.dp)), + ) { + if (imageUrl != null && !error) { + AsyncImage( + model = + ImageRequest + .Builder(LocalContext.current) + .data(imageUrl) + .crossfade(true) + .build(), + contentScale = ContentScale.FillBounds, + contentDescription = null, + onError = { + error = true + }, + modifier = + Modifier + .alpha(.75f) + .aspectRatio(AspectRatios.WIDE) + .fillMaxSize(), + ) + } else { + Box( + modifier = + Modifier + .aspectRatio(AspectRatios.WIDE) + .fillMaxSize() + .background(background), + ) { + Text( + text = name ?: "", + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + modifier = + Modifier + .padding(16.dp) + .align(Alignment.Center), + ) + } + } + } + } +} + +@PreviewTvSpec +@Composable +private fun GenreCardPreview() { + WholphinTheme { + val studio = + Studio( + UUID.randomUUID(), + "Adventure", + null, + ) + StudioCard( + studio = studio, + onClick = {}, + onLongClick = {}, + modifier = Modifier.width(180.dp), + ) + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/FilterByButton.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/FilterByButton.kt index 95926eed..e881bb98 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/FilterByButton.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/FilterByButton.kt @@ -46,6 +46,7 @@ import com.github.damontecres.wholphin.data.filter.GenreFilter import com.github.damontecres.wholphin.data.filter.ItemFilterBy import com.github.damontecres.wholphin.data.filter.OfficialRatingFilter import com.github.damontecres.wholphin.data.filter.PlayedFilter +import com.github.damontecres.wholphin.data.filter.StudioFilter import com.github.damontecres.wholphin.data.filter.VideoTypeFilter import com.github.damontecres.wholphin.data.filter.YearFilter import com.github.damontecres.wholphin.data.model.GetItemsFilter @@ -206,7 +207,9 @@ fun FilterByButton( val isSelected = remember(currentValue) { when (filterOption) { - GenreFilter -> { + GenreFilter, + StudioFilter, + -> { (currentValue as? List) .orEmpty() .contains(value.value) @@ -271,7 +274,9 @@ fun FilterByButton( onClick = { val newFilter = when (filterOption) { - GenreFilter -> { + GenreFilter, + StudioFilter, + -> { val list = (currentValue as? List).orEmpty() val newValue = list diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt index e85b1d02..21ac519d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt @@ -139,6 +139,7 @@ class GenreViewModel nameLessThan = letter.toString(), limit = 0, enableTotalRecordCount = true, + includeItemTypes = includeItemTypes, ) val result by GetGenresRequestHandler.execute(api, request) return@withContext result.totalRecordCount diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/StudioCardGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/StudioCardGrid.kt new file mode 100644 index 00000000..b52c7a8e --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/StudioCardGrid.kt @@ -0,0 +1,220 @@ +package com.github.damontecres.wholphin.ui.components + +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.times +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.createStudioDestination +import com.github.damontecres.wholphin.services.ImageUrlService +import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect +import com.github.damontecres.wholphin.ui.SlimItemFields +import com.github.damontecres.wholphin.ui.cards.StudioCard +import com.github.damontecres.wholphin.ui.detail.CardGrid +import com.github.damontecres.wholphin.ui.detail.CardGridItem +import com.github.damontecres.wholphin.ui.setValueOnMain +import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.util.GetStudiosRequestHandler +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 kotlinx.coroutines.Dispatchers +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.ImageType +import org.jellyfin.sdk.model.api.request.GetStudiosRequest +import java.util.UUID + +@HiltViewModel(assistedFactory = StudioViewModel.Factory::class) +class StudioViewModel + @AssistedInject + constructor( + private val api: ApiClient, + private val imageUrlService: ImageUrlService, + private val serverRepository: ServerRepository, + val navigationManager: NavigationManager, + @Assisted private val itemId: UUID, + @Assisted private val includeItemTypes: List?, + ) : ViewModel() { + @AssistedFactory + interface Factory { + fun create( + itemId: UUID, + includeItemTypes: List?, + ): StudioViewModel + } + + val item = MutableLiveData(null) + val loading = MutableLiveData(LoadingState.Pending) + val studios = MutableLiveData>(listOf()) + + fun init(cardWidthPx: Int) { + loading.value = LoadingState.Loading + viewModelScope.launch(Dispatchers.IO + LoadingExceptionHandler(loading, "Failed to fetch genres")) { + val item = + api.userLibraryApi.getItem(itemId = itemId).content.let { + BaseItem(it, false) + } + this@StudioViewModel.item.setValueOnMain(item) + val request = + GetStudiosRequest( + userId = serverRepository.currentUser.value?.id, + parentId = itemId, + fields = SlimItemFields, + includeItemTypes = includeItemTypes, + ) + val studios = + GetStudiosRequestHandler + .execute(api, request) + .content.items + .map { + val imageUrl = + imageUrlService.getItemImageUrl( + itemId = it.id, + imageType = ImageType.THUMB, + fillWidth = cardWidthPx, + ) + Studio(it.id, it.name ?: "", imageUrl) + } + withContext(Dispatchers.Main) { + this@StudioViewModel.studios.value = studios + loading.value = LoadingState.Success + } + } + } + + suspend fun positionOfLetter(letter: Char): Int = + withContext(Dispatchers.IO) { + val request = + GetStudiosRequest( + userId = serverRepository.currentUser.value?.id, + parentId = itemId, + nameLessThan = letter.toString(), + limit = 0, + enableTotalRecordCount = true, + includeItemTypes = includeItemTypes, + ) + val result by GetStudiosRequestHandler.execute(api, request) + return@withContext result.totalRecordCount + } + } + +@Stable +data class Studio( + val id: UUID, + val name: String, + val imageUrl: String?, +) : CardGridItem { + override val gridId: String get() = id.toString() + override val playable: Boolean = false + override val sortName: String get() = name +} + +@Composable +fun StudioCardGrid( + itemId: UUID, + includeItemTypes: List?, + modifier: Modifier = Modifier, + viewModel: StudioViewModel = + hiltViewModel( + creationCallback = { it.create(itemId, includeItemTypes) }, + ), +) { + val columns = 4 + val spacing = 16.dp + val density = LocalDensity.current + val configuration = LocalConfiguration.current + val cardWidthPx = + remember { + with(density) { + // Grid has 16dp padding on either side & 16dp spacing between 4 cards + // This isn't exact though because it doesn't account for nav drawer or letters, but it's close and the calculation is much faster + // E.g. on 1080p, this results in 440px versus 395px actual, so only minimal scaling down is required + (configuration.screenWidthDp.dp - (2 * 16.dp + 3 * spacing)) + .div(columns) + .roundToPx() + } + } + OneTimeLaunchedEffect { + viewModel.init(cardWidthPx) + } + val loading by viewModel.loading.observeAsState(LoadingState.Pending) + val studios by viewModel.studios.observeAsState(listOf()) + + val gridFocusRequester = remember { FocusRequester() } + when (val st = loading) { + LoadingState.Pending, + LoadingState.Loading, + -> { + LoadingPage(modifier.focusable()) + } + + is LoadingState.Error -> { + ErrorMessage(st, modifier.focusable()) + } + + LoadingState.Success -> { + Box(modifier = modifier) { + LaunchedEffect(Unit) { gridFocusRequester.tryRequestFocus() } + val item by viewModel.item.observeAsState(null) + CardGrid( + pager = studios, + onClickItem = { _, studio -> + viewModel.navigationManager.navigateTo( + createStudioDestination( + studioId = studio.id, + name = studio.name, + parentId = itemId, + parentName = item?.title, + includeItemTypes = includeItemTypes, + ), + ) + }, + onLongClickItem = { _, _ -> }, + onClickPlay = { _, _ -> }, + letterPosition = { viewModel.positionOfLetter(it) }, + gridFocusRequester = gridFocusRequester, + showJumpButtons = false, + showLetterButtons = true, + modifier = Modifier.fillMaxSize(), + initialPosition = 0, + positionCallback = { columns, position -> + }, + columns = columns, + spacing = spacing, + cardContent = { item: Studio?, onClick: () -> Unit, onLongClick: () -> Unit, mod: Modifier -> + StudioCard( + studio = item, + onClick = onClick, + onLongClick = onLongClick, + modifier = mod, + ) + }, + ) + } + } + } +} 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 5cf0789e..8e7c89b1 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 @@ -40,6 +40,7 @@ data class ItemDetailsDialogInfo( val overview: String?, val genres: List, val files: List, + val studios: List = emptyList(), ) /** @@ -74,6 +75,12 @@ fun ItemDetailsDialog( text = info.title, style = MaterialTheme.typography.headlineSmall, ) + if (info.studios.isNotEmpty()) { + Text( + text = info.studios.joinToString(", "), + style = MaterialTheme.typography.titleMedium, + ) + } if (info.genres.isNotEmpty()) { Text( text = info.genres.joinToString(", "), diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderTv.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderTv.kt index 68b9dd18..cbc87bae 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderTv.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderTv.kt @@ -21,6 +21,7 @@ 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.DefaultTvFilterOptions import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.CollectionFolderFilter import com.github.damontecres.wholphin.data.model.GetItemsFilter @@ -29,6 +30,7 @@ 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.RecommendedTvShow +import com.github.damontecres.wholphin.ui.components.StudioCardGrid import com.github.damontecres.wholphin.ui.components.TabRow import com.github.damontecres.wholphin.ui.components.ViewOptionsPoster import com.github.damontecres.wholphin.ui.data.SeriesSortOptions @@ -53,6 +55,7 @@ fun CollectionFolderTv( stringResource(R.string.recommended), stringResource(R.string.library), stringResource(R.string.genres), + stringResource(R.string.studios), ) var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) } val focusRequester = remember { FocusRequester() } @@ -123,6 +126,7 @@ fun CollectionFolderTv( showTitle = false, recursive = true, sortOptions = SeriesSortOptions, + filterOptions = DefaultTvFilterOptions, defaultViewOptions = ViewOptionsPoster, modifier = Modifier @@ -151,6 +155,18 @@ fun CollectionFolderTv( ) } + // Studios + 3 -> { + StudioCardGrid( + itemId = destination.itemId, + includeItemTypes = listOf(BaseItemKind.SERIES), + modifier = + Modifier + .fillMaxSize() + .focusRequester(focusRequester), + ) + } + else -> { ErrorMessage("Invalid tab index $selectedTabIndex", null) } 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 31e6233c..440e8d4a 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 @@ -35,6 +35,7 @@ import com.github.damontecres.wholphin.data.model.DiscoverItem import com.github.damontecres.wholphin.data.model.Person import com.github.damontecres.wholphin.data.model.Trailer import com.github.damontecres.wholphin.data.model.aspectRatioFloat +import com.github.damontecres.wholphin.data.model.studioNames import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.TrailerService import com.github.damontecres.wholphin.ui.AspectRatios @@ -185,6 +186,7 @@ fun MovieDetails( overview = movie.data.overview, genres = movie.data.genres.orEmpty(), files = movie.data.mediaSources.orEmpty(), + studios = movie.studioNames, ) }, moreOnClick = { 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 0e1e6a8b..91baa312 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 @@ -41,12 +41,14 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.LifecycleResumeEffect +import androidx.tv.material3.MaterialTheme import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.ExtrasItem import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.DiscoverItem import com.github.damontecres.wholphin.data.model.Person import com.github.damontecres.wholphin.data.model.Trailer +import com.github.damontecres.wholphin.data.model.studioNames import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.TrailerService import com.github.damontecres.wholphin.ui.Cards @@ -213,6 +215,7 @@ fun SeriesDetails( title = item.name ?: context.getString(R.string.unknown), overview = item.data.overview, genres = item.data.genres.orEmpty(), + studios = item.studioNames, files = listOf(), ) }, @@ -397,6 +400,7 @@ fun SeriesDetailsContent( series = series, showLogo = preferences.appPreferences.interfacePreferences.showLogos, overviewOnClick = overviewOnClick, + bringIntoViewRequester = bringIntoViewRequester, modifier = Modifier .fillMaxWidth() @@ -686,6 +690,7 @@ fun SeriesDetailsHeader( series: BaseItem, showLogo: Boolean, overviewOnClick: () -> Unit, + bringIntoViewRequester: BringIntoViewRequester, modifier: Modifier = Modifier, ) { val scope = rememberCoroutineScope() @@ -711,8 +716,16 @@ fun SeriesDetailsHeader( null, Modifier.padding(start = HeaderUtils.startPadding), ) + dto.studios?.let { + val studios = remember { series.studioNames } + GenreText( + studios, + textStyle = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(start = HeaderUtils.startPadding), + ) + } dto.genres?.letNotEmpty { - GenreText(it, Modifier.padding(start = HeaderUtils.startPadding, bottom = 8.dp)) + GenreText(it, Modifier.padding(start = HeaderUtils.startPadding, bottom = 4.dp)) } dto.overview?.let { overview -> OverviewText( @@ -720,6 +733,14 @@ fun SeriesDetailsHeader( 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/main/HomePage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt index 9addbb0e..b9608dcc 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 @@ -54,6 +54,7 @@ import com.github.damontecres.wholphin.ui.cards.BannerCard import com.github.damontecres.wholphin.ui.cards.BannerCardWithTitle import com.github.damontecres.wholphin.ui.cards.GenreCard import com.github.damontecres.wholphin.ui.cards.ItemRow +import com.github.damontecres.wholphin.ui.cards.StudioCard import com.github.damontecres.wholphin.ui.components.CircularProgress import com.github.damontecres.wholphin.ui.components.ConfirmDeleteDialog import com.github.damontecres.wholphin.ui.components.DialogParams @@ -535,6 +536,17 @@ fun HomePageCardContent( ) } + BaseItemKind.STUDIO -> { + StudioCard( + studioId = item.id, + name = item.name, + imageUrl = item.imageUrlOverride, + onClick = onClick, + onLongClick = onLongClick, + modifier = modifier.height(viewOptions.heightDp.dp), + ) + } + else -> { val imageType = remember(item, viewOptions) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeLibraryRowTypeList.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeLibraryRowTypeList.kt index 4236c03a..8c603911 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeLibraryRowTypeList.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeLibraryRowTypeList.kt @@ -80,6 +80,16 @@ fun getSupportedRowTypes(library: Library): List { ) } + library.collectionType == CollectionType.TVSHOWS -> { + listOf( + LibraryRowType.RECENTLY_ADDED, + LibraryRowType.RECENTLY_RELEASED, + LibraryRowType.SUGGESTIONS, + LibraryRowType.GENRES, + LibraryRowType.STUDIOS, + ) + } + supportsSuggestions -> { listOf( LibraryRowType.RECENTLY_ADDED, @@ -124,6 +134,7 @@ enum class LibraryRowType( RECENTLY_RELEASED(R.string.recently_released), SUGGESTIONS(R.string.suggestions), GENRES(R.string.genres), + STUDIOS(R.string.studios), TV_CHANNELS(R.string.channels), TV_PROGRAMS(R.string.live_tv), RECENTLY_RECORDED(R.string.recently_recorded), 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 b3997bf5..c4874aa0 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 @@ -323,6 +323,16 @@ class HomeSettingsViewModel ) } + LibraryRowType.STUDIOS -> { + val title = + library.name.let { context.getString(R.string.studios_in, it) } + HomeRowConfigDisplay( + id = id, + title = title, + config = HomeRowConfig.Studios(library.itemId), + ) + } + LibraryRowType.SUGGESTIONS -> { val title = library.name.let { context.getString(R.string.suggestions_for, it) } @@ -700,6 +710,10 @@ class HomeSettingsViewModel it.config.updateViewOptions(it.config.viewOptions.copy(heightDp = preset.genreSize)) } + is HomeRowConfig.Studios -> { + it.config.updateViewOptions(it.config.viewOptions.copy(heightDp = preset.genreSize)) + } + is HomeRowConfig.GetItems -> { it.config } 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 df6053ad..a7559914 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 @@ -99,6 +99,7 @@ sealed class Destination( @Serializable data class FilteredCollection( val itemId: UUID, + val parentType: BaseItemKind, val filter: CollectionFolderFilter, val recursive: Boolean, ) : Destination(false) 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 ca80bb66..1aa92bb3 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 @@ -6,6 +6,7 @@ import androidx.compose.ui.Modifier import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import com.github.damontecres.wholphin.data.filter.DefaultForGenresFilterOptions +import com.github.damontecres.wholphin.data.filter.DefaultForStudiosFilterOptions import com.github.damontecres.wholphin.data.model.SeerrItemType import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.components.ItemGrid @@ -252,7 +253,12 @@ fun DestinationContent( recursive = destination.recursive, usePosters = true, playEnabled = true, // TODO only genres use this currently, so might need to change in future - filterOptions = DefaultForGenresFilterOptions, + filterOptions = + when (destination.parentType) { + BaseItemKind.GENRE -> DefaultForGenresFilterOptions + BaseItemKind.STUDIO -> DefaultForStudiosFilterOptions + else -> throw IllegalArgumentException("Unsupported parentType ${destination.parentType}") + }, modifier = modifier, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/util/FilterUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/util/FilterUtils.kt index 5358a14e..a39cae25 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/util/FilterUtils.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/util/FilterUtils.kt @@ -9,6 +9,7 @@ import com.github.damontecres.wholphin.data.filter.GenreFilter import com.github.damontecres.wholphin.data.filter.ItemFilterBy import com.github.damontecres.wholphin.data.filter.OfficialRatingFilter import com.github.damontecres.wholphin.data.filter.PlayedFilter +import com.github.damontecres.wholphin.data.filter.StudioFilter import com.github.damontecres.wholphin.data.filter.VideoTypeFilter import com.github.damontecres.wholphin.data.filter.YearFilter import kotlinx.coroutines.Dispatchers @@ -16,7 +17,9 @@ import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.genresApi import org.jellyfin.sdk.api.client.extensions.localizationApi +import org.jellyfin.sdk.api.client.extensions.studiosApi import org.jellyfin.sdk.api.client.extensions.yearsApi +import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.ItemSortBy import org.jellyfin.sdk.model.api.SortOrder import timber.log.Timber @@ -47,6 +50,16 @@ object FilterUtils { .map { FilterValueOption(it.name ?: "", it.id) } } + StudioFilter -> { + api.studiosApi + .getStudios( + parentId = parentId, + userId = userId, + includeItemTypes = listOf(BaseItemKind.SERIES), + ).content.items + .map { FilterValueOption(it.name ?: "", it.id) } + } + FavoriteFilter, PlayedFilter, -> { diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/ApiRequestPager.kt b/app/src/main/java/com/github/damontecres/wholphin/util/ApiRequestPager.kt index f57dfa46..a64acb70 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/ApiRequestPager.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/ApiRequestPager.kt @@ -12,6 +12,7 @@ import org.jellyfin.sdk.api.client.extensions.itemsApi import org.jellyfin.sdk.api.client.extensions.liveTvApi import org.jellyfin.sdk.api.client.extensions.personsApi import org.jellyfin.sdk.api.client.extensions.playlistsApi +import org.jellyfin.sdk.api.client.extensions.studiosApi import org.jellyfin.sdk.api.client.extensions.suggestionsApi import org.jellyfin.sdk.api.client.extensions.tvShowsApi import org.jellyfin.sdk.api.client.extensions.userLibraryApi @@ -24,6 +25,7 @@ import org.jellyfin.sdk.model.api.request.GetNextUpRequest import org.jellyfin.sdk.model.api.request.GetPersonsRequest import org.jellyfin.sdk.model.api.request.GetPlaylistItemsRequest import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest +import org.jellyfin.sdk.model.api.request.GetStudiosRequest import org.jellyfin.sdk.model.api.request.GetSuggestionsRequest import timber.log.Timber import java.util.UUID @@ -306,3 +308,23 @@ val GetPersonsHandler = request: GetPersonsRequest, ): Response = api.personsApi.getPersons((request)) } + +val GetStudiosRequestHandler = + object : RequestHandler { + override fun prepare( + request: GetStudiosRequest, + startIndex: Int, + limit: Int, + enableTotalRecordCount: Boolean, + ): GetStudiosRequest = + request.copy( + startIndex = startIndex, + limit = limit, + enableTotalRecordCount = enableTotalRecordCount, + ) + + override suspend fun execute( + api: ApiClient, + request: GetStudiosRequest, + ): Response = api.studiosApi.getStudios(request) + } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c41b5511..46afd62a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -752,5 +752,6 @@ View more Discover TV Shows Discover Movies + Studios in %1$s diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestHomeRowSamples.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestHomeRowSamples.kt index 84aa72b6..23ab54d5 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/test/TestHomeRowSamples.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestHomeRowSamples.kt @@ -43,6 +43,7 @@ class TestHomeRowSamples { parentId = UUID.randomUUID(), viewOptions = HomeRowViewOptions(), ), + HomeRowConfig.Studios(parentId = UUID.randomUUID()), HomeRowConfig.ContinueWatching( viewOptions = HomeRowViewOptions(), ), @@ -98,6 +99,7 @@ class TestHomeRowSamples { is HomeRowConfig.TvPrograms -> foundTypes.add(it::class) is HomeRowConfig.Suggestions -> foundTypes.add(it::class) is HomeRowConfig.TvChannels -> foundTypes.add(it::class) + is HomeRowConfig.Studios -> foundTypes.add(it::class) } } Assert.assertEquals(HomeRowConfig::class.sealedSubclasses.size, foundTypes.size) From 4eabaa9a525d0be84bafe8343f2ab4607b131e06 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Tue, 7 Apr 2026 16:04:21 -0400 Subject: [PATCH 091/148] Add option to remove item from Continue Watching and Next Up rows (#1140) ## Description Adds context menu options for "Remove from continue watching" and "Remove series from next up" ### Remove from continue watching This marks the item as unplayed so it will not be shown on the Continue Watching row anymore. It may still show on a Next Up or combined row though. ### Remove series from next up This first does the same as "Remove from continue watching" above. But it also stores the series' ID in your display preferences as removed. Wholphin will filter out this series from appearing in the Next Up row until you watch _any_ episode in the series in the future. This only works for Wholphin clients! There's a dialog to view which series have been removed and to re-add them manually. Settings->Customize Home->Settings->View removed next up ### Related issues Closes #906 ### Testing Mostly emulator testing ## Screenshots ## AI or LLM usage None --- .../damontecres/wholphin/MainActivity.kt | 4 + .../wholphin/data/model/BaseItem.kt | 6 +- .../services/DisplayPreferencesService.kt | 59 ++++ .../wholphin/services/FavoriteWatchManager.kt | 4 + .../wholphin/services/HomeSettingsService.kt | 64 ++--- .../wholphin/services/LatestNextUpService.kt | 143 +++++++++- .../wholphin/services/LatestNextUpWorker.kt | 139 ++++++++++ .../wholphin/ui/detail/DetailUtils.kt | 23 ++ .../damontecres/wholphin/ui/main/HomePage.kt | 35 ++- .../wholphin/ui/main/HomeViewModel.kt | 16 ++ .../ui/main/settings/HomeSettingsGlobal.kt | 15 + .../ui/main/settings/HomeSettingsPage.kt | 14 + .../ui/main/settings/RemovedNextUpContent.kt | 259 ++++++++++++++++++ .../damontecres/wholphin/util/LoadingState.kt | 2 + .../wholphin/util/LocalDateSerializer.kt | 18 ++ app/src/main/res/values/strings.xml | 3 + .../services/LatestNextUpServiceTests.kt | 187 +++++++++++++ .../damontecres/wholphin/test/NextUpTest.kt | 21 +- .../wholphin/test/TestHomeRowSamples.kt | 1 + 19 files changed, 964 insertions(+), 49 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/services/DisplayPreferencesService.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpWorker.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/RemovedNextUpContent.kt create mode 100644 app/src/test/java/com/github/damontecres/wholphin/services/LatestNextUpServiceTests.kt 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 44b0bcb9..5984a046 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -35,6 +35,7 @@ import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.DatePlayedInvalidationService import com.github.damontecres.wholphin.services.DeviceProfileService import com.github.damontecres.wholphin.services.ImageUrlService +import com.github.damontecres.wholphin.services.LatestNextUpSchedulerService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.PlaybackLifecycleObserver import com.github.damontecres.wholphin.services.RefreshRateService @@ -115,6 +116,9 @@ class MainActivity : AppCompatActivity() { @Inject lateinit var suggestionsSchedulerService: SuggestionsSchedulerService + @Inject + lateinit var latestNextUpSchedulerService: LatestNextUpSchedulerService + @Inject lateinit var backdropService: BackdropService 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 1b9523cc..efb21849 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 @@ -79,8 +79,7 @@ data class BaseItem( val canDelete: Boolean get() = data.canDelete == true - @Transient - val aspectRatio: Float? = data.primaryImageAspectRatio?.toFloat()?.takeIf { it > 0 } + val aspectRatio: Float? get() = data.primaryImageAspectRatio?.toFloat()?.takeIf { it > 0 } val indexNumber get() = data.indexNumber @@ -92,8 +91,7 @@ data class BaseItem( val favorite get() = data.userData?.isFavorite ?: false - @Transient - val timeRemainingOrRuntime: Duration? = data.timeRemaining ?: data.runTimeTicks?.ticks + val timeRemainingOrRuntime: Duration? get() = data.timeRemaining ?: data.runTimeTicks?.ticks /** * Contains pre computed UI elements that would be expensive to create on the main thread diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/DisplayPreferencesService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/DisplayPreferencesService.kt new file mode 100644 index 00000000..d5691fe6 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/DisplayPreferencesService.kt @@ -0,0 +1,59 @@ +package com.github.damontecres.wholphin.services + +import android.content.Context +import com.github.damontecres.wholphin.BuildConfig +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.displayPreferencesApi +import org.jellyfin.sdk.model.UUID +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class DisplayPreferencesService + @Inject + constructor( + @param:ApplicationContext private val context: Context, + private val api: ApiClient, + ) { + private val mutex = Mutex() + + suspend fun getDisplayPreferences( + userId: UUID, + displayPreferencesId: String = DEFAULT_DISPLAY_PREF_ID, + client: String = DEFAULT_CLIENT, + ) = api.displayPreferencesApi + .getDisplayPreferences( + userId = userId, + displayPreferencesId = displayPreferencesId, + client = client, + ).content + + suspend fun updateDisplayPreferences( + userId: UUID, + displayPreferencesId: String = DEFAULT_DISPLAY_PREF_ID, + client: String = DEFAULT_CLIENT, + block: MutableMap.() -> Unit, + ) { + mutex.withLock { + val current = getDisplayPreferences(userId, DEFAULT_DISPLAY_PREF_ID) + val customPrefs = + current.customPrefs.toMutableMap().apply { + block.invoke(this) + } + api.displayPreferencesApi.updateDisplayPreferences( + displayPreferencesId = displayPreferencesId, + userId = userId, + client = client, + data = current.copy(customPrefs = customPrefs), + ) + } + } + + companion object { + const val DEFAULT_DISPLAY_PREF_ID = "default" + val DEFAULT_CLIENT = if (BuildConfig.DEBUG) "Wholphin (Debug)" else "Wholphin" + } + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/FavoriteWatchManager.kt b/app/src/main/java/com/github/damontecres/wholphin/services/FavoriteWatchManager.kt index cdaa0db4..93b3ebe4 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/FavoriteWatchManager.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/FavoriteWatchManager.kt @@ -1,5 +1,6 @@ package com.github.damontecres.wholphin.services +import com.github.damontecres.wholphin.data.model.BaseItem import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.playStateApi import org.jellyfin.sdk.api.client.extensions.userLibraryApi @@ -39,4 +40,7 @@ class FavoriteWatchManager } else { api.userLibraryApi.unmarkFavoriteItem(itemId).content } + + suspend fun removeContinueWatching(item: BaseItem) { + } } 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 cbad6d32..784440aa 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 @@ -42,7 +42,6 @@ import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.api.client.extensions.displayPreferencesApi import org.jellyfin.sdk.api.client.extensions.liveTvApi import org.jellyfin.sdk.api.client.extensions.userApi import org.jellyfin.sdk.api.client.extensions.userLibraryApi @@ -81,6 +80,7 @@ class HomeSettingsService private val latestNextUpService: LatestNextUpService, private val imageUrlService: ImageUrlService, private val suggestionService: SuggestionService, + private val displayPreferencesService: DisplayPreferencesService, ) { @OptIn(ExperimentalSerializationApi::class) val jsonParser = @@ -103,19 +103,11 @@ class HomeSettingsService suspend fun saveToServer( userId: UUID, settings: HomePageSettings, - displayPreferencesId: String = DISPLAY_PREF_ID, + displayPreferencesId: String = DisplayPreferencesService.DEFAULT_DISPLAY_PREF_ID, ) { - val current = getDisplayPreferences(userId, DISPLAY_PREF_ID) - val customPrefs = - current.customPrefs.toMutableMap().apply { - put(CUSTOM_PREF_ID, jsonParser.encodeToString(settings)) - } - api.displayPreferencesApi.updateDisplayPreferences( - displayPreferencesId = displayPreferencesId, - userId = userId, - client = context.getString(R.string.app_name), - data = current.copy(customPrefs = customPrefs), - ) + displayPreferencesService.updateDisplayPreferences(userId, displayPreferencesId) { + put(CUSTOM_PREF_ID, jsonParser.encodeToString(settings)) + } } /** @@ -127,24 +119,15 @@ class HomeSettingsService */ suspend fun loadFromServer( userId: UUID, - displayPreferencesId: String = DISPLAY_PREF_ID, - ): HomePageSettings? { - val current = getDisplayPreferences(userId, displayPreferencesId) - return current.customPrefs[CUSTOM_PREF_ID]?.let { - val jsonElement = jsonParser.parseToJsonElement(it) - decode(jsonElement) - } - } - - private suspend fun getDisplayPreferences( - userId: UUID, - displayPreferencesId: String, - ) = api.displayPreferencesApi - .getDisplayPreferences( - userId = userId, - displayPreferencesId = displayPreferencesId, - client = context.getString(R.string.app_name), - ).content + displayPreferencesId: String = DisplayPreferencesService.DEFAULT_DISPLAY_PREF_ID, + ): HomePageSettings? = + displayPreferencesService + .getDisplayPreferences(userId, displayPreferencesId) + .customPrefs[CUSTOM_PREF_ID] + ?.let { + val jsonElement = jsonParser.parseToJsonElement(it) + decode(jsonElement) + } /** * Computes the filename for locally saved [HomePageSettings] @@ -334,12 +317,12 @@ class HomeSettingsService */ suspend fun parseFromWebConfig(userId: UUID): HomePageResolvedSettings? { val customPrefs = - api.displayPreferencesApi + displayPreferencesService .getDisplayPreferences( displayPreferencesId = "usersettings", userId = userId, client = "emby", - ).content.customPrefs + ).customPrefs val userDto by api.userApi.getUserById(userId) val config = userDto.configuration ?: DefaultUserConfiguration val libraries = @@ -604,6 +587,7 @@ class HomeSettingsService title = context.getString(R.string.continue_watching), items = resume, viewOptions = row.viewOptions, + rowType = row, ) } @@ -622,6 +606,7 @@ class HomeSettingsService title = context.getString(R.string.next_up), items = nextUp, viewOptions = row.viewOptions, + rowType = row, ) } @@ -651,6 +636,7 @@ class HomeSettingsService nextUp, ), viewOptions = row.viewOptions, + rowType = row, ) } @@ -710,6 +696,7 @@ class HomeSettingsService title, genres, viewOptions = row.viewOptions, + rowType = row, ) } @@ -788,6 +775,7 @@ class HomeSettingsService title, it, row.viewOptions, + rowType = row, ) } latest @@ -820,6 +808,7 @@ class HomeSettingsService title, it, row.viewOptions, + rowType = row, ) } } @@ -848,6 +837,7 @@ class HomeSettingsService name ?: context.getString(R.string.collection), it, row.viewOptions, + rowType = row, ) } } @@ -875,6 +865,7 @@ class HomeSettingsService row.name, it, row.viewOptions, + rowType = row, ) } } @@ -925,6 +916,7 @@ class HomeSettingsService title, it, row.viewOptions, + rowType = row, ) } } @@ -949,6 +941,7 @@ class HomeSettingsService context.getString(R.string.active_recordings), it, row.viewOptions, + rowType = row, ) } } @@ -973,6 +966,7 @@ class HomeSettingsService context.getString(R.string.live_tv), it, row.viewOptions, + rowType = row, ) } } @@ -990,6 +984,7 @@ class HomeSettingsService context.getString(R.string.channels), it, row.viewOptions, + rowType = row, ) } } @@ -1012,12 +1007,14 @@ class HomeSettingsService title, suggestions.items, row.viewOptions, + rowType = row, ) } else if (suggestions is SuggestionsResource.Empty) { Success( title, listOf(), row.viewOptions, + rowType = row, ) } else { Error( @@ -1029,7 +1026,6 @@ class HomeSettingsService } companion object { - const val DISPLAY_PREF_ID = "default" const val CUSTOM_PREF_ID = "home_settings" } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt index ce945b69..01bdba3f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt @@ -1,22 +1,32 @@ +@file:UseSerializers( + UUIDSerializer::class, + LocalDateTimeSerializer::class, +) + package com.github.damontecres.wholphin.services -import android.content.Context import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.ui.SlimItemFields +import com.github.damontecres.wholphin.util.LocalDateTimeSerializer import com.github.damontecres.wholphin.util.supportItemKinds -import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable +import kotlinx.serialization.UseSerializers +import kotlinx.serialization.json.Json import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.itemsApi import org.jellyfin.sdk.api.client.extensions.tvShowsApi 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.GetNextUpRequest import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest +import org.jellyfin.sdk.model.serializer.UUIDSerializer import timber.log.Timber import java.time.LocalDateTime import java.util.UUID @@ -31,9 +41,10 @@ import kotlin.time.Duration.Companion.milliseconds class LatestNextUpService @Inject constructor( - @param:ApplicationContext private val context: Context, private val api: ApiClient, private val datePlayedService: DatePlayedService, + private val displayPreferencesService: DisplayPreferencesService, + private val favoriteWatchManager: FavoriteWatchManager, ) { /** * Get resume (continue watching) items for a user @@ -80,6 +91,7 @@ class LatestNextUpService maxDays: Int, useSeriesForPrimary: Boolean = true, ): List { + val removedSeries = getRemovedFromNextUp(userId) val nextUpDateCutoff = maxDays.takeIf { it > 0 }?.let { LocalDateTime.now().minusDays(it.toLong()) } val request = @@ -100,6 +112,23 @@ class LatestNextUpService .content .items .map { BaseItem.from(it, api, useSeriesForPrimary) } + .filter { + val seriesId = it.data.seriesId + if (seriesId != null && seriesId in removedSeries) { + // User has previously removed the series + val lastPlayedDate = it.data.userData?.lastPlayedDate + if (lastPlayedDate != null) { + // If item played it after it was removed, should include it + lastPlayedDate > removedSeries[seriesId] + } else { + // If unknown last played, filter out + false + } + } else { + true + } + } + return nextUp } @@ -140,4 +169,112 @@ class LatestNextUpService Timber.v("buildCombined took %s", duration) return@withContext result } + + /** + * Remove a series from next up + */ + suspend fun removeFromNextUp( + userId: UUID, + episode: BaseItem, + ) { + favoriteWatchManager.setWatched(episode.id, false) + episode.data.seriesId?.let { seriesId -> + displayPreferencesService.updateDisplayPreferences(userId) { + val removedIds = + get(REMOVED_KEY) + ?.let { + Json.decodeFromString(it).value + }.orEmpty() + .toMutableMap() + removedIds[seriesId] = LocalDateTime.now() + put( + REMOVED_KEY, + Json.encodeToString(RemovedSeriesIds(removedIds)), + ) + } + } + } + + /** + * Get when series were removed from next up + */ + suspend fun getRemovedFromNextUp(userId: UUID): Map = + displayPreferencesService + .getDisplayPreferences(userId) + .customPrefs[REMOVED_KEY] + ?.let { + Json.decodeFromString(it).value + }.orEmpty() + + suspend fun allowSeriesRemovedFromNextUp( + userId: UUID, + seriesId: UUID, + ) { + displayPreferencesService.updateDisplayPreferences(userId) { + val ids = + get(REMOVED_KEY) + ?.let { + Json.decodeFromString(it).value + }.orEmpty() + .toMutableMap() + ids.remove(seriesId) + put( + REMOVED_KEY, + Json.encodeToString(RemovedSeriesIds(ids)), + ) + } + } + + /** + * Check if user has watched a series since removing it + */ + suspend fun updateRemovedFromNextUp(userId: UUID) { + val removed = getRemovedFromNextUp(userId) + val newRemoved = removed.toMutableMap() + var changed = false + removed.forEach { (seriesId, timestamp) -> + val item = + api.itemsApi + .getItems( + userId = userId, + parentId = seriesId, + recursive = true, + includeItemTypes = listOf(BaseItemKind.EPISODE), + sortBy = listOf(ItemSortBy.DATE_PLAYED), + sortOrder = listOf(SortOrder.DESCENDING), + limit = 1, + ).content.items + .firstOrNull() + if (item != null) { + val lastPlayed = item.userData?.lastPlayedDate + if (lastPlayed != null && lastPlayed > timestamp) { + Timber.v("Updating removed next up for series %s", seriesId) + newRemoved.remove(seriesId) + changed = true + } + } else { + // Series doesn't exist anymore + Timber.v("Updating removed next up for missing series %s", seriesId) + newRemoved.remove(seriesId) + changed = true + } + } + if (changed) { + displayPreferencesService.updateDisplayPreferences(userId) { + put( + REMOVED_KEY, + Json.encodeToString(RemovedSeriesIds(newRemoved)), + ) + } + } + } + + companion object { + const val REMOVED_KEY = "removeNextUp" + } } + +@Serializable +data class RemovedSeriesIds( + val value: Map, +) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpWorker.kt b/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpWorker.kt new file mode 100644 index 00000000..328346f3 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpWorker.kt @@ -0,0 +1,139 @@ +package com.github.damontecres.wholphin.services + +import android.content.Context +import androidx.appcompat.app.AppCompatActivity +import androidx.hilt.work.HiltWorker +import androidx.lifecycle.lifecycleScope +import androidx.work.BackoffPolicy +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.NetworkType +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import androidx.work.workDataOf +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.util.ExceptionHandler +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import dagger.hilt.android.qualifiers.ActivityContext +import dagger.hilt.android.scopes.ActivityScoped +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.model.serializer.toUUIDOrNull +import timber.log.Timber +import java.util.UUID +import javax.inject.Inject +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes +import kotlin.time.toJavaDuration + +@HiltWorker +class LatestNextUpWorker + @AssistedInject + constructor( + @Assisted private val context: Context, + @Assisted workerParams: WorkerParameters, + private val serverRepository: ServerRepository, + private val api: ApiClient, + private val latestNextUpService: LatestNextUpService, + ) : CoroutineWorker(context, workerParams) { + override suspend fun doWork(): Result { + Timber.d("Start") + val serverId = + inputData.getString(PARAM_SERVER_ID)?.toUUIDOrNull() ?: return Result.failure() + val userId = + inputData.getString(PARAM_USER_ID)?.toUUIDOrNull() ?: return Result.failure() + + try { + if (api.baseUrl.isNullOrBlank() || api.accessToken.isNullOrBlank()) { + // Not active + var currentUser = serverRepository.current.value + if (currentUser == null) { + serverRepository.restoreSession(serverId, userId) + currentUser = serverRepository.current.value + } + if (currentUser == null) { + Timber.w("No user found during run") + return Result.failure() + } + } + latestNextUpService.updateRemovedFromNextUp(userId) + return Result.success() + } catch (ex: Exception) { + Timber.e(ex, "Error during updateRemovedFromNextUp") + return Result.retry() + } + } + + companion object { + const val WORK_NAME = "com.github.damontecres.wholphin.services.LatestNextUpWorker" + const val PARAM_USER_ID = "userId" + const val PARAM_SERVER_ID = "serverId" + } + } + +@ActivityScoped +class LatestNextUpSchedulerService + @Inject + constructor( + @param:ActivityContext private val context: Context, + private val serverRepository: ServerRepository, + private val workManager: WorkManager, + ) { + private val activity = + (context as? AppCompatActivity) + ?: throw IllegalStateException( + "SuggestionsSchedulerService requires an AppCompatActivity context, but received: ${context::class.java.name}", + ) + + // Exposed for testing + internal var dispatcher: CoroutineDispatcher = Dispatchers.IO + + init { + serverRepository.current.observe(activity) { user -> + Timber.v("New user %s", user?.user?.id) + if (user == null) { + workManager.cancelUniqueWork(SuggestionsWorker.WORK_NAME) + } else { + activity.lifecycleScope.launch(dispatcher + ExceptionHandler()) { + scheduleWork(user.user.id, user.server.id) + } + } + } + } + + private suspend fun scheduleWork( + userId: UUID, + serverId: UUID, + ) { + val constraints = + Constraints + .Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + val periodicWorkRequestBuilder = + PeriodicWorkRequestBuilder( + repeatInterval = 4.hours.toJavaDuration(), + ).setBackoffCriteria( + BackoffPolicy.EXPONENTIAL, + 15.minutes.toJavaDuration(), + ).setConstraints(constraints) + .setInputData( + workDataOf( + LatestNextUpWorker.PARAM_USER_ID to userId.toString(), + LatestNextUpWorker.PARAM_SERVER_ID to serverId.toString(), + ), + ) + Timber.i("Scheduling periodic LatestNextUpWorker") + + workManager.enqueueUniquePeriodicWork( + uniqueWorkName = LatestNextUpWorker.WORK_NAME, + existingPeriodicWorkPolicy = ExistingPeriodicWorkPolicy.REPLACE, + request = periodicWorkRequestBuilder.build(), + ) + } + } 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 080c1c4e..74fdaa1e 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 @@ -31,6 +31,7 @@ data class MoreDialogActions( val onSendMediaInfo: (UUID) -> Unit, val onClickDelete: (BaseItem) -> Unit, val onClickGoTo: (BaseItem) -> Unit = { navigateTo(it.destination()) }, + val onClickRemoveFromNextUp: (BaseItem) -> Unit = {}, ) enum class ClearChosenStreams { @@ -270,6 +271,8 @@ fun buildMoreDialogItemsForHome( favorite: Boolean, canDelete: Boolean, actions: MoreDialogActions, + canRemoveContinueWatching: Boolean = false, + canRemoveNextUp: Boolean = false, ): List = buildList { val itemId = item.id @@ -347,6 +350,26 @@ fun buildMoreDialogItemsForHome( }, ) } + if (canRemoveContinueWatching && !watched && playbackPosition > Duration.ZERO) { + add( + DialogItem( + text = R.string.remove_continue_watching, + iconStringRes = R.string.fa_eye, + ) { + actions.onClickWatch.invoke(itemId, false) + }, + ) + } + if (canRemoveNextUp && item.type == BaseItemKind.EPISODE && item.data.seriesId != null) { + add( + DialogItem( + text = R.string.remove_next_up, + iconStringRes = R.string.fa_tag, + ) { + actions.onClickRemoveFromNextUp.invoke(item) + }, + ) + } add( DialogItem( text = if (watched) R.string.mark_unwatched else R.string.mark_watched, 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 b9608dcc..ef0fdbad 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 @@ -22,7 +22,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider 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 @@ -47,6 +46,7 @@ import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.HomeRowConfig import com.github.damontecres.wholphin.data.model.HomeRowViewOptions import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.Cards @@ -138,6 +138,12 @@ fun HomePage( remember { { clickedPosition: RowColumn, item: BaseItem -> position = clickedPosition + val row = + (homeRows.getOrNull(clickedPosition.row) as? HomeRowLoadingState.Success) + val canRemoveContinueWatching = + row?.rowType is HomeRowConfig.ContinueWatching || row?.rowType is HomeRowConfig.ContinueWatchingCombined + val canRemoveNextUp = + row?.rowType is HomeRowConfig.NextUp || row?.rowType is HomeRowConfig.ContinueWatchingCombined val dialogItems = buildMoreDialogItemsForHome( context = context, @@ -147,6 +153,8 @@ fun HomePage( watched = item.played, favorite = item.favorite, canDelete = viewModel.canDelete(item, preferences.appPreferences), + canRemoveNextUp = canRemoveNextUp, + canRemoveContinueWatching = canRemoveContinueWatching, actions = MoreDialogActions( navigateTo = viewModel.navigationManager::navigateTo, @@ -164,6 +172,7 @@ fun HomePage( onClickDelete = { showDeleteDialog = RowColumnItem(position, item) }, + onClickRemoveFromNextUp = viewModel::removeFromNextUp, ), ) dialog = @@ -260,7 +269,9 @@ fun HomePageContent( ) { val focusedItem = remember(homeRows, position) { - (homeRows.getOrNull(position.row) as? HomeRowLoadingState.Success)?.items?.getOrNull(position.column) + (homeRows.getOrNull(position.row) as? HomeRowLoadingState.Success)?.items?.getOrNull( + position.column, + ) } val rowFocusRequesters = remember(homeRows.size) { List(homeRows.size) { FocusRequester() } } @@ -354,7 +365,13 @@ fun HomePageContent( onClickItem = remember(rowIndex, onClickItem) { { index, item -> - onClickItem.invoke(RowColumn(rowIndex, index), item) + onClickItem.invoke( + RowColumn( + rowIndex, + index, + ), + item, + ) } }, onLongClickItem = @@ -378,7 +395,12 @@ fun HomePageContent( remember(rowIndex, index) { { isFocused: Boolean -> if (isFocused) { - currentOnFocusPosition(RowColumn(rowIndex, index)) + currentOnFocusPosition( + RowColumn( + rowIndex, + index, + ), + ) } } } @@ -387,7 +409,10 @@ fun HomePageContent( { event: androidx.compose.ui.input.key.KeyEvent -> if (isPlayKeyUp(event) && item?.type?.playable == true) { Timber.v("Clicked play on ${item.id}") - currentOnClickPlay(currentPosition, item) + currentOnClickPlay( + currentPosition, + item, + ) true } else { false diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt index f53f80f7..6d1517c2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt @@ -12,6 +12,7 @@ import com.github.damontecres.wholphin.services.DatePlayedService import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.HomePageResolvedSettings import com.github.damontecres.wholphin.services.HomeSettingsService +import com.github.damontecres.wholphin.services.LatestNextUpService import com.github.damontecres.wholphin.services.MediaManagementService import com.github.damontecres.wholphin.services.MediaReportService import com.github.damontecres.wholphin.services.NavDrawerService @@ -39,6 +40,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.withContext +import org.jellyfin.sdk.model.api.BaseItemKind import timber.log.Timber import java.util.UUID import javax.inject.Inject @@ -58,6 +60,7 @@ class HomeViewModel private val backdropService: BackdropService, private val userPreferencesService: UserPreferencesService, private val mediaManagementService: MediaManagementService, + private val latestNextUpService: LatestNextUpService, ) : ViewModel() { private val _state = MutableStateFlow(HomeState.EMPTY) val state: StateFlow = _state @@ -231,6 +234,19 @@ class HomeViewModel item: BaseItem, appPreferences: AppPreferences, ): Boolean = mediaManagementService.canDelete(item, appPreferences) + + fun removeFromNextUp(item: BaseItem) { + if (item.type == BaseItemKind.EPISODE) { + viewModelScope.launchDefault { + serverRepository.currentUser.value?.id?.let { userId -> + latestNextUpService.removeFromNextUp(userId, item) + init() + } + } + } else { + Timber.w("Item is not an episode %s", item.id) + } + } } data class HomeState( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsGlobal.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsGlobal.kt index e4b61881..35c17a7e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsGlobal.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsGlobal.kt @@ -38,6 +38,7 @@ fun HomeSettingsGlobal( onClickLoad: () -> Unit, onClickLoadWeb: () -> Unit, onClickReset: () -> Unit, + onClickViewNextUp: () -> Unit, modifier: Modifier = Modifier, ) { val firstFocus: FocusRequester = remember { FocusRequester() } @@ -123,6 +124,20 @@ fun HomeSettingsGlobal( ) } item { HorizontalDivider() } + item { + HomeSettingsListItem( + selected = false, + headlineText = stringResource(R.string.view_removed_next_up), + leadingContent = { + Text( + text = stringResource(R.string.fa_eye), + fontFamily = FontAwesome, + ) + }, + onClick = onClickViewNextUp, + modifier = Modifier, + ) + } item { HomeSettingsListItem( selected = false, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt index a2a1ac63..3ec26f92 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt @@ -33,6 +33,7 @@ import com.github.damontecres.wholphin.data.model.HomeRowConfig 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.ui.components.BasicDialog import com.github.damontecres.wholphin.ui.components.ConfirmDialog import com.github.damontecres.wholphin.ui.data.RowColumn import com.github.damontecres.wholphin.ui.detail.search.SearchForDialog @@ -61,6 +62,7 @@ fun HomeSettingsPage( val backStack = rememberNavBackStack(HomeSettingsDestination.RowList) var showConfirmDialog by remember { mutableStateOf(null) } var searchForDialog by remember { mutableStateOf(null) } + var showRemovedNextUpDialog by remember { mutableStateOf(false) } val state by viewModel.state.collectAsState() var position by rememberPosition(0, 0) @@ -284,6 +286,9 @@ fun HomeSettingsPage( addRow(false) { viewModel.resetToDefault() } } }, + onClickViewNextUp = { + showRemovedNextUpDialog = true + }, modifier = destModifier, ) } @@ -340,6 +345,15 @@ fun HomeSettingsPage( }, ) } + if (showRemovedNextUpDialog) { + BasicDialog( + onDismissRequest = { showRemovedNextUpDialog = false }, + ) { + RemovedNextUpContent( + modifier = Modifier.padding(16.dp), + ) + } + } } data class ShowConfirm( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/RemovedNextUpContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/RemovedNextUpContent.kt new file mode 100644 index 00000000..605f76d5 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/RemovedNextUpContent.kt @@ -0,0 +1,259 @@ +package com.github.damontecres.wholphin.ui.main.settings + +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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.collectAsState +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.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.asFlow +import androidx.lifecycle.viewModelScope +import androidx.tv.material3.Icon +import androidx.tv.material3.ListItem +import androidx.tv.material3.ListItemDefaults +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.services.ImageUrlService +import com.github.damontecres.wholphin.services.LatestNextUpService +import com.github.damontecres.wholphin.ui.components.Button +import com.github.damontecres.wholphin.ui.components.ErrorMessage +import com.github.damontecres.wholphin.ui.components.LoadingPage +import com.github.damontecres.wholphin.ui.formatDateTime +import com.github.damontecres.wholphin.ui.launchDefault +import com.github.damontecres.wholphin.ui.toBaseItems +import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.util.DataLoadingState +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.update +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.model.api.ImageType +import timber.log.Timber +import java.time.LocalDateTime +import javax.inject.Inject + +@HiltViewModel +class RemovedNextUpContentViewModel + @Inject + constructor( + private val api: ApiClient, + private val serverRepository: ServerRepository, + private val latestNextUpService: LatestNextUpService, + private val imageUrlService: ImageUrlService, + ) : ViewModel() { + private val mutex = Mutex() + + private val _state = MutableStateFlow(RemovedNextUpState()) + val state: StateFlow = _state + + init { + viewModelScope.launchDefault { + serverRepository.currentUser.asFlow().collectLatest { user -> + _state.update { RemovedNextUpState() } + if (user == null) { + return@collectLatest + } + try { + val removed = latestNextUpService.getRemovedFromNextUp(user.id) + val series = mutableListOf() + removed.keys.chunked(50).forEach { ids -> + val results = + api.itemsApi + .getItems( + userId = user.id, + ids = ids, + ).toBaseItems(api, false) + results.forEach { + val imageUrl = imageUrlService.getItemImageUrl(it, ImageType.PRIMARY) + series.add(RemovedItem(it, imageUrl, removed[it.id]!!)) + } + } + _state.update { it.copy(loading = DataLoadingState.Success(series)) } + } catch (ex: Exception) { + Timber.e(ex, "Error fetching removed series") + _state.update { it.copy(loading = DataLoadingState.Error(ex)) } + } + } + } + } + + fun remove(item: RemovedItem) { + serverRepository.currentUser.value?.let { user -> + viewModelScope.launchDefault { + mutex.withLock { + _state.update { it.copy(removedEnabled = false) } + try { + latestNextUpService.allowSeriesRemovedFromNextUp(user.id, item.series.id) + val newItems = + (_state.value.loading as? DataLoadingState.Success>) + ?.data + ?.toMutableList() + ?.apply { + removeIf { it.series.id == item.series.id } + } + val loading = + if (newItems != null) { + DataLoadingState.Success(newItems) + } else { + DataLoadingState.Error("Error occurred") + } + _state.update { + it.copy( + loading = loading, + removedEnabled = true, + ) + } + } catch (ex: Exception) { + Timber.e(ex, "Error removing %s from removed next up", item.series.id) + } finally { + _state.update { it.copy(removedEnabled = true) } + } + } + } + } + } + } + +@Stable +data class RemovedItem( + val series: BaseItem, + val imageUrl: String?, + val datetime: LocalDateTime, +) + +data class RemovedNextUpState( + val loading: DataLoadingState> = DataLoadingState.Pending, + val removedEnabled: Boolean = true, +) + +@Composable +fun RemovedNextUpContent( + modifier: Modifier = Modifier, + viewModel: RemovedNextUpContentViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsState() + Column( + modifier = modifier, + ) { + Text( + text = "Removed from next up", + style = MaterialTheme.typography.displaySmall, + ) + when (val s = state.loading) { + DataLoadingState.Pending, + DataLoadingState.Loading, + -> { + LoadingPage(Modifier.fillMaxWidth()) + } + + is DataLoadingState.Error -> { + ErrorMessage(s, Modifier.fillMaxWidth()) + } + + is DataLoadingState.Success> -> { + if (s.data.isEmpty()) { + Text( + text = stringResource(R.string.no_results), + ) + } else { + val focusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } + LazyColumn( + contentPadding = PaddingValues(horizontal = 8.dp), + modifier = + Modifier + .fillMaxWidth() + .focusRequester(focusRequester), + ) { + items(s.data, key = { it.series.id }) { item -> + RemovedListItem( + item = item, + removedEnabled = state.removedEnabled, + onClickRemove = + remember { + { viewModel.remove(item) } + }, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } + } + } + } +} + +@Composable +fun RemovedListItem( + item: RemovedItem, + removedEnabled: Boolean, + onClickRemove: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = modifier, + ) { + ListItem( + selected = false, + onClick = {}, + leadingContent = { + AsyncImage( + model = item.imageUrl, + contentDescription = item.series.title, + modifier = Modifier.height(80.dp), + ) + }, + headlineContent = { + Text( + text = item.series.title ?: item.series.id.toString(), + ) + }, + supportingContent = { + Text( + text = formatDateTime(item.datetime), + ) + }, + scale = ListItemDefaults.scale(focusedScale = 1f), + modifier = Modifier.weight(1f), + ) + Button( + onClick = onClickRemove, + enabled = removedEnabled, + ) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = "delete", + modifier = Modifier, + ) + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/LoadingState.kt b/app/src/main/java/com/github/damontecres/wholphin/util/LoadingState.kt index 5da98fec..97308113 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/LoadingState.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/LoadingState.kt @@ -1,6 +1,7 @@ package com.github.damontecres.wholphin.util import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.HomeRowConfig import com.github.damontecres.wholphin.data.model.HomeRowViewOptions /** @@ -64,6 +65,7 @@ sealed interface HomeRowLoadingState { override val title: String, val items: List, val viewOptions: HomeRowViewOptions = HomeRowViewOptions(), + val rowType: HomeRowConfig? = null, ) : HomeRowLoadingState data class Error( diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/LocalDateSerializer.kt b/app/src/main/java/com/github/damontecres/wholphin/util/LocalDateSerializer.kt index 89b900f0..b88bcb8a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/LocalDateSerializer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/LocalDateSerializer.kt @@ -6,6 +6,7 @@ import kotlinx.serialization.descriptors.SerialDescriptor import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder import java.time.LocalDate +import java.time.LocalDateTime import java.time.format.DateTimeFormatter object LocalDateSerializer : KSerializer { @@ -22,3 +23,20 @@ object LocalDateSerializer : KSerializer { override fun deserialize(decoder: Decoder): LocalDate = decoder.decodeString().let { LocalDate.parse(it, DateTimeFormatter.ISO_LOCAL_DATE) } } + +object LocalDateTimeSerializer : KSerializer { + override val descriptor: SerialDescriptor + get() = SerialDescriptor("LocalDateTime", String.serializer().descriptor) + + override fun serialize( + encoder: Encoder, + value: LocalDateTime, + ) { + encoder.encodeString(DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(value)) + } + + override fun deserialize(decoder: Decoder): LocalDateTime = + decoder + .decodeString() + .let { LocalDateTime.parse(it, DateTimeFormatter.ISO_LOCAL_DATE_TIME) } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 46afd62a..154aeeae 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -749,6 +749,9 @@ Separate types Prefer showing logos for titles Wholphin updated to %s + Remove from continue watching + Remove series from next up + View series removed from next up View more Discover TV Shows Discover Movies diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/LatestNextUpServiceTests.kt b/app/src/test/java/com/github/damontecres/wholphin/services/LatestNextUpServiceTests.kt new file mode 100644 index 00000000..4c605df4 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/services/LatestNextUpServiceTests.kt @@ -0,0 +1,187 @@ +@file:UseSerializers( + UUIDSerializer::class, + DateTimeSerializer::class, +) + +package com.github.damontecres.wholphin.services + +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlinx.serialization.UseSerializers +import kotlinx.serialization.json.Json +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.Response +import org.jellyfin.sdk.api.client.extensions.tvShowsApi +import org.jellyfin.sdk.api.operations.TvShowsApi +import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult +import org.jellyfin.sdk.model.api.DisplayPreferencesDto +import org.jellyfin.sdk.model.api.ScrollDirection +import org.jellyfin.sdk.model.api.SortOrder +import org.jellyfin.sdk.model.api.UserItemDataDto +import org.jellyfin.sdk.model.api.request.GetNextUpRequest +import org.jellyfin.sdk.model.serializer.DateTimeSerializer +import org.jellyfin.sdk.model.serializer.UUIDSerializer +import org.junit.After +import org.junit.Assert +import org.junit.Before +import org.junit.Test +import java.time.LocalDateTime +import java.util.UUID + +class LatestNextUpServiceTests { + private val testDispatcher = StandardTestDispatcher() + + private val userId = UUID.randomUUID() + private val seriesId1 = UUID.randomUUID() + private val seriesId2 = UUID.randomUUID() + private val seriesId3 = UUID.randomUUID() + + private val mockApi = mockk(relaxed = true) + private val mockTvShowsApi = mockk() + private val mockDatePlayedService = mockk() + private val mockDisplayPreferencesService = mockk() + private val mockFavoriteWatchManager = mockk(relaxed = true) + + private val latestNextUpService = + LatestNextUpService(mockApi, mockDatePlayedService, mockDisplayPreferencesService, mockFavoriteWatchManager) + + @OptIn(ExperimentalCoroutinesApi::class) + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + every { mockApi.tvShowsApi } returns mockTvShowsApi + } + + @OptIn(ExperimentalCoroutinesApi::class) + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `Test nothing is filtered out`() = + runTest { + coEvery { mockTvShowsApi.getNextUp(any() as GetNextUpRequest) } returns mockResponse + coEvery { mockDisplayPreferencesService.getDisplayPreferences(any(), any(), any()) } returns + buildRemoved() + val result = latestNextUpService.getNextUp(userId, 20, false, false, 0) + Assert.assertEquals(3, result.size) + val seriesIds = result.map { it.data.seriesId } + Assert.assertTrue(seriesIds.containsAll(listOf(seriesId1, seriesId2, seriesId3))) + } + + @Test + fun `Test seriesId1 is filtered out`() = + runTest { + coEvery { mockTvShowsApi.getNextUp(any() as GetNextUpRequest) } returns mockResponse + coEvery { mockDisplayPreferencesService.getDisplayPreferences(any()) } returns + buildRemoved(seriesId1 to LocalDateTime.now().minusDays(1)) + val result = latestNextUpService.getNextUp(userId, 20, false, false, 0) + Assert.assertEquals(2, result.size) + val seriesIds = result.map { it.data.seriesId } + Assert.assertTrue(seriesId1 !in seriesIds) + Assert.assertTrue(seriesIds.containsAll(listOf(seriesId2, seriesId3))) + } + + @Test + fun `Test seriesId2 is filtered out`() = + runTest { + coEvery { mockTvShowsApi.getNextUp(any() as GetNextUpRequest) } returns mockResponse + coEvery { mockDisplayPreferencesService.getDisplayPreferences(any()) } returns + buildRemoved(seriesId2 to LocalDateTime.now().minusDays(1)) + val result = latestNextUpService.getNextUp(userId, 20, false, false, 0) + Assert.assertEquals(2, result.size) + val seriesIds = result.map { it.data.seriesId } + Assert.assertTrue(seriesId2 !in seriesIds) + Assert.assertTrue(seriesIds.containsAll(listOf(seriesId1, seriesId3))) + } + + @Test + fun `Test seriesId1 and seriesId2 are filtered out`() = + runTest { + coEvery { mockTvShowsApi.getNextUp(any() as GetNextUpRequest) } returns mockResponse + coEvery { mockDisplayPreferencesService.getDisplayPreferences(any()) } returns + buildRemoved( + seriesId1 to LocalDateTime.now().minusDays(1), + seriesId2 to LocalDateTime.now().minusDays(1), + ) + val result = latestNextUpService.getNextUp(userId, 20, false, false, 0) + Assert.assertEquals(1, result.size) + val seriesIds = result.map { it.data.seriesId } + Assert.assertTrue(seriesId1 !in seriesIds) + Assert.assertTrue(seriesId2 !in seriesIds) + Assert.assertTrue(seriesIds.containsAll(listOf(seriesId3))) + } + + fun buildRemoved(vararg values: Pair): DisplayPreferencesDto = + testDisplayPreferencesDto.copy( + customPrefs = + mutableMapOf().apply { + val str = Json.encodeToString(RemovedSeriesIds(values.toMap())) + put(LatestNextUpService.REMOVED_KEY, str) + }, + ) + + private val testUserItemDataDto = + UserItemDataDto( + playbackPositionTicks = 0L, + playCount = 0, + isFavorite = false, + lastPlayedDate = null, + played = false, + key = "", + itemId = UUID.randomUUID(), + ) + + private val mockResponse: Response = + Response( + content = + BaseItemDtoQueryResult( + items = + listOf( + mockk(relaxed = true) { + every { seriesId } returns seriesId1 + every { userData } returns testUserItemDataDto.copy(lastPlayedDate = LocalDateTime.now().minusDays(7)) + }, + mockk(relaxed = true) { + every { seriesId } returns seriesId2 + every { userData } returns testUserItemDataDto.copy(lastPlayedDate = null) + }, + mockk(relaxed = true) { + every { seriesId } returns seriesId3 + every { userData } returns testUserItemDataDto.copy(lastPlayedDate = LocalDateTime.now().plusDays(7)) + }, + ), + totalRecordCount = 3, + startIndex = 0, + ), + status = 200, + headers = mapOf(), + ) +} + +val testDisplayPreferencesDto = + DisplayPreferencesDto( + id = "default", + viewType = null, + sortBy = null, + indexBy = null, + rememberIndexing = false, + primaryImageHeight = 0, + primaryImageWidth = 0, + customPrefs = mapOf(), + scrollDirection = ScrollDirection.VERTICAL, + showBackdrop = false, + rememberSorting = false, + sortOrder = SortOrder.ASCENDING, + showSidebar = false, + client = null, + ) diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/NextUpTest.kt b/app/src/test/java/com/github/damontecres/wholphin/test/NextUpTest.kt index 0e9bb92e..c5c2ffee 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/test/NextUpTest.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/test/NextUpTest.kt @@ -1,13 +1,15 @@ package com.github.damontecres.wholphin.test -import android.content.Context import androidx.arch.core.executor.testing.InstantTaskExecutorRule import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.updateHomePagePreferences import com.github.damontecres.wholphin.services.DatePlayedService +import com.github.damontecres.wholphin.services.DisplayPreferencesService +import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.LatestNextUpService import com.github.damontecres.wholphin.services.mockQueryResult +import com.github.damontecres.wholphin.services.testDisplayPreferencesDto import io.mockk.CapturingSlot import io.mockk.coEvery import io.mockk.every @@ -33,15 +35,28 @@ class NextUpTest { private val mockTvShowsApi = mockk() private val mockApi = mockk(relaxed = true) - private val mockContext = mockk() private val mockDatePlayedService = mockk() + private val mockDisplayPreferencesService = mockk() + private val mockFavoriteWatchManager = mockk(relaxed = true) private val latestNextUpService = - LatestNextUpService(mockContext, mockApi, mockDatePlayedService) + LatestNextUpService( + mockApi, + mockDatePlayedService, + mockDisplayPreferencesService, + mockFavoriteWatchManager, + ) @Before fun setUp() { every { mockApi.tvShowsApi } returns mockTvShowsApi + coEvery { + mockDisplayPreferencesService.getDisplayPreferences( + any(), + any(), + any(), + ) + } returns testDisplayPreferencesDto } @Test diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestHomeRowSamples.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestHomeRowSamples.kt index 23ab54d5..e8c2fed1 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/test/TestHomeRowSamples.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestHomeRowSamples.kt @@ -131,6 +131,7 @@ class TestHomeRowSamples { latestNextUpService = mockk(), imageUrlService = mockk(), suggestionService = mockk(), + displayPreferencesService = mockk(), ) val str = """{ From c968c07d0bd22e19aadae8360a7a0e6ae0b31493 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 7 Apr 2026 16:12:10 -0400 Subject: [PATCH 092/148] fix(deps): update dependencies to v0.40.2 (#1202) --- 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 49b8b45a..abfef091 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -15,7 +15,7 @@ appcompat = "1.7.1" composeBom = "2026.03.01" mockk = "1.14.9" robolectric = "4.16.1" -multiplatformMarkdownRenderer = "0.39.2" +multiplatformMarkdownRenderer = "0.40.2" okhttpBom = "5.3.2" programguide = "1.6.0" slf4j2Timber = "1.2" From 6aa28e22da648719abc34f06fcadeb5ad5732f8d Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 7 Apr 2026 16:12:39 -0400 Subject: [PATCH 093/148] fix(deps): update dependencies to v14 (major) (#1208) --- 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 abfef091..aaaa95fa 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -aboutLibraries = "13.2.1" +aboutLibraries = "14.0.0" acra = "5.13.1" agp = "9.0.1" auto-service = "1.1.1" From ff37eff27d5873a35414cc3d51abef07aec9d036 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Thu, 9 Apr 2026 14:42:30 -0400 Subject: [PATCH 094/148] Update rennovate settings (#1217) ## Description Ignore dependencies from `wholphin-extensions` & the Jellyfin SDK. These dependencies should be updated manually. ### Related issues N/A ### Testing N/A ## Screenshots N/A ## AI or LLM usage None --- renovate.json | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/renovate.json b/renovate.json index 03e6a404..bba12817 100644 --- a/renovate.json +++ b/renovate.json @@ -23,7 +23,8 @@ "groupName": "Jellyfin SDK", "matchPackageNames": [ "org.jellyfin.sdk:**" - ] + ], + "enabled": false }, { "groupName": "AGP", @@ -36,6 +37,13 @@ "matchPackageNames": [ "org.jetbrains.kotlin**" ] + }, + { + "groupName": "Internal dependencies", + "matchPackageNames": [ + "com.github.damontecres.wholphin**" + ], + "enabled": false } ] } From 97e10f57ca98cd382e2eaa41a7c74d0789e1e943 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Fri, 10 Apr 2026 14:56:43 -0400 Subject: [PATCH 095/148] Few UI improvments & fixes (#1229) ## Description - Add indicator to mark the chosen version on the choose version dialog - Add an option for album's context menu to add it to the now playing queue - Fix how season cards refresh on the series detail page so the images don't glitch ### Related issues Related to #1220 ### Testing Emulator mostly ## Screenshots The indicator shows which version will play, whether explicitly chosen by the user or implicitly picked by Wholphin. ![choose_version_indicator Large](https://github.com/user-attachments/assets/7440ee1a-b9eb-4b34-ac39-86206c2eca09) ## AI or LLM usage None --- .../wholphin/services/MusicService.kt | 1 + .../ui/components/CollectionFolderGrid.kt | 11 ++++ .../wholphin/ui/components/Dialogs.kt | 7 ++ .../ui/components/RecommendedContent.kt | 13 ++++ .../ui/components/RecommendedMovie.kt | 6 +- .../ui/components/RecommendedMusic.kt | 6 +- .../ui/components/RecommendedTvShow.kt | 6 +- .../wholphin/ui/detail/DetailUtils.kt | 12 ++++ .../wholphin/ui/detail/PlaylistDetails.kt | 1 + .../ui/detail/collection/CollectionDetails.kt | 2 + .../detail/collection/CollectionViewModel.kt | 8 +++ .../ui/detail/episode/EpisodeDetails.kt | 1 + .../wholphin/ui/detail/movie/MovieDetails.kt | 1 + .../ui/detail/music/MusicViewModel.kt | 65 ++++++++++--------- .../ui/detail/series/SeriesOverview.kt | 1 + .../ui/detail/series/SeriesViewModel.kt | 3 +- .../damontecres/wholphin/util/RequestPager.kt | 7 ++ 17 files changed, 117 insertions(+), 34 deletions(-) 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 index 0fb0c774..ff7699a6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt @@ -279,6 +279,7 @@ class MusicService onMain { player.addMediaItems(mediaItems) } } updateQueueSize() + start() } /** diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt index ae945726..f574e883 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt @@ -64,6 +64,7 @@ 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.NavigationManager import com.github.damontecres.wholphin.services.ThemeSongPlayer import com.github.damontecres.wholphin.services.UserPreferencesService @@ -80,6 +81,7 @@ 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.detail.music.addToQueue import com.github.damontecres.wholphin.ui.equalsNotNull import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchIO @@ -139,6 +141,7 @@ class CollectionFolderViewModel private val themeSongPlayer: ThemeSongPlayer, private val userPreferencesService: UserPreferencesService, private val mediaManagementService: MediaManagementService, + private val musicService: MusicService, val mediaReportService: MediaReportService, @Assisted itemId: String, @Assisted initialSortAndDirection: SortAndDirection?, @@ -532,6 +535,11 @@ class CollectionFolderViewModel item: BaseItem, appPreferences: AppPreferences, ): Boolean = mediaManagementService.canDelete(item, appPreferences) + + fun addToQueue( + item: BaseItem, + index: Int, + ) = addToQueue(api, musicService, item, index) } /** @@ -776,6 +784,9 @@ fun CollectionFolderGrid( onClickGoTo = { onClickItem.invoke(position, it) }, + onClickAddToQueue = { + viewModel.addToQueue(it, 0) + }, ), ), onDismissRequest = { moreDialog.makeAbsent() }, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt index 37de91ce..538f623b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt @@ -76,6 +76,8 @@ import kotlinx.coroutines.launch import org.jellyfin.sdk.model.api.MediaSourceInfo import org.jellyfin.sdk.model.api.MediaStream import org.jellyfin.sdk.model.api.MediaStreamType +import org.jellyfin.sdk.model.serializer.toUUIDOrNull +import java.util.UUID /** * Parameters for rendering a [DialogPopup] @@ -597,6 +599,7 @@ fun ConfirmDeleteDialog( fun chooseVersionParams( context: Context, sources: List, + chosenSourceId: UUID?, onClick: (Int) -> Unit, ): DialogParams = DialogParams( @@ -604,6 +607,7 @@ fun chooseVersionParams( title = context.getString(R.string.choose_stream, context.getString(R.string.version)), items = sources.filter { it.id.isNotNullOrBlank() }.mapIndexed { index, source -> + val uuid = source.id?.toUUIDOrNull() val videoStream = source.mediaStreams?.firstOrNull { it.type == MediaStreamType.VIDEO } val title = source.name ?: source.path ?: source.id ?: "" @@ -611,6 +615,9 @@ fun chooseVersionParams( headlineContent = { Text(text = title) }, + leadingContent = { + SelectedLeadingContent(uuid != null && uuid == chosenSourceId) + }, supportingContent = { videoStream?.displayTitle?.let { Text(text = it) } }, 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 5eb1a210..1e2d3e61 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 @@ -26,6 +26,7 @@ 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.NavigationManager import com.github.damontecres.wholphin.services.deleteItem import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect @@ -35,6 +36,7 @@ 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.detail.music.addToQueue import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.main.HomePageContent @@ -48,6 +50,7 @@ import kotlinx.coroutines.Deferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow +import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.model.api.MediaType import java.util.UUID @@ -56,9 +59,11 @@ import java.util.UUID */ abstract class RecommendedViewModel( @param:ApplicationContext val context: Context, + val api: ApiClient, val navigationManager: NavigationManager, val favoriteWatchManager: FavoriteWatchManager, val mediaReportService: MediaReportService, + private val musicService: MusicService, private val backdropService: BackdropService, private val mediaManagementService: MediaManagementService, ) : ViewModel() { @@ -147,6 +152,11 @@ abstract class RecommendedViewModel( item: BaseItem, appPreferences: AppPreferences, ): Boolean = mediaManagementService.canDelete(item, appPreferences) + + fun addToQueue( + item: BaseItem, + index: Int, + ) = addToQueue(api, musicService, item, index) } @Composable @@ -244,6 +254,9 @@ fun RecommendedContent( }, onSendMediaInfo = viewModel.mediaReportService::sendReportFor, onClickDelete = { showDeleteDialog = RowColumnItem(position, item) }, + onClickAddToQueue = { + viewModel.addToQueue(it, 0) + }, ), ), onDismissRequest = { moreDialog.makeAbsent() }, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMovie.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMovie.kt index 392c77a5..3228046b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMovie.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMovie.kt @@ -16,6 +16,7 @@ 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.NavigationManager import com.github.damontecres.wholphin.services.SuggestionService import com.github.damontecres.wholphin.services.SuggestionsResource @@ -55,7 +56,8 @@ class RecommendedMovieViewModel @AssistedInject constructor( @ApplicationContext context: Context, - private val api: ApiClient, + api: ApiClient, + musicService: MusicService, private val serverRepository: ServerRepository, private val preferencesDataStore: DataStore, private val suggestionService: SuggestionService, @@ -67,9 +69,11 @@ class RecommendedMovieViewModel mediaManagementService: MediaManagementService, ) : RecommendedViewModel( context, + api, navigationManager, favoriteWatchManager, mediaReportService, + musicService, backdropService, mediaManagementService, ) { 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 index 87cc7c0d..e659347b 100644 --- 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 @@ -17,6 +17,7 @@ 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.NavigationManager import com.github.damontecres.wholphin.services.SuggestionService import com.github.damontecres.wholphin.services.SuggestionsResource @@ -54,7 +55,8 @@ class RecommendedMusicViewModel @AssistedInject constructor( @ApplicationContext context: Context, - private val api: ApiClient, + api: ApiClient, + musicService: MusicService, private val serverRepository: ServerRepository, private val preferencesDataStore: DataStore, private val suggestionService: SuggestionService, @@ -66,9 +68,11 @@ class RecommendedMusicViewModel mediaManagementService: MediaManagementService, ) : RecommendedViewModel( context, + api, navigationManager, favoriteWatchManager, mediaReportService, + musicService, backdropService, mediaManagementService, ) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedTvShow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedTvShow.kt index db7f7b4f..a48af22d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedTvShow.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedTvShow.kt @@ -16,6 +16,7 @@ import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.LatestNextUpService 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.SuggestionService import com.github.damontecres.wholphin.services.SuggestionsResource @@ -58,7 +59,8 @@ class RecommendedTvShowViewModel @AssistedInject constructor( @ApplicationContext context: Context, - private val api: ApiClient, + api: ApiClient, + musicService: MusicService, private val serverRepository: ServerRepository, private val preferencesDataStore: DataStore, private val lastestNextUpService: LatestNextUpService, @@ -71,9 +73,11 @@ class RecommendedTvShowViewModel mediaManagementService: MediaManagementService, ) : RecommendedViewModel( context, + api, navigationManager, favoriteWatchManager, mediaReportService, + musicService, backdropService, mediaManagementService, ) { 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 74fdaa1e..049569d9 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 @@ -3,6 +3,7 @@ package com.github.damontecres.wholphin.ui.detail import android.content.Context import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowForward +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.Info @@ -32,6 +33,7 @@ data class MoreDialogActions( val onClickDelete: (BaseItem) -> Unit, val onClickGoTo: (BaseItem) -> Unit = { navigateTo(it.destination()) }, val onClickRemoveFromNextUp: (BaseItem) -> Unit = {}, + val onClickAddToQueue: (BaseItem) -> Unit = {}, ) enum class ClearChosenStreams { @@ -331,6 +333,16 @@ fun buildMoreDialogItemsForHome( ) } } + if (item.type == BaseItemKind.MUSIC_ALBUM) { + add( + DialogItem( + context.getString(R.string.add_to_queue), + Icons.Default.Add, + ) { + actions.onClickAddToQueue(item) + }, + ) + } add( DialogItem( text = R.string.add_to_playlist, 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 d50b7af5..ff5809fb 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 @@ -430,6 +430,7 @@ fun PlaylistDetails( }, onSendMediaInfo = viewModel::sendMediaReport, onClickDelete = { showDeleteDialog = it }, + onClickAddToQueue = { viewModel.addToQueue(it, 0) }, ) PlaylistDetailsContent( 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 index bd647d47..96a4ea4f 100644 --- 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 @@ -124,6 +124,7 @@ fun CollectionDetails( }, onSendMediaInfo = viewModel.mediaReportService::sendReportFor, onClickDelete = { item -> showDeleteDialog = Pair(position, item) }, + onClickAddToQueue = { viewModel.addToQueue(it, 0) }, ), ) moreDialog = @@ -244,6 +245,7 @@ fun CollectionDetails( }, onSendMediaInfo = viewModel.mediaReportService::sendReportFor, onClickDelete = { item -> showDeleteDialog = Pair(null, item) }, + onClickAddToQueue = { viewModel.addToQueue(it, 0) }, ), ) moreDialog = 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 index 4a717ee0..b0a6c606 100644 --- 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 @@ -19,6 +19,7 @@ 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.MusicService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.ThemeSongPlayer import com.github.damontecres.wholphin.services.UserPreferencesService @@ -27,6 +28,7 @@ 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.detail.music.addToQueue import com.github.damontecres.wholphin.ui.formatTypeName import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchIO @@ -86,6 +88,7 @@ class CollectionViewModel private val keyValueService: KeyValueService, private val libraryDisplayInfoDao: LibraryDisplayInfoDao, private val imageUrlService: ImageUrlService, + private val musicService: MusicService, val mediaReportService: MediaReportService, @Assisted private val itemId: UUID, ) : ViewModel() { @@ -462,6 +465,11 @@ class CollectionViewModel } } + fun addToQueue( + item: BaseItem, + index: Int, + ) = addToQueue(api, musicService, item, index) + companion object { val typesInCollection = listOf( 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 e4b5a047..bdacc389 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 @@ -176,6 +176,7 @@ fun EpisodeDetails( chooseVersionParams( context, ep.data.mediaSources!!, + chosenStreams?.source?.id?.toUUIDOrNull(), ) { idx -> val source = ep.data.mediaSources!![idx] viewModel.savePlayVersion( 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 440e8d4a..bf7d4601 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 @@ -210,6 +210,7 @@ fun MovieDetails( chooseVersionParams( context, movie.data.mediaSources!!, + chosenStreams?.source?.id?.toUUIDOrNull(), ) { idx -> val source = movie.data.mediaSources!![idx] viewModel.savePlayVersion( 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 index e7b85418..812927f1 100644 --- 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 @@ -81,35 +81,7 @@ abstract class MusicViewModel( 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) - } - } - } - } + ) = addToQueue(api, musicService, item, index) fun startInstantMix(itemId: UUID) { viewModelScope.launchIO { @@ -140,3 +112,38 @@ abstract class MusicViewModel( internal abstract fun init() } + +fun ViewModel.addToQueue( + api: ApiClient, + musicService: MusicService, + 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) + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt index 84180f20..e048a008 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt @@ -225,6 +225,7 @@ fun SeriesOverview( chooseVersionParams( context, ep.data.mediaSources!!, + chosenStreams?.source?.id?.toUUIDOrNull(), ) { idx -> val source = ep.data.mediaSources!![idx] viewModel.savePlayVersion( 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 cfc43be9..de04149b 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 @@ -287,8 +287,7 @@ class SeriesViewModel item.value?.let { item -> if (loading.value == LoadingState.Success) { viewModelScope.launchIO { - val seasons = getSeasons(item, null).await() - this@SeriesViewModel.seasons.setValueOnMain(seasons) + (seasons.value as? ApiRequestPager<*>)?.refresh() } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/RequestPager.kt b/app/src/main/java/com/github/damontecres/wholphin/util/RequestPager.kt index c9f91534..b478338b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/RequestPager.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/RequestPager.kt @@ -107,6 +107,13 @@ abstract class RequestPager( } } + suspend fun refresh() { + cachedPages.asMap().keys.forEachIndexed { index, pageNum -> + if (DEBUG) Timber.v("refresh: pageNum=%s", pageNum) + fetchPageBlocking(pageNum * pageSize, index == 0) + } + } + protected abstract suspend fun fetchPage( pageNumber: Int, includeTotalCount: Boolean, From 0f165d1dbf5e47a06756e2177ff443c204345b81 Mon Sep 17 00:00:00 2001 From: opakholis Date: Tue, 31 Mar 2026 07:31:39 +0000 Subject: [PATCH 096/148] Translated using Weblate (Indonesian) Currently translated at 95.8% (508 of 530 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/id/ --- app/src/main/res/values-in/strings.xml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index ae739f25..6bf057c9 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -534,4 +534,8 @@ Layar penuh Lebar penuh Tinggi penuh + Utamakan logo untuk judul + Album + Artis + Lagu From 32258407df9e303fa4d97638a1e43226c2d35e12 Mon Sep 17 00:00:00 2001 From: arcker95 Date: Tue, 31 Mar 2026 14:21:39 +0000 Subject: [PATCH 097/148] Translated using Weblate (Italian) Currently translated at 100.0% (530 of 530 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/it/ --- app/src/main/res/values-it/strings.xml | 30 ++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 604a9ed5..e487bde1 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -442,8 +442,8 @@ Ruota a destra Ingrandisci Rimpicciolisci - Durata slideshow - Riproduci video durante lo slideshow + Durata dello slideshow + Riproduci i video durante lo slideshow Invia log informazioni media al server %s giorno @@ -570,4 +570,30 @@ Sei sicuro di voler cancellare questo elemento? Mostra opzioni di gestione dei media Seleziona tutti + Album + Artisti + Canzoni + Vai all\'artista + In riproduzione + Mix istantaneo + Vai all\'album + Aggiungi alla coda + Album artista + Album + Canzoni popolari + Riproduci successivo + Video musicali + Testi + Nascondi testi + Mostra testi + La canzone ha i testi + Rimuovi dalla coda + Mostra copertina album + Mostra visualizzatore + Mostra sfondo + Riproduci come? + Per visualizzare l’audio in riproduzione è necessario il permesso di registrare l’audio. + Continua + Tipi separati + Preferisci mostrare i loghi per i titoli From a29cdae0a4bed4063deb0927a3524622edcf90f5 Mon Sep 17 00:00:00 2001 From: lednurb Date: Tue, 31 Mar 2026 20:45:19 +0000 Subject: [PATCH 098/148] Translated using Weblate (Dutch) Currently translated at 74.3% (394 of 530 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/nl/ --- app/src/main/res/values-nl/strings.xml | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 1afae518..6878d1a0 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -13,7 +13,7 @@ Serieopname afbreken Annuleren Hoofdstukken - ‘%1$s’ kiezen + %1$s kiezen Afbeeldingscache wissen Collectie Collecties @@ -57,8 +57,8 @@ Bezig met laden… Serie als bekeken markeren? Serie als niet-bekeken markeren? - Markeren als niet-bekeken - Markeren als bekeken + Als niet-bekeken markeren + Als bekeken markeren Max. bitsnelheid Soortgelijk Meer @@ -325,7 +325,7 @@ Niveau Resolutie Anamorfisch - Met interlatie + Geïnterlinieerd Framesnelheid Bitdiepte Videobereik @@ -374,12 +374,12 @@ Login bij Seerr server Zoek %s Acteur - Afspeel voorkeuren wissen + Afspeelvoorkeuren wissen Aflevering thumbnails Auteur Voor afleveringen Start na - Toevoegen aan wachtrij + Aan wachtrij toevoegen Componist Serie thumbnails Weet u zeker dat u dit item wilt verwijderen? @@ -401,7 +401,7 @@ Verwachte series Verzoek in 4K Helderheid - Stuur media-info log naar server + Media-info log naar server Resolutie schakelen Lokaal Achtergrondstijl @@ -432,7 +432,7 @@ Geen limiet Startscherm aanpassen Startrijen - "Aanbevelingen voor %1$s" + Aanbevelingen voor %1$s Laagst Automatisch overslaan Dirigent From bfbcfe9678f2ec289eadf2c5f6759f4335298249 Mon Sep 17 00:00:00 2001 From: Chosnuk Date: Tue, 31 Mar 2026 21:12:36 +0000 Subject: [PATCH 099/148] Translated using Weblate (Polish) Currently translated at 48.8% (259 of 530 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/pl/ --- app/src/main/res/values-pl/strings.xml | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 53cd49fc..bcdae23a 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -253,7 +253,7 @@ Playlista Streszczenie Zapisz serial - + Pokaż Pobieranie napisów trwa zbyt długo, może być konieczne ponowne uruchomienie odtwarzacza Harmonogram nagrywania @@ -298,4 +298,18 @@ Cofnij gdy wznawiasz odtwarzanie Player odtwarzający Ochrona przed uśpieniem + Kończy się o %1$s + Wprowadź adres serwera + Natychmiastowo + Nie znaleziono serwerów + Tylko wymuszone napisy + Wyszukiwanie głosowe + Uruchamianie + Mów żeby wyszukać + Przetwarzanie + Wciśnij wstecz aby anulować + Błąd nagrywania dźwięku + Błąd rozpoznawania mowy + Wymagane uprawnienia mikrofonu + Błąd sieci From 97690bdb7ba4058be25c98a24bd62e49c6e2b980 Mon Sep 17 00:00:00 2001 From: Outbreak2096 Date: Wed, 1 Apr 2026 04:35:54 +0000 Subject: [PATCH 100/148] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (530 of 530 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hans/ --- app/src/main/res/values-zh-rCN/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index de4c04c7..341fb704 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -559,4 +559,5 @@ 可视化当前播放的音频需要录音权限。 继续 分开类型 + 优先显示标题图标 From 36d2926ff79375c8eac231f8c575c2a17997fbce Mon Sep 17 00:00:00 2001 From: Pwnisher Date: Tue, 31 Mar 2026 07:30:53 +0000 Subject: [PATCH 101/148] Translated using Weblate (Romanian) Currently translated at 63.9% (339 of 530 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/ro/ --- app/src/main/res/values-ro/strings.xml | 111 ++++++++++++++++++++++--- 1 file changed, 101 insertions(+), 10 deletions(-) diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index b99bf40b..46a38847 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -1,7 +1,7 @@ Despre - Favorite + Favorit Adaugă server Adaugă utilizator Audio @@ -42,8 +42,8 @@ Nu este disponibilă nicio actualizare Filme - - + + Librărie Mergi la @@ -55,8 +55,8 @@ Marchează ca vizionat Persoane - - + + Număr de redări Mai mult @@ -173,7 +173,7 @@ Cheie API Conectează-te la serverul Seerr Servere descoperite - + Reclamă Evaluarea comunității A apărut o eroare! Apasă butonul pentru a trimite jurnalele către serverul tău. @@ -215,8 +215,8 @@ Sezoane Seriale TV - - + + Interfață Necunoscut @@ -228,8 +228,8 @@ Pauză cu un click Interviuri - - + + Fundal @@ -306,4 +306,95 @@ Cântecul are versuri Selectează tot Redă ca? + Anulează înregistrarea seriei + Informații privind licența + Forțat + Ascunde informațiile de depanare + Imediat + Niciuna + Redă de aici + Viteza de redare + Redare + Listă de redare + Afișează informații de depanare privind redarea + Doar subtitrări forțate + Salvează + + Reia + Începe + Vorbește pentru a căuta + Repornire + Eroare rețea + Amestecă + Cele mai bine cotate, nevizionate + + Trailere + + + + Redă cu transcodare + Informații media + Creează o listă noua de redare + Adaugă în lista de redare + + Altele + + + + + În spatele scenelor + + + + + Melodii tematice + + + + + Scene șterse + + + + Evaluare parentală + Timp de execuție + Extra + + Clipuri + + + + %s favorit + Temă aplicație + Verifică automat dacă există actualizări + Dispozitivul acceptă formatele AC3/Dolby Digital + Setări avansate + Interfață avansată + Redă automat următorul + Se aplică numai serialelor TV + Faceți clic pentru a schimba paginile + Sări peste segment + Sări peste final + Autentificare prin server + Apăsați în centru pentru a confirma + Solicită codul PIN pentru profil + Opțiuni de vizualizare + Autentificare + Aspect + Rotește la stânga + Suprascrie setările pe server? + Suprascrie setările locale? + Începe după + Pentru ce tipuri de articole să se afișeze imagini + Negru + Ignoră + La sfârșitul redării + În timpul genericului de final/outro + Albastru aldin + Font aldin + Motorul de redare + MPV: Utilizează decodarea hardware + Dezactivează această opțiune dacă aplicația se blochează + Opacitatea fundalului + Profil From ffb7293368519f8b9924aed93363250924f78719 Mon Sep 17 00:00:00 2001 From: lednurb Date: Wed, 1 Apr 2026 16:12:42 +0000 Subject: [PATCH 102/148] Translated using Weblate (Dutch) Currently translated at 91.1% (483 of 530 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/nl/ --- app/src/main/res/values-nl/strings.xml | 100 +++++++++++++++++++++++-- 1 file changed, 95 insertions(+), 5 deletions(-) diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 6878d1a0..b9ee6cd2 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -23,7 +23,7 @@ Bevestigen Verder kijken Beoordeling (recensie) - %.1f seconden + %.2f seconden Standaard Verwijderen Overleden @@ -115,7 +115,7 @@ Studio\'s Versturen Het downloaden duurt erg lang - het kan zijn dat je opnieuw moet beginnen met afspelen - Ondertiteling + Onderschrift Ondertiteling Suggesties Andere server kiezen @@ -123,8 +123,8 @@ Best beoordeeld, niet-bekeken Trailer - Trailers - + Trailer + Trailers DVR-schema Gids @@ -311,7 +311,7 @@ Gastrollen Randbreedte - Automatische ververssnelheid toepassen + Beeldfrequentie omschakelen Automatisch Gebruikersnaam/wachtwoord gebruiken Inloggen @@ -441,4 +441,94 @@ %s dag %s dagen + + %s minuut + %s minuten + + Een ander apparaat toestaan om in te loggen op uw account + Quick Connect code invoeren + Code moet 6 cijfers bevatten + Apparaat succesvol geautoriseerd + MPV is nu de standaard speler behalve voor HDR. \nU kunt dit in de instellingen wijzigen. + Bewaar voor album + Diavoorstelling afspelen + Diavoorstelling stoppen + Aan het begin + Geen foto\'s meer + Duur diavoorstelling + Speel video\'s tijdens diavoorstelling + Artiesten + Nummers + Alle kaarten vergroten + Alle kaarten verkleinen + Kleine afbeeldingen gebruiken + Weergavevoorkeuren + Ingebouwde presets om snel alle rijen te stijlen + Kies rijen en afbeeldingen op startscherm + Animeer + Max leeftijdsclassificatie + Geen max + Schermbeveiliging + Instellingen schermbeveiliging + Start schermbeveiliging + Foto\'s + Soort toevoegen + Welke soorten afbeeldingen moeten worden getoond + Passend + Bijsnijden + Vullen + Vulbreedte + Vulhoogte + Wholphin standaard + Wholphin compact + Laag + Middel + Hoog + Volledig + Paars + Oranje + Felblauw + Zwart + Alleen FFmpeg gebruiken als er geen ingebouwde decoder is + Voorkeur voor FFmpeg boven ingebouwde decoders + Nooit FFmpeg decoders gebruiken + Aan het einde van de weergave + Tijdens aftiteling/outro + Wit + Lichtgrijs + Donkergrijs + Geel + Cyaan + Omtrek + Schaduw + Voorkeur voor MPV + ExoPlayer voor HDR gebruiken + Vierkant (1:1) + Primair + Miniatuur + Afbeelding met dynamische kleuren + Alleen afbeelding + Jellyfin gebruiker + Lokale gebruiker + Geen externe ondertiteling gevonden + Huidig + In-app schermbeveiliging gebruiken + Media-management opties tonen + Tekstschrijver + Arrangeur + Technicus + Bedenker + Artiest + Album artiest + Populaire nummers + Volgende afspelen + Tekst + Tekst verbergen + Tekst tonen + Nummer bevat tekst + Albumhoes tonen + Visualisatie tonen + Achtergrond tonen + Afspelen als? + Visualisatie van de huidig afspelende audio vereist toestemming voor opname. From d055ba4ab4421ea05f1fa245f7ce0641389ba2fb Mon Sep 17 00:00:00 2001 From: Chosnuk Date: Wed, 1 Apr 2026 13:30:57 +0000 Subject: [PATCH 103/148] Translated using Weblate (Polish) Currently translated at 49.8% (264 of 530 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/pl/ --- app/src/main/res/values-pl/strings.xml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index bcdae23a..d1c14e19 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -312,4 +312,9 @@ Błąd rozpoznawania mowy Wymagane uprawnienia mikrofonu Błąd sieci + Błąd serwera + Ponów + Wideo + Filmy + Oglądanie na żywo From 81a0ae39f0881b7b3e736d837b52977e13b929c4 Mon Sep 17 00:00:00 2001 From: idezentas Date: Wed, 1 Apr 2026 08:43:59 +0000 Subject: [PATCH 104/148] Translated using Weblate (Turkish) Currently translated at 100.0% (530 of 530 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/tr/ --- app/src/main/res/values-tr/strings.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index ede526c0..4667c806 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -585,4 +585,6 @@ Nasıl çalınsın? Çalan sesi görselleştirmek için ses kaydetme izni gerekiyor. Devam et + Farklı türler + Başlık yerine logo göster From 9e2df5660af4f5c5bce06634064960744096a108 Mon Sep 17 00:00:00 2001 From: jbrek Date: Thu, 2 Apr 2026 14:06:28 +0000 Subject: [PATCH 105/148] Added translation using Weblate (Thai) --- app/src/main/res/values-th/strings.xml | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 app/src/main/res/values-th/strings.xml diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml new file mode 100644 index 00000000..55344e51 --- /dev/null +++ b/app/src/main/res/values-th/strings.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file From ac0e42320fac7fdbe9cc7fe8082ac0e87ee71aeb Mon Sep 17 00:00:00 2001 From: opakholis Date: Fri, 3 Apr 2026 04:00:14 +0000 Subject: [PATCH 106/148] Translated using Weblate (Indonesian) Currently translated at 100.0% (530 of 530 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/id/ --- app/src/main/res/values-in/strings.xml | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index 6bf057c9..3f8fa5ea 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -41,7 +41,7 @@ Nonaktif Aktif Genre - Masuk ke serial + Buka serial Masuk ke Paksa Sembunyikan kontrol pemutaran @@ -538,4 +538,26 @@ Album Artis Lagu + Sedang diputar + Campuran instan + Tambahkan ke antrean + Artis album + Album + Lagu populer + Putar berikutnya + Video musik + Lirik + Sembunyikan lirik + Tampilkan lirik + Tersedia lirik + Hapus dari antrean + Tampilkan sampul album + Tampilkan visualisator + Tampilkan backdrop + Putar sebagai? + Memvisualisasikan audio yang sedang diputar memerlukan izin untuk merekam audio. + Lanjutkan + Pisahkan jenis + Buka artis + Buka album From 278d2cc3e6fdef4ed2a600fc72c04d801db4c78e Mon Sep 17 00:00:00 2001 From: jbrek Date: Fri, 3 Apr 2026 07:57:37 +0000 Subject: [PATCH 107/148] Translated using Weblate (Thai) Currently translated at 29.2% (155 of 530 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/th/ --- app/src/main/res/values-th/strings.xml | 262 ++++++++++++++++++++++++- 1 file changed, 261 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index 55344e51..0eac48c2 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -1,3 +1,263 @@ - \ No newline at end of file + เกี่ยวกับแอปนี้ + การบันทึกที่ใช้งานอยู่ + ชื่นชอบ + เพิ่มเซิร์ฟเวอร์ + เพิ่มผู้ใช้ + เสียง + บ้านเกิด + บิตเรท + เกิด + ยกเลิกการบันทึก + ยกเลิกการบันทึกซีรีส์ + ยกเลิก + ฉาก + เลือก %1$s + ล้างแคชภาพ + คอลเลคชัน + คอลเลกชัน + ทางการค้า + การให้คะแนนของชุมชน + เกิดข้อผิดพลาด! กดปุ่มเพื่อส่งบันทึกไปยังเซิร์ฟเวอร์ของคุณ + ตกลง + ดูต่อครับ + การจัดอันดับนักวิจารณ์ + %.2f วินาที + ค่าเริ่มต้น + ลบ\" + เสียชีวิต + กำกับโดย %1$s + ผู้กำกับ + ปิดใช้งาน + เซิร์ฟเวอร์ที่ค้นพบ + ดาวน์โหลดและอัปเดต + กำลังดาวน์โหลด… + ใช้งาน + จบที่ %1$s + ป้อนที่อยู่ IP หรือ URL ของเซิร์ฟเวอร์ + ป้อนที่อยู่เซิร์ฟเวอร์ + ตอน + เกิดข้อผิดพลาดในการโหลดคอลเล็กชัน %1$s + ภายนอก + ชื่นชอบ + ขนาด + บังคับ + ประเภทเพลง + ไปซีรีย์ + ไปที่ + ซ่อนการควบคุมการเล่น + ซ่อนข้อมูลการแก้ไขข้อบกพร่อง + ซ่อน + หน้าหลัก + ทันที + บทนำ + #ABCDEFGHIJKLMNOPQRSTUVWXYZ + คลังเพลง + ข้อมูลใบอนุญาต + ทีวี สด + กำลังโหลด… + ทำเครื่องหมายว่าเล่นซีรีส์ทั้งหมดแล้วหรือไม่? + ทำเครื่องหมายซีรีส์ทั้งหมดว่ายังไม่ได้เล่น? + ทำเครื่องหมายว่ายังไม่ได้ดู + ทำเครื่องหมายว่าดูแล้ว + บิตเรตสูงสุด + เช่นนี้มากขึ้น + เพิ่มเติม + + ภาพยนตร์ + + ชื่อ + ถัดไปขึ้นมา + ไม่มีข้อมูล + ไม่มีผลลัพธ์ + ไม่พบเซิร์ฟเวอร์ + ไม่มีการบันทึกตามกำหนดเวลา + ไม่มีการอัปเดต + ไม่มี + คำบรรยายแบบบังคับเท่านั้น + เอาท์โตร + เส้นทาง + + ผู้คน + + จำนวนครั้งที่เล่น + เล่นจากที่นี่ + เล่น + แสดงข้อมูลการแก้ไขข้อผิดพลาดในการเล่น + ความเร็วในการเล่น + การเล่น + เพลย์ลิสต์ + เพลย์ลิสต์ + ดูตัวอย่าง + การตั้งค่าโปรไฟล์ผู้ใช้ + คิว + สรุป + ที่เพิ่งเพิ่ม %1$s + ที่เพิ่งเพิ่ม + บันทึกล่าสุด + ที่เพิ่งวางขาย + แนะนำ + โปรแกรมบันทึก + บันทึกซีรีส์ + ไม่เป็นที่ชื่นชอบ + เริ่มใหม่ + เล่นเกมต่อ + บันทึก + ค้นหาและดาวน์โหลด + ค้นหา + ค้นหา… + ค้นหาด้วยเสียง + กำลังเริ่มต้น + พูดเพื่อค้นหา + กำลังประมวลผล + กดกลับเพื่อยกเลิก + ข้อผิดพลาดในการบันทึกเสียง + ข้อผิดพลาดในการจดจำเสียง + ต้องได้รับอนุญาตจากไมโครโฟน + ข้อผิดพลาดของเครือข่าย + หมดเวลาเครือข่าย + ไม่มีคำพูดใดที่ได้รับการยอมรับ + การจดจำเสียงไม่ว่าง + ข้อผิดพลาดของเซิร์ฟเวอร์ + ไม่พบคำพูด + ข้อผิดพลาดที่ไม่รู้จัก + ไม่สามารถเริ่มการจดจำเสียงได้ + การจดจำเสียงหมดเวลา + ทำซ้ำ + เลือกเซิร์ฟเวอร์ + เลือกผู้ใช้งาน + แสดงข้อมูลการแก้ไขข้อบกพร่อง + แสดงต่อไป + แสดง + สุ่ม + ข้าม + บันทึกวันที่แล้ว + วันที่เพิ่มตอน + วันที่เล่น + วันที่เผยแพร่ + ชื่อ + สุ่ม + สตูดิโอ + ยืนยัน + การดาวน์โหลดใช้เวลานาน คุณอาจต้องเริ่มเล่นใหม่ + คำบรรยาย + คำบรรยาย + คำแนะนำ + สลับเซิร์ฟเวอร์ + สวิตช์ + เรตติ้งสูงสุด (ยังไม่มีคนดู) + ตัวอย่าง + + รถพ่วง + + กำหนดการ DVR + แนะนำ + ฤดูกาล + ฤดูกาล + + รายการทีวี + + อินเทอร์เฟซ + ไม่มีข้อมูล + มีอัพเดต + เวอร์ชัน + สเกลวิดีโอ + วิดีโอ + วิดีโอ + ชมสด + %1$d ปี + เล่นกับการแปลงรหัส + ข้อมูลสื่อ + แสดงนาฬิกา + ลำดับตอนที่ออกอากาศ + สร้างเพลย์ลิสต์ใหม่ + เพิ่มชื่อเพลย์ลิสต์ + หยุดเกมด้วยการคลิกเพียงครั้งเดียว + กดปุ่ม D-Pad ตรงกลางเพื่อหยุด/เล่น + ทำตัวเอียง + ฟ้อนต์ + พื้นหลัง + สำเร็จ + การให้คะแนนโดยผู้ปกครอง + รันไทม์ + บริการพิเศษ + + อื่นๆ + + + เบื้องหลัง + + + เพลงธีม + + + วิดีโอธีม + + + คลิป + + + ฉากที่ถูกลบ + + + สัมภาษณ์ + + + ซีน + + + ตัวอย่าง + + + สารคดี + + + กางเกงขาสั้น + + ชื่นชอบ %s + อุปกรณ์รองรับระบบเสียง AC3/Dolby Digital + การตั้งค่าขั้นสูง + UI ขั้นสูง + ธีมแอปพลิเคชัน + ตรวจสอบการอัปเดตโดยอัตโนมัติ + หน่วงเวลาก่อนเล่นเพลงถัดไป + เล่นอัตโนมัติถัดไป + ตรวจสอบอัปเดต + ใช้ได้กับซีรีส์โทรทัศน์เท่านั้น + รวมตัวเลือก \"ดูต่อ\" และ \"รายการถัดไป\" เข้าด้วยกัน + เล่นคำบรรยาย ASS โดยตรง + เล่นคำบรรยาย PGS โดยตรง + ลดระดับเสียงลงเป็นสเตอริโอเสมอ + ใช้โมดูลถอดรหัส FFmpeg + ขนาดเนื้อหาเริ่มต้น + ติดตั้งอัปเดต + เวอร์ชันที่ติดตั้ง + จำนวนรายการสูงสุดในแถวหน้าแรก + เลือกรายการเริ่มต้นที่จะแสดง รายการอื่นๆ จะถูกซ่อนไว้ + ปรับแต่งรายการเมนูนำทาง + คลิกเพื่อสลับหน้า + สลับหน้าเมนูนำทางเมื่อโฟกัส + การป้องกันการส่งผ่าน + เล่นเพลงธีม + การเล่นจะแทนที่ + จำแท็บที่เลือก + เปิดใช้งานการรับชมซ้ำในครั้งต่อไป + ค้นหาขั้นบันได + มีประโยชน์สำหรับการแก้ไขข้อผิดพลาด + ส่งบันทึกแอปไปยังเซิร์ฟเวอร์ปัจจุบัน + จะพยายามส่งไปยังเซิร์ฟเวอร์ที่เชื่อมต่อล่าสุด + ส่งรายงานข้อขัดข้อง + ตั้งค่า + ย้อนกลับเมื่อกลับมาเล่นต่อ + ข้ามกลับ + พฤติกรรมการข้ามโฆษณา + ข้ามไปข้างหน้า + ข้ามพฤติกรรมการแนะนำ + ข้ามพฤติกรรมตอนจบ + ข้ามพฤติกรรมการแสดงตัวอย่าง + ข้ามพฤติกรรมการสรุป + มีอัปเดตใหม่ + URL ที่ใช้ตรวจสอบการอัปเดตแอป + อัปเดต URL + From 7f1ccc6146dcfedf6a176f020c3d9e6ddde0c66f Mon Sep 17 00:00:00 2001 From: jbrek Date: Fri, 3 Apr 2026 14:35:37 +0000 Subject: [PATCH 108/148] Translated using Weblate (Thai) Currently translated at 98.8% (524 of 530 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/th/ --- app/src/main/res/values-th/strings.xml | 291 +++++++++++++++++++++++++ 1 file changed, 291 insertions(+) diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index 0eac48c2..8e59e73a 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -260,4 +260,295 @@ มีอัปเดตใหม่ URL ที่ใช้ตรวจสอบการอัปเดตแอป อัปเดต URL + ขนาดตัวอักษร + สีตัวอักษร + ความทึบของตัวอักษร + สไตล์ขอบ + สีขอบ + ความเข้มจางของพื้นหลัง + สไตล์พื้นหลัง + สไตล์คำบรรยาย + สีพื้นหลัง + รีเซ็ต + ตัวอักษรตัวหนา + การเล่นแบ็กเอนด์ + MPV: ใช้การถอดรหัสฮาร์ดแวร์ + ปิดใช้งานหากพบปัญหาโปรแกรมหยุดทำงาน + MPV ตัวเลือก + ตัวเลือก ExoPlayer + ข้ามส่วน + ข้ามโฆษณา + ข้ามการแสดงตัวอย่าง + ข้ามสรุป + ข้ามเอาท์โทร + ข้ามบทนำ + เล่นแล้ว + ตัวกรอง + ปี + ทศวรรษ + ลบ + Dolby Vision + ทิ้งการเปลี่ยนแปลง? + ขอบ + การบันทึกข้อมูลแบบละเอียด + ป้อนรหัส PIN + ลงชื่อเข้าใช้โดยอัตโนมัติ + เข้าสู่ระบบผ่านเซิร์ฟเวอร์ + กดตรงกลางเพื่อยืนยัน + ต้องใส่รหัส PIN สำหรับโปรไฟล์ + ยืนยันรหัส PIN + ไม่ถูกต้อง + รหัส PIN ต้องมีอย่างน้อย 4 ปุ่ม + จะลบ PIN + ขนาดแคชดิสก์รูปภาพ (เมกะไบต์) + ตัวเลือกการดู + คอลัมน์ + ระยะห่าง + อัตราส่วนภาพ + แสดงรายละเอียด + ประเภทรูปภาพ + นักแสดงและทีมงาน + ดารารับเชิญ + ขนาดขอบ + การสลับอัตราการรีเฟรช + อัตโนมัติ + ใช้ชื่อผู้ใช้/รหัสผ่าน + เข้าสู่ระบบ + ทั่วไป + คอนเทนเนอร์ + ชื่อเรื่อง + Codec + โปรไฟล์ + เลเวล + ความละเอียด + อะนามอร์ฟิก + อินเทอร์เลซ + อัตราเฟรม + ความคมชัดสี + ช่วงวิดีโอ + ประเภทช่วงวิดีโอ + พื้นที่สี + การถ่ายโอนสี + แม่สี + รูปแบบพิกเซล + เฟรมอ้างอิง + NAL + ภาษา + การจัดวาง + ช่อง + อัตราการสุ่มตัวอย่าง + AVC + ใช่ + ไม่ + SDR + HDR + HDR10 + HDR10+ + HLG + bit + Hz + แสดงชื่อเรื่อง + ทำซ้ำ + แสดงช่องโปรดก่อน + เรียงลำดับช่องตามลำดับการรับชมล่าสุด + โปรแกรมรหัสสี + คำบรรยายล่าช้า + สไตล์ฉากหลัง + การสลับความละเอียด + ท้องถิ่น + เล่นตัวอย่าง + ไม่มีรถพ่วง + ล้างตัวเลือกแทร็ก + DTS:X + DTS:HD + DTS:HD MA + TrueHD + DD + DD+ + DTS + Direct play Dolby Vision Profile 7 + ไม่สนใจการตรวจสอบความเข้ากันได้ของอุปกรณ์ + ค้นพบ + คำขอ + รอดำเนินการ + การผสานรวม Seerr + ลบเซิร์ฟเวอร์ Seerr + เพิ่มเซิร์ฟเวอร์ Seerr แล้ว + เชื่อมต่อด่วน + อนุญาตให้อุปกรณ์อื่นเข้าสู่ระบบบัญชีของคุณ + ป้อนรหัสการเชื่อมต่อด่วน + รหัสต้องมี 6 หลัก + อนุญาตอุปกรณ์สำเร็จแล้ว + รหัสผ่าน + ชื่อผู้ใช้ + URL + เทรนด์ + ภาพยนตร์ที่จะเกิดขึ้น + รายการทีวีที่จะออกอากาศเร็วๆ นี้ + ขอในความละเอียด 4K + AV1 software decoding + ขณะนี้ MPV เป็นโปรแกรมเล่นวิดีโอเริ่มต้น ยกเว้นวิดีโอ HDR\nคุณสามารถเปลี่ยนการตั้งค่านี้ได้ในเมนูการตั้งค่า + รูปแบบคำบรรยาย HDR + ความทึบของคำบรรยายภาพ + ความสว่าง + คอนทราสต์ + ท้องอิ่ม + เนื้อสี + แดง + เขียว + น้ำเงิน + เบลอ + บันทึกไว้ในอัลบั้ม + เล่นสไลด์โชว์ + หยุดสไลด์โชว์ + เมื่อเริ่มต้น + ไม่มีรูปถ่ายอีกต่อไป + หมุนไปทางซ้าย + หมุนไปทางขวา + ขยายเข้า + ซูมออก + ระยะเวลาการแสดงภาพสไลด์ + เล่นวิดีโอระหว่างการนำเสนอสไลด์ + ส่งบันทึกข้อมูลสื่อไปยังเซิร์ฟเวอร์ + ไม่มีขีดจำกัด + จำนวนวันสูงสุดใน Next Up + อัลบั้ม + ศิลปิน + เพลง + ไปยังศิลปิน + ขณะนี้กำลังเล่น + ผสมทันที + ไปยังอัลบัม + เพิ่มเข้าคิวเพลง + เพิ่มแถว + Dolby Atmos + MPV: ใช้ gpu-next + แก้ไข mpv.conf + ประเภทใน %1$s + เพิ่งเปิดตัวใน %1$s + ความสูง + นำไปใช้กับทุกแถว + ปรับแต่งหน้าแรก + แถวบ้าน + โหลดจากโปรไฟล์ผู้ใช้เซิร์ฟเวอร์ + ดาวน์โหลดจากโปรไฟล์ผู้ใช้คืนนี้ + โหลดจากเว็บไคลเอ็นต์ + ใช้ภาพชุด + เพิ่มแถวสำหรับ %1$s + ตั้งค่าทับบนเซิร์ฟเวอร์หรือไม่? + แทนที่การตั้งค่าในเครื่องหรือไม่? + สำหรับตอนต่างๆ + คำแนะนำสำหรับ %1$s + เพิ่มขนาดสำหรับการ์ดทุกใบ + ลดขนาดการ์ดทั้งหมด + ใช้ภาพขนาดย่อ + บันทึกการตั้งค่าแล้ว + แสดงค่าที่ตั้งไว้ล่วงหน้า + มีค่าที่ตั้งไว้ล่วงหน้าเพื่อจัดสไตล์ทุกแถวได้อย่างรวดเร็ว + เลือกแถวและรูปภาพบนหน้าแรก + เริ่มทีหลัง + เคลื่อนไหว + ระดับอายุสูงสุด + ไม่มีสูงสุด + สำหรับทุกเพศทุกวัย + อายุไม่เกิน %s + รักษาหน้าจอ + การตั้งค่าโปรแกรมรักษาหน้าจอ + เริ่มโปรแกรมรักษาหน้าจอ + ระยะเวลา + ภาพถ่าย + รวมประเภท + ควรแสดงภาพสินค้าประเภทใดบ้าง + Fit + ครอบตัด + สีพื้น + เติมความกว้าง + เติมความสูง + Wholphin ค่าเริ่มต้น + Wholphin คอมแพค + ภาพขนาดย่อของซีรี่ส์ + ภาพขนาดย่อของตอน + ต่ำสุด + ต่ำ + ปานกลาง + สูง + เต็มดาว + สีม่วง + สีส้ม + ฟ้าใส + ดำ + ไม่สนใจ + ข้ามโดยอัตโนมัติ + ขอให้ข้ามไป + ใช้ FFmpeg เฉพาะในกรณีที่ไม่มีตัวถอดรหัสในตัวเท่านั้น + ควรเลือกใช้ FFmpeg มากกว่าตัวถอดรหัสในตัว + ห้ามใช้ตัวถอดรหัส FFmpeg เด็ดขาด + เมื่อการเล่นจบลง + ระหว่างเครดิตท้ายเรื่อง/ฉากจบ + ขาว + สีเทาอ่อน + สีเทาเข้ม + เหลือง + น้ำเงินเขียว + บานเย็น + โครงร่าง + เงา + ห่อ + ชนิดบรรจุกล่อง + เลือกใช้ MPV + ใช้ ExoPlayer สำหรับการเล่นวิดีโอ HDR + โปสเตอร์ (2:3) + 16:9 + 4:3 + สี่เหลี่ยมจัตุรัส (1:1) + หลัก + นิ้วหัวแม่มือ + ภาพที่มีสีแบบไดนามิก + รูปภาพเท่านั้น + ป้อน URL และ API Key + คีย์ API + เข้าสู่ระบบเซิร์ฟเวอร์ Seerr + Jellyfin ผู้ใช้ + ผู้ใช้ท้องถิ่น + ค้นหาและดาวน์โหลดคำบรรยาย + ไม่พบคำบรรยายระยะไกล + ปัจจุบัน + ใช้โปรแกรมรักษาหน้าจอในแอป + คุณแน่ใจหรือไม่ว่าต้องการลบรายการนี้? + แสดงตัวเลือกการจัดการสื่อ + ค้นหา %s + ผู้ดำเนินการ + ผู้แต่ง + นักเขียน + ดารารับเชิญ + ผู้ผลิต + คอนดักเตอร์ + นักแต่งเพลง + ผู้จัด + วิศวกร + มิกเซอร์ + ผู้สร้าง + ศิลปิน + อัลบัมศิลปิน + อัลบัม + เพลงฮิต + เล่นถัดไป + มิวสิควิดีโอ + เนื้อเพลง + ซ่อนเนื้อเพลง + แสดงเนื้อเพลง + เพลงมีเนื้อเพลง + เอาออกจากคิว + แสดงรูปปกอัลบัม + แสดงวิชวลไลเซอร์ + แสดงฉากหลัง + เล่นเป็น? + การแสดงภาพเสียงที่กำลังเล่นอยู่จำเป็นต้องได้รับอนุญาตในการบันทึกเสียง + ต่อไป + เลือกทั้งหมด + แยกประเภท + ควรแสดงโลโก้แทนชื่อเรื่อง + + + From f53b0dd507c560459fe4229ca4ccfa31d8ee1528 Mon Sep 17 00:00:00 2001 From: Codeberg Translate Date: Fri, 3 Apr 2026 16:45:09 +0000 Subject: [PATCH 109/148] Update translation files Updated by "Remove blank strings" add-on in Weblate. Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/ --- app/src/main/res/values-th/strings.xml | 3 --- 1 file changed, 3 deletions(-) diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index 8e59e73a..42948ac7 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -548,7 +548,4 @@ เลือกทั้งหมด แยกประเภท ควรแสดงโลโก้แทนชื่อเรื่อง - - - From cae9f8d25d0e885c3a0d5027b25f4e0dfc2f76ec Mon Sep 17 00:00:00 2001 From: danpergal84 Date: Fri, 3 Apr 2026 21:36:52 +0000 Subject: [PATCH 110/148] Translated using Weblate (Spanish) Currently translated at 100.0% (531 of 531 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/es/ --- app/src/main/res/values-es/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 13147e59..0147bc55 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -596,4 +596,5 @@ Eliminar de la cola Separar por tipos Preferir logotipos para los títulos + Wholphin se ha actualizado a la versión %s From a442fd21b876fe60ebe563cd979becf6776ff0ba Mon Sep 17 00:00:00 2001 From: arcker95 Date: Sat, 4 Apr 2026 14:57:02 +0000 Subject: [PATCH 111/148] Translated using Weblate (Italian) Currently translated at 100.0% (531 of 531 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/it/ --- app/src/main/res/values-it/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index e487bde1..88a3da3c 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -596,4 +596,5 @@ Continua Tipi separati Preferisci mostrare i loghi per i titoli + Wholphin aggiornato a %s From d4f1de461dc0f469e2193b493f1743ce1dbbedaa Mon Sep 17 00:00:00 2001 From: lednurb Date: Fri, 3 Apr 2026 20:45:03 +0000 Subject: [PATCH 112/148] Translated using Weblate (Dutch) Currently translated at 90.9% (483 of 531 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/nl/ --- app/src/main/res/values-nl/strings.xml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index b9ee6cd2..3054e981 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -88,7 +88,7 @@ Onlangs toegevoegd aan %1$s Onlangs toegevoegd Onlangs opgenomen - Onlangs uitgebracht + Onlangs verschenen Aanbevolen Programma opnemen Serie opnemen @@ -109,7 +109,7 @@ Toegevoegd Toegevoegd (aflevering) Bekeken - Uitgebracht + Verschenen Naam Willekeurig Studio\'s @@ -120,7 +120,7 @@ Suggesties Andere server kiezen Kiezen - Best beoordeeld, niet-bekeken + Best beoordeeld niet-bekeken Trailer Trailer @@ -130,7 +130,7 @@ Gids Seizoen Seizoenen - Afleveringen + Series Vormgeving Onbekend Updates @@ -388,7 +388,7 @@ Muziekvideos Max. aantal dagen in Volgende Wordt nu afgespeeld - Aanvragen + Aangevraagd Trailer afspelen Geen servers gevonden Alleen geforceerde ondertiteling @@ -399,7 +399,7 @@ Wachtwoord Verwachte films Verwachte series - Verzoek in 4K + Aanvragen in 4K Helderheid Media-info log naar server Resolutie schakelen From 664e7edb12af8480c91686295bfd6790b1736087 Mon Sep 17 00:00:00 2001 From: idezentas Date: Fri, 3 Apr 2026 23:23:53 +0000 Subject: [PATCH 113/148] Translated using Weblate (Turkish) Currently translated at 100.0% (531 of 531 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/tr/ --- app/src/main/res/values-tr/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 4667c806..07d2cb02 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -587,4 +587,5 @@ Devam et Farklı türler Başlık yerine logo göster + Wholphin %s sürümüne güncellendi From 90ef9fda2edc62914ef972637da9055734f8c526 Mon Sep 17 00:00:00 2001 From: North-DaCoder Date: Mon, 6 Apr 2026 07:15:57 +0000 Subject: [PATCH 114/148] Translated using Weblate (German) Currently translated at 100.0% (531 of 531 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/de/ --- app/src/main/res/values-de/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 70aa278d..e56a1a43 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -578,4 +578,5 @@ Fortfahren Verschiedene Arten Logos anstatt Titelnamen anzeigen + Wholphin aktualisiert auf %s From 1e1af85196d176119e6760475d157b5bb868bf7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Priit=20J=C3=B5er=C3=BC=C3=BCt?= Date: Sun, 5 Apr 2026 13:19:05 +0000 Subject: [PATCH 115/148] Translated using Weblate (Estonian) Currently translated at 100.0% (531 of 531 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/et/ --- app/src/main/res/values-et/strings.xml | 27 ++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index eab60eff..7bb9f326 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -552,4 +552,31 @@ Lõputiitrite ja lõpuklipi ajal Jätkuv sari Mis tüüpi objekte peaks piltide kontekstis näitama + Albumid + Esitajad + Lood + Mine esitaja juurde + Hetkel esitamisel + Kiirmiks + Mine albumi juurde + Lisa esitusjärjekorda + Albumi esitaja + Album + Populaarsed lood + Esita järgmisena + Muusikavideod + Laulusõnad + Peida laulusõnad + Näita laulusõnu + Laulusõnad on olemas + Eemalda esitusjärjekorrast + Näita albumi kaanepilti + Näita visualiseerijat + Näita tausta + Kas esitad kui? + Hetkel esitamisel heliriba visualiseerimine toimib, kui rakendusel on õigus heli salvestada. + Jätka + Eraldi tüübid + Pealkirjade puhul eelista logode kuvamist + Wholphin on uuendatud versioonini %s From 9674f8e8b50c23fa8deff7e713d866e0446157cc Mon Sep 17 00:00:00 2001 From: lednurb Date: Sun, 5 Apr 2026 19:23:25 +0000 Subject: [PATCH 116/148] Translated using Weblate (Dutch) Currently translated at 90.9% (483 of 531 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/nl/ --- app/src/main/res/values-nl/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 3054e981..647c32c6 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -388,7 +388,7 @@ Muziekvideos Max. aantal dagen in Volgende Wordt nu afgespeeld - Aangevraagd + Aanvragen Trailer afspelen Geen servers gevonden Alleen geforceerde ondertiteling From 4e63f528fae0908717141c1968ed0df7d0545033 Mon Sep 17 00:00:00 2001 From: SimonHung Date: Mon, 6 Apr 2026 17:48:23 +0000 Subject: [PATCH 117/148] Translated using Weblate (Chinese (Traditional Han script)) Currently translated at 100.0% (534 of 534 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hant/ --- app/src/main/res/values-zh-rTW/strings.xml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index ecb15f67..2c7c1554 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -560,4 +560,8 @@ 即時混播 依類型分開顯示 優先顯示圖像標題 + Wholphin 已更新至 %s + 查看更多 + 探索電視劇 + 探索電影 From a700b370078bca172d7e281d43df235409232f3d Mon Sep 17 00:00:00 2001 From: SimonHung Date: Mon, 6 Apr 2026 17:47:23 +0000 Subject: [PATCH 118/148] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 99.2% (530 of 534 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hans/ --- app/src/main/res/values-zh-rCN/strings.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 341fb704..71a78f7b 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -560,4 +560,6 @@ 继续 分开类型 优先显示标题图标 + + From c2b620f15afc5291f7eb1728e56f344f84062740 Mon Sep 17 00:00:00 2001 From: Codeberg Translate Date: Mon, 6 Apr 2026 19:24:49 +0000 Subject: [PATCH 119/148] Update translation files Updated by "Remove blank strings" add-on in Weblate. Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/ --- app/src/main/res/values-zh-rCN/strings.xml | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 71a78f7b..341fb704 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -560,6 +560,4 @@ 继续 分开类型 优先显示标题图标 - - From c539ba1bf6023d5b5e1e47fd9a41138869f06e84 Mon Sep 17 00:00:00 2001 From: danpergal84 Date: Mon, 6 Apr 2026 22:55:01 +0000 Subject: [PATCH 120/148] Translated using Weblate (Spanish) Currently translated at 100.0% (534 of 534 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/es/ --- app/src/main/res/values-es/strings.xml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 0147bc55..ee2848fb 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -573,7 +573,7 @@ Mostrar portada del álbum Mostrar visualizador Mostrar imagen de fondo - Reproducir en + Reproducir como... Para visualizar el audio en reproducción, se requiere permiso para grabar audio. Continuar Álbumes @@ -597,4 +597,7 @@ Separar por tipos Preferir logotipos para los títulos Wholphin se ha actualizado a la versión %s + Ver más + Descubrir series + Descubrir películas From cadbf04b4f79e8ed227402224727f7e49d337cbd Mon Sep 17 00:00:00 2001 From: Alan700 Date: Tue, 7 Apr 2026 11:46:22 +0000 Subject: [PATCH 121/148] Translated using Weblate (French) Currently translated at 100.0% (534 of 534 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/fr/ --- app/src/main/res/values-fr/strings.xml | 30 ++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 05968332..96bf1756 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -570,4 +570,34 @@ Afficher les options de gestion des médias Recherche %s Tout sélectionner + Albums + Artistes + Chansons + Aller à l\'artiste + Lecture en cours + Mix instantané + Voir l\'album + Ajouter à la file + Artiste de l\'album + Album + Titres populaires + Lire ensuite + Clips + Paroles + Masquer les paroles + Afficher les paroles + Contient des paroles + Retirer de la file + Afficher la pochette + Afficher le visualiseur + Afficher l\'arrière-plan + Qui écoute ? + L\'affichage du visualiseur nécessite l\'autorisation d\'enregistrer du contenu audio. + Suivant + Séparer par types + Préférer les logos pour les titres + Wholphin a été mis à jour vers la version %s + Voir plus + Parcourir les séries + Parcourir les films From a5a0d3f34904960fd41a54ee04101ca47d379176 Mon Sep 17 00:00:00 2001 From: Outbreak2096 Date: Tue, 7 Apr 2026 00:20:32 +0000 Subject: [PATCH 122/148] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (534 of 534 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hans/ --- app/src/main/res/values-zh-rCN/strings.xml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 341fb704..d2871763 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -560,4 +560,8 @@ 继续 分开类型 优先显示标题图标 + Wholphin 已更新至 %s + 查看更多 + 发现电视节目 + 发现电影 From a8a906b81fe24c8bee009024976a3dc55146ca27 Mon Sep 17 00:00:00 2001 From: Alan700 Date: Tue, 7 Apr 2026 12:17:24 +0000 Subject: [PATCH 123/148] Translated using Weblate (French) Currently translated at 100.0% (534 of 534 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/fr/ --- app/src/main/res/values-fr/strings.xml | 36 +++++++++++++------------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 96bf1756..970cdf16 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -35,7 +35,7 @@ Taille Forcé Genres - Aller à la série + Voir la série Aller à Masquer Accueil @@ -197,7 +197,7 @@ Échelle de contenu par défaut Installer la mise à jour Version installée - Eléments sur les lignes de la page d\'accueil + Éléments sur les lignes de la page d\'accueil Choisissez les éléments à afficher par défaut, les autres seront masqués Personnaliser les éléments du menu de navigation Cliquez pour changer de page @@ -289,7 +289,7 @@ - + Calendrier du DVR @@ -315,7 +315,7 @@ Code PIN requis pour le profil Confirmer le code PIN Erroné - Le code PIN doit comporter au moins 4 éléments + Le code PIN doit comporter au moins 4 chiffres Supprimera le code PIN Taille du cache disque de l\'image (Mo) Options d\'affichage @@ -424,7 +424,7 @@ Décodage logiciel AV1 MPV est désormais le lecteur par défaut, sauf pour HDR.\nVous pouvez modifier cela dans les paramètres. Style de sous-titres HDR - opacité du sous-titre de l\'image + Opacité du sous-titre de l\'image Luminosité Contraste Saturation @@ -475,7 +475,7 @@ Suggestions pour %1$s Augmenter la taille pour toutes les cartes Diminuer la taille pour toutes les cartes - Utilisez des images de miniatures + Utiliser des miniatures Paramètres enregistrés Afficher les préréglages Préréglages intégrés pour styliser rapidement toutes les lignes @@ -511,18 +511,18 @@ Le plus bas Faible Moyen - Haute - Complet + Haut + Maximum Violet Orange - Bleu gras + Bleu vif Noir Ignorer Passer automatiquement Demander à passer - N\'utilisez FFmpeg que si aucun décodeur intégré n\'existe - Préférez utiliser FFmpeg plutôt que les décodeurs intégrés - N\'utilisez jamais les décodeurs FFmpeg + N\'utiliser FFmpeg que si aucun décodeur intégré n\'existe + Préférer l\'utilisation de FFmpeg plutôt que les décodeurs intégrés + Ne jamais utiliser les décodeurs FFmpeg À la fin de la lecture Pendant le générique de fin/outro Blanc @@ -545,25 +545,25 @@ Miniature Image avec couleur dynamique Image uniquement - Entrez l\'URL et la clé API + Clé API Se connecter au serveur Seerr Utilisateur Jellyfin Utilisateur local Rechercher et télécharger des sous-titres Aucun sous-titre distant n\'a été trouvé - Présent + En cours Utiliser l\'économiseur d\'écran intégré Acteur Compositeur - Écrivain + Scénariste Invité spécial Producteur - Conducteur + Chef d\'orchestre Parolier Arrangeur Ingénieur - Mélangeur + Mixeur Créateur Artiste Êtes-vous sûr de vouloir supprimer cet élément ? @@ -573,7 +573,7 @@ Albums Artistes Chansons - Aller à l\'artiste + Voir l\'artiste Lecture en cours Mix instantané Voir l\'album From 7718eca0c42497fc31a6a26666b087417eed19a8 Mon Sep 17 00:00:00 2001 From: idezentas Date: Tue, 7 Apr 2026 14:28:27 +0000 Subject: [PATCH 124/148] Translated using Weblate (Turkish) Currently translated at 100.0% (534 of 534 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/tr/ --- app/src/main/res/values-tr/strings.xml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 07d2cb02..9425e4b4 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -588,4 +588,7 @@ Farklı türler Başlık yerine logo göster Wholphin %s sürümüne güncellendi + Daha fazla göster + Dizileri Keşfet + Filmleri Keşfet From e18a4ebfe2dcf3da38fa79e97c8b16521b9004fc Mon Sep 17 00:00:00 2001 From: danpergal84 Date: Wed, 8 Apr 2026 05:20:01 +0000 Subject: [PATCH 125/148] Translated using Weblate (Spanish) Currently translated at 100.0% (538 of 538 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/es/ --- app/src/main/res/values-es/strings.xml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index ee2848fb..fa312ec6 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -600,4 +600,8 @@ Ver más Descubrir series Descubrir películas + Eliminar de \"Continuar viendo\" + Eliminar de \"Siguiente\" + Ver series eliminadas de \"Siguiente\" + Estudios en %1$s From 9a4bf6eaa8fe7d1bd748c9bd116b29b16d8365cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Priit=20J=C3=B5er=C3=BC=C3=BCt?= Date: Wed, 8 Apr 2026 06:34:47 +0000 Subject: [PATCH 126/148] Translated using Weblate (Estonian) Currently translated at 100.0% (538 of 538 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/et/ --- app/src/main/res/values-et/strings.xml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index 7bb9f326..3d535faf 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -579,4 +579,11 @@ Eraldi tüübid Pealkirjade puhul eelista logode kuvamist Wholphin on uuendatud versioonini %s + Eemalda „Jätka vaatamist“ valikust + Eemalda sari valikust „Järgmisena esitamisel“ + Vaata sarja, mis sai eemaldatud valikust „Järgmisena esitamisel“ + Vaata veel + Leia telesaateid + Leia filme + Stuudiod: %1$s From 1f7b7c8b24c29c69e91f75d4c31c40622d318314 Mon Sep 17 00:00:00 2001 From: Chosnuk Date: Wed, 8 Apr 2026 13:40:45 +0000 Subject: [PATCH 127/148] Translated using Weblate (Polish) Currently translated at 55.0% (296 of 538 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/pl/ --- app/src/main/res/values-pl/strings.xml | 32 ++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index d1c14e19..c096e2ba 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -317,4 +317,36 @@ Wideo Filmy Oglądanie na żywo + Urządzenie uwierzytelniono pomyślnie + Hasło + Nazwa użytkownika + URL + Popularne + Nadchodzące filmy + Nadchodzące seriale + Poproś o 4K + AV1 dekodowanie programowe + MPV jest teraz domyślnym odtwarzaczem, z wyjątkiem HDR.\nMożesz to zmienić w ustawieniach. + Styl napisów HDR + Obróć w lewo + Obróć w prawo + Powiększ + Pomniejsz + Wyślij dane dziennika mediów do serwera + Bez limitu + Maksymalna ilość dni w \"Do obejrzenia\" + Albumy + Artyści + Utwory + Przejdź do artysty + Teraz odtwarzane + Przejdź do albumu + Dodaj do kolejki + Dodaj wiersz + Wysokość + Zastosuj do wszystkich wierszy + Personalizacja strony głównej + Wczytaj profil użytkownika z serwera + Zapisz do profilu użytkownika na serwerze + Wczytaj z klienta webowego From 89dc42871e393918a2d267fc8a174c0f021516c3 Mon Sep 17 00:00:00 2001 From: Outbreak2096 Date: Wed, 8 Apr 2026 00:13:11 +0000 Subject: [PATCH 128/148] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (538 of 538 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hans/ --- app/src/main/res/values-zh-rCN/strings.xml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index d2871763..921f46c8 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -564,4 +564,8 @@ 查看更多 发现电视节目 发现电影 + 从继续观看中移除 + 从即将播放中移除剧集 + 查看已从即将播放中移除的剧集 + %1$s 制片公司 From acf4eab1d92e9e8e1d3d31f71962890316d30483 Mon Sep 17 00:00:00 2001 From: idezentas Date: Thu, 9 Apr 2026 22:01:42 +0000 Subject: [PATCH 129/148] Translated using Weblate (Turkish) Currently translated at 100.0% (538 of 538 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/tr/ --- app/src/main/res/values-tr/strings.xml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 9425e4b4..e1f87f9e 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -591,4 +591,8 @@ Daha fazla göster Dizileri Keşfet Filmleri Keşfet + İzlemeye devam et listesinden kaldır + Sıradaki bölümlerden kaldır + Sıradaki bölümlerden kaldırılan dizileri göster + Stüdyolar: %1$s From 2a9f8a27af7c3df1dfad089467f510748dbe6406 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Sat, 11 Apr 2026 13:40:20 -0400 Subject: [PATCH 130/148] Fix formatting --- app/src/main/res/values-fr/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 970cdf16..894c286c 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -545,7 +545,7 @@ Miniature Image avec couleur dynamique Image uniquement - + Saisir l\'URL et la clé API Clé API Se connecter au serveur Seerr Utilisateur Jellyfin From 287a7b0c69f81032520df007cb1d2f856514d122 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Sat, 11 Apr 2026 19:30:23 -0400 Subject: [PATCH 131/148] Create FireTV specific app variant (#1230) ## Description Adds a variant of Wholphin targeted for the Amazon Fire TV App store. ### Related issues See #1192 for more details ### Testing Emulator ## Screenshots N/A ## AI or LLM usage None --- .github/workflows/release.yml | 2 +- app/build.gradle.kts | 27 ++++++- .../wholphin/preferences/AppPreference.kt | 11 ++- .../damontecres/wholphin/services/SeerrApi.kt | 3 +- .../services/SeerrServerRepository.kt | 75 ++++++++++--------- .../wholphin/services/UpdateChecker.kt | 2 +- 6 files changed, 73 insertions(+), 47 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5a7de9cb..f144385e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -36,7 +36,7 @@ jobs: ORG_GRADLE_PROJECT_WholphinExtensionsUsername: "${{ secrets.EXTENSIONS_USERNAME }}" ORG_GRADLE_PROJECT_WholphinExtensionsPassword: "${{ secrets.EXTENSIONS_PASSWORD }}" run: | - ./gradlew clean assembleDefaultRelease bundleAppstoreRelease --no-daemon + ./gradlew clean assembleDefaultRelease bundleAppstoreRelease bundleFiretvRelease --no-daemon - name: Verify signatures run: | diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 80a6b2e4..3504f887 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,3 +1,4 @@ +import com.android.build.api.dsl.ProductFlavor import com.google.protobuf.gradle.id import com.mikepenz.aboutlibraries.plugin.DuplicateMode import com.mikepenz.aboutlibraries.plugin.DuplicateRule @@ -112,16 +113,34 @@ android { } flavorDimensions += "version" productFlavors { + val featureLeanback = "leanback" + val featureUpdate = "UPDATING_ENABLED" + val featureDiscover = "DISCOVER_ENABLED" + + fun ProductFlavor.setFeatureFlag( + name: String, + enabled: Boolean, + ) { + this.buildConfigField("boolean", name, "Boolean.parseBoolean(\"${enabled}\")") + } create("default") { dimension = "version" isDefault = true - manifestPlaceholders += mapOf("leanback" to false) - buildConfigField("boolean", "UPDATING_ENABLED", "true") + manifestPlaceholders += mapOf(featureLeanback to false) + setFeatureFlag(featureUpdate, true) + setFeatureFlag(featureDiscover, true) } create("appstore") { dimension = "version" - manifestPlaceholders += mapOf("leanback" to true) - buildConfigField("boolean", "UPDATING_ENABLED", "false") + manifestPlaceholders += mapOf(featureLeanback to true) + setFeatureFlag(featureUpdate, false) + setFeatureFlag(featureDiscover, true) + } + create("firetv") { + dimension = "version" + manifestPlaceholders += mapOf(featureLeanback to true) + setFeatureFlag(featureUpdate, false) + setFeatureFlag(featureDiscover, false) } } compileOptions { diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt index e88c1ffb..26e8e623 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt @@ -5,6 +5,7 @@ import androidx.annotation.ArrayRes import androidx.annotation.StringRes import androidx.core.content.edit import androidx.preference.PreferenceManager +import com.github.damontecres.wholphin.BuildConfig import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.WholphinApplication import com.github.damontecres.wholphin.services.UpdateChecker @@ -1085,10 +1086,12 @@ val basicPreferences = PreferenceGroup( title = R.string.more, preferences = - listOf( - AppPreference.SeerrIntegration, - AppPreference.AdvancedSettings, - ), + buildList { + if (BuildConfig.DISCOVER_ENABLED) { + add(AppPreference.SeerrIntegration) + } + add(AppPreference.AdvancedSettings) + }, ), ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrApi.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrApi.kt index 5c62655f..685b858c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrApi.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrApi.kt @@ -1,5 +1,6 @@ package com.github.damontecres.wholphin.services +import com.github.damontecres.wholphin.BuildConfig import com.github.damontecres.wholphin.api.seerr.SeerrApiClient import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.setup.seerr.createSeerrApiUrl @@ -19,7 +20,7 @@ class SeerrApi( ) private set - val active: Boolean get() = api.baseUrl.isNotNullOrBlank() + val active: Boolean get() = api.baseUrl.isNotNullOrBlank() && BuildConfig.DISCOVER_ENABLED fun update( baseUrl: String, diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt index 0f073af6..7096b833 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt @@ -4,6 +4,7 @@ import android.content.Context import androidx.appcompat.app.AppCompatActivity import androidx.lifecycle.asFlow import androidx.lifecycle.lifecycleScope +import com.github.damontecres.wholphin.BuildConfig import com.github.damontecres.wholphin.api.seerr.SeerrApiClient import com.github.damontecres.wholphin.api.seerr.model.AuthJellyfinPostRequest import com.github.damontecres.wholphin.api.seerr.model.AuthLocalPostRequest @@ -292,44 +293,46 @@ class UserSwitchListener launchIO { homeSettingsService.loadCurrentSettings(user.id) } - // Check for seerr server - launchIO { - seerrServerDao - .getUsersByJellyfinUser(user.rowId) - .lastOrNull() - ?.let { seerrUser -> - val server = - seerrServerDao.getServer(seerrUser.serverId)?.server - if (server != null) { - Timber.i("Found a seerr user & server") - try { - seerrApi.update(server.url, seerrUser.credential) - val userConfig = - if (seerrUser.authMethod != SeerrAuthMethod.API_KEY) { - login( - seerrApi.api, - seerrUser.authMethod, - seerrUser.username, - seerrUser.password, - ) - } else { - seerrApi.api.usersApi.authMeGet() - } - seerrServerRepository.set( - server, - seerrUser, - userConfig, - ) - } catch (ex: Exception) { - Timber.w( - ex, - "Error logging into %s", - server.url, - ) - seerrServerRepository.error(server, seerrUser, ex) + if (BuildConfig.DISCOVER_ENABLED) { + // Check for seerr server + launchIO { + seerrServerDao + .getUsersByJellyfinUser(user.rowId) + .lastOrNull() + ?.let { seerrUser -> + val server = + seerrServerDao.getServer(seerrUser.serverId)?.server + if (server != null) { + Timber.i("Found a seerr user & server") + try { + seerrApi.update(server.url, seerrUser.credential) + val userConfig = + if (seerrUser.authMethod != SeerrAuthMethod.API_KEY) { + login( + seerrApi.api, + seerrUser.authMethod, + seerrUser.username, + seerrUser.password, + ) + } else { + seerrApi.api.usersApi.authMeGet() + } + seerrServerRepository.set( + server, + seerrUser, + userConfig, + ) + } catch (ex: Exception) { + Timber.w( + ex, + "Error logging into %s", + server.url, + ) + seerrServerRepository.error(server, seerrUser, ex) + } } } - } + } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt b/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt index 40a04218..f2a0bd23 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt @@ -65,7 +65,7 @@ class UpdateChecker private val NOTE_REGEX = Regex("") - const val ACTIVE = BuildConfig.UPDATING_ENABLED + val ACTIVE = BuildConfig.UPDATING_ENABLED } /** From 9037527d2ac2119caf5a7e49e85a7124fdbf7270 Mon Sep 17 00:00:00 2001 From: SimonHung Date: Sat, 11 Apr 2026 19:05:57 +0000 Subject: [PATCH 132/148] Translated using Weblate (Chinese (Traditional Han script)) Currently translated at 100.0% (538 of 538 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hant/ --- app/src/main/res/values-zh-rTW/strings.xml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 2c7c1554..35d3502b 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -199,7 +199,7 @@ 移出我的最愛 繼續播放 - 製片公司 + 製作公司 高評分未觀看 錄影排程 電視劇 @@ -564,4 +564,8 @@ 查看更多 探索電視劇 探索電影 + 從繼續觀看中移除 + 從接下來播放中移除劇集 + 查看從接下來播放中移除的劇集 + %1$s 製作公司 From 24ed1bb18257894a134e328abdd74892e4377fa7 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Sun, 12 Apr 2026 08:44:17 -0400 Subject: [PATCH 133/148] Release v0.6.1 From 57e954f502065f43ab06b7ee3fcc309cb02942c0 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Sun, 12 Apr 2026 09:00:57 -0400 Subject: [PATCH 134/148] Fix build script --- .github/workflows/main.yml | 2 -- .github/workflows/release.yml | 5 ----- 2 files changed, 7 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 6f130105..b50a58b8 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -71,8 +71,6 @@ jobs: echo "$line => $short_name" cp "$line" "$short_name" done - apks=$(find app/build/outputs/apk -name '*.apk' -print0 | tr '\0' ',' | sed 's/,$//') - echo "apks=$apks" >> "$GITHUB_OUTPUT" - name: Checksums run: | echo "SHA256 checksums:" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f144385e..fb9d21f1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -53,11 +53,6 @@ jobs: echo "$line => $short_name" cp "$line" "$short_name" done - apks=$(find app/build/outputs/apk -name '*.apk' -print0 | tr '\0' ',' | sed 's/,$//') - echo "apks=$apks" >> "$GITHUB_OUTPUT" - - aab=$(find app/build/outputs -name '*.aab') - echo "aab=$aab" >> "$GITHUB_OUTPUT" - name: Checksums run: | echo "SHA256 checksums:" From c923afde8da231053a6d0e7582b5f36c12f7c263 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:50:24 -0400 Subject: [PATCH 135/148] Use wholphin-extensions v0.1.1 (#1241) ## Description Updates to use the latest wholphin-extensions which includes https://github.com/damontecres/wholphin-extensions/pull/2 The crash in #1239 occurs if "AV1 software decoding" is enabled and seemingly only if the app was installed via APK, including app stores. ### Related issues Fixes #1239 ### Testing Emulator, nvidia shield ## Screenshots N/A ## AI or LLM usage None --- DEVELOPMENT.md | 2 ++ gradle/libs.versions.toml | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 21d41e3e..2026c9ee 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -49,6 +49,8 @@ Wholphin uses several native components for extra playback compatibility. This i If you want to include these in a local build, see the [instructions here](https://github.com/damontecres/wholphin-extensions?tab=readme-ov-file#usage) for configuring the repository. +You can also build the extensions locally from https://github.com/damontecres/wholphin-extensions and include them in `app/libs`. The gradle build dependency resolution prefers these local files over fetching from the remote maven registry. + ### App settings App settings are available with the `AppPreferences` object and defined by different `AppPreference` objects (note the `s` differences). diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index aaaa95fa..ad6fa059 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -44,7 +44,7 @@ kotlinxCoroutinesTest = "1.10.2" coreTesting = "2.2.0" openapi-generator = "7.21.0" runner = "1.7.0" -wholphin-extensions = "0.1.0" +wholphin-extensions = "0.1.1" [libraries] wholphin-extensions-mpv = { module = "com.github.damontecres.wholphin.mpv:wholphin-mpv", version.ref = "wholphin-extensions" } From 923a3713aff9b9540e219d5d1d0b9bee8c378342 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Mon, 13 Apr 2026 19:20:13 -0400 Subject: [PATCH 136/148] Release v0.6.2 From 95859be3c64c8c78e13cd578e9a1f40e8e18159c Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Wed, 15 Apr 2026 17:49:54 -0400 Subject: [PATCH 137/148] Add a small glow effect to the segment skip button (#1228) ## Description Adds a small glow to the border of the segment skip buttons, ie "Skip Intro", etc. Since this button appears as focused immediately, there's no contrast and it can be missed sometimes. So this glowing border draws the eye a bit better. Also a small change to reduce the minimum skip forward amount to 5 seconds . ### Related issues Skip forward: #1203 ### Testing Emulator. But this needs some "real world" testing on a TV, too, so this may need tweaking ## Screenshots https://github.com/user-attachments/assets/5488d468-aee4-4448-a823-6326ccb003da ## AI or LLM usage None --- .../wholphin/preferences/AppPreference.kt | 2 +- .../wholphin/ui/playback/PlaybackPage.kt | 6 +- .../wholphin/ui/playback/SkipSegmentButton.kt | 92 +++++++++++++++++++ 3 files changed, 95 insertions(+), 5 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/playback/SkipSegmentButton.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt index 26e8e623..253d8c6d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt @@ -60,7 +60,7 @@ sealed interface AppPreference { AppSliderPreference( title = R.string.skip_forward_preference, defaultValue = 30, - min = 10, + min = 5, max = 5.minutes.inWholeSeconds, interval = 5, getter = { 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 3159c4dd..8fb08653 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 @@ -68,14 +68,12 @@ import com.github.damontecres.wholphin.ui.AspectRatios import com.github.damontecres.wholphin.ui.LocalImageUrlService import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.LoadingPage -import com.github.damontecres.wholphin.ui.components.TextButton import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings.applyToMpv import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings.calculateEdgeSize import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings.toSubtitleStyle import com.github.damontecres.wholphin.ui.seasonEpisode -import com.github.damontecres.wholphin.ui.skipStringRes import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.LoadingState @@ -463,8 +461,8 @@ fun PlaybackPageContent( delay(10.seconds) viewModel.updateSegment(segment.segment.id, true) } - TextButton( - stringRes = segment.segment.type.skipStringRes, + SkipSegmentButton( + type = segment.segment.type, onClick = { viewModel.updateSegment(segment.segment.id, false) }, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SkipSegmentButton.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SkipSegmentButton.kt new file mode 100644 index 00000000..4270bf48 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SkipSegmentButton.kt @@ -0,0 +1,92 @@ +package com.github.damontecres.wholphin.ui.playback + +import androidx.compose.animation.animateColor +import androidx.compose.animation.core.InfiniteRepeatableSpec +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.github.damontecres.wholphin.ui.PreviewTvSpec +import com.github.damontecres.wholphin.ui.components.Button +import com.github.damontecres.wholphin.ui.skipStringRes +import com.github.damontecres.wholphin.ui.theme.PreviewInteractionSource +import com.github.damontecres.wholphin.ui.theme.WholphinTheme +import org.jellyfin.sdk.model.api.MediaSegmentType + +@Composable +fun SkipSegmentButton( + type: MediaSegmentType, + onClick: () -> Unit, + modifier: Modifier = Modifier, + onLongClick: (() -> Unit)? = null, + interactionSource: MutableInteractionSource? = null, +) { + val infiniteTransition = rememberInfiniteTransition("SkipSegmentButton") + val color by infiniteTransition.animateColor( + initialValue = MaterialTheme.colorScheme.onSurface, + targetValue = Color.Transparent, + animationSpec = + InfiniteRepeatableSpec( + animation = tween(durationMillis = 1500, easing = LinearEasing), + repeatMode = RepeatMode.Reverse, + ), + label = "SkipSegmentButton_color", + ) + Button( + onClick = onClick, + modifier = + modifier.drawBehind { + val px = 6.dp.toPx() + drawRoundRect( + color = color, + cornerRadius = CornerRadius(this.size.width * .5f), + topLeft = Offset(-px, -px), + size = Size(this.size.width + px * 2, this.size.height + px * 2), + ) + }, + onLongClick = onLongClick, + enabled = true, + contentPadding = + PaddingValues( + start = 8.dp, + top = 4.dp, + end = 8.dp, + bottom = 4.dp, + ), + contentHeight = 32.dp, + interactionSource = interactionSource, + content = { + Text(text = stringResource(type.skipStringRes)) + }, + ) +} + +@PreviewTvSpec +@Composable +fun SkipSegmentButtonPreview() { + WholphinTheme { + val source = remember { PreviewInteractionSource() } + SkipSegmentButton( + type = MediaSegmentType.INTRO, + onClick = {}, + modifier = Modifier.padding(16.dp), + interactionSource = source, + ) + } +} From b18416c954e63ccbb1a8523fcbbdf6582224fade Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Wed, 15 Apr 2026 17:50:34 -0400 Subject: [PATCH 138/148] Integrate with libass-android to support SSA/ASS subtitles in ExoPlayer (#1052) ## Description Add better ASS/SSA support to ExoPlayer by using [`libass-android`](https://github.com/peerless2012/libass-android). This is enabled with the "Use libass for ASS subtitles" advanced settings for ExoPlayer. It is enabled by default. ### Related issues Related to #22 Replaces reverted #569 Fixes #1107 --- app/build.gradle.kts | 7 + .../wholphin/preferences/AppPreference.kt | 20 +- .../preferences/AppPreferencesSerializer.kt | 3 +- .../wholphin/services/AppUpgradeHandler.kt | 10 +- .../wholphin/services/DeviceProfileService.kt | 7 +- .../wholphin/services/PlayerFactory.kt | 121 ++- .../wholphin/ui/playback/PlaybackPage.kt | 56 +- .../wholphin/ui/playback/PlaybackViewModel.kt | 10 +- .../wholphin/ui/slideshow/SlideshowPage.kt | 764 +++++++++--------- .../ui/slideshow/SlideshowViewModel.kt | 121 +-- app/src/main/proto/WholphinDataStore.proto | 9 +- app/src/main/res/values/strings.xml | 16 +- gradle/libs.versions.toml | 3 + 13 files changed, 626 insertions(+), 521 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3504f887..02b20fb9 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -171,6 +171,12 @@ android { isUniversalApk = true } } + packaging { + jniLibs { + // Work around because libass-android & wholphin-mpv both (incorrectly) package libc++_shared.so + pickFirsts += "lib/*/libc++_shared.so" + } + } sourceSets { getByName("main") { @@ -269,6 +275,7 @@ dependencies { implementation(libs.androidx.media3.exoplayer.dash) implementation(libs.androidx.media3.ui) implementation(libs.androidx.media3.ui.compose) + implementation(libs.ass.media) implementation(libs.coil.core) implementation(libs.coil.compose) diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt index 253d8c6d..149b0098 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt @@ -431,16 +431,18 @@ sealed interface AppPreference { summaryOn = R.string.enabled, summaryOff = R.string.disabled, ) - val DirectPlayAss = - AppSwitchPreference( - title = R.string.direct_play_ass, - defaultValue = true, - getter = { it.playbackPreferences.overrides.directPlayAss }, + val AssSubtitleMode = + AppChoicePreference( + title = R.string.ass_subtitle_playback, + defaultValue = AssPlaybackMode.ASS_LIBASS, + getter = { it.playbackPreferences.overrides.assPlaybackMode }, setter = { prefs, value -> - prefs.updatePlaybackOverrides { directPlayAss = value } + prefs.updatePlaybackOverrides { assPlaybackMode = value } }, - summaryOn = R.string.enabled, - summaryOff = R.string.disabled, + displayValues = R.array.ass_subtitle_modes, + subtitles = R.array.ass_subtitle_modes_summary, + indexToValue = { AssPlaybackMode.forNumber(it) }, + valueToIndex = { if (it != AssPlaybackMode.UNRECOGNIZED) it.number else AssPlaybackMode.ASS_LIBASS.number }, ) val DirectPlayPgs = AppSwitchPreference( @@ -1100,7 +1102,7 @@ private val ExoPlayerSettings = AppPreference.FfmpegPreference, AppPreference.DownMixStereo, AppPreference.Ac3Supported, - AppPreference.DirectPlayAss, + AppPreference.AssSubtitleMode, AppPreference.DirectPlayPgs, AppPreference.DirectPlayDoviProfile7, AppPreference.DecodeAv1, 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 e202fca3..9b46de83 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 @@ -61,10 +61,11 @@ class AppPreferencesSerializer .apply { ac3Supported = AppPreference.Ac3Supported.defaultValue downmixStereo = AppPreference.DownMixStereo.defaultValue - directPlayAss = AppPreference.DirectPlayAss.defaultValue +// directPlayAss = AppPreference.DirectPlayAss.defaultValue directPlayPgs = AppPreference.DirectPlayPgs.defaultValue mediaExtensionsEnabled = AppPreference.FfmpegPreference.defaultValue + assPlaybackMode = AppPreference.AssSubtitleMode.defaultValue }.build() mpvOptions = 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 23093cfd..8899fe8e 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 @@ -141,7 +141,7 @@ class AppUpgradeHandler it.updatePlaybackOverrides { ac3Supported = AppPreference.Ac3Supported.defaultValue downmixStereo = AppPreference.DownMixStereo.defaultValue - directPlayAss = AppPreference.DirectPlayAss.defaultValue +// directPlayAss = AppPreference.DirectPlayAss.defaultValue directPlayPgs = AppPreference.DirectPlayPgs.defaultValue } } @@ -319,5 +319,13 @@ class AppUpgradeHandler } } } + + if (previous.isEqualOrBefore(Version.fromString("0.6.2-1-g0"))) { + appPreferences.updateData { + it.updatePlaybackOverrides { + assPlaybackMode = AppPreference.AssSubtitleMode.defaultValue + } + } + } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/DeviceProfileService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/DeviceProfileService.kt index a001eb6a..4405092e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/DeviceProfileService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/DeviceProfileService.kt @@ -1,6 +1,7 @@ package com.github.damontecres.wholphin.services import android.content.Context +import com.github.damontecres.wholphin.preferences.AssPlaybackMode import com.github.damontecres.wholphin.preferences.PlaybackPreferences import com.github.damontecres.wholphin.util.profile.MediaCodecCapabilitiesTest import com.github.damontecres.wholphin.util.profile.createDeviceProfile @@ -43,7 +44,7 @@ class DeviceProfileService maxBitrate = prefs.maxBitrate.toInt(), isAC3Enabled = prefs.overrides.ac3Supported, downMixAudio = prefs.overrides.downmixStereo, - assDirectPlay = prefs.overrides.directPlayAss, + assPlaybackMode = prefs.overrides.assPlaybackMode, pgsDirectPlay = prefs.overrides.directPlayPgs, dolbyVisionELDirectPlay = prefs.overrides.directPlayDolbyVisionEL, decodeAv1 = prefs.overrides.decodeAv1, @@ -58,7 +59,7 @@ class DeviceProfileService maxBitrate = newConfig.maxBitrate, isAC3Enabled = newConfig.isAC3Enabled, downMixAudio = newConfig.downMixAudio, - assDirectPlay = newConfig.assDirectPlay, + assDirectPlay = newConfig.assPlaybackMode != AssPlaybackMode.ASS_TRANSCODE, pgsDirectPlay = newConfig.pgsDirectPlay, dolbyVisionELDirectPlay = newConfig.dolbyVisionELDirectPlay, decodeAv1 = prefs.overrides.decodeAv1, @@ -77,7 +78,7 @@ data class DeviceProfileConfiguration( val maxBitrate: Int, val isAC3Enabled: Boolean, val downMixAudio: Boolean, - val assDirectPlay: Boolean, + val assPlaybackMode: AssPlaybackMode, val pgsDirectPlay: Boolean, val dolbyVisionELDirectPlay: Boolean, val decodeAv1: Boolean, diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/PlayerFactory.kt b/app/src/main/java/com/github/damontecres/wholphin/services/PlayerFactory.kt index 425ab94d..335438e5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/PlayerFactory.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/PlayerFactory.kt @@ -10,22 +10,29 @@ import androidx.datastore.core.DataStore import androidx.media3.common.C import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.DefaultDataSource import androidx.media3.exoplayer.DefaultRenderersFactory import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.Renderer +import androidx.media3.exoplayer.RenderersFactory import androidx.media3.exoplayer.mediacodec.MediaCodecSelector +import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import androidx.media3.exoplayer.video.MediaCodecVideoRenderer import androidx.media3.exoplayer.video.VideoRendererEventListener -import com.github.damontecres.wholphin.preferences.AppPreference +import androidx.media3.extractor.DefaultExtractorsFactory import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.AssPlaybackMode import com.github.damontecres.wholphin.preferences.MediaExtensionStatus import com.github.damontecres.wholphin.preferences.PlaybackPreferences import com.github.damontecres.wholphin.preferences.PlayerBackend import com.github.damontecres.wholphin.util.mpv.MpvPlayer import dagger.hilt.android.qualifiers.ApplicationContext +import io.github.peerless2012.ass.media.AssHandler +import io.github.peerless2012.ass.media.factory.AssRenderersFactory +import io.github.peerless2012.ass.media.kt.withAssMkvSupport +import io.github.peerless2012.ass.media.parser.AssSubtitleParserFactory +import io.github.peerless2012.ass.media.type.AssRenderType import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.firstOrNull -import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import timber.log.Timber import java.lang.reflect.Constructor @@ -46,71 +53,17 @@ class PlayerFactory var currentPlayer: Player? = null private set - fun createVideoPlayer(): Player { - if (currentPlayer?.isReleased == false) { - Timber.w("Player was not released before trying to create a new one!") - currentPlayer?.release() - } - - val prefs = runBlocking { appPreferences.data.firstOrNull()?.playbackPreferences } - val backend = prefs?.playerBackend ?: AppPreference.PlayerBackendPref.defaultValue - val newPlayer = - when (backend) { - PlayerBackend.PREFER_MPV, - PlayerBackend.MPV, - -> { - val enableHardwareDecoding = - prefs?.mpvOptions?.enableHardwareDecoding - ?: AppPreference.MpvHardwareDecoding.defaultValue - val useGpuNext = - prefs?.mpvOptions?.useGpuNext - ?: AppPreference.MpvGpuNext.defaultValue - MpvPlayer(context, enableHardwareDecoding, useGpuNext) - .apply { - playWhenReady = true - } - } - - PlayerBackend.EXO_PLAYER, - PlayerBackend.UNRECOGNIZED, - -> { - val extensions = prefs?.overrides?.mediaExtensionsEnabled - val decodeAv1 = prefs?.overrides?.decodeAv1 == true - Timber.v("extensions=$extensions") - val rendererMode = - when (extensions) { - MediaExtensionStatus.MES_FALLBACK -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON - MediaExtensionStatus.MES_PREFERRED -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER - MediaExtensionStatus.MES_DISABLED -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF - else -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON - } - ExoPlayer - .Builder(context) - .setRenderersFactory( - WholphinRenderersFactory(context, decodeAv1) - .setEnableDecoderFallback(true) - .setExtensionRendererMode(rendererMode), - ).build() - .apply { - playWhenReady = true - } - } - } - currentPlayer = newPlayer - return newPlayer - } - suspend fun createVideoPlayer( backend: PlayerBackend, prefs: PlaybackPreferences, - ): Player { + ): PlayerCreation { withContext(Dispatchers.Main) { if (currentPlayer?.isReleased == false) { Timber.w("Player was not released before trying to create a new one!") currentPlayer?.release() } } - + var assHandler: AssHandler? = null val newPlayer = when (backend) { PlayerBackend.PREFER_MPV, @@ -125,8 +78,14 @@ class PlayerFactory PlayerBackend.UNRECOGNIZED, -> { val extensions = prefs.overrides.mediaExtensionsEnabled + val useLibAss = + prefs.overrides.assPlaybackMode == AssPlaybackMode.ASS_LIBASS val decodeAv1 = prefs.overrides.decodeAv1 - Timber.v("extensions=$extensions") + Timber.v( + "extensions=%s, assPlaybackMode=%s", + extensions, + prefs.overrides.assPlaybackMode, + ) val rendererMode = when (extensions) { MediaExtensionStatus.MES_FALLBACK -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON @@ -134,17 +93,42 @@ class PlayerFactory MediaExtensionStatus.MES_DISABLED -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF else -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON } + val dataSourceFactory = DefaultDataSource.Factory(context) + val extractorsFactory = DefaultExtractorsFactory() + var renderersFactory: RenderersFactory = + WholphinRenderersFactory(context, decodeAv1) + .setEnableDecoderFallback(true) + .setExtensionRendererMode(rendererMode) + val mediaSourceFactory = + if (useLibAss) { + assHandler = AssHandler(AssRenderType.OVERLAY_OPEN_GL) + val assSubtitleParserFactory = AssSubtitleParserFactory(assHandler) + renderersFactory = AssRenderersFactory(assHandler, renderersFactory) + DefaultMediaSourceFactory( + dataSourceFactory, + extractorsFactory.withAssMkvSupport( + assSubtitleParserFactory, + assHandler, + ), + ).setSubtitleParserFactory(assSubtitleParserFactory) + } else { + DefaultMediaSourceFactory( + dataSourceFactory, + extractorsFactory, + ) + } ExoPlayer .Builder(context) - .setRenderersFactory( - WholphinRenderersFactory(context, decodeAv1) - .setEnableDecoderFallback(true) - .setExtensionRendererMode(rendererMode), - ).build() + .setMediaSourceFactory(mediaSourceFactory) + .setRenderersFactory(renderersFactory) + .build() + .apply { + assHandler?.init(this) + } } } currentPlayer = newPlayer - return newPlayer + return PlayerCreation(newPlayer, assHandler) } } @@ -157,6 +141,11 @@ val Player.isReleased: Boolean } } +data class PlayerCreation( + val player: Player, + val assHandler: AssHandler? = null, +) + // Code is adapted from https://github.com/androidx/media/blob/release/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/DefaultRenderersFactory.java#L436 class WholphinRenderersFactory( context: Context, 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 8fb08653..334cd062 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 @@ -1,5 +1,8 @@ package com.github.damontecres.wholphin.ui.playback +import android.view.Gravity +import android.view.ViewGroup +import android.widget.FrameLayout import androidx.activity.compose.BackHandler import androidx.annotation.Dimension import androidx.annotation.OptIn @@ -40,16 +43,18 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.intl.Locale +import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties +import androidx.core.view.children import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.LifecycleStartEffect -import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi import androidx.media3.ui.SubtitleView import androidx.media3.ui.compose.PlayerSurface @@ -61,6 +66,7 @@ import androidx.tv.material3.MaterialTheme import androidx.tv.material3.surfaceColorAtElevation import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.data.model.Playlist +import com.github.damontecres.wholphin.preferences.AssPlaybackMode import com.github.damontecres.wholphin.preferences.PlayerBackend import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.skipBackOnResume @@ -79,6 +85,7 @@ import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.Media3SubtitleOverride import com.github.damontecres.wholphin.util.mpv.MpvPlayer +import io.github.peerless2012.ass.media.widget.AssSubtitleView import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -123,8 +130,7 @@ fun PlaybackPage( LoadingState.Success -> { val playerState by viewModel.currentPlayer.collectAsState() PlaybackPageContent( - player = playerState!!.player, - playerBackend = playerState!!.backend, + playerState = playerState!!, preferences = preferences, destination = destination, viewModel = viewModel, @@ -137,13 +143,15 @@ fun PlaybackPage( @OptIn(UnstableApi::class) @Composable fun PlaybackPageContent( - player: Player, - playerBackend: PlayerBackend, + playerState: PlayerState, preferences: UserPreferences, destination: Destination, modifier: Modifier = Modifier, viewModel: PlaybackViewModel, ) { + val player = playerState.player + val playerBackend = playerState.backend + val prefs = preferences.appPreferences.playbackPreferences val scope = rememberCoroutineScope() val configuration = LocalConfiguration.current @@ -316,10 +324,14 @@ fun PlaybackPageContent( .focusRequester(focusRequester) .focusable(), ) { + var playerSurfaceSize by remember { mutableStateOf(IntSize.Zero) } PlayerSurface( player = player, surfaceType = SURFACE_TYPE_SURFACE_VIEW, - modifier = scaledModifier, + modifier = + scaledModifier.onSizeChanged { + playerSurfaceSize = it + }, ) if (presentationState.coverSurface) { Box( @@ -415,7 +427,7 @@ fun PlaybackPageContent( remember(subtitleSettings) { subtitleSettings.imageSubtitleOpacity / 100f } // Subtitles - if (skipIndicatorDuration == 0L && currentItemPlayback.subtitleIndexEnabled) { + if (skipIndicatorDuration == 0L && currentItemPlayback.subtitleIndexEnabled && !presentationState.coverSurface) { val maxSize by animateFloatAsState(if (controllerViewState.controlsVisible) .7f else 1f) val isImageSubtitles = remember(cues) { cues.firstOrNull()?.bitmap != null } AndroidView( @@ -426,12 +438,42 @@ fun PlaybackPageContent( setFixedTextSize(Dimension.SP, it.fontSize.toFloat()) setBottomPaddingFraction(it.margin.toFloat() / 100f) } + playerState.assHandler?.let { assHandler -> + if (prefs.overrides.assPlaybackMode == AssPlaybackMode.ASS_LIBASS) { + Timber.v("Adding AssSubtitleView") + addView( + AssSubtitleView(context, assHandler).apply { + layoutParams = + FrameLayout + .LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ).apply { gravity = Gravity.CENTER } + }, + ) + } + } } }, update = { it.setCues(cues) Media3SubtitleOverride(subtitleSettings.calculateEdgeSize(density)) .apply(it) + it.children.firstOrNull { it is AssSubtitleView }?.let { + (it as? AssSubtitleView)?.apply { + val resized = + layoutParams.let { it.width != playerSurfaceSize.width || it.height != playerSurfaceSize.height } + if (resized) { + Timber.v("Resizing AssSubtitleView: $playerSurfaceSize") + layoutParams = + FrameLayout + .LayoutParams( + playerSurfaceSize.width, + playerSurfaceSize.height, + ).apply { gravity = Gravity.CENTER } + } + } + } }, onReset = { it.setCues(null) 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 e9af15f1..b9e51bb9 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 @@ -75,6 +75,7 @@ import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext +import io.github.peerless2012.ass.media.AssHandler import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -221,7 +222,8 @@ class PlaybackViewModel isHdr: Boolean, is4k: Boolean, ) { - val softwareDecoding = !preferences.appPreferences.playbackPreferences.mpvOptions.enableHardwareDecoding + val softwareDecoding = + !preferences.appPreferences.playbackPreferences.mpvOptions.enableHardwareDecoding val playerBackend = when (preferences.appPreferences.playbackPreferences.playerBackend) { PlayerBackend.UNRECOGNIZED, @@ -240,13 +242,14 @@ class PlaybackViewModel disconnectPlayer() } - player = + val playerCreation = playerFactory.createVideoPlayer( playerBackend, preferences.appPreferences.playbackPreferences, ) + this.player = playerCreation.player currentPlayer.update { - PlayerState(player, playerBackend) + PlayerState(playerCreation.player, playerBackend, playerCreation.assHandler) } configurePlayer() } @@ -1450,6 +1453,7 @@ class PlaybackViewModel data class PlayerState( val player: Player, val backend: PlayerBackend, + val assHandler: AssHandler?, ) data class MediaSegmentState( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowPage.kt index 2b3ef667..faccb4cc 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowPage.kt @@ -69,6 +69,7 @@ import com.github.damontecres.wholphin.ui.playback.isDirectionalDpad import com.github.damontecres.wholphin.ui.playback.isDpad import com.github.damontecres.wholphin.ui.playback.isEnterKey import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.util.LoadingState import org.jellyfin.sdk.model.api.MediaType import timber.log.Timber import kotlin.math.abs @@ -92,403 +93,418 @@ fun SlideshowPage( ), ) { val context = LocalContext.current + val loading by viewModel.loading.collectAsState() - val loadingState by viewModel.loadingState.observeAsState(ImageLoadingState.Loading) - val imageFilter by viewModel.imageFilter.observeAsState(VideoFilter()) - val position by viewModel.position.observeAsState(0) - val pager by viewModel.pager.observeAsState() -// val imageState by viewModel.image.observeAsState() - - var zoomFactor by rememberSaveable { mutableFloatStateOf(1f) } - val isZoomed = zoomFactor * 100 > 102 - var rotation by rememberSaveable { mutableFloatStateOf(0f) } - var showOverlay by rememberSaveable { mutableStateOf(false) } - var showFilterDialog by rememberSaveable { mutableStateOf(false) } - var panX by rememberSaveable { mutableFloatStateOf(0f) } - var panY by rememberSaveable { mutableFloatStateOf(0f) } - - val slideshowControls = - object : SlideshowControls { - override fun startSlideshow() { - showOverlay = false - viewModel.startSlideshow() - } - - override fun stopSlideshow() { - viewModel.stopSlideshow() - } + when (val st = loading) { + is LoadingState.Error -> { + ErrorMessage(st, modifier) } - val rotateAnimation: Float by animateFloatAsState( - targetValue = rotation, - label = "image_rotation", - ) - val zoomAnimation: Float by animateFloatAsState( - targetValue = zoomFactor, - label = "image_zoom", - ) - val panXAnimation: Float by animateFloatAsState( - targetValue = panX, - label = "image_panX", - ) - val panYAnimation: Float by animateFloatAsState( - targetValue = panY, - label = "image_panY", - ) - - val slideshowState by viewModel.slideshow.collectAsState() - val slideshowActive by viewModel.slideshowActive.collectAsState(false) - - val focusRequester = remember { FocusRequester() } - - LaunchedEffect(Unit) { - focusRequester.tryRequestFocus() - } - - val density = LocalDensity.current - val screenHeight = LocalWindowInfo.current.containerSize.height - val screenWidth = LocalWindowInfo.current.containerSize.width - - val maxPanX = screenWidth * .75f - val maxPanY = screenHeight * .75f - - fun reset(resetRotate: Boolean) { - zoomFactor = 1f - panX = 0f - panY = 0f - if (resetRotate) rotation = 0f - } - - fun pan( - xFactor: Int, - yFactor: Int, - ) { - if (xFactor != 0) { - panX = (panX + with(density) { xFactor.dp.toPx() }).coerceIn(-maxPanX, maxPanX) + LoadingState.Loading, + LoadingState.Pending, + -> { + LoadingPage(modifier) } - if (yFactor != 0) { - panY = (panY + with(density) { yFactor.dp.toPx() }).coerceIn(-maxPanY, maxPanY) - } - } - fun zoom(factor: Float) { - if (factor < 0) { - val diffFactor = factor / (zoomFactor - 1f) - // zooming out - val panXDiff = abs(panX * diffFactor) - val panYDiff = abs(panY * diffFactor) - if (DEBUG) { - Timber.d( - "zoomFactor=$zoomFactor, factor=$factor, panX=$panX, panY=$panY, panXDiff=$panXDiff, panYDiff=$panYDiff", - ) - } - if (panX > 0f) { - panX -= panXDiff - } else if (panX < 0f) { - panX += panXDiff - } - if (panY > 0f) { - panY -= panYDiff - } else if (panY < 0f) { - panY += panYDiff - } - } - zoomFactor = (zoomFactor + factor).coerceIn(1f, 5f) - if (!isZoomed) { - // Always reset if not zoomed - panX = 0f - panY = 0f - } - } + LoadingState.Success -> { + val loadingState by viewModel.loadingState.observeAsState(ImageLoadingState.Loading) + val imageFilter by viewModel.imageFilter.observeAsState(VideoFilter()) + val position by viewModel.position.observeAsState(0) + val pager by viewModel.pager.observeAsState() +// val imageState by viewModel.image.observeAsState() - val player = viewModel.player - val presentationState = rememberPresentationState(player) - LaunchedEffect(slideshowActive) { - player.repeatMode = - if (slideshowState.enabled) Player.REPEAT_MODE_OFF else Player.REPEAT_MODE_ONE - } + var zoomFactor by rememberSaveable { mutableFloatStateOf(1f) } + val isZoomed = zoomFactor * 100 > 102 + var rotation by rememberSaveable { mutableFloatStateOf(0f) } + var showOverlay by rememberSaveable { mutableStateOf(false) } + var showFilterDialog by rememberSaveable { mutableStateOf(false) } + var panX by rememberSaveable { mutableFloatStateOf(0f) } + var panY by rememberSaveable { mutableFloatStateOf(0f) } - var longPressing by remember { mutableStateOf(false) } - - Box( - modifier = - modifier - .background(Color.Black) - .focusRequester(focusRequester) - .focusable() - .onKeyEvent { - val isOverlayShowing = showOverlay || showFilterDialog - var result = false - if (!isOverlayShowing) { - if (longPressing && it.type == KeyEventType.KeyUp) { - // User stopped long pressing, so cancel the zooming action, but still consume the event so it doesn't move the image - longPressing = false - return@onKeyEvent true - } - longPressing = - it.nativeKeyEvent.isLongPress || - it.nativeKeyEvent.repeatCount > 0 - if (longPressing) { - when (it.key) { - Key.DirectionUp -> zoom(.05f) - Key.DirectionDown -> zoom(-.05f) - - // These work, but feel awkward because Up/Down zoom, so you can't long press them to pan - // Key.DirectionLeft -> panX += with(density) { 15.dp.toPx() } - // Key.DirectionRight -> panX -= with(density) { 15.dp.toPx() } - } - return@onKeyEvent true - } - } - if (it.type != KeyEventType.KeyUp) { - result = false - } else if (!isOverlayShowing && isZoomed && isDirectionalDpad(it)) { - // Image is zoomed in - when (it.key) { - Key.DirectionLeft -> pan(30, 0) - Key.DirectionRight -> pan(-30, 0) - Key.DirectionUp -> pan(0, 30) - Key.DirectionDown -> pan(0, -30) - } - result = true - } else if (!isOverlayShowing && isZoomed && it.key == Key.Back) { - reset(false) - result = true - } else if (!isOverlayShowing && (it.key == Key.DirectionLeft || it.key == Key.DirectionRight)) { - when (it.key) { - Key.DirectionLeft, Key.DirectionUpLeft, Key.DirectionDownLeft -> { - if (!viewModel.previousImage()) { - Toast - .makeText( - context, - R.string.slideshow_at_beginning, - Toast.LENGTH_SHORT, - ).show() - } - } - - Key.DirectionRight, Key.DirectionUpRight, Key.DirectionDownRight -> { - if (!viewModel.nextImage()) { - Toast - .makeText( - context, - R.string.no_more_images, - Toast.LENGTH_SHORT, - ).show() - } - } - } - } else if (isOverlayShowing && it.key == Key.Back) { + val slideshowControls = + object : SlideshowControls { + override fun startSlideshow() { showOverlay = false - viewModel.unpauseSlideshow() - result = true - } else if (!isOverlayShowing && (isDpad(it) || isEnterKey(it))) { - showOverlay = true - viewModel.pauseSlideshow() - result = true + viewModel.startSlideshow() } - if (result) { - // Handled the key, so reset the slideshow timer - viewModel.pulseSlideshow() + + override fun stopSlideshow() { + viewModel.stopSlideshow() } - result - }, - ) { - when (val st = loadingState) { - ImageLoadingState.Error -> { - ErrorMessage("Error loading image", null, modifier) - } - - ImageLoadingState.Loading -> { - LoadingPage(modifier, false) - } - - is ImageLoadingState.Success -> { - val imageState = st.image - LaunchedEffect(imageState) { - reset(true) } - if (imageState.image.data.mediaType == MediaType.VIDEO) { - LaunchedEffect(imageState.id) { - val mediaItem = - MediaItem - .Builder() - .setUri(imageState.url) - .build() - player.setMediaItem(mediaItem) - player.repeatMode = - if (slideshowState.enabled) { - Player.REPEAT_MODE_OFF - } else { - Player.REPEAT_MODE_ONE - } - player.prepare() - player.play() - viewModel.pulseSlideshow(Long.MAX_VALUE) - } - LifecycleStartEffect(Unit) { - onStopOrDispose { - player.stop() - } - } - val contentScale = ContentScale.Fit - val scaledModifier = - Modifier.resizeWithContentScale( - contentScale, - presentationState.videoSizeDp, - ) - PlayerSurface( - player = player, - surfaceType = SURFACE_TYPE_SURFACE_VIEW, - modifier = - scaledModifier - .fillMaxSize() - .graphicsLayer { - scaleX = zoomAnimation - scaleY = zoomAnimation - translationX = panXAnimation - translationY = panYAnimation - }.rotate(rotateAnimation), - ) - if (presentationState.coverSurface) { - Box( - Modifier - .matchParentSize() - .background(Color.Black), - ) - } - } else { - val colorFilter = - remember(imageState.id, imageFilter) { - if (imageFilter.hasImageFilter()) { - ColorMatrixColorFilter(imageFilter.colorMatrix) - } else { - null - } - } - // If the image loading is large, show the thumbnail while waiting - // TODO - val showLoadingThumbnail = true - SubcomposeAsyncImage( - modifier = - Modifier - .fillMaxSize() - .graphicsLayer { - scaleX = zoomAnimation - scaleY = zoomAnimation - translationX = panXAnimation - translationY = panYAnimation - val xTransform = - (screenWidth - panXAnimation) / (screenWidth * 2) - val yTransform = - (screenHeight - panYAnimation) / (screenHeight * 2) - if (DEBUG) { - Timber.d( - "graphicsLayer: xTransform=$xTransform, yTransform=$yTransform", - ) + val rotateAnimation: Float by animateFloatAsState( + targetValue = rotation, + label = "image_rotation", + ) + val zoomAnimation: Float by animateFloatAsState( + targetValue = zoomFactor, + label = "image_zoom", + ) + val panXAnimation: Float by animateFloatAsState( + targetValue = panX, + label = "image_panX", + ) + val panYAnimation: Float by animateFloatAsState( + targetValue = panY, + label = "image_panY", + ) + + val slideshowState by viewModel.slideshow.collectAsState() + val slideshowActive by viewModel.slideshowActive.collectAsState(false) + + val focusRequester = remember { FocusRequester() } + + LaunchedEffect(Unit) { + focusRequester.tryRequestFocus() + } + + val density = LocalDensity.current + val screenHeight = LocalWindowInfo.current.containerSize.height + val screenWidth = LocalWindowInfo.current.containerSize.width + + val maxPanX = screenWidth * .75f + val maxPanY = screenHeight * .75f + + fun reset(resetRotate: Boolean) { + zoomFactor = 1f + panX = 0f + panY = 0f + if (resetRotate) rotation = 0f + } + + fun pan( + xFactor: Int, + yFactor: Int, + ) { + if (xFactor != 0) { + panX = (panX + with(density) { xFactor.dp.toPx() }).coerceIn(-maxPanX, maxPanX) + } + if (yFactor != 0) { + panY = (panY + with(density) { yFactor.dp.toPx() }).coerceIn(-maxPanY, maxPanY) + } + } + + fun zoom(factor: Float) { + if (factor < 0) { + val diffFactor = factor / (zoomFactor - 1f) + // zooming out + val panXDiff = abs(panX * diffFactor) + val panYDiff = abs(panY * diffFactor) + if (DEBUG) { + Timber.d( + "zoomFactor=$zoomFactor, factor=$factor, panX=$panX, panY=$panY, panXDiff=$panXDiff, panYDiff=$panYDiff", + ) + } + if (panX > 0f) { + panX -= panXDiff + } else if (panX < 0f) { + panX += panXDiff + } + if (panY > 0f) { + panY -= panYDiff + } else if (panY < 0f) { + panY += panYDiff + } + } + zoomFactor = (zoomFactor + factor).coerceIn(1f, 5f) + if (!isZoomed) { + // Always reset if not zoomed + panX = 0f + panY = 0f + } + } + + val player = viewModel.player + val presentationState = rememberPresentationState(player) + LaunchedEffect(slideshowActive) { + player.repeatMode = + if (slideshowState.enabled) Player.REPEAT_MODE_OFF else Player.REPEAT_MODE_ONE + } + + var longPressing by remember { mutableStateOf(false) } + + Box( + modifier = + modifier + .background(Color.Black) + .focusRequester(focusRequester) + .focusable() + .onKeyEvent { + val isOverlayShowing = showOverlay || showFilterDialog + var result = false + if (!isOverlayShowing) { + if (longPressing && it.type == KeyEventType.KeyUp) { + // User stopped long pressing, so cancel the zooming action, but still consume the event so it doesn't move the image + longPressing = false + return@onKeyEvent true + } + longPressing = + it.nativeKeyEvent.isLongPress || + it.nativeKeyEvent.repeatCount > 0 + if (longPressing) { + when (it.key) { + Key.DirectionUp -> zoom(.05f) + Key.DirectionDown -> zoom(-.05f) + + // These work, but feel awkward because Up/Down zoom, so you can't long press them to pan + // Key.DirectionLeft -> panX += with(density) { 15.dp.toPx() } + // Key.DirectionRight -> panX -= with(density) { 15.dp.toPx() } + } + return@onKeyEvent true + } + } + if (it.type != KeyEventType.KeyUp) { + result = false + } else if (!isOverlayShowing && isZoomed && isDirectionalDpad(it)) { + // Image is zoomed in + when (it.key) { + Key.DirectionLeft -> pan(30, 0) + Key.DirectionRight -> pan(-30, 0) + Key.DirectionUp -> pan(0, 30) + Key.DirectionDown -> pan(0, -30) + } + result = true + } else if (!isOverlayShowing && isZoomed && it.key == Key.Back) { + reset(false) + result = true + } else if (!isOverlayShowing && (it.key == Key.DirectionLeft || it.key == Key.DirectionRight)) { + when (it.key) { + Key.DirectionLeft, Key.DirectionUpLeft, Key.DirectionDownLeft -> { + if (!viewModel.previousImage()) { + Toast + .makeText( + context, + R.string.slideshow_at_beginning, + Toast.LENGTH_SHORT, + ).show() + } } - transformOrigin = TransformOrigin(xTransform, yTransform) - }.rotate(rotateAnimation), - model = - ImageRequest - .Builder(LocalContext.current) - .data(imageState.url) - .apply { - if (isZoomed) size(Size.ORIGINAL) - }.transitionFactory(CrossFadeFactory(750.milliseconds)) - .useExistingImageAsPlaceholder(true) - .build(), - contentDescription = null, - contentScale = ContentScale.Fit, - colorFilter = colorFilter, - error = { - Text( + Key.DirectionRight, Key.DirectionUpRight, Key.DirectionDownRight -> { + if (!viewModel.nextImage()) { + Toast + .makeText( + context, + R.string.no_more_images, + Toast.LENGTH_SHORT, + ).show() + } + } + } + } else if (isOverlayShowing && it.key == Key.Back) { + showOverlay = false + viewModel.unpauseSlideshow() + result = true + } else if (!isOverlayShowing && (isDpad(it) || isEnterKey(it))) { + showOverlay = true + viewModel.pauseSlideshow() + result = true + } + if (result) { + // Handled the key, so reset the slideshow timer + viewModel.pulseSlideshow() + } + result + }, + ) { + when (val st = loadingState) { + ImageLoadingState.Error -> { + ErrorMessage("Error loading image", null, modifier) + } + + ImageLoadingState.Loading -> { + LoadingPage(modifier, false) + } + + is ImageLoadingState.Success -> { + val imageState = st.image + LaunchedEffect(imageState) { + reset(true) + } + if (imageState.image.data.mediaType == MediaType.VIDEO) { + LaunchedEffect(imageState.id) { + val mediaItem = + MediaItem + .Builder() + .setUri(imageState.url) + .build() + player.setMediaItem(mediaItem) + player.repeatMode = + if (slideshowState.enabled) { + Player.REPEAT_MODE_OFF + } else { + Player.REPEAT_MODE_ONE + } + player.prepare() + player.play() + viewModel.pulseSlideshow(Long.MAX_VALUE) + } + LifecycleStartEffect(Unit) { + onStopOrDispose { + player.stop() + } + } + val contentScale = ContentScale.Fit + val scaledModifier = + Modifier.resizeWithContentScale( + contentScale, + presentationState.videoSizeDp, + ) + PlayerSurface( + player = player, + surfaceType = SURFACE_TYPE_SURFACE_VIEW, + modifier = + scaledModifier + .fillMaxSize() + .graphicsLayer { + scaleX = zoomAnimation + scaleY = zoomAnimation + translationX = panXAnimation + translationY = panYAnimation + }.rotate(rotateAnimation), + ) + if (presentationState.coverSurface) { + Box( + Modifier + .matchParentSize() + .background(Color.Black), + ) + } + } else { + val colorFilter = + remember(imageState.id, imageFilter) { + if (imageFilter.hasImageFilter()) { + ColorMatrixColorFilter(imageFilter.colorMatrix) + } else { + null + } + } + // If the image loading is large, show the thumbnail while waiting + // TODO + val showLoadingThumbnail = true + SubcomposeAsyncImage( modifier = Modifier - .align(Alignment.Center), - text = "Error loading image", - color = MaterialTheme.colorScheme.onBackground, + .fillMaxSize() + .graphicsLayer { + scaleX = zoomAnimation + scaleY = zoomAnimation + translationX = panXAnimation + translationY = panYAnimation + + val xTransform = + (screenWidth - panXAnimation) / (screenWidth * 2) + val yTransform = + (screenHeight - panYAnimation) / (screenHeight * 2) + if (DEBUG) { + Timber.d( + "graphicsLayer: xTransform=$xTransform, yTransform=$yTransform", + ) + } + + transformOrigin = + TransformOrigin(xTransform, yTransform) + }.rotate(rotateAnimation), + model = + ImageRequest + .Builder(LocalContext.current) + .data(imageState.url) + .size(Size.ORIGINAL) + .transitionFactory(CrossFadeFactory(750.milliseconds)) + .useExistingImageAsPlaceholder(true) + .build(), + contentDescription = null, + contentScale = ContentScale.Fit, + colorFilter = colorFilter, + error = { + Text( + modifier = + Modifier + .align(Alignment.Center), + text = "Error loading image", + color = MaterialTheme.colorScheme.onBackground, + ) + }, + // Ensure that if an image takes a long time to load, it won't be skipped + onLoading = { + viewModel.pulseSlideshow(Long.MAX_VALUE) + }, + onSuccess = { + viewModel.pulseSlideshow() + }, + onError = { + Timber.e( + it.result.throwable, + "Error loading image ${imageState.id}", + ) + Toast + .makeText( + context, + "Error loading image: ${it.result.throwable.localizedMessage}", + Toast.LENGTH_LONG, + ).show() + viewModel.pulseSlideshow() + }, ) - }, - // Ensure that if an image takes a long time to load, it won't be skipped - onLoading = { - viewModel.pulseSlideshow(Long.MAX_VALUE) - }, - onSuccess = { - viewModel.pulseSlideshow() - }, - onError = { - Timber.e( - it.result.throwable, - "Error loading image ${imageState.id}", + } + } + } + AnimatedVisibility( + showOverlay, + enter = slideInVertically { it }, + exit = slideOutVertically { it }, + modifier = Modifier.align(Alignment.BottomStart), + ) { + when (val st = loadingState) { + ImageLoadingState.Error -> {} + + ImageLoadingState.Loading -> {} + + is ImageLoadingState.Success -> { + val imageState = st.image + ImageOverlay( + modifier = + Modifier + .fillMaxWidth() + .background(AppColors.TransparentBlack50), + onDismiss = { showOverlay = false }, + player = player, + slideshowControls = slideshowControls, + slideshowEnabled = slideshowState.enabled, + image = imageState, + position = position, + count = pager?.size ?: -1, + onClickItem = {}, + onLongClickItem = {}, + onZoom = ::zoom, + onRotate = { rotation += it }, + onReset = { reset(true) }, + onShowFilterDialogClick = { + showFilterDialog = true + showOverlay = false + viewModel.pauseSlideshow() + }, ) - Toast - .makeText( - context, - "Error loading image: ${it.result.throwable.localizedMessage}", - Toast.LENGTH_LONG, - ).show() + } + } + } + AnimatedVisibility(showFilterDialog) { + ImageFilterDialog( + filter = imageFilter, + showVideoOptions = false, + showSaveGalleryButton = true, + onChange = viewModel::updateImageFilter, + onClickSave = viewModel::saveImageFilter, + onClickSaveGallery = viewModel::saveGalleryFilter, + onDismissRequest = { + showFilterDialog = false + viewModel.unpauseSlideshow() viewModel.pulseSlideshow() }, ) } } } - AnimatedVisibility( - showOverlay, - enter = slideInVertically { it }, - exit = slideOutVertically { it }, - modifier = Modifier.align(Alignment.BottomStart), - ) { - when (val st = loadingState) { - ImageLoadingState.Error -> {} - - ImageLoadingState.Loading -> {} - - is ImageLoadingState.Success -> { - val imageState = st.image - ImageOverlay( - modifier = - Modifier - .fillMaxWidth() - .background(AppColors.TransparentBlack50), - onDismiss = { showOverlay = false }, - player = player, - slideshowControls = slideshowControls, - slideshowEnabled = slideshowState.enabled, - image = imageState, - position = position, - count = pager?.size ?: -1, - onClickItem = {}, - onLongClickItem = {}, - onZoom = ::zoom, - onRotate = { rotation += it }, - onReset = { reset(true) }, - onShowFilterDialogClick = { - showFilterDialog = true - showOverlay = false - viewModel.pauseSlideshow() - }, - ) - } - } - } - AnimatedVisibility(showFilterDialog) { - ImageFilterDialog( - filter = imageFilter, - showVideoOptions = false, - showSaveGalleryButton = true, - onChange = viewModel::updateImageFilter, - onClickSave = viewModel::saveImageFilter, - onClickSaveGallery = viewModel::saveGalleryFilter, - onDismissRequest = { - showFilterDialog = false - viewModel.unpauseSlideshow() - viewModel.pulseSlideshow() - }, - ) - } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowViewModel.kt index 3a4f3bce..f9abe15d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowViewModel.kt @@ -16,6 +16,7 @@ import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.PlaybackEffect import com.github.damontecres.wholphin.data.model.VideoFilter import com.github.damontecres.wholphin.preferences.AppPreference +import com.github.damontecres.wholphin.preferences.PlayerBackend import com.github.damontecres.wholphin.services.ImageUrlService import com.github.damontecres.wholphin.services.PlayerFactory import com.github.damontecres.wholphin.services.ScreensaverService @@ -30,6 +31,7 @@ import com.github.damontecres.wholphin.ui.util.ThrottledLiveData 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 @@ -77,9 +79,8 @@ class SlideshowViewModel fun create(slideshow: Destination.Slideshow): SlideshowViewModel } - val player by lazy { - playerFactory.createVideoPlayer() - } + lateinit var player: Player + private set private var saveFilters = true @@ -96,7 +97,6 @@ class SlideshowViewModel var slideshowDelay by Delegates.notNull() - // private val album = MutableLiveData() private val _pager = MutableLiveData>() val pager: LiveData> = _pager.map { it } val position = MutableLiveData(0) @@ -104,6 +104,8 @@ class SlideshowViewModel private val _image = MutableLiveData() val image: LiveData = _image + val loading = MutableStateFlow(LoadingState.Pending) + val loadingState = MutableLiveData(ImageLoadingState.Loading) private val _imageFilter = MutableLiveData(VideoFilter()) val imageFilter = ThrottledLiveData(_imageFilter, 500L) @@ -113,58 +115,67 @@ class SlideshowViewModel init { addCloseable { screensaverService.keepScreenOn(false) - player.removeListener(this@SlideshowViewModel) - player.release() - } - player.addListener(this@SlideshowViewModel) - viewModelScope.launchIO { - val photoPrefs = userPreferencesService.getCurrent().appPreferences.photoPreferences - slideshowDelay = - photoPrefs.slideshowDuration.takeIf { it >= AppPreference.SlideshowDuration.min } - ?: AppPreference.SlideshowDuration.defaultValue -// val album = -// api.userLibraryApi -// .getItem( -// itemId = slideshowSettings.parentId, -// ).content -// .let { BaseItem(it, false) } -// this@SlideshowViewModel.album.setValueOnMain(album) - val includeItemTypes = - if (photoPrefs.slideshowPlayVideos) { - listOf(BaseItemKind.PHOTO, BaseItemKind.VIDEO) - } else { - listOf(BaseItemKind.PHOTO) - } - val request = - slideshowSettings.filter.filter.applyTo( - GetItemsRequest( - parentId = slideshowSettings.parentId, - includeItemTypes = includeItemTypes, - fields = PhotoItemFields, - recursive = slideshowSettings.recursive, - sortBy = listOf(slideshowSettings.sortAndDirection.sort), - sortOrder = listOf(slideshowSettings.sortAndDirection.direction), - ), - ) - serverRepository.currentUser.value?.let { user -> - val filter = - playbackEffectDao - .getPlaybackEffect( - user.rowId, - slideshowSettings.parentId, - BaseItemKind.PHOTO_ALBUM, - )?.videoFilter - if (filter != null) { - Timber.v("Got filter for album %s", slideshowSettings.parentId) - albumImageFilter = filter - } + if (this@SlideshowViewModel::player.isInitialized) { + player.removeListener(this@SlideshowViewModel) + player.release() + } + } + viewModelScope.launchIO { + try { + val appPreferences = userPreferencesService.getCurrent().appPreferences + val playerCreation = + playerFactory.createVideoPlayer( + backend = PlayerBackend.EXO_PLAYER, + appPreferences.playbackPreferences, + ) + player = playerCreation.player + player.addListener(this@SlideshowViewModel) + + val photoPrefs = appPreferences.photoPreferences + slideshowDelay = + photoPrefs.slideshowDuration.takeIf { it >= AppPreference.SlideshowDuration.min } + ?: AppPreference.SlideshowDuration.defaultValue + val includeItemTypes = + if (photoPrefs.slideshowPlayVideos) { + listOf(BaseItemKind.PHOTO, BaseItemKind.VIDEO) + } else { + listOf(BaseItemKind.PHOTO) + } + val request = + slideshowSettings.filter.filter.applyTo( + GetItemsRequest( + parentId = slideshowSettings.parentId, + includeItemTypes = includeItemTypes, + fields = PhotoItemFields, + recursive = slideshowSettings.recursive, + sortBy = listOf(slideshowSettings.sortAndDirection.sort), + sortOrder = listOf(slideshowSettings.sortAndDirection.direction), + ), + ) + serverRepository.currentUser.value?.let { user -> + val filter = + playbackEffectDao + .getPlaybackEffect( + user.rowId, + slideshowSettings.parentId, + BaseItemKind.PHOTO_ALBUM, + )?.videoFilter + if (filter != null) { + Timber.v("Got filter for album %s", slideshowSettings.parentId) + albumImageFilter = filter + } + } + val pager = + ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope) + .init(slideshowSettings.index) + this@SlideshowViewModel._pager.setValueOnMain(pager) + loading.update { LoadingState.Success } + updatePosition(slideshowSettings.index)?.join() + if (slideshowSettings.startSlideshow) onMain { startSlideshow() } + } catch (ex: Exception) { + Timber.e(ex, "Error") + loading.update { LoadingState.Error(ex) } } - val pager = - ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope) - .init(slideshowSettings.index) - this@SlideshowViewModel._pager.setValueOnMain(pager) - updatePosition(slideshowSettings.index)?.join() - if (slideshowSettings.startSlideshow) onMain { startSlideshow() } } } diff --git a/app/src/main/proto/WholphinDataStore.proto b/app/src/main/proto/WholphinDataStore.proto index ce9080a7..a817fbae 100644 --- a/app/src/main/proto/WholphinDataStore.proto +++ b/app/src/main/proto/WholphinDataStore.proto @@ -40,14 +40,21 @@ message MpvOptions{ bool use_gpu_next = 2; } +enum AssPlaybackMode{ + ASS_LIBASS = 0; + ASS_EXO_PLAYER = 1; + ASS_TRANSCODE = 2; +} + message PlaybackOverrides{ bool ac3_supported = 1; bool downmix_stereo = 2; - bool direct_play_ass = 3; + bool direct_play_ass = 3 [deprecated = true]; bool direct_play_pgs = 4; MediaExtensionStatus media_extensions_enabled = 5; bool direct_play_dolby_vision_e_l = 6; bool decode_av1 = 7; + AssPlaybackMode ass_playback_mode = 8; } message PlaybackPreferences { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 154aeeae..68b2f5d3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -312,7 +312,7 @@ Check for updates Applies to TV Series only - Direct play ASS subtitles + Use libass for ASS subtitles Direct play PGS subtitles Always downmix to stereo Use FFmpeg decoder module @@ -757,4 +757,18 @@ Discover Movies Studios in %1$s + Direct play with libass + Direct play with ExoPlayer built-in + Burn in/transcode on server + SSA/ASS subtitle playback + + @string/ass_subtitle_mode_libass + @string/ass_subtitle_mode_exoplayer + @string/ass_subtitle_mode_transcode + + + @string/default_track + + + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ad6fa059..df0d6731 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -40,6 +40,7 @@ preferenceKtx = "1.2.1" tvprovider = "1.1.0" workRuntimeKtx = "2.11.2" paletteKtx = "1.0.0" +assMedia = "0.4.0" kotlinxCoroutinesTest = "1.10.2" coreTesting = "2.2.0" openapi-generator = "7.21.0" @@ -139,6 +140,8 @@ kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-t androidx-core-testing = { module = "androidx.arch.core:core-testing", version.ref = "coreTesting" } androidx-runner = { group = "androidx.test", name = "runner", version.ref = "runner" } +ass-media = { group = "io.github.peerless2012", name = "ass-media", version.ref = "assMedia" } + [plugins] android-application = { id = "com.android.application", version.ref = "agp" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } From b5422fe82b83bc96a3ad791a802b3a76595f8e41 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Thu, 16 Apr 2026 00:26:57 -0400 Subject: [PATCH 139/148] Show last played date on overview dialog (#1248) ## Description Adds a line to the overview/media info dialog with the date when the item was last played. ### Related issues N/A ### Testing Emulator ## Screenshots last_played Large ## AI or LLM usage None --- .../damontecres/wholphin/ui/Formatting.kt | 2 ++ .../wholphin/ui/data/ItemDetailsDialogInfo.kt | 25 ++++++++++++++++++- .../ui/detail/collection/CollectionDetails.kt | 8 +----- .../ui/detail/episode/EpisodeDetails.kt | 15 ++--------- .../wholphin/ui/detail/movie/MovieDetails.kt | 17 ++----------- .../ui/detail/series/SeriesDetails.kt | 9 +------ .../ui/detail/series/SeriesOverview.kt | 18 ++----------- app/src/main/res/values/strings.xml | 1 + 8 files changed, 35 insertions(+), 60 deletions(-) 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 7435cca8..7b64b1a4 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 @@ -47,6 +47,8 @@ fun formatDateTime(dateTime: LocalDateTime): String = getDateFormatter().format( fun formatDate(dateTime: LocalDate): String = getDateFormatter().format(dateTime) +fun formatDate(dateTime: LocalDateTime): String = getDateFormatter().format(dateTime) + fun toLocalDate(date: String?): LocalDate? = date?.let { try { 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 8e7c89b1..67e4cc3e 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,9 +19,12 @@ 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.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.studioNames 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.formatDate import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.letNotEmpty import com.github.damontecres.wholphin.ui.util.StreamFormatting.formatAudioCodec @@ -33,6 +36,7 @@ import org.jellyfin.sdk.model.api.MediaStreamType import org.jellyfin.sdk.model.api.VideoRange import org.jellyfin.sdk.model.api.VideoRangeType import org.jellyfin.sdk.model.extensions.ticks +import java.time.LocalDateTime import java.util.Locale data class ItemDetailsDialogInfo( @@ -41,7 +45,17 @@ data class ItemDetailsDialogInfo( val genres: List, val files: List, val studios: List = emptyList(), -) + val lastPlayed: LocalDateTime? = null, +) { + constructor(item: BaseItem) : this( + title = item.name ?: "", + overview = item.data.overview, + genres = item.data.genres.orEmpty(), + files = item.data.mediaSources.orEmpty(), + studios = item.studioNames, + lastPlayed = item.data.userData?.lastPlayedDate, + ) +} /** * Dialog showing metadata about an item @@ -62,6 +76,7 @@ fun ItemDetailsDialog( val bitrateLabel = stringResource(R.string.bitrate) val unknown = stringResource(R.string.unknown) val runtimeLabel = stringResource(R.string.runtime_sort) + val lastPlayedLabel = stringResource(R.string.last_played) ScrollableDialog( onDismissRequest = onDismissRequest, @@ -93,6 +108,14 @@ fun ItemDetailsDialog( style = MaterialTheme.typography.bodyMedium, ) } + val lastPlayed = + remember(info.lastPlayed) { info.lastPlayed?.let { formatDate(it) } } + if (lastPlayed != null) { + Text( + text = "$lastPlayedLabel: $lastPlayed", + style = MaterialTheme.typography.bodyMedium, + ) + } } } 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 index 96a4ea4f..7e0ca325 100644 --- 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 @@ -191,13 +191,7 @@ fun CollectionDetails( modifier = modifier, overviewOnClick = { val collection = state.collection!! - overviewDialog = - ItemDetailsDialogInfo( - title = collection.title ?: "", - overview = collection.data.overview, - genres = collection.data.genres.orEmpty(), - files = emptyList(), - ) + overviewDialog = ItemDetailsDialogInfo(collection) }, favoriteOnClick = remember { 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 bdacc389..79637ee0 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 @@ -149,12 +149,7 @@ fun EpisodeDetails( }, overviewOnClick = { overviewDialog = - ItemDetailsDialogInfo( - title = ep.name ?: context.getString(R.string.unknown), - overview = ep.data.overview, - genres = ep.data.genres.orEmpty(), - files = ep.data.mediaSources.orEmpty(), - ) + ItemDetailsDialogInfo(ep) }, moreOnClick = { moreDialog = @@ -218,13 +213,7 @@ fun EpisodeDetails( onShowOverview = { val source = chosenStreams?.source ?: ep.data.mediaSources?.firstOrNull() if (source != null) { - overviewDialog = - ItemDetailsDialogInfo( - title = ep.name ?: context.getString(R.string.unknown), - overview = ep.data.overview, - genres = ep.data.genres.orEmpty(), - files = listOf(source), - ) + overviewDialog = ItemDetailsDialogInfo(ep) } }, onClearChosenStreams = { 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 bf7d4601..64bf2f1c 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 @@ -35,7 +35,6 @@ import com.github.damontecres.wholphin.data.model.DiscoverItem import com.github.damontecres.wholphin.data.model.Person import com.github.damontecres.wholphin.data.model.Trailer import com.github.damontecres.wholphin.data.model.aspectRatioFloat -import com.github.damontecres.wholphin.data.model.studioNames import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.TrailerService import com.github.damontecres.wholphin.ui.AspectRatios @@ -181,13 +180,7 @@ fun MovieDetails( }, overviewOnClick = { overviewDialog = - ItemDetailsDialogInfo( - title = movie.name ?: unknownStr, - overview = movie.data.overview, - genres = movie.data.genres.orEmpty(), - files = movie.data.mediaSources.orEmpty(), - studios = movie.studioNames, - ) + ItemDetailsDialogInfo(movie) }, moreOnClick = { moreDialog = @@ -250,13 +243,7 @@ fun MovieDetails( } }, onShowOverview = { - overviewDialog = - ItemDetailsDialogInfo( - title = movie.name ?: unknownStr, - overview = movie.data.overview, - genres = movie.data.genres.orEmpty(), - files = movie.data.mediaSources.orEmpty(), - ) + overviewDialog = ItemDetailsDialogInfo(movie) }, onClearChosenStreams = { viewModel.clearChosenStreams(chosenStreams) 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 91baa312..3c6b7d5c 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 @@ -210,14 +210,7 @@ fun SeriesDetails( } }, overviewOnClick = { - overviewDialog = - ItemDetailsDialogInfo( - title = item.name ?: context.getString(R.string.unknown), - overview = item.data.overview, - genres = item.data.genres.orEmpty(), - studios = item.studioNames, - files = listOf(), - ) + overviewDialog = ItemDetailsDialogInfo(item) }, playOnClick = { shuffle -> if (shuffle) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt index e048a008..23633651 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt @@ -265,13 +265,7 @@ fun SeriesOverview( } }, onShowOverview = { - overviewDialog = - ItemDetailsDialogInfo( - title = ep.name ?: context.getString(R.string.unknown), - overview = ep.data.overview, - genres = ep.data.genres.orEmpty(), - files = ep.data.mediaSources.orEmpty(), - ) + overviewDialog = ItemDetailsDialogInfo(ep) }, onClearChosenStreams = { viewModel.clearChosenStreams(ep, chosenStreams) @@ -357,15 +351,7 @@ fun SeriesOverview( }, overviewOnClick = { episodeList?.getOrNull(position.episodeRowIndex)?.let { - scope.launchDefault { - overviewDialog = - ItemDetailsDialogInfo( - title = it.name ?: context.getString(R.string.unknown), - overview = it.data.overview, - genres = it.data.genres.orEmpty(), - files = it.data.mediaSources.orEmpty(), - ) - } + overviewDialog = ItemDetailsDialogInfo(it) } }, personOnClick = { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 68b2f5d3..5628f293 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -756,6 +756,7 @@ Discover TV Shows Discover Movies Studios in %1$s + Last played Direct play with libass Direct play with ExoPlayer built-in From b3d630f2729b404d83131c0ed68611b44797b6d4 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Thu, 16 Apr 2026 17:28:02 -0400 Subject: [PATCH 140/148] Add option to never show next up episodes (#1257) ## Description Adds an option to never show next up. This means when an episode completes, the app always returns to the previous page instead of suggesting the next episode. ### Related issues Closes #1100 ### Testing Emulator ## Screenshots N/A ## AI or LLM usage None --- .../damontecres/wholphin/preferences/AppPreference.kt | 4 ++-- .../damontecres/wholphin/ui/playback/PlaybackViewModel.kt | 8 +++++--- app/src/main/proto/WholphinDataStore.proto | 1 + app/src/main/res/values/strings.xml | 2 ++ 4 files changed, 10 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt index 149b0098..4463dd97 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt @@ -826,8 +826,8 @@ sealed interface AppPreference { }, displayValues = R.array.player_backend_options, subtitles = R.array.player_backend_options_subtitles, - indexToValue = { PlayerBackend.forNumber(it) }, - valueToIndex = { it.number }, + indexToValue = { PlayerBackend.forNumber(it) ?: PlayerBackend.EXO_PLAYER }, + valueToIndex = { if (it != PlayerBackend.UNRECOGNIZED) it.number else PlayerBackend.EXO_PLAYER.number }, ) val ExoPlayerSettings = 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 b9e51bb9..f6bc9018 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 @@ -337,8 +337,10 @@ class PlaybackViewModel navigationManager.goBack() return } - withContext(Dispatchers.Main) { - this@PlaybackViewModel.playlist.value = r.playlist + if (preferences.appPreferences.playbackPreferences.showNextUpWhen != ShowNextUpWhen.NEXT_UP_NEVER) { + withContext(Dispatchers.Main) { + this@PlaybackViewModel.playlist.value = r.playlist + } } r.playlist.items .first() @@ -363,7 +365,7 @@ class PlaybackViewModel playNextUp() } - if (!isPlaylist) { + if (!isPlaylist && preferences.appPreferences.playbackPreferences.showNextUpWhen != ShowNextUpWhen.NEXT_UP_NEVER) { val result = playlistCreator.createFrom(queriedItem) if (result is PlaylistCreationResult.Success && result.playlist.items.isNotEmpty()) { withContext(Dispatchers.Main) { diff --git a/app/src/main/proto/WholphinDataStore.proto b/app/src/main/proto/WholphinDataStore.proto index a817fbae..60dd3943 100644 --- a/app/src/main/proto/WholphinDataStore.proto +++ b/app/src/main/proto/WholphinDataStore.proto @@ -6,6 +6,7 @@ option java_multiple_files = true; enum ShowNextUpWhen{ END_OF_PLAYBACK = 0; DURING_CREDITS = 1; + NEXT_UP_NEVER = 2; } enum SkipSegmentBehavior{ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5628f293..d00eed2d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -615,9 +615,11 @@ At the end of playback During end credits/outro + Never @string/next_up_playback_end @string/next_up_outro + @string/next_up_never White From a6eb6bcec928633b4fb5fb7a323b206e9e7c23ca Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Fri, 17 Apr 2026 13:20:26 -0400 Subject: [PATCH 141/148] Clarify seerr integration Clarify that Seerr integration is only for github & play store releases --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 6a3c4c65..3a915159 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,8 @@ This is not a fork of the [official client](https://github.com/jellyfin/jellyfin - Customize the home page to see the content you are interested in - Use poster or thumb images, show/hide titles, add/remove/re-order different types of rows! - A navigation drawer for quick access to libraries, favorites, search, and settings from almost anywhere in the app -- Integration with [Jellyseerr](https://github.com/seerr-team/seerr) to discover new movies and TV shows +- Integration with [Jellyseerr/Seerr](https://github.com/seerr-team/seerr) to discover new movies and TV shows + - Note: only available when installed from [GitHub](https://github.com/damontecres/Wholphin/releases/latest) or the [Play store](https://play.google.com/store/apps/details?id=com.github.damontecres.wholphin) - Option to combine Continue Watching & Next Up rows - Show Movie/TV Show titles when browsing libraries - Play theme music, if available @@ -58,7 +59,6 @@ This is not a fork of the [official client](https://github.com/jellyfin/jellyfin - Trickplay support - Subtly show playback position along the bottom of the screen while seeking w/ D-Pad - ### Roadmap See [here for the roadmap](https://github.com/damontecres/Wholphin/wiki#roadmap) From 6e58a089f4807bf2943d50b237b4c0ae57cbc72a Mon Sep 17 00:00:00 2001 From: Damontecres Date: Fri, 17 Apr 2026 13:45:34 -0400 Subject: [PATCH 142/148] Fix linting --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 3a915159..0957c4ed 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ This is not a fork of the [official client](https://github.com/jellyfin/jellyfin - Use poster or thumb images, show/hide titles, add/remove/re-order different types of rows! - A navigation drawer for quick access to libraries, favorites, search, and settings from almost anywhere in the app - Integration with [Jellyseerr/Seerr](https://github.com/seerr-team/seerr) to discover new movies and TV shows - - Note: only available when installed from [GitHub](https://github.com/damontecres/Wholphin/releases/latest) or the [Play store](https://play.google.com/store/apps/details?id=com.github.damontecres.wholphin) + - Note: only available when installed from [GitHub](https://github.com/damontecres/Wholphin/releases/latest) or the [Play store](https://play.google.com/store/apps/details?id=com.github.damontecres.wholphin) - Option to combine Continue Watching & Next Up rows - Show Movie/TV Show titles when browsing libraries - Play theme music, if available From 5050b087e13d8b49b32869dcaf2d8e56e204cdba Mon Sep 17 00:00:00 2001 From: dudecuda <94335388+dudecuda@users.noreply.github.com> Date: Sat, 18 Apr 2026 17:07:18 -0400 Subject: [PATCH 143/148] Fix trickplay image grid Y-coordinate calculation (#943) (#1259) ## Description Issue 943 where trickplay images were misaligned with seek preview mages. I made a post in issues but figured i'd go ahead and make the pull request. per gemini: In SeekPreviewImage.kt, the code calculates which tile in the grid to display based on the current timestamp: val x = (tileIndex % trickPlayInfo.tileWidth) // x position within tile grid val y = (tileIndex / trickPlayInfo.tileHeight) // y position <-- BUG IS HERE To find the correct row (y) in a grid, you are supposed to divide the current index by the width of the row (the number of columns), not the height. Why this breaks: Let's assume a standard trickplay grid that is 10 images wide (tileWidth = 10) and 5 images tall (tileHeight = 5). If you are looking at the 12th image (tileIndex = 11), it should be on Row 1 (the second row). The code calculates y = 11 / 5 = 2. It skips to Row 2! Because it divides by the smaller height value, the y coordinate increases much faster than it should. This causes the preview to visually "jump" down to future rows prematurely, and then snap back as the x coordinate resets, causing the exact "flashing back and forth" you saw with the credits and intros. ### Related issue Fixes #943 ### Testing Tested in Android TV (Android 9) and it does indeed display proper seek previews using trickplay images. ## AI or LLM usage Gemini Pro was used to find and fix this bug. --- .../damontecres/wholphin/ui/CoilTrickplayTransformation.kt | 2 +- .../github/damontecres/wholphin/ui/playback/SeekPreviewImage.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/CoilTrickplayTransformation.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/CoilTrickplayTransformation.kt index ace495bf..dc91eb3b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/CoilTrickplayTransformation.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/CoilTrickplayTransformation.kt @@ -18,7 +18,7 @@ class CoilTrickplayTransformation( val index: Int, ) : Transformation() { private val x: Int = imageIndex % numColumns - private val y: Int = imageIndex / numRows + private val y: Int = imageIndex / numColumns override val cacheKey: String get() = "CoilTrickplayTransformation_$index,$x,$y" diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekPreviewImage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekPreviewImage.kt index 8be0b509..4d8ee0e5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekPreviewImage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekPreviewImage.kt @@ -107,7 +107,7 @@ fun SeekPreviewImage( val tileIndex = index % numberOfTilesPerImage // Index of tile within the current image val x = (tileIndex % trickPlayInfo.tileWidth) // x position within tile grid - val y = (tileIndex / trickPlayInfo.tileHeight) // y position + val y = (tileIndex / trickPlayInfo.tileWidth) // y position Box( modifier = modifier From 790069d818c5d701de234aba4fb4701a8e8d1ccc Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Sun, 19 Apr 2026 14:41:54 -0400 Subject: [PATCH 144/148] Add support for external player playback (#1256) ## Description Adds a new playback "backend" to play media in another app such as VLC. By default, Wholphin will use the system default app. Typically if you haven't chosen one, a dialog will show to pick which app. You can also set a specific external app to use in Wholphin independent of the system default. Currently, this only support single item playback, ie playing next up episodes is not supported yet. Also since there is no standardized way to send resume position, external subtitle info, etc, Wholphin makes a best effort. VLC, mpv-android, & MX-Player are specifically tested/supported. ### Related issues Closes #85 ### Testing Emulator & nvidia shield w/ VLC, mpv-android, & MX-player ## Screenshots image ## AI or LLM usage None --- app/build.gradle.kts | 4 + app/src/main/AndroidManifest.xml | 4 + .../damontecres/wholphin/MainActivity.kt | 21 +- .../wholphin/preferences/AppPreference.kt | 15 + .../services/PlaybackLifecycleObserver.kt | 16 +- .../wholphin/services/PlayerFactory.kt | 6 + .../wholphin/ui/nav/DestinationContent.kt | 20 +- .../wholphin/ui/playback/PlayExternalPage.kt | 417 ++++++++++++++++++ .../wholphin/ui/playback/PlaybackViewModel.kt | 5 +- .../ui/playback/TrackSelectionUtils.kt | 4 + .../ui/preferences/ChoicePreference.kt | 69 +++ .../ui/preferences/ComposablePreference.kt | 53 +-- .../ui/preferences/PreferencesContent.kt | 44 ++ .../ui/preferences/PreferencesViewModel.kt | 59 ++- app/src/main/proto/WholphinDataStore.proto | 2 + app/src/main/res/values/strings.xml | 5 +- 16 files changed, 679 insertions(+), 65 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlayExternalPage.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/preferences/ChoicePreference.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 02b20fb9..56c80b90 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -189,6 +189,10 @@ android { isIncludeAndroidResources = true } } + + lint { + disable.add("MissingTranslation") + } } protobuf { diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8dd32ce9..e75ab500 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -30,6 +30,10 @@ + + + + >(backStackStr) - val lastDest = backStack.lastOrNull() - if (lastDest is Destination.Playback || - lastDest is Destination.PlaybackList || - lastDest is Destination.Slideshow - ) { - backStack = backStack.toMutableList().apply { removeAt(lastIndex) } + if (!savedInstanceState.getBoolean(KEY_EXTERNAL_PLAYER)) { + val lastDest = backStack.lastOrNull() + if (lastDest is Destination.Playback || + lastDest is Destination.PlaybackList || + lastDest is Destination.Slideshow + ) { + Timber.v("Restoring back stack with playback") + backStack = backStack.toMutableList().apply { removeAt(lastIndex) } + } } navigationManager.backStack = NavBackStack(*backStack.toTypedArray()) } else { @@ -314,6 +319,9 @@ class MainActivity : AppCompatActivity() { Timber.d("onSaveInstanceState") val str = json.encodeToString(navigationManager.backStack.toList()) outState.putString(KEY_BACK_STACK, str) + val playerBackend = + runBlocking { userPreferencesDataStore.data.firstOrNull() }?.playbackPreferences?.playerBackend + outState.putBoolean(KEY_EXTERNAL_PLAYER, playerBackend == PlayerBackend.EXTERNAL_PLAYER) } override fun onRestoreInstanceState(savedInstanceState: Bundle) { @@ -390,6 +398,7 @@ class MainActivity : AppCompatActivity() { const val INTENT_SEASON_ID = "seaId" private const val KEY_BACK_STACK = "backStack" + private const val KEY_EXTERNAL_PLAYER = "extPlayer" lateinit var instance: MainActivity private set diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt index 4463dd97..9ca6df06 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt @@ -830,6 +830,17 @@ sealed interface AppPreference { valueToIndex = { if (it != PlayerBackend.UNRECOGNIZED) it.number else PlayerBackend.EXO_PLAYER.number }, ) + val ExternalPlayerApp = + AppStringPreference( + title = R.string.external_player, + defaultValue = "", + getter = { it.playbackPreferences.externalPlayer }, + setter = { prefs, value -> + prefs.updatePlaybackPreferences { externalPlayer = value } + }, + summary = null, + ) + val ExoPlayerSettings = AppDestinationPreference( title = R.string.exoplayer_options, @@ -1199,6 +1210,10 @@ val advancedPreferences = AppPreference.MpvSettings, ), ), + ConditionalPreferences( + { it.playbackPreferences.playerBackend == PlayerBackend.EXTERNAL_PLAYER }, + listOf(AppPreference.ExternalPlayerApp), + ), ), ), ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/PlaybackLifecycleObserver.kt b/app/src/main/java/com/github/damontecres/wholphin/services/PlaybackLifecycleObserver.kt index 4be082a5..66fb851c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/PlaybackLifecycleObserver.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/PlaybackLifecycleObserver.kt @@ -2,7 +2,6 @@ package com.github.damontecres.wholphin.services import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner -import com.github.damontecres.wholphin.ui.nav.Destination import dagger.hilt.android.scopes.ActivityRetainedScoped import javax.inject.Inject @@ -20,13 +19,14 @@ class PlaybackLifecycleObserver private var wasPlaying: Boolean? = null override fun onStart(owner: LifecycleOwner) { - val lastDest = navigationManager.backStack.lastOrNull() - if (lastDest is Destination.Playback || - lastDest is Destination.PlaybackList || - lastDest is Destination.Slideshow - ) { - navigationManager.goBack() - } + // TODO +// val lastDest = navigationManager.backStack.lastOrNull() +// if (lastDest is Destination.Playback || +// lastDest is Destination.PlaybackList || +// lastDest is Destination.Slideshow +// ) { +// navigationManager.goBack() +// } wasPlaying = null } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/PlayerFactory.kt b/app/src/main/java/com/github/damontecres/wholphin/services/PlayerFactory.kt index 335438e5..f8d5ad2b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/PlayerFactory.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/PlayerFactory.kt @@ -9,6 +9,7 @@ import androidx.annotation.OptIn import androidx.datastore.core.DataStore import androidx.media3.common.C import androidx.media3.common.Player +import androidx.media3.common.util.ExperimentalApi import androidx.media3.common.util.UnstableApi import androidx.media3.datasource.DefaultDataSource import androidx.media3.exoplayer.DefaultRenderersFactory @@ -126,6 +127,10 @@ class PlayerFactory assHandler?.init(this) } } + + PlayerBackend.EXTERNAL_PLAYER -> { + throw IllegalArgumentException("Cannot create a player for external playback") + } } currentPlayer = newPlayer return PlayerCreation(newPlayer, assHandler) @@ -151,6 +156,7 @@ class WholphinRenderersFactory( context: Context, private val av1Enabled: Boolean, ) : DefaultRenderersFactory(context) { + @OptIn(ExperimentalApi::class) override fun buildVideoRenderers( context: Context, extensionRendererMode: Int, 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 1aa92bb3..f6f3faa7 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 @@ -8,6 +8,7 @@ import androidx.tv.material3.Text import com.github.damontecres.wholphin.data.filter.DefaultForGenresFilterOptions import com.github.damontecres.wholphin.data.filter.DefaultForStudiosFilterOptions import com.github.damontecres.wholphin.data.model.SeerrItemType +import com.github.damontecres.wholphin.preferences.PlayerBackend import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.components.ItemGrid import com.github.damontecres.wholphin.ui.components.LicenseInfo @@ -40,6 +41,7 @@ import com.github.damontecres.wholphin.ui.discover.DiscoverRequestGrid import com.github.damontecres.wholphin.ui.main.HomePage import com.github.damontecres.wholphin.ui.main.SearchPage import com.github.damontecres.wholphin.ui.main.settings.HomeSettingsPage +import com.github.damontecres.wholphin.ui.playback.PlayExternalPage import com.github.damontecres.wholphin.ui.playback.PlaybackPage import com.github.damontecres.wholphin.ui.preferences.PreferencesPage import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleStylePage @@ -77,11 +79,19 @@ fun DestinationContent( is Destination.PlaybackList, is Destination.Playback, -> { - PlaybackPage( - preferences = preferences, - destination = destination, - modifier = modifier, - ) + if (preferences.appPreferences.playbackPreferences.playerBackend == PlayerBackend.EXTERNAL_PLAYER) { + PlayExternalPage( + preferences = preferences, + destination = destination, + modifier = modifier, + ) + } else { + PlaybackPage( + preferences = preferences, + destination = destination, + modifier = modifier, + ) + } } is Destination.Settings -> { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlayExternalPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlayExternalPage.kt new file mode 100644 index 00000000..c14d184d --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlayExternalPage.kt @@ -0,0 +1,417 @@ +package com.github.damontecres.wholphin.ui.playback + +import android.app.Activity +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.ActivityResult +import androidx.activity.result.contract.ActivityResultContracts +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.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.core.net.toUri +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.compose.LifecycleStartEffect +import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.data.ItemPlaybackDao +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.NavigationManager +import com.github.damontecres.wholphin.services.PlaylistCreationResult +import com.github.damontecres.wholphin.services.PlaylistCreator +import com.github.damontecres.wholphin.services.StreamChoiceService +import com.github.damontecres.wholphin.services.UserPreferencesService +import com.github.damontecres.wholphin.ui.components.ErrorMessage +import com.github.damontecres.wholphin.ui.components.LoadingPage +import com.github.damontecres.wholphin.ui.indexOfFirstOrNull +import com.github.damontecres.wholphin.ui.isNotNullOrBlank +import com.github.damontecres.wholphin.ui.launchDefault +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.preferences.getExternalPlayers +import com.github.damontecres.wholphin.ui.showToast +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.CancellationException +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.playStateApi +import org.jellyfin.sdk.api.client.extensions.subtitleApi +import org.jellyfin.sdk.api.client.extensions.userLibraryApi +import org.jellyfin.sdk.api.client.extensions.videosApi +import org.jellyfin.sdk.model.api.MediaStream +import org.jellyfin.sdk.model.api.PlaybackStopInfo +import org.jellyfin.sdk.model.extensions.inWholeTicks +import org.jellyfin.sdk.model.extensions.ticks +import timber.log.Timber +import java.io.File +import java.util.UUID +import kotlin.time.Duration.Companion.milliseconds + +@HiltViewModel(assistedFactory = PlayExternalViewModel.Factory::class) +class PlayExternalViewModel + @AssistedInject + constructor( + private val savedStateHandle: SavedStateHandle, + @param:ApplicationContext private val context: Context, + private val api: ApiClient, + private val serverRepository: ServerRepository, + private val itemPlaybackDao: ItemPlaybackDao, + private val playlistCreator: PlaylistCreator, + private val streamChoiceService: StreamChoiceService, + private val navigationManager: NavigationManager, + private val userPreferencesService: UserPreferencesService, + @Assisted val destination: Destination, + ) : ViewModel() { + @AssistedFactory + interface Factory { + fun create(destination: Destination): PlayExternalViewModel + } + + val state = MutableStateFlow(PlayExternalState()) + + fun init() { + viewModelScope.launchDefault { + val prefs = userPreferencesService.getCurrent() + val positionMs: Long + val itemId = + when (val d = destination) { + is Destination.Playback -> { + positionMs = d.positionMs + d.itemId + } + + is Destination.PlaybackList -> { + positionMs = 0 + d.itemId + } + + else -> { + throw IllegalArgumentException("Destination not supported: $destination") + } + } + try { + val queriedItem = api.userLibraryApi.getItem(itemId).content + val base = + if (queriedItem.type.playable) { + queriedItem + } else if (destination is Destination.PlaybackList) { + val playlistResult = + playlistCreator.createFrom( + item = queriedItem, + startIndex = destination.startIndex ?: 0, + sortAndDirection = destination.sortAndDirection, + shuffled = destination.shuffle, + recursive = destination.recursive, + filter = destination.filter, + ) + when (val r = playlistResult) { + is PlaylistCreationResult.Error -> { + state.update { + it.copy( + loading = LoadingState.Error(r.message, r.ex), + ) + } + return@launchDefault + } + + is PlaylistCreationResult.Success -> { + if (r.playlist.items.isEmpty()) { + showToast(context, "Playlist is empty", Toast.LENGTH_SHORT) + navigationManager.goBack() + return@launchDefault + } + r.playlist.items + .first() + .data + } + } + } else { + throw IllegalArgumentException("Item is not playable and not PlaybackList: ${queriedItem.type}") + } + val item = BaseItem(base, false) + val playbackConfig = + serverRepository.currentUser.value?.let { user -> + itemPlaybackDao.getItem(user, base.id)?.let { + Timber.v("Fetched itemPlayback from DB: %s", it) + if (it.sourceId != null) { + it + } else { + null + } + } + } + val mediaSource = streamChoiceService.chooseSource(base, playbackConfig) + val plc = streamChoiceService.getPlaybackLanguageChoice(base) + if (mediaSource == null) { + Timber.w("Media source is null") + return@launchDefault + } + savedStateHandle[KEY_ID] = base.id + savedStateHandle[KEY_MEDIA_ID] = mediaSource.id + val subtitleIndex = + streamChoiceService + .chooseSubtitleStream( + source = mediaSource, + audioStream = null, + seriesId = base.seriesId, + itemPlayback = playbackConfig, + plc = plc, + prefs = prefs, + )?.index + val externalSubtitles = + mediaSource.mediaStreams + ?.filter { it.isExternal } + ?.sortedWith(compareBy { it.index == subtitleIndex }.thenBy { it.isDefault }) + .orEmpty() + val subtitleUrls = + externalSubtitles.map { + val format = it.path?.let { File(it).extension } ?: "srt" + api.subtitleApi + .getSubtitleUrl( + routeItemId = itemId, + routeMediaSourceId = mediaSource.id!!, + routeIndex = it.index, + routeFormat = format, + ).toUri() + } + + val uri = + api.videosApi + .getVideoStreamUrl( + itemId = item.id, + mediaSourceId = mediaSource.id, + static = true, + ).toUri() + val playerId = prefs.appPreferences.playbackPreferences.externalPlayer + // Make sure player is available, user could have uninstalled it + val foundPlayer = + getExternalPlayers(context).firstOrNull { it.identifier == playerId } != null + val component = + if (playerId.isNotNullOrBlank() && foundPlayer) { + ComponentName.unflattenFromString(playerId) + } else { + null + } + Timber.v("playerId=%s, component=%s", playerId, component) + val title = "${item.title} ${item.subtitleLong}" + val intent = + Intent(Intent.ACTION_VIEW).apply { + setComponent(component) + setDataAndTypeAndNormalize(uri, "video/*") + putExtra("title", title) + putExtra("position", positionMs.toInt()) + + // MX/mpv + putExtra("return_result", true) + putExtra("secure_uri", true) + putExtra("subs", subtitleUrls.toTypedArray()) + putExtra( + "subs.name", + externalSubtitles + .map { it.displayTitle ?: it.index.toString() } + .toTypedArray(), + ) + if (subtitleIndex != null) { + externalSubtitles + .indexOfFirstOrNull { it.index == subtitleIndex } + ?.let { + putExtra("subs.enable", arrayOf(subtitleUrls[it])) + } + } + + // VLC + if (subtitleUrls.isNotEmpty()) { + putExtra("subtitles_location", subtitleUrls.first().toString()) + } + mediaSource.runTimeTicks?.ticks?.inWholeMilliseconds?.let { + putExtra("extra_duration", it) + } + + // Vimu - https://vimu.tv/player-api/ + putExtra("startfrom", positionMs.toInt()) + putExtra("forceresume", false) + putExtra("forcename", title) + externalSubtitles + .indexOfFirstOrNull { it.index == subtitleIndex && it.codec == "srt" } + ?.let { + putExtra("forcedsrt", subtitleUrls[it]) + } + } + + state.update { + PlayExternalState( + loading = LoadingState.Success, + intent = intent, + ) + } + } catch (ex: Exception) { + Timber.e(ex, "Error for destination %s", destination) + state.update { + it.copy(loading = LoadingState.Error(ex)) + } + } + } + } + + fun onResult(result: ActivityResult) { + viewModelScope.launchDefault { + val itemId = savedStateHandle.get(KEY_ID) + try { + val mediaSourceId = savedStateHandle.get(KEY_MEDIA_ID) + if (itemId == null) { + Timber.w("itemId is null") + return@launchDefault + } + Timber.v( + "Result: result=%s, itemId=%s action=%s", + result.resultCode, + itemId, + result.data?.action, + ) + if (result.resultCode == Activity.RESULT_OK || result.resultCode == Activity.RESULT_CANCELED || + // Vimu return 1 for video completion + (result.data?.action == "net.gtvbox.videoplayer.result" && result.resultCode == 1) + ) { + val position: Long? + val data = result.data + when (data?.action) { + // VLC: https://wiki.videolan.org/Android_Player_Intents/ + "org.videolan.vlc.player.result" -> { + position = + data + .getLongExtra("extra_position", Long.MIN_VALUE) + .takeIf { it >= 0 } + } + + // mpv-android: https://mpv-android.github.io/mpv-android/intent.html + "is.xyz.mpv.MPVActivity.result", + // MX player: https://mx.j2inter.com/api + "com.mxtech.intent.result.VIEW", + // VIMU: https://vimu.tv/player-api/ + "net.gtvbox.videoplayer.result", + -> { + position = + data + .getIntExtra("position", Int.MIN_VALUE) + .toLong() + .takeIf { it >= 0 } + } + + else -> { + // Unsupported app + val posInt = + data + ?.getIntExtra("position", Int.MIN_VALUE) + ?.takeIf { it >= 0 } + ?.toLong() + position = + posInt ?: data + ?.getLongExtra("position", -1L) + ?.takeIf { it >= 0 } + } + } + Timber.v("Result position: %s", position?.milliseconds) + api.playStateApi.reportPlaybackStopped( + PlaybackStopInfo( + itemId = itemId, + mediaSourceId = mediaSourceId, + positionTicks = position?.milliseconds?.inWholeTicks, + failed = false, + ), + ) + } else { + Timber.w("Activity result: %s", result.resultCode) + showToast(context, "Unknown result from external player") + } + navigationManager.goBack() + } catch (_: CancellationException) { + } catch (ex: Exception) { + Timber.e(ex, "Error during external playback of %s", itemId) + state.update { it.copy(loading = LoadingState.Error(ex)) } + } + } + } + + fun reportException(ex: Exception) { + Timber.e(ex, "Error launching activity") + state.update { it.copy(loading = LoadingState.Error(ex)) } + } + + companion object { + private const val KEY_ID = "itemId" + private const val KEY_MEDIA_ID = "mediaId" + } + } + +data class PlayExternalState( + val loading: LoadingState = LoadingState.Loading, + val intent: Intent = Intent(), +) + +@Composable +fun PlayExternalPage( + preferences: UserPreferences, + destination: Destination, + modifier: Modifier = Modifier, + viewModel: PlayExternalViewModel = + hiltViewModel( + creationCallback = { it.create(destination) }, + ), +) { + val launcher = + rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult(), + onResult = viewModel::onResult, + ) + + val state by viewModel.state.collectAsState() + var launched by rememberSaveable { mutableStateOf(false) } + if (!launched) { + LaunchedEffect(Unit) { + viewModel.init() + } + } + + when (val l = state.loading) { + LoadingState.Pending, + LoadingState.Loading, + -> { + LoadingPage(modifier) + } + + is LoadingState.Error -> { + ErrorMessage(l, modifier) + } + + LoadingState.Success -> { + LoadingPage(modifier) + if (!launched) { + LifecycleStartEffect(Unit) { + Timber.i("Launching external playback") + launched = true + try { + launcher.launch(state.intent) + } catch (ex: Exception) { + viewModel.reportException(ex) + } + onStopOrDispose { } + } + } + } + } +} 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 f6bc9018..9bb0e999 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 @@ -185,7 +185,7 @@ class PlaybackViewModel private val jobs = mutableListOf() val nextUp = MutableLiveData() - private var isPlaylist = false + private val isPlaylist = destination is Destination.PlaybackList val playlist = MutableLiveData(Playlist(listOf())) val subtitleSearchStatus = MutableLiveData(null) @@ -233,6 +233,8 @@ class PlaybackViewModel PlayerBackend.MPV -> PlayerBackend.MPV PlayerBackend.PREFER_MPV -> if (isHdr || (is4k && softwareDecoding)) PlayerBackend.EXO_PLAYER else PlayerBackend.MPV + + PlayerBackend.EXTERNAL_PLAYER -> throw IllegalStateException("Cannot use this for external playback") } Timber.d("Selected backend: %s", playerBackend) @@ -315,7 +317,6 @@ class PlaybackViewModel if (queriedItem.type.playable) { queriedItem } else if (destination is Destination.PlaybackList) { - isPlaylist = true val playlistResult = playlistCreator.createFrom( item = queriedItem, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/TrackSelectionUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/TrackSelectionUtils.kt index d056e6f3..b7566482 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/TrackSelectionUtils.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/TrackSelectionUtils.kt @@ -201,6 +201,10 @@ object TrackSelectionUtils { } } } + + PlayerBackend.EXTERNAL_PLAYER -> { + throw IllegalStateException("Cannot calculate tracks external playback") + } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/ChoicePreference.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/ChoicePreference.kt new file mode 100644 index 00000000..b21479c8 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/ChoicePreference.kt @@ -0,0 +1,69 @@ +package com.github.damontecres.wholphin.ui.preferences + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.interaction.MutableInteractionSource +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.tv.material3.Text +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.SelectedLeadingContent + +@Composable +fun ChoicePreference( + title: String, + summary: String?, + possibleValues: List, + selectedIndex: Int, + onValueChange: (Int) -> Unit, + modifier: Modifier = Modifier, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + valueDisplay: @Composable (index: Int, item: T) -> Unit = { _, item -> Text(item.toString()) }, + subtitleDisplay: (index: Int, item: T) -> @Composable (() -> Unit)? = { _, _ -> null }, +) { + var dialogParams by remember { mutableStateOf(null) } + ClickPreference( + title = title, + summary = summary, + onClick = { + dialogParams = + DialogParams( + title = title, + fromLongClick = false, + items = + possibleValues.mapIndexed { index, item -> + DialogItem( + headlineContent = { valueDisplay.invoke(index, item) }, + leadingContent = { + SelectedLeadingContent(index == selectedIndex) + }, + supportingContent = subtitleDisplay.invoke(index, item), + onClick = { + onValueChange.invoke(index) + dialogParams = null + }, + ) + }, + ) + }, + interactionSource = interactionSource, + modifier = modifier, + ) + AnimatedVisibility(dialogParams != null) { + dialogParams?.let { + DialogPopup( + showDialog = true, + title = it.title, + dialogItems = it.items, + onDismissRequest = { dialogParams = null }, + waitToLoad = false, + dismissOnClick = false, + ) + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/ComposablePreference.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/ComposablePreference.kt index 1762f916..6d96667a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/ComposablePreference.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/ComposablePreference.kt @@ -3,8 +3,6 @@ package com.github.damontecres.wholphin.ui.preferences import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Done import androidx.compose.runtime.Composable import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.runtime.getValue @@ -23,7 +21,6 @@ import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp -import androidx.tv.material3.Icon import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import androidx.tv.material3.surfaceColorAtElevation @@ -35,7 +32,6 @@ import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppSliderPreference import com.github.damontecres.wholphin.preferences.AppStringPreference import com.github.damontecres.wholphin.preferences.AppSwitchPreference -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.isNotNullOrBlank @@ -181,46 +177,23 @@ fun ComposablePreference( .valueToIndex(value as T) .let { values[it] } val selectedIndex = remember(value) { preference.valueToIndex.invoke(value as T) } - ClickPreference( + ChoicePreference( title = title, summary = summary, - onClick = { - dialogParams = - DialogParams( - title = title, - fromLongClick = false, - items = - values.mapIndexed { index, it -> - DialogItem( - headlineContent = { - Text(it) - }, - leadingContent = { - if (index == selectedIndex) { - Icon( - imageVector = Icons.Default.Done, - contentDescription = "selected", - ) - } - }, - supportingContent = { - subtitles?.let { - val text = subtitles[index] - if (text.isNotNullOrBlank()) { - Text(text) - } - } - }, - onClick = { - onValueChange(preference.indexToValue(index)) - dialogParams = null - }, - ) - }, - ) + possibleValues = values, + selectedIndex = selectedIndex, + onValueChange = { index -> + onValueChange(preference.indexToValue(index)) }, - interactionSource = interactionSource, modifier = modifier, + interactionSource = interactionSource, + subtitleDisplay = { index, _ -> + subtitles?.getOrNull(index)?.takeIf { it.isNotNullOrBlank() }?.let { + { + Text(text = it) + } + } + }, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt index 18b630f6..a4234f17 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt @@ -6,6 +6,7 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideOutHorizontally +import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsFocusedAsState @@ -13,10 +14,12 @@ 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.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable @@ -62,6 +65,7 @@ import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.components.ScrollableDialog import com.github.damontecres.wholphin.ui.ifElse +import com.github.damontecres.wholphin.ui.indexOfFirstOrNull import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.playOnClickSound @@ -99,6 +103,7 @@ fun PreferencesContent( val currentServer by seerrVm.currentSeerrServer.collectAsState(null) var showPinFlow by remember { mutableStateOf(false) } var showVersionDialog by remember { mutableStateOf(false) } + val players by viewModel.externalPlayers.collectAsState() var cacheUsage by remember { mutableStateOf(CacheUsage(0, 0, 0)) } val seerrConnection by viewModel.seerrConnection.collectAsState() @@ -471,6 +476,45 @@ fun PreferencesContent( ) } + AppPreference.ExternalPlayerApp -> { + val value = pref.getter.invoke(preferences).toString() + val selectedIndex = + remember(value, players) { + players.indexOfFirstOrNull { it.identifier == value } + } ?: 0 + ChoicePreference( + title = stringResource(pref.title), + summary = players[selectedIndex].name, + possibleValues = players, + selectedIndex = selectedIndex, + onValueChange = { index -> + scope.launch(ExceptionHandler()) { + val newValue = + players.getOrNull(index)?.identifier ?: "" + preferences = + viewModel.preferenceDataStore.updateData { prefs -> + pref.setter.invoke(prefs, newValue) + } + } + }, + valueDisplay = { index, item -> + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (item.icon != null) { + Image( + bitmap = item.icon, + contentDescription = null, + modifier = Modifier.width(40.dp), + ) + } + Text(item.name) + } + }, + ) + } + else -> { val value = pref.getter.invoke(preferences) ComposablePreference( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt index 80dd7934..32d085a5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt @@ -1,9 +1,17 @@ package com.github.damontecres.wholphin.ui.preferences +import android.content.ComponentName import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap +import androidx.core.graphics.drawable.toBitmap +import androidx.core.net.toUri import androidx.datastore.core.DataStore import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.preferences.AppPreferences @@ -59,11 +67,22 @@ class PreferencesViewModel val releaseNotes = MutableStateFlow>(DataLoadingState.Pending) + val externalPlayers = MutableStateFlow>(emptyList()) + init { viewModelScope.launchIO { - serverRepository.currentUser.value?.let { user -> -// fetchNavDrawerPins(user) - } + val fakeIntent = + Intent(Intent.ACTION_VIEW).apply { + setDataAndType("https://damontecres.com/video.mp4".toUri(), "video/*") + } + val externalPlayers = getExternalPlayers(context) + val systemDefault = + ExternalPlayerApp( + name = context.getString(R.string.system_default), + icon = null, + identifier = "", + ) + this@PreferencesViewModel.externalPlayers.update { listOf(systemDefault) + externalPlayers } } } @@ -134,3 +153,37 @@ class PreferencesViewModel } } } + +data class ExternalPlayerApp( + val name: String, + val icon: ImageBitmap?, + val identifier: String, +) + +fun getExternalPlayers(context: Context): List { + val fakeIntent = + Intent(Intent.ACTION_VIEW).apply { + setDataAndType("https://damontecres.com/video.mp4".toUri(), "video/*") + } + val externalPlayers = + context.packageManager + .queryIntentActivities(fakeIntent, PackageManager.MATCH_ALL) + .filter { it.priority >= 0 } + .map { + val component = + ComponentName( + it.activityInfo.packageName, + it.activityInfo.name, + ) + ExternalPlayerApp( + name = it.loadLabel(context.packageManager).toString(), + icon = + it + .loadIcon(context.packageManager) + .toBitmap() + .asImageBitmap(), + identifier = component.flattenToString(), + ) + } + return externalPlayers +} diff --git a/app/src/main/proto/WholphinDataStore.proto b/app/src/main/proto/WholphinDataStore.proto index 60dd3943..7b7b543b 100644 --- a/app/src/main/proto/WholphinDataStore.proto +++ b/app/src/main/proto/WholphinDataStore.proto @@ -34,6 +34,7 @@ enum PlayerBackend{ EXO_PLAYER = 0; MPV = 1; PREFER_MPV = 2; + EXTERNAL_PLAYER = 3; } message MpvOptions{ @@ -84,6 +85,7 @@ message PlaybackPreferences { MpvOptions mpv_options = 21; bool refresh_rate_switching = 22; bool resolution_switching = 23; + string external_player = 24; } message HomePagePreferences{ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d00eed2d..8857efff 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -660,10 +660,12 @@ ExoPlayer MPV Prefer MPV + External Player @string/exoplayer @string/mpv @string/prefer_mpv + @string/external_player Use ExoPlayer for HDR playback @@ -671,6 +673,7 @@ @string/player_backend_options_subtitles_prefer_mpv + Poster (2:3) @@ -759,7 +762,7 @@ Discover Movies Studios in %1$s Last played - + System default Direct play with libass Direct play with ExoPlayer built-in Burn in/transcode on server From 08836e3c5d8a43acbb2b08f21980eb7dd8bdd9c2 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Sun, 19 Apr 2026 15:10:15 -0400 Subject: [PATCH 145/148] Update ExoPlayer with better audio configuration (#1258) ## Description Makes some changes when building ExoPlayer to help improve pass through audio. The major changes are enabled constant bitrate seeking and audio offloading. Documentation is thin on these features, but [this blog post](https://www.qualcomm.com/developer/blog/2023/08/audio-offload-support-exoplayer) while focused on ExoPlayer2 and power usage, does explain a bit about audio offloading. Small section on constant bitrate seeking: https://developer.android.com/media/media3/exoplayer/customization#enabling-constant-bitrate-seeking ### Related issues N/A ### Testing Emulator, nvidia shield w/ AVR to check pass through ## Screenshots N/A ## AI or LLM usage None --- .../wholphin/services/MusicService.kt | 25 +----- .../wholphin/services/PlayerFactory.kt | 78 ++++++++++++++++++- 2 files changed, 77 insertions(+), 26 deletions(-) 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 index ff7699a6..6eddb51c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt @@ -5,20 +5,14 @@ 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 @@ -44,7 +38,6 @@ 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 @@ -74,9 +67,9 @@ 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 playerFactory: PlayerFactory, private val serverRepository: ServerRepository, private val imageUrlService: ImageUrlService, ) { @@ -86,21 +79,9 @@ class MusicService private val audioFormats by lazy { listOf(*supportedAudioCodecs) } val player: Player by lazy { - ExoPlayer - .Builder(context) - .setMediaSourceFactory( - DefaultMediaSourceFactory( - OkHttpDataSource.Factory(authOkHttpClient), - ), - ).build() + playerFactory + .createAudioPlayer() .also { - it.setAudioAttributes( - AudioAttributes - .Builder() - .setContentType(C.AUDIO_CONTENT_TYPE_MUSIC) - .build(), - false, - ) it.addListener(MusicPlayerListener(it, _state)) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/PlayerFactory.kt b/app/src/main/java/com/github/damontecres/wholphin/services/PlayerFactory.kt index f8d5ad2b..da00fd3d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/PlayerFactory.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/PlayerFactory.kt @@ -6,26 +6,29 @@ import android.content.Context import android.os.Build import android.os.Handler import androidx.annotation.OptIn -import androidx.datastore.core.DataStore +import androidx.media3.common.AudioAttributes import androidx.media3.common.C import androidx.media3.common.Player +import androidx.media3.common.TrackSelectionParameters.AudioOffloadPreferences import androidx.media3.common.util.ExperimentalApi import androidx.media3.common.util.UnstableApi import androidx.media3.datasource.DefaultDataSource +import androidx.media3.datasource.okhttp.OkHttpDataSource import androidx.media3.exoplayer.DefaultRenderersFactory import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.Renderer import androidx.media3.exoplayer.RenderersFactory import androidx.media3.exoplayer.mediacodec.MediaCodecSelector import androidx.media3.exoplayer.source.DefaultMediaSourceFactory +import androidx.media3.exoplayer.trackselection.DefaultTrackSelector import androidx.media3.exoplayer.video.MediaCodecVideoRenderer import androidx.media3.exoplayer.video.VideoRendererEventListener import androidx.media3.extractor.DefaultExtractorsFactory -import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AssPlaybackMode import com.github.damontecres.wholphin.preferences.MediaExtensionStatus import com.github.damontecres.wholphin.preferences.PlaybackPreferences import com.github.damontecres.wholphin.preferences.PlayerBackend +import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient import com.github.damontecres.wholphin.util.mpv.MpvPlayer import dagger.hilt.android.qualifiers.ApplicationContext import io.github.peerless2012.ass.media.AssHandler @@ -35,6 +38,7 @@ import io.github.peerless2012.ass.media.parser.AssSubtitleParserFactory import io.github.peerless2012.ass.media.type.AssRenderType import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient import timber.log.Timber import java.lang.reflect.Constructor import javax.inject.Inject @@ -48,7 +52,7 @@ class PlayerFactory @Inject constructor( @param:ApplicationContext private val context: Context, - private val appPreferences: DataStore, + @param:AuthOkHttpClient private val authOkHttpClient: OkHttpClient, ) { @Volatile var currentPlayer: Player? = null @@ -95,7 +99,7 @@ class PlayerFactory else -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON } val dataSourceFactory = DefaultDataSource.Factory(context) - val extractorsFactory = DefaultExtractorsFactory() + val extractorsFactory = createExtractorsFactory() var renderersFactory: RenderersFactory = WholphinRenderersFactory(context, decodeAv1) .setEnableDecoderFallback(true) @@ -118,13 +122,25 @@ class PlayerFactory extractorsFactory, ) } + val trackSelector = createTrackSelector() + ExoPlayer .Builder(context) .setMediaSourceFactory(mediaSourceFactory) .setRenderersFactory(renderersFactory) + .setTrackSelector(trackSelector) .build() .apply { assHandler?.init(this) + withContext(Dispatchers.Main) { + setAudioAttributes( + AudioAttributes + .Builder() + .setContentType(C.AUDIO_CONTENT_TYPE_MOVIE) + .build(), + false, + ) + } } } @@ -135,6 +151,60 @@ class PlayerFactory currentPlayer = newPlayer return PlayerCreation(newPlayer, assHandler) } + + fun createAudioPlayer(extensions: MediaExtensionStatus = MediaExtensionStatus.MES_FALLBACK): ExoPlayer { + val rendererMode = + when (extensions) { + MediaExtensionStatus.MES_FALLBACK -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON + MediaExtensionStatus.MES_PREFERRED -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER + MediaExtensionStatus.MES_DISABLED -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF + else -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON + } + val extractorsFactory = createExtractorsFactory() + val renderersFactory: RenderersFactory = + WholphinRenderersFactory(context, false) + .setEnableDecoderFallback(true) + .setExtensionRendererMode(rendererMode) + val mediaSourceFactory = + DefaultMediaSourceFactory( + OkHttpDataSource.Factory(authOkHttpClient), + extractorsFactory, + ) + val trackSelector = createTrackSelector() + return ExoPlayer + .Builder(context) + .setMediaSourceFactory(mediaSourceFactory) + .setRenderersFactory(renderersFactory) + .setTrackSelector(trackSelector) + .build() + .also { + it.setAudioAttributes( + AudioAttributes + .Builder() + .setContentType(C.AUDIO_CONTENT_TYPE_MUSIC) + .build(), + false, + ) + } + } + + private fun createExtractorsFactory() = + DefaultExtractorsFactory() + .setConstantBitrateSeekingEnabled(true) + .setConstantBitrateSeekingAlwaysEnabled(true) + + private fun createTrackSelector() = + DefaultTrackSelector(context).apply { + setParameters( + buildUponParameters() + .setAudioOffloadPreferences( + AudioOffloadPreferences + .Builder() + .setAudioOffloadMode(AudioOffloadPreferences.AUDIO_OFFLOAD_MODE_ENABLED) + .build(), + ), + ) + } } val Player.isReleased: Boolean From 29a0ddaeb2c4cb497800765c05868ed377e6fcf7 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 19 Apr 2026 15:10:28 -0400 Subject: [PATCH 146/148] Update Dependencies (#1221) --- 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 df0d6731..2b9e9b25 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -aboutLibraries = "14.0.0" +aboutLibraries = "14.0.1" acra = "5.13.1" agp = "9.0.1" auto-service = "1.1.1" @@ -20,14 +20,14 @@ okhttpBom = "5.3.2" programguide = "1.6.0" slf4j2Timber = "1.2" timber = "5.0.1" -tvFoundation = "1.0.0-beta01" +tvFoundation = "1.0.0-rc01" tvMaterial = "1.0.1" lifecycleRuntimeKtx = "2.10.0" activityCompose = "1.13.0" androidx-media3 = "1.10.0" coil = "3.4.0" jellyfin-sdk = "1.7.1" -nav3Core = "1.0.1" +nav3Core = "1.1.0" lifecycleViewmodelNav3 = "2.10.0" material3AdaptiveNav3 = "1.0.0-alpha03" protobuf = "0.9.6" From 27310d71b41b185b37039c8e60b5510a5c5139e5 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 19 Apr 2026 15:11:08 -0400 Subject: [PATCH 147/148] Update Kotlin serialization to v1.11.0 (#1224) --- 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 2b9e9b25..0235f007 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -32,7 +32,7 @@ lifecycleViewmodelNav3 = "2.10.0" material3AdaptiveNav3 = "1.0.0-alpha03" protobuf = "0.9.6" datastore = "1.2.1" -kotlinx-serialization = "1.10.0" +kotlinx-serialization = "1.11.0" protobuf-javalite = "4.34.1" hilt = "2.59.2" room = "2.8.4" From 61afe6ba86e77bd0704765144b1b02992d4684b3 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Sun, 19 Apr 2026 15:20:46 -0400 Subject: [PATCH 148/148] Port two changes to ExoPlayer device profile from the official app (#1263) ## Description Ports two changes in the official jellyfin android TV app here: - https://github.com/jellyfin/jellyfin-androidtv/pull/5240 - https://github.com/jellyfin/jellyfin-androidtv/pull/5186 The code changes are copied under GPL-2.0 from the above pull requests. ### Related issues Closes #1260 ### Testing Emulator mostly ## Screenshots N/A ## AI or LLM usage None --------- Co-authored-by: Niels van Velzen Co-authored-by: clams4shoes --- .../util/profile/DeviceProfileUtils.kt | 5 +- .../profile/MediaCodecCapabilitiesTest.kt | 84 +++++++++++++------ 2 files changed, 60 insertions(+), 29 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/profile/DeviceProfileUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/util/profile/DeviceProfileUtils.kt index 6272af5a..2de5310e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/profile/DeviceProfileUtils.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/profile/DeviceProfileUtils.kt @@ -153,9 +153,10 @@ fun createDeviceProfile( type = DlnaProfileType.AUDIO context = EncodingContext.STREAMING - container = Codec.Container.MP3 + container = Codec.Container.TS + protocol = MediaStreamProtocol.HLS - audioCodec(Codec.Audio.MP3) + audioCodec(Codec.Audio.AAC) } // / Direct play profiles diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/profile/MediaCodecCapabilitiesTest.kt b/app/src/main/java/com/github/damontecres/wholphin/util/profile/MediaCodecCapabilitiesTest.kt index 23a3cc0c..ab4de03e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/profile/MediaCodecCapabilitiesTest.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/profile/MediaCodecCapabilitiesTest.kt @@ -1,6 +1,6 @@ package com.github.damontecres.wholphin.util.profile -// Copied from https://github.com/jellyfin/jellyfin-androidtv/blob/v0.19.4/app/src/main/java/org/jellyfin/androidtv/util/profile/MediaCodecCapabilitiesTest.kt +// Copied from https://github.com/jellyfin/jellyfin-androidtv/blob/v0.19.7/app/src/main/java/org/jellyfin/androidtv/util/profile/MediaCodecCapabilitiesTest.kt import android.content.Context import android.media.MediaCodecInfo.CodecProfileLevel @@ -41,11 +41,46 @@ class MediaCodecCapabilitiesTest( -1 } } - val Profile10: Int by lazy { + } + + // Some devices (e.g., Fire OS) may support AV1 below the official API level + // Use the platform constant if the API level is met; otherwise fall back to the literal value + // Reference: + // https://cs.android.com/android/platform/superproject/main/+/main:frameworks/base/media/java/android/media/MediaCodecInfo.java + private object AV1ProfileLevel { + val ProfileMain10: Int by lazy { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + CodecProfileLevel.AV1ProfileMain10 + } else { + 0x2 + } + } + val ProfileMain10HDR10: Int by lazy { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + CodecProfileLevel.AV1ProfileMain10HDR10 + } else { + 0x1000 + } + } + val ProfileMain10HDR10Plus: Int by lazy { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + CodecProfileLevel.AV1ProfileMain10HDR10Plus + } else { + 0x2000 + } + } + val DolbyVisionProfile10: Int by lazy { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { CodecProfileLevel.DolbyVisionProfileDvav110 } else { - -1 + 0x400 + } + } + val Level5: Int by lazy { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + CodecProfileLevel.AV1Level5 + } else { + 0x1000 } } } @@ -90,41 +125,36 @@ class MediaCodecCapabilitiesTest( CodecProfileLevel.HEVCMainTierLevel62 to 186, ) - fun supportsAV1(): Boolean = - Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && - hasCodecForMime(MediaFormat.MIMETYPE_VIDEO_AV1) + fun supportsAV1(): Boolean = hasCodecForMime(MimeTypes.VIDEO_AV1) fun supportsAV1Main10(): Boolean = - Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && - hasDecoder( - MediaFormat.MIMETYPE_VIDEO_AV1, - CodecProfileLevel.AV1ProfileMain10, - CodecProfileLevel.AV1Level5, - ) + hasDecoder( + MimeTypes.VIDEO_AV1, + AV1ProfileLevel.ProfileMain10, + AV1ProfileLevel.Level5, + ) fun supportsAV1DolbyVision(): Boolean = - Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && + Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && hasDecoder( - MediaFormat.MIMETYPE_VIDEO_DOLBY_VISION, - DolbyVisionProfiles.Profile10, + MimeTypes.VIDEO_DOLBY_VISION, + AV1ProfileLevel.DolbyVisionProfile10, CodecProfileLevel.DolbyVisionLevelHd24, ) fun supportsAV1HDR10(): Boolean = - Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && - hasDecoder( - MediaFormat.MIMETYPE_VIDEO_AV1, - CodecProfileLevel.AV1ProfileMain10HDR10, - CodecProfileLevel.AV1Level5, - ) + hasDecoder( + MimeTypes.VIDEO_AV1, + AV1ProfileLevel.ProfileMain10HDR10, + AV1ProfileLevel.Level5, + ) fun supportsAV1HDR10Plus(): Boolean = - Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && - hasDecoder( - MediaFormat.MIMETYPE_VIDEO_AV1, - CodecProfileLevel.AV1ProfileMain10HDR10Plus, - CodecProfileLevel.AV1Level5, - ) + hasDecoder( + MimeTypes.VIDEO_AV1, + AV1ProfileLevel.ProfileMain10HDR10Plus, + AV1ProfileLevel.Level5, + ) fun supportsAVC(): Boolean = hasCodecForMime(MediaFormat.MIMETYPE_VIDEO_AVC)