Merge branch 'main' into develop/mpv

This commit is contained in:
Damontecres 2025-11-11 16:02:14 -05:00
commit 96c856e8fc
No known key found for this signature in database
10 changed files with 261 additions and 122 deletions

View file

@ -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()
}
}

View file

@ -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)

View file

@ -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)

View file

@ -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<UUID?>(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(

View file

@ -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>(LoadingState.Pending)
val homeRows = MutableLiveData<List<HomeRowLoadingState>>()
val refreshState = MutableLiveData<LoadingState>(LoadingState.Pending)
val watchingRows = MutableLiveData<List<HomeRowLoadingState>>(listOf())
val latestRows = MutableLiveData<List<HomeRowLoadingState>>(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<UUID>,
): List<HomeRowLoadingState> {
val latestMediaIncludes =
user.configuration
?.orderedViews
.orEmpty()
.toMutableList()
.apply {
removeAll(user.configuration?.latestItemsExcludes.orEmpty())
}.filter { includedIds.contains(it) }
): List<LatestData> {
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<LatestData>) {
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,
)

View file

@ -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,

View file

@ -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<AppPreferences>,
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>(LoadingState.Loading)

View file

@ -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()
}
}
}

View file

@ -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<AppPreferences>,
) {
@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}")
}
}

View file

@ -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,