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 1/3] 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 2/3] 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 3/3] 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,