Merge branch 'main' into fea/music

This commit is contained in:
Damontecres 2026-03-06 12:42:43 -05:00
commit d5f5c5fb0a
No known key found for this signature in database
13 changed files with 658 additions and 579 deletions

View file

@ -231,6 +231,7 @@ dependencies {
implementation(libs.androidx.media3.exoplayer.dash) implementation(libs.androidx.media3.exoplayer.dash)
implementation(libs.androidx.media3.ui) implementation(libs.androidx.media3.ui)
implementation(libs.androidx.media3.ui.compose) implementation(libs.androidx.media3.ui.compose)
implementation(libs.ass.media)
implementation(libs.coil.core) implementation(libs.coil.core)
implementation(libs.coil.compose) implementation(libs.coil.compose)

View file

@ -53,6 +53,7 @@ import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.theme.WholphinTheme 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.DebugLogTree
import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.ExceptionHandler
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
@ -212,11 +213,11 @@ class MainActivity : AppCompatActivity() {
true, true,
appThemeColors = appPreferences.interfacePreferences.appThemeColors, appThemeColors = appPreferences.interfacePreferences.appThemeColors,
) { ) {
ProvideLocalClock {
val requestedDestination = val requestedDestination =
remember(intent) { remember(intent) {
intent?.let(::extractDestination) ?: // Destination.Home() intent?.let(::extractDestination) // ?: Destination.Home()
// TODO ?: Destination.MediaItem(
Destination.MediaItem(
itemId = "011ef0c7ca45684f2cd9dd3b020ca5f6".toUUID(), itemId = "011ef0c7ca45684f2cd9dd3b020ca5f6".toUUID(),
type = BaseItemKind.MUSIC_ALBUM, type = BaseItemKind.MUSIC_ALBUM,
) )
@ -235,6 +236,7 @@ class MainActivity : AppCompatActivity() {
} }
} }
} }
}
override fun dispatchKeyEvent(event: KeyEvent): Boolean { override fun dispatchKeyEvent(event: KeyEvent): Boolean {
if (screensaverService.state.value.show) { if (screensaverService.state.value.show) {

View file

@ -96,7 +96,6 @@ fun MainContent(
backdropService.clearBackdrop() backdropService.clearBackdrop()
} }
val current = key.current val current = key.current
ProvideLocalClock {
val preferences = val preferences =
remember(appPreferences) { remember(appPreferences) {
UserPreferences(appPreferences) UserPreferences(appPreferences)
@ -133,7 +132,6 @@ fun MainContent(
} }
} }
} }
}
}, },
) )
val screenSaverState by screensaverService.state.collectAsState() val screenSaverState by screensaverService.state.collectAsState()

View file

@ -5,11 +5,13 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ComposeView
import androidx.datastore.core.DataStore
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleRegistry import androidx.lifecycle.LifecycleRegistry
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
@ -18,14 +20,19 @@ import androidx.savedstate.SavedStateRegistry
import androidx.savedstate.SavedStateRegistryController import androidx.savedstate.SavedStateRegistryController
import androidx.savedstate.SavedStateRegistryOwner import androidx.savedstate.SavedStateRegistryOwner
import androidx.savedstate.setViewTreeSavedStateRegistryOwner 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.preferences.UserPreferences
import com.github.damontecres.wholphin.services.ScreensaverService import com.github.damontecres.wholphin.services.ScreensaverService
import com.github.damontecres.wholphin.services.UserPreferencesService import com.github.damontecres.wholphin.services.UserPreferencesService
import com.github.damontecres.wholphin.ui.components.AppScreensaverContent import com.github.damontecres.wholphin.ui.components.AppScreensaverContent
import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.theme.WholphinTheme import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import com.github.damontecres.wholphin.ui.util.ProvideLocalClock import com.github.damontecres.wholphin.ui.util.ProvideLocalClock
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.first
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import javax.inject.Inject import javax.inject.Inject
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
@ -33,11 +40,14 @@ import kotlin.time.Duration.Companion.milliseconds
class WholphinDreamService : class WholphinDreamService :
DreamService(), DreamService(),
SavedStateRegistryOwner { SavedStateRegistryOwner {
@Inject
lateinit var serverRepository: ServerRepository
@Inject @Inject
lateinit var screensaverService: ScreensaverService lateinit var screensaverService: ScreensaverService
@Inject @Inject
lateinit var userPreferencesService: UserPreferencesService lateinit var preferencesDataStore: DataStore<AppPreferences>
private val lifecycleRegistry = LifecycleRegistry(this) private val lifecycleRegistry = LifecycleRegistry(this)
@ -54,6 +64,12 @@ class WholphinDreamService :
savedStateRegistryController.performRestore(null) savedStateRegistryController.performRestore(null)
lifecycleRegistry.currentState = Lifecycle.State.CREATED lifecycleRegistry.currentState = Lifecycle.State.CREATED
lifecycleScope.launchDefault {
if (serverRepository.current.value == null) {
val prefs = preferencesDataStore.data.first()
serverRepository.restoreSession(prefs.currentServerId.toUUIDOrNull(), prefs.currentUserId.toUUIDOrNull())
}
}
} }
override fun onAttachedToWindow() { override fun onAttachedToWindow() {
@ -64,15 +80,16 @@ class WholphinDreamService :
setViewTreeLifecycleOwner(this@WholphinDreamService) setViewTreeLifecycleOwner(this@WholphinDreamService)
setViewTreeSavedStateRegistryOwner(this@WholphinDreamService) setViewTreeSavedStateRegistryOwner(this@WholphinDreamService)
setContent { setContent {
var prefs by remember { mutableStateOf<UserPreferences?>(null) } val user by serverRepository.currentUser.observeAsState()
if (user != null) {
var prefs by remember { mutableStateOf<AppPreferences?>(null) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
userPreferencesService.flow.collectLatest { prefs = it } preferencesDataStore.data.collectLatest { prefs = it }
} }
prefs?.let { prefs -> prefs?.let { prefs ->
WholphinTheme(appThemeColors = prefs.appPreferences.interfacePreferences.appThemeColors) { WholphinTheme(appThemeColors = prefs.interfacePreferences.appThemeColors) {
ProvideLocalClock { ProvideLocalClock {
val screensaverPrefs = val screensaverPrefs = prefs.interfacePreferences.screensaverPreference
prefs.appPreferences.interfacePreferences.screensaverPreference
val currentItem by itemFlow.collectAsState(null) val currentItem by itemFlow.collectAsState(null)
AppScreensaverContent( AppScreensaverContent(
currentItem = currentItem, currentItem = currentItem,
@ -85,6 +102,7 @@ class WholphinDreamService :
} }
} }
} }
}
}, },
) )
} }

View file

@ -10,22 +10,28 @@ import androidx.datastore.core.DataStore
import androidx.media3.common.C import androidx.media3.common.C
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.DefaultDataSource
import androidx.media3.exoplayer.DefaultRenderersFactory import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.Renderer import androidx.media3.exoplayer.Renderer
import androidx.media3.exoplayer.RenderersFactory
import androidx.media3.exoplayer.mediacodec.MediaCodecSelector import androidx.media3.exoplayer.mediacodec.MediaCodecSelector
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.exoplayer.video.MediaCodecVideoRenderer import androidx.media3.exoplayer.video.MediaCodecVideoRenderer
import androidx.media3.exoplayer.video.VideoRendererEventListener 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.AppPreferences
import com.github.damontecres.wholphin.preferences.MediaExtensionStatus import com.github.damontecres.wholphin.preferences.MediaExtensionStatus
import com.github.damontecres.wholphin.preferences.PlaybackPreferences import com.github.damontecres.wholphin.preferences.PlaybackPreferences
import com.github.damontecres.wholphin.preferences.PlayerBackend import com.github.damontecres.wholphin.preferences.PlayerBackend
import com.github.damontecres.wholphin.util.mpv.MpvPlayer import com.github.damontecres.wholphin.util.mpv.MpvPlayer
import dagger.hilt.android.qualifiers.ApplicationContext 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.Dispatchers
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import timber.log.Timber import timber.log.Timber
import java.lang.reflect.Constructor import java.lang.reflect.Constructor
@ -46,71 +52,17 @@ class PlayerFactory
var currentPlayer: Player? = null var currentPlayer: Player? = null
private set 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( suspend fun createVideoPlayer(
backend: PlayerBackend, backend: PlayerBackend,
prefs: PlaybackPreferences, prefs: PlaybackPreferences,
): Player { ): PlayerCreation {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
if (currentPlayer?.isReleased == false) { if (currentPlayer?.isReleased == false) {
Timber.w("Player was not released before trying to create a new one!") Timber.w("Player was not released before trying to create a new one!")
currentPlayer?.release() currentPlayer?.release()
} }
} }
var assHandler: AssHandler? = null
val newPlayer = val newPlayer =
when (backend) { when (backend) {
PlayerBackend.PREFER_MPV, PlayerBackend.PREFER_MPV,
@ -125,8 +77,9 @@ class PlayerFactory
PlayerBackend.UNRECOGNIZED, PlayerBackend.UNRECOGNIZED,
-> { -> {
val extensions = prefs.overrides.mediaExtensionsEnabled val extensions = prefs.overrides.mediaExtensionsEnabled
val directPlayAss = prefs.overrides.directPlayAss
val decodeAv1 = prefs.overrides.decodeAv1 val decodeAv1 = prefs.overrides.decodeAv1
Timber.v("extensions=$extensions") Timber.v("extensions=$extensions, directPlayAss=$directPlayAss")
val rendererMode = val rendererMode =
when (extensions) { when (extensions) {
MediaExtensionStatus.MES_FALLBACK -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON MediaExtensionStatus.MES_FALLBACK -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON
@ -134,17 +87,42 @@ class PlayerFactory
MediaExtensionStatus.MES_DISABLED -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF MediaExtensionStatus.MES_DISABLED -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF
else -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON else -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON
} }
ExoPlayer val dataSourceFactory = DefaultDataSource.Factory(context)
.Builder(context) val extractorsFactory = DefaultExtractorsFactory()
.setRenderersFactory( var renderersFactory: RenderersFactory =
WholphinRenderersFactory(context, decodeAv1) WholphinRenderersFactory(context, decodeAv1)
.setEnableDecoderFallback(true) .setEnableDecoderFallback(true)
.setExtensionRendererMode(rendererMode), .setExtensionRendererMode(rendererMode)
).build() val mediaSourceFactory =
if (directPlayAss) {
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)
.setMediaSourceFactory(mediaSourceFactory)
.setRenderersFactory(renderersFactory)
.build()
.apply {
assHandler?.init(this)
}
} }
} }
currentPlayer = newPlayer currentPlayer = newPlayer
return newPlayer return PlayerCreation(newPlayer, assHandler)
} }
} }
@ -157,6 +135,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 // Code is adapted from https://github.com/androidx/media/blob/release/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/DefaultRenderersFactory.java#L436
class WholphinRenderersFactory( class WholphinRenderersFactory(
context: Context, context: Context,

View file

@ -1,5 +1,8 @@
package com.github.damontecres.wholphin.ui.playback 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.activity.compose.BackHandler
import androidx.annotation.Dimension import androidx.annotation.Dimension
import androidx.annotation.OptIn import androidx.annotation.OptIn
@ -42,16 +45,18 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.intl.Locale import androidx.compose.ui.text.intl.Locale
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
import androidx.core.view.children
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.LifecycleStartEffect import androidx.lifecycle.compose.LifecycleStartEffect
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.media3.ui.SubtitleView import androidx.media3.ui.SubtitleView
import androidx.media3.ui.compose.PlayerSurface import androidx.media3.ui.compose.PlayerSurface
@ -83,6 +88,7 @@ import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
import com.github.damontecres.wholphin.util.Media3SubtitleOverride import com.github.damontecres.wholphin.util.Media3SubtitleOverride
import com.github.damontecres.wholphin.util.mpv.MpvPlayer import com.github.damontecres.wholphin.util.mpv.MpvPlayer
import io.github.peerless2012.ass.media.widget.AssSubtitleView
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -127,8 +133,7 @@ fun PlaybackPage(
LoadingState.Success -> { LoadingState.Success -> {
val playerState by viewModel.currentPlayer.collectAsState() val playerState by viewModel.currentPlayer.collectAsState()
PlaybackPageContent( PlaybackPageContent(
player = playerState!!.player, playerState = playerState!!,
playerBackend = playerState!!.backend,
preferences = preferences, preferences = preferences,
destination = destination, destination = destination,
viewModel = viewModel, viewModel = viewModel,
@ -141,13 +146,15 @@ fun PlaybackPage(
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
@Composable @Composable
fun PlaybackPageContent( fun PlaybackPageContent(
player: Player, playerState: PlayerState,
playerBackend: PlayerBackend,
preferences: UserPreferences, preferences: UserPreferences,
destination: Destination, destination: Destination,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: PlaybackViewModel, viewModel: PlaybackViewModel,
) { ) {
val player = playerState.player
val playerBackend = playerState.backend
val prefs = preferences.appPreferences.playbackPreferences val prefs = preferences.appPreferences.playbackPreferences
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val configuration = LocalConfiguration.current val configuration = LocalConfiguration.current
@ -320,10 +327,14 @@ fun PlaybackPageContent(
.focusRequester(focusRequester) .focusRequester(focusRequester)
.focusable(), .focusable(),
) { ) {
var playerSurfaceSize by remember { mutableStateOf(IntSize.Zero) }
PlayerSurface( PlayerSurface(
player = player, player = player,
surfaceType = SURFACE_TYPE_SURFACE_VIEW, surfaceType = SURFACE_TYPE_SURFACE_VIEW,
modifier = scaledModifier, modifier =
scaledModifier.onSizeChanged {
playerSurfaceSize = it
},
) )
if (presentationState.coverSurface) { if (presentationState.coverSurface) {
Box( Box(
@ -416,7 +427,7 @@ fun PlaybackPageContent(
remember(subtitleSettings) { subtitleSettings.imageSubtitleOpacity / 100f } remember(subtitleSettings) { subtitleSettings.imageSubtitleOpacity / 100f }
// Subtitles // Subtitles
if (skipIndicatorDuration == 0L && currentItemPlayback.subtitleIndexEnabled) { if (skipIndicatorDuration == 0L && currentItemPlayback.subtitleIndexEnabled && !presentationState.coverSurface) {
val maxSize by animateFloatAsState(if (controllerViewState.controlsVisible) .7f else 1f) val maxSize by animateFloatAsState(if (controllerViewState.controlsVisible) .7f else 1f)
val isImageSubtitles = remember(cues) { cues.firstOrNull()?.bitmap != null } val isImageSubtitles = remember(cues) { cues.firstOrNull()?.bitmap != null }
AndroidView( AndroidView(
@ -427,12 +438,42 @@ fun PlaybackPageContent(
setFixedTextSize(Dimension.SP, it.fontSize.toFloat()) setFixedTextSize(Dimension.SP, it.fontSize.toFloat())
setBottomPaddingFraction(it.margin.toFloat() / 100f) setBottomPaddingFraction(it.margin.toFloat() / 100f)
} }
playerState.assHandler?.let { assHandler ->
if (prefs.overrides.directPlayAss) {
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 = { update = {
it.setCues(cues) it.setCues(cues)
Media3SubtitleOverride(subtitleSettings.calculateEdgeSize(density)) Media3SubtitleOverride(subtitleSettings.calculateEdgeSize(density))
.apply(it) .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 = { onReset = {
it.setCues(null) it.setCues(null)

View file

@ -74,6 +74,7 @@ import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import io.github.peerless2012.ass.media.AssHandler
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@ -220,7 +221,8 @@ class PlaybackViewModel
isHdr: Boolean, isHdr: Boolean,
is4k: Boolean, is4k: Boolean,
) { ) {
val softwareDecoding = !preferences.appPreferences.playbackPreferences.mpvOptions.enableHardwareDecoding val softwareDecoding =
!preferences.appPreferences.playbackPreferences.mpvOptions.enableHardwareDecoding
val playerBackend = val playerBackend =
when (preferences.appPreferences.playbackPreferences.playerBackend) { when (preferences.appPreferences.playbackPreferences.playerBackend) {
PlayerBackend.UNRECOGNIZED, PlayerBackend.UNRECOGNIZED,
@ -239,13 +241,14 @@ class PlaybackViewModel
disconnectPlayer() disconnectPlayer()
} }
player = val playerCreation =
playerFactory.createVideoPlayer( playerFactory.createVideoPlayer(
playerBackend, playerBackend,
preferences.appPreferences.playbackPreferences, preferences.appPreferences.playbackPreferences,
) )
this.player = playerCreation.player
currentPlayer.update { currentPlayer.update {
PlayerState(player, playerBackend) PlayerState(playerCreation.player, playerBackend, playerCreation.assHandler)
} }
configurePlayer() configurePlayer()
} }
@ -1441,6 +1444,7 @@ class PlaybackViewModel
data class PlayerState( data class PlayerState(
val player: Player, val player: Player,
val backend: PlayerBackend, val backend: PlayerBackend,
val assHandler: AssHandler?,
) )
data class MediaSegmentState( data class MediaSegmentState(

View file

@ -153,7 +153,7 @@ fun PreferencesContent(
try { try {
System.loadLibrary("mpv") System.loadLibrary("mpv")
System.loadLibrary("player") System.loadLibrary("player")
} catch (ex: Exception) { } catch (ex: UnsatisfiedLinkError) {
Timber.w(ex, "Could not load libmpv") Timber.w(ex, "Could not load libmpv")
showToast(context, "MPV is not supported on this device") showToast(context, "MPV is not supported on this device")
viewModel.preferenceDataStore.updateData { viewModel.preferenceDataStore.updateData {

View file

@ -67,6 +67,7 @@ import com.github.damontecres.wholphin.ui.playback.isDirectionalDpad
import com.github.damontecres.wholphin.ui.playback.isDpad import com.github.damontecres.wholphin.ui.playback.isDpad
import com.github.damontecres.wholphin.ui.playback.isEnterKey import com.github.damontecres.wholphin.ui.playback.isEnterKey
import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.LoadingState
import org.jellyfin.sdk.model.api.MediaType import org.jellyfin.sdk.model.api.MediaType
import timber.log.Timber import timber.log.Timber
import kotlin.math.abs import kotlin.math.abs
@ -88,7 +89,20 @@ fun SlideshowPage(
), ),
) { ) {
val context = LocalContext.current val context = LocalContext.current
val loading by viewModel.loading.collectAsState()
when (val st = loading) {
is LoadingState.Error -> {
ErrorMessage(st, modifier)
}
LoadingState.Loading,
LoadingState.Pending,
-> {
LoadingPage(modifier)
}
LoadingState.Success -> {
val loadingState by viewModel.loadingState.observeAsState(ImageLoadingState.Loading) val loadingState by viewModel.loadingState.observeAsState(ImageLoadingState.Loading)
val imageFilter by viewModel.imageFilter.observeAsState(VideoFilter()) val imageFilter by viewModel.imageFilter.observeAsState(VideoFilter())
val position by viewModel.position.observeAsState(0) val position by viewModel.position.observeAsState(0)
@ -395,7 +409,8 @@ fun SlideshowPage(
) )
} }
transformOrigin = TransformOrigin(xTransform, yTransform) transformOrigin =
TransformOrigin(xTransform, yTransform)
}.rotate(rotateAnimation), }.rotate(rotateAnimation),
model = model =
ImageRequest ImageRequest
@ -498,3 +513,5 @@ fun SlideshowPage(
} }
} }
} }
}
}

View file

@ -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.PlaybackEffect
import com.github.damontecres.wholphin.data.model.VideoFilter import com.github.damontecres.wholphin.data.model.VideoFilter
import com.github.damontecres.wholphin.preferences.AppPreference 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.ImageUrlService
import com.github.damontecres.wholphin.services.PlayerFactory import com.github.damontecres.wholphin.services.PlayerFactory
import com.github.damontecres.wholphin.services.ScreensaverService 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.ApiRequestPager
import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import com.github.damontecres.wholphin.util.LoadingState
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
@ -77,9 +79,8 @@ class SlideshowViewModel
fun create(slideshow: Destination.Slideshow): SlideshowViewModel fun create(slideshow: Destination.Slideshow): SlideshowViewModel
} }
val player by lazy { lateinit var player: Player
playerFactory.createVideoPlayer() private set
}
private var saveFilters = true private var saveFilters = true
@ -104,6 +105,8 @@ class SlideshowViewModel
private val _image = MutableLiveData<ImageState>() private val _image = MutableLiveData<ImageState>()
val image: LiveData<ImageState> = _image val image: LiveData<ImageState> = _image
val loading = MutableStateFlow<LoadingState>(LoadingState.Pending)
val loadingState = MutableLiveData<ImageLoadingState>(ImageLoadingState.Loading) val loadingState = MutableLiveData<ImageLoadingState>(ImageLoadingState.Loading)
private val _imageFilter = MutableLiveData(VideoFilter()) private val _imageFilter = MutableLiveData(VideoFilter())
val imageFilter = ThrottledLiveData(_imageFilter, 500L) val imageFilter = ThrottledLiveData(_imageFilter, 500L)
@ -113,22 +116,26 @@ class SlideshowViewModel
init { init {
addCloseable { addCloseable {
screensaverService.keepScreenOn(false) screensaverService.keepScreenOn(false)
if (this@SlideshowViewModel::player.isInitialized) {
player.removeListener(this@SlideshowViewModel) player.removeListener(this@SlideshowViewModel)
player.release() player.release()
} }
player.addListener(this@SlideshowViewModel) }
viewModelScope.launchIO { viewModelScope.launchIO {
val photoPrefs = userPreferencesService.getCurrent().appPreferences.photoPreferences 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 = slideshowDelay =
photoPrefs.slideshowDuration.takeIf { it >= AppPreference.SlideshowDuration.min } photoPrefs.slideshowDuration.takeIf { it >= AppPreference.SlideshowDuration.min }
?: AppPreference.SlideshowDuration.defaultValue ?: AppPreference.SlideshowDuration.defaultValue
// val album =
// api.userLibraryApi
// .getItem(
// itemId = slideshowSettings.parentId,
// ).content
// .let { BaseItem(it, false) }
// this@SlideshowViewModel.album.setValueOnMain(album)
val includeItemTypes = val includeItemTypes =
if (photoPrefs.slideshowPlayVideos) { if (photoPrefs.slideshowPlayVideos) {
listOf(BaseItemKind.PHOTO, BaseItemKind.VIDEO) listOf(BaseItemKind.PHOTO, BaseItemKind.VIDEO)
@ -163,8 +170,13 @@ class SlideshowViewModel
ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope) ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope)
.init(slideshowSettings.index) .init(slideshowSettings.index)
this@SlideshowViewModel._pager.setValueOnMain(pager) this@SlideshowViewModel._pager.setValueOnMain(pager)
loading.update { LoadingState.Success }
updatePosition(slideshowSettings.index)?.join() updatePosition(slideshowSettings.index)?.join()
if (slideshowSettings.startSlideshow) onMain { startSlideshow() } if (slideshowSettings.startSlideshow) onMain { startSlideshow() }
} catch (ex: Exception) {
Timber.e(ex, "Error")
loading.update { LoadingState.Error(ex) }
}
} }
} }

View file

@ -34,7 +34,7 @@ data class Clock(
fun ProvideLocalClock(content: @Composable () -> Unit) { fun ProvideLocalClock(content: @Composable () -> Unit) {
val clock = remember { Clock() } val clock = remember { Clock() }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
withContext(Dispatchers.IO) { withContext(Dispatchers.Default) {
while (isActive) { while (isActive) {
val now = LocalDateTime.now() val now = LocalDateTime.now()
val time = TimeFormatter.format(now) val time = TimeFormatter.format(now)

View file

@ -312,7 +312,7 @@
<string name="check_for_updates">Check for updates</string> <string name="check_for_updates">Check for updates</string>
<string name="combine_continue_next_summary">Applies to TV Series only</string> <string name="combine_continue_next_summary">Applies to TV Series only</string>
<string name="combine_continue_next"><![CDATA[Combine Continue Watching & Next Up]]></string> <string name="combine_continue_next"><![CDATA[Combine Continue Watching & Next Up]]></string>
<string name="direct_play_ass">Direct play ASS subtitles</string> <string name="direct_play_ass">Use libass for ASS subtitles</string>
<string name="direct_play_pgs">Direct play PGS subtitles</string> <string name="direct_play_pgs">Direct play PGS subtitles</string>
<string name="downmix_stereo">Always downmix to stereo</string> <string name="downmix_stereo">Always downmix to stereo</string>
<string name="ffmpeg_extension_pref">Use FFmpeg decoder module</string> <string name="ffmpeg_extension_pref">Use FFmpeg decoder module</string>

View file

@ -40,6 +40,7 @@ preferenceKtx = "1.2.1"
tvprovider = "1.1.0" tvprovider = "1.1.0"
workRuntimeKtx = "2.11.1" workRuntimeKtx = "2.11.1"
paletteKtx = "1.0.0" paletteKtx = "1.0.0"
assMedia = "0.4.0"
kotlinxCoroutinesTest = "1.10.2" kotlinxCoroutinesTest = "1.10.2"
coreTesting = "2.2.0" coreTesting = "2.2.0"
openapi-generator = "7.20.0" openapi-generator = "7.20.0"
@ -134,6 +135,8 @@ kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-t
androidx-core-testing = { module = "androidx.arch.core:core-testing", version.ref = "coreTesting" } androidx-core-testing = { module = "androidx.arch.core:core-testing", version.ref = "coreTesting" }
androidx-runner = { group = "androidx.test", name = "runner", version.ref = "runner" } androidx-runner = { group = "androidx.test", name = "runner", version.ref = "runner" }
ass-media = { group = "io.github.peerless2012", name = "ass-media", version.ref = "assMedia" }
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }