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 8ca45aa3..72b02939 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -37,6 +37,7 @@ import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.NavigationManager import com.github.damontecres.wholphin.ui.theme.WholphinTheme import com.github.damontecres.wholphin.util.AppUpgradeHandler +import com.github.damontecres.wholphin.util.PlaybackLifecycleObserver import com.github.damontecres.wholphin.util.ServerEventListener import com.github.damontecres.wholphin.util.UpdateChecker import com.github.damontecres.wholphin.util.profile.createDeviceProfile @@ -70,13 +71,17 @@ class MainActivity : AppCompatActivity() { @Inject lateinit var serverEventListener: ServerEventListener + @Inject + lateinit var playbackLifecycleObserver: PlaybackLifecycleObserver + @OptIn(ExperimentalTvMaterial3Api::class) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + Timber.i("MainActivity.onCreate") + lifecycle.addObserver(playbackLifecycleObserver) if (savedInstanceState == null) { appUpgradeHandler.copySubfont(false) } - Timber.i("MainActivity.onCreate") setContent { CoilConfig(okHttpClient, false) val appPreferences by userPreferencesDataStore.data.collectAsState(null) @@ -178,11 +183,4 @@ class MainActivity : AppCompatActivity() { appUpgradeHandler.run() } } - - override fun onPause() { - if (navigationManager.backStack.lastOrNull() is Destination.Playback) { - navigationManager.goBack() - } - super.onPause() - } } 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 df1f80bb..2f9e971e 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 @@ -223,7 +223,7 @@ fun RecommendedMovie( viewModel.init() } val loading by viewModel.loading.observeAsState(LoadingState.Loading) - val rows by viewModel.rows.collectAsState(listOf()) + val rows by viewModel.rows.collectAsState() when (val state = loading) { is LoadingState.Error -> ErrorMessage(state) 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 1ce8cff2..d19baf0e 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 @@ -253,7 +253,7 @@ fun RecommendedTvShow( } val loading by viewModel.loading.observeAsState(LoadingState.Loading) - val rows by viewModel.rows.collectAsState(listOf()) + val rows by viewModel.rows.collectAsState() when (val state = loading) { is LoadingState.Error -> ErrorMessage(state) 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 9a189973..ae67b21c 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 @@ -91,7 +91,9 @@ fun HomePage( firstLoad = false } val loading by viewModel.loadingState.observeAsState(LoadingState.Loading) - val homeRows by viewModel.homeRows.observeAsState(listOf()) + val refreshing by viewModel.refreshState.observeAsState(LoadingState.Loading) + val watchingRows by viewModel.watchingRows.observeAsState(listOf()) + val latestRows by viewModel.latestRows.observeAsState(listOf()) LaunchedEffect(loading) { val state = loading if (!firstLoad && state is LoadingState.Error) { @@ -106,7 +108,7 @@ fun HomePage( } } - when (val state = if (firstLoad) loading else LoadingState.Success) { + when (val state = loading) { is LoadingState.Error -> ErrorMessage(state) LoadingState.Loading, @@ -118,7 +120,7 @@ fun HomePage( var showPlaylistDialog by remember { mutableStateOf(null) } val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) HomePageContent( - homeRows, + watchingRows + latestRows, onClickItem = { viewModel.navigationManager.navigateTo(it.destination()) }, @@ -154,7 +156,7 @@ fun HomePage( items = dialogItems, ) }, - loadingState = loading, + loadingState = refreshing, showClock = preferences.appPreferences.interfacePreferences.showClock, modifier = modifier, ) @@ -277,7 +279,7 @@ fun HomePageContent( -> { Column( verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = modifier, + modifier = Modifier.animateItem(), ) { Text( text = r.title, @@ -295,7 +297,7 @@ fun HomePageContent( is HomeRowLoadingState.Error -> { Column( verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = modifier, + modifier = Modifier.animateItem(), ) { Text( text = r.title, @@ -323,7 +325,10 @@ fun HomePageContent( } }, onLongClickItem = onLongClickItem, - modifier = Modifier.fillMaxWidth(), + modifier = + Modifier + .fillMaxWidth() + .animateItem(), cardContent = { index, item, cardModifier, onClick, onLongClick -> // TODO better aspect ration handling? BannerCard( 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 623a5bb2..4292909e 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.preferences.UserPreferences import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.nav.NavigationManager import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem +import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.HomeRowLoadingState import com.github.damontecres.wholphin.util.LoadingExceptionHandler @@ -51,12 +52,18 @@ class HomeViewModel val navDrawerItemRepository: NavDrawerItemRepository, ) : ViewModel() { val loadingState = MutableLiveData(LoadingState.Pending) - val homeRows = MutableLiveData>() + val refreshState = MutableLiveData(LoadingState.Pending) + val watchingRows = MutableLiveData>(listOf()) + val latestRows = MutableLiveData>(listOf()) private lateinit var preferences: UserPreferences fun init(preferences: UserPreferences): Job { - loadingState.value = LoadingState.Loading + val reload = loadingState.value != LoadingState.Success + if (reload) { + loadingState.value = LoadingState.Loading + } + refreshState.value = LoadingState.Loading this.preferences = preferences val prefs = preferences.appPreferences.homePagePreferences val limit = prefs.maxItemsPerRow @@ -84,9 +91,7 @@ class HomeViewModel prefs.enableRewatchingNextUp, prefs.combineContinueNext, ) - val latest = getLatest(userDto, limit, includedIds) - - val homeRows = + val watching = buildList { if (resume.isNotEmpty()) { add( @@ -104,12 +109,20 @@ class HomeViewModel ), ) } - addAll(latest) } + + val latest = getLatest(userDto, limit, includedIds) + val pendingLatest = latest.map { HomeRowLoadingState.Loading(it.title) } + withContext(Dispatchers.Main) { - this@HomeViewModel.homeRows.value = homeRows + this@HomeViewModel.watchingRows.value = watching + if (reload) { + this@HomeViewModel.latestRows.value = pendingLatest + } loadingState.value = LoadingState.Success } + loadLatest(latest) + refreshState.setValueOnMain(LoadingState.Success) } } } @@ -140,7 +153,7 @@ class HomeViewModel .getResumeItems(request) .content .items - .map { BaseItem.Companion.from(it, api, true) } + .map { BaseItem.from(it, api, true) } return items } @@ -174,70 +187,71 @@ class HomeViewModel user: UserDto, limit: Int, includedIds: List, - ): List { - val latestMediaIncludes = - user.configuration - ?.orderedViews - .orEmpty() - .toMutableList() - .apply { - removeAll(user.configuration?.latestItemsExcludes.orEmpty()) - }.filter { includedIds.contains(it) } - + ): List { + val excluded = user.configuration?.latestItemsExcludes.orEmpty() val views by api.userViewsApi.getUserViews() - val rows = - latestMediaIncludes - .mapNotNull { viewId -> views.items.firstOrNull { it.id == viewId } } - .filter { it.collectionType in supportedLatestCollectionTypes } - .mapNotNull { view -> + val latestData = + views.items + .filter { + it.id in includedIds && it.id !in excluded && + it.collectionType in supportedLatestCollectionTypes + }.mapNotNull { view -> val title = if (view.collectionType == CollectionType.LIVETV) { context.getString(R.string.recently_recorded) } else { view.name?.let { context.getString(R.string.recently_added_in, it) } } ?: context.getString(R.string.recently_added) - try { - val viewId = - if (view.collectionType == CollectionType.LIVETV) { - api.liveTvApi - .getRecordingFolders( - userId = user.id, - ).content.items - .firstOrNull() - ?.id - } else { - view.id - } - viewId?.let { - val request = - GetLatestMediaRequest( - fields = SlimItemFields, - imageTypeLimit = 1, - parentId = viewId, - groupItems = true, - limit = limit, - isPlayed = null, // Server will handle user's preference - ) - val latest = - api.userLibraryApi - .getLatestMedia(request) - .content - .map { BaseItem.from(it, api, true) } - HomeRowLoadingState.Success( - title = title, - items = latest, - ) + val viewId = + if (view.collectionType == CollectionType.LIVETV) { + api.liveTvApi + .getRecordingFolders( + userId = user.id, + ).content.items + .firstOrNull() + ?.id + } else { + view.id } - } catch (ex: Exception) { - Timber.e(ex, "Exception fetching %s", title) - HomeRowLoadingState.Error( - title = title, - exception = ex, - ) + viewId?.let { + val request = + GetLatestMediaRequest( + fields = SlimItemFields, + imageTypeLimit = 1, + parentId = viewId, + groupItems = true, + limit = limit, + isPlayed = null, // Server will handle user's preference + ) + LatestData(title, request) } } - return rows + return latestData + } + + private suspend fun loadLatest(latestData: List) { + val rows = + latestData.map { (title, request) -> + try { + val latest = + api.userLibraryApi + .getLatestMedia(request) + .content + .map { BaseItem.from(it, api, true) } + HomeRowLoadingState.Success( + title = title, + items = latest, + ) + } catch (ex: Exception) { + Timber.e(ex, "Exception fetching %s", title) + HomeRowLoadingState.Error( + title = title, + exception = ex, + ) + } + } + latestRows.setValueOnMain(rows) } fun setWatched( @@ -275,3 +289,8 @@ val supportedLatestCollectionTypes = CollectionType.TVSHOWS, CollectionType.LIVETV, ) + +data class LatestData( + val title: String, + val request: GetLatestMediaRequest, +) 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 72d3fdd0..099f4816 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 @@ -148,6 +148,20 @@ fun DestinationContent( BaseItemKind.USER_VIEW -> when (destination.item?.data?.collectionType) { + CollectionType.TVSHOWS -> + CollectionFolderTv( + preferences, + destination, + modifier, + ) + + CollectionType.MOVIES -> + CollectionFolderMovie( + preferences, + destination, + modifier, + ) + CollectionType.LIVETV -> CollectionFolderLiveTv( preferences, 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 ff413110..833be14c 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 @@ -17,8 +17,6 @@ import androidx.media3.common.Player import androidx.media3.common.TrackSelectionOverride import androidx.media3.common.Tracks import androidx.media3.common.util.UnstableApi -import androidx.media3.exoplayer.DefaultRenderersFactory -import androidx.media3.exoplayer.ExoPlayer import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.ItemPlaybackDao import com.github.damontecres.wholphin.data.ItemPlaybackRepository @@ -33,7 +31,6 @@ import com.github.damontecres.wholphin.data.model.chooseSource import com.github.damontecres.wholphin.data.model.chooseStream import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreferences -import com.github.damontecres.wholphin.preferences.MediaExtensionStatus import com.github.damontecres.wholphin.preferences.PlayerBackend import com.github.damontecres.wholphin.preferences.ShowNextUpWhen import com.github.damontecres.wholphin.preferences.SkipSegmentBehavior @@ -50,11 +47,11 @@ import com.github.damontecres.wholphin.util.EqualityMutableLiveData 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.util.PlayerFactory import com.github.damontecres.wholphin.util.TrackActivityPlaybackListener import com.github.damontecres.wholphin.util.TrackSupport import com.github.damontecres.wholphin.util.checkForSupport import com.github.damontecres.wholphin.util.formatDateTime -import com.github.damontecres.wholphin.util.mpv.MpvPlayer import com.github.damontecres.wholphin.util.mpv.mpvDeviceProfile import com.github.damontecres.wholphin.util.seasonEpisodePadded import com.github.damontecres.wholphin.util.subtitleMimeTypes @@ -65,12 +62,10 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.isActive import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.mediaInfoApi @@ -123,44 +118,11 @@ class PlaybackViewModel val serverRepository: ServerRepository, val itemPlaybackRepository: ItemPlaybackRepository, val appPreferences: DataStore, + private val playerFactory: PlayerFactory, ) : ViewModel(), Player.Listener { val player by lazy { - val prefs = runBlocking { appPreferences.data.firstOrNull()?.playbackPreferences } - - val backend = prefs?.playerBackend ?: AppPreference.PlayerBackendPref.defaultValue - when (backend) { - PlayerBackend.MPV -> { - val enableHardwareDecoding = - prefs?.mpvOptions?.enableHardwareDecoding - ?: AppPreference.MpvHardwareDecoding.defaultValue - MpvPlayer(context, enableHardwareDecoding) - .apply { - playWhenReady = true - } - } - - PlayerBackend.EXO_PLAYER, - PlayerBackend.UNRECOGNIZED, - -> { - val extensions = prefs?.overrides?.mediaExtensionsEnabled - 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( - DefaultRenderersFactory(context) - .setEnableDecoderFallback(true) - .setExtensionRendererMode(rendererMode), - ).build() - } - } + playerFactory.createVideoPlayer() } val loading = MutableLiveData(LoadingState.Loading) diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/PlaybackLifecycleObserver.kt b/app/src/main/java/com/github/damontecres/wholphin/util/PlaybackLifecycleObserver.kt new file mode 100644 index 00000000..fcc49917 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/util/PlaybackLifecycleObserver.kt @@ -0,0 +1,46 @@ +package com.github.damontecres.wholphin.util + +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.nav.NavigationManager +import dagger.hilt.android.scopes.ActivityRetainedScoped +import javax.inject.Inject + +/** + * Observes the activity lifecycle in order to pause/resume/stop playback + */ +@ActivityRetainedScoped +class PlaybackLifecycleObserver + @Inject + constructor( + private val navigationManager: NavigationManager, + private val playerFactory: PlayerFactory, + ) : DefaultLifecycleObserver { + private var wasPlaying: Boolean? = null + + override fun onStart(owner: LifecycleOwner) { + wasPlaying = null + } + + override fun onResume(owner: LifecycleOwner) { + if (wasPlaying == true) { + playerFactory.currentPlayer?.let { + if (!it.isReleased) it.play() + } + } + } + + override fun onPause(owner: LifecycleOwner) { + playerFactory.currentPlayer?.let { + wasPlaying = it.isPlaying + it.pause() + } + } + + override fun onStop(owner: LifecycleOwner) { + if (navigationManager.backStack.lastOrNull() is Destination.Playback) { + navigationManager.goBack() + } + } + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/PlayerFactory.kt b/app/src/main/java/com/github/damontecres/wholphin/util/PlayerFactory.kt new file mode 100644 index 00000000..f841fa9c --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/util/PlayerFactory.kt @@ -0,0 +1,95 @@ +@file:OptIn(markerClass = [UnstableApi::class]) + +package com.github.damontecres.wholphin.util + +import android.content.Context +import androidx.annotation.OptIn +import androidx.datastore.core.DataStore +import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.DefaultRenderersFactory +import androidx.media3.exoplayer.ExoPlayer +import com.github.damontecres.wholphin.preferences.AppPreference +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.MediaExtensionStatus +import com.github.damontecres.wholphin.preferences.PlayerBackend +import com.github.damontecres.wholphin.util.mpv.MpvPlayer +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.runBlocking +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Constructs a [Player] instance for video playback + */ +@Singleton +class PlayerFactory + @Inject + constructor( + @param:ApplicationContext private val context: Context, + private val appPreferences: DataStore, + ) { + @Volatile + 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.MPV -> { + val enableHardwareDecoding = + prefs?.mpvOptions?.enableHardwareDecoding + ?: AppPreference.MpvHardwareDecoding.defaultValue + MpvPlayer(context, enableHardwareDecoding) + .apply { + playWhenReady = true + } + } + + PlayerBackend.EXO_PLAYER, + PlayerBackend.UNRECOGNIZED, + -> { + val extensions = + runBlocking { appPreferences.data.firstOrNull() }?.playbackPreferences?.overrides?.mediaExtensionsEnabled + 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( + DefaultRenderersFactory(context) + .setEnableDecoderFallback(true) + .setExtensionRendererMode(rendererMode), + ).build() + .apply { + playWhenReady = true + } + } + } + currentPlayer = newPlayer + return newPlayer + } + } + +val Player.isReleased: Boolean + get() { + return when (this) { + is ExoPlayer -> isReleased + is MpvPlayer -> isReleased + else -> throw IllegalStateException("Unknown Player type: ${this::class.qualifiedName}") + } + } 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 f37dbb5f..1e50d4a0 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 @@ -76,13 +76,13 @@ class TrackActivityPlaybackListener( fun release() { task.cancel() TIMER.purge() - val position = player.currentPosition.milliseconds.inWholeTicks + val position = player.currentPosition.milliseconds coroutineScope.launch(Dispatchers.IO + ExceptionHandler()) { - Timber.v("reportPlaybackStopped for ${itemPlayback.itemId}") + Timber.v("reportPlaybackStopped for ${itemPlayback.itemId} at $position") api.playStateApi.reportPlaybackStopped( PlaybackStopInfo( itemId = itemPlayback.itemId, - positionTicks = position, + positionTicks = position.inWholeTicks, failed = false, playSessionId = playback.playSessionId, liveStreamId = playback.liveStreamId,