Integrate with libass-android to support SSA/ASS subtitles in ExoPlayer (#1052)

## Description
Add better ASS/SSA support to ExoPlayer by using
[`libass-android`](https://github.com/peerless2012/libass-android).

This is enabled with the "Use libass for ASS subtitles" advanced
settings for ExoPlayer. It is enabled by default.

### Related issues
Related to #22
Replaces reverted #569
Fixes #1107
This commit is contained in:
Ray 2026-04-15 17:50:34 -04:00 committed by GitHub
parent 95859be3c6
commit b18416c954
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 626 additions and 521 deletions

View file

@ -171,6 +171,12 @@ android {
isUniversalApk = true
}
}
packaging {
jniLibs {
// Work around because libass-android & wholphin-mpv both (incorrectly) package libc++_shared.so
pickFirsts += "lib/*/libc++_shared.so"
}
}
sourceSets {
getByName("main") {
@ -269,6 +275,7 @@ dependencies {
implementation(libs.androidx.media3.exoplayer.dash)
implementation(libs.androidx.media3.ui)
implementation(libs.androidx.media3.ui.compose)
implementation(libs.ass.media)
implementation(libs.coil.core)
implementation(libs.coil.compose)

View file

@ -431,16 +431,18 @@ sealed interface AppPreference<Pref, T> {
summaryOn = R.string.enabled,
summaryOff = R.string.disabled,
)
val DirectPlayAss =
AppSwitchPreference<AppPreferences>(
title = R.string.direct_play_ass,
defaultValue = true,
getter = { it.playbackPreferences.overrides.directPlayAss },
val AssSubtitleMode =
AppChoicePreference<AppPreferences, AssPlaybackMode>(
title = R.string.ass_subtitle_playback,
defaultValue = AssPlaybackMode.ASS_LIBASS,
getter = { it.playbackPreferences.overrides.assPlaybackMode },
setter = { prefs, value ->
prefs.updatePlaybackOverrides { directPlayAss = value }
prefs.updatePlaybackOverrides { assPlaybackMode = value }
},
summaryOn = R.string.enabled,
summaryOff = R.string.disabled,
displayValues = R.array.ass_subtitle_modes,
subtitles = R.array.ass_subtitle_modes_summary,
indexToValue = { AssPlaybackMode.forNumber(it) },
valueToIndex = { if (it != AssPlaybackMode.UNRECOGNIZED) it.number else AssPlaybackMode.ASS_LIBASS.number },
)
val DirectPlayPgs =
AppSwitchPreference<AppPreferences>(
@ -1100,7 +1102,7 @@ private val ExoPlayerSettings =
AppPreference.FfmpegPreference,
AppPreference.DownMixStereo,
AppPreference.Ac3Supported,
AppPreference.DirectPlayAss,
AppPreference.AssSubtitleMode,
AppPreference.DirectPlayPgs,
AppPreference.DirectPlayDoviProfile7,
AppPreference.DecodeAv1,

View file

@ -61,10 +61,11 @@ class AppPreferencesSerializer
.apply {
ac3Supported = AppPreference.Ac3Supported.defaultValue
downmixStereo = AppPreference.DownMixStereo.defaultValue
directPlayAss = AppPreference.DirectPlayAss.defaultValue
// directPlayAss = AppPreference.DirectPlayAss.defaultValue
directPlayPgs = AppPreference.DirectPlayPgs.defaultValue
mediaExtensionsEnabled =
AppPreference.FfmpegPreference.defaultValue
assPlaybackMode = AppPreference.AssSubtitleMode.defaultValue
}.build()
mpvOptions =

View file

@ -141,7 +141,7 @@ class AppUpgradeHandler
it.updatePlaybackOverrides {
ac3Supported = AppPreference.Ac3Supported.defaultValue
downmixStereo = AppPreference.DownMixStereo.defaultValue
directPlayAss = AppPreference.DirectPlayAss.defaultValue
// directPlayAss = AppPreference.DirectPlayAss.defaultValue
directPlayPgs = AppPreference.DirectPlayPgs.defaultValue
}
}
@ -319,5 +319,13 @@ class AppUpgradeHandler
}
}
}
if (previous.isEqualOrBefore(Version.fromString("0.6.2-1-g0"))) {
appPreferences.updateData {
it.updatePlaybackOverrides {
assPlaybackMode = AppPreference.AssSubtitleMode.defaultValue
}
}
}
}
}

View file

@ -1,6 +1,7 @@
package com.github.damontecres.wholphin.services
import android.content.Context
import com.github.damontecres.wholphin.preferences.AssPlaybackMode
import com.github.damontecres.wholphin.preferences.PlaybackPreferences
import com.github.damontecres.wholphin.util.profile.MediaCodecCapabilitiesTest
import com.github.damontecres.wholphin.util.profile.createDeviceProfile
@ -43,7 +44,7 @@ class DeviceProfileService
maxBitrate = prefs.maxBitrate.toInt(),
isAC3Enabled = prefs.overrides.ac3Supported,
downMixAudio = prefs.overrides.downmixStereo,
assDirectPlay = prefs.overrides.directPlayAss,
assPlaybackMode = prefs.overrides.assPlaybackMode,
pgsDirectPlay = prefs.overrides.directPlayPgs,
dolbyVisionELDirectPlay = prefs.overrides.directPlayDolbyVisionEL,
decodeAv1 = prefs.overrides.decodeAv1,
@ -58,7 +59,7 @@ class DeviceProfileService
maxBitrate = newConfig.maxBitrate,
isAC3Enabled = newConfig.isAC3Enabled,
downMixAudio = newConfig.downMixAudio,
assDirectPlay = newConfig.assDirectPlay,
assDirectPlay = newConfig.assPlaybackMode != AssPlaybackMode.ASS_TRANSCODE,
pgsDirectPlay = newConfig.pgsDirectPlay,
dolbyVisionELDirectPlay = newConfig.dolbyVisionELDirectPlay,
decodeAv1 = prefs.overrides.decodeAv1,
@ -77,7 +78,7 @@ data class DeviceProfileConfiguration(
val maxBitrate: Int,
val isAC3Enabled: Boolean,
val downMixAudio: Boolean,
val assDirectPlay: Boolean,
val assPlaybackMode: AssPlaybackMode,
val pgsDirectPlay: Boolean,
val dolbyVisionELDirectPlay: Boolean,
val decodeAv1: Boolean,

View file

@ -10,22 +10,29 @@ import androidx.datastore.core.DataStore
import androidx.media3.common.C
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.DefaultDataSource
import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.Renderer
import androidx.media3.exoplayer.RenderersFactory
import androidx.media3.exoplayer.mediacodec.MediaCodecSelector
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.exoplayer.video.MediaCodecVideoRenderer
import androidx.media3.exoplayer.video.VideoRendererEventListener
import com.github.damontecres.wholphin.preferences.AppPreference
import androidx.media3.extractor.DefaultExtractorsFactory
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.AssPlaybackMode
import com.github.damontecres.wholphin.preferences.MediaExtensionStatus
import com.github.damontecres.wholphin.preferences.PlaybackPreferences
import com.github.damontecres.wholphin.preferences.PlayerBackend
import com.github.damontecres.wholphin.util.mpv.MpvPlayer
import dagger.hilt.android.qualifiers.ApplicationContext
import io.github.peerless2012.ass.media.AssHandler
import io.github.peerless2012.ass.media.factory.AssRenderersFactory
import io.github.peerless2012.ass.media.kt.withAssMkvSupport
import io.github.peerless2012.ass.media.parser.AssSubtitleParserFactory
import io.github.peerless2012.ass.media.type.AssRenderType
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.lang.reflect.Constructor
@ -46,71 +53,17 @@ class PlayerFactory
var currentPlayer: Player? = null
private set
fun createVideoPlayer(): Player {
if (currentPlayer?.isReleased == false) {
Timber.w("Player was not released before trying to create a new one!")
currentPlayer?.release()
}
val prefs = runBlocking { appPreferences.data.firstOrNull()?.playbackPreferences }
val backend = prefs?.playerBackend ?: AppPreference.PlayerBackendPref.defaultValue
val newPlayer =
when (backend) {
PlayerBackend.PREFER_MPV,
PlayerBackend.MPV,
-> {
val enableHardwareDecoding =
prefs?.mpvOptions?.enableHardwareDecoding
?: AppPreference.MpvHardwareDecoding.defaultValue
val useGpuNext =
prefs?.mpvOptions?.useGpuNext
?: AppPreference.MpvGpuNext.defaultValue
MpvPlayer(context, enableHardwareDecoding, useGpuNext)
.apply {
playWhenReady = true
}
}
PlayerBackend.EXO_PLAYER,
PlayerBackend.UNRECOGNIZED,
-> {
val extensions = prefs?.overrides?.mediaExtensionsEnabled
val decodeAv1 = prefs?.overrides?.decodeAv1 == true
Timber.v("extensions=$extensions")
val rendererMode =
when (extensions) {
MediaExtensionStatus.MES_FALLBACK -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON
MediaExtensionStatus.MES_PREFERRED -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER
MediaExtensionStatus.MES_DISABLED -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF
else -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON
}
ExoPlayer
.Builder(context)
.setRenderersFactory(
WholphinRenderersFactory(context, decodeAv1)
.setEnableDecoderFallback(true)
.setExtensionRendererMode(rendererMode),
).build()
.apply {
playWhenReady = true
}
}
}
currentPlayer = newPlayer
return newPlayer
}
suspend fun createVideoPlayer(
backend: PlayerBackend,
prefs: PlaybackPreferences,
): Player {
): PlayerCreation {
withContext(Dispatchers.Main) {
if (currentPlayer?.isReleased == false) {
Timber.w("Player was not released before trying to create a new one!")
currentPlayer?.release()
}
}
var assHandler: AssHandler? = null
val newPlayer =
when (backend) {
PlayerBackend.PREFER_MPV,
@ -125,8 +78,14 @@ class PlayerFactory
PlayerBackend.UNRECOGNIZED,
-> {
val extensions = prefs.overrides.mediaExtensionsEnabled
val useLibAss =
prefs.overrides.assPlaybackMode == AssPlaybackMode.ASS_LIBASS
val decodeAv1 = prefs.overrides.decodeAv1
Timber.v("extensions=$extensions")
Timber.v(
"extensions=%s, assPlaybackMode=%s",
extensions,
prefs.overrides.assPlaybackMode,
)
val rendererMode =
when (extensions) {
MediaExtensionStatus.MES_FALLBACK -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON
@ -134,17 +93,42 @@ class PlayerFactory
MediaExtensionStatus.MES_DISABLED -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF
else -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON
}
ExoPlayer
.Builder(context)
.setRenderersFactory(
val dataSourceFactory = DefaultDataSource.Factory(context)
val extractorsFactory = DefaultExtractorsFactory()
var renderersFactory: RenderersFactory =
WholphinRenderersFactory(context, decodeAv1)
.setEnableDecoderFallback(true)
.setExtensionRendererMode(rendererMode),
).build()
.setExtensionRendererMode(rendererMode)
val mediaSourceFactory =
if (useLibAss) {
assHandler = AssHandler(AssRenderType.OVERLAY_OPEN_GL)
val assSubtitleParserFactory = AssSubtitleParserFactory(assHandler)
renderersFactory = AssRenderersFactory(assHandler, renderersFactory)
DefaultMediaSourceFactory(
dataSourceFactory,
extractorsFactory.withAssMkvSupport(
assSubtitleParserFactory,
assHandler,
),
).setSubtitleParserFactory(assSubtitleParserFactory)
} else {
DefaultMediaSourceFactory(
dataSourceFactory,
extractorsFactory,
)
}
ExoPlayer
.Builder(context)
.setMediaSourceFactory(mediaSourceFactory)
.setRenderersFactory(renderersFactory)
.build()
.apply {
assHandler?.init(this)
}
}
}
currentPlayer = newPlayer
return newPlayer
return PlayerCreation(newPlayer, assHandler)
}
}
@ -157,6 +141,11 @@ val Player.isReleased: Boolean
}
}
data class PlayerCreation(
val player: Player,
val assHandler: AssHandler? = null,
)
// Code is adapted from https://github.com/androidx/media/blob/release/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/DefaultRenderersFactory.java#L436
class WholphinRenderersFactory(
context: Context,

View file

@ -1,5 +1,8 @@
package com.github.damontecres.wholphin.ui.playback
import android.view.Gravity
import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.activity.compose.BackHandler
import androidx.annotation.Dimension
import androidx.annotation.OptIn
@ -40,16 +43,18 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.intl.Locale
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.core.view.children
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.LifecycleStartEffect
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.ui.SubtitleView
import androidx.media3.ui.compose.PlayerSurface
@ -61,6 +66,7 @@ import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.surfaceColorAtElevation
import com.github.damontecres.wholphin.data.model.ItemPlayback
import com.github.damontecres.wholphin.data.model.Playlist
import com.github.damontecres.wholphin.preferences.AssPlaybackMode
import com.github.damontecres.wholphin.preferences.PlayerBackend
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.preferences.skipBackOnResume
@ -79,6 +85,7 @@ import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
import com.github.damontecres.wholphin.util.Media3SubtitleOverride
import com.github.damontecres.wholphin.util.mpv.MpvPlayer
import io.github.peerless2012.ass.media.widget.AssSubtitleView
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@ -123,8 +130,7 @@ fun PlaybackPage(
LoadingState.Success -> {
val playerState by viewModel.currentPlayer.collectAsState()
PlaybackPageContent(
player = playerState!!.player,
playerBackend = playerState!!.backend,
playerState = playerState!!,
preferences = preferences,
destination = destination,
viewModel = viewModel,
@ -137,13 +143,15 @@ fun PlaybackPage(
@OptIn(UnstableApi::class)
@Composable
fun PlaybackPageContent(
player: Player,
playerBackend: PlayerBackend,
playerState: PlayerState,
preferences: UserPreferences,
destination: Destination,
modifier: Modifier = Modifier,
viewModel: PlaybackViewModel,
) {
val player = playerState.player
val playerBackend = playerState.backend
val prefs = preferences.appPreferences.playbackPreferences
val scope = rememberCoroutineScope()
val configuration = LocalConfiguration.current
@ -316,10 +324,14 @@ fun PlaybackPageContent(
.focusRequester(focusRequester)
.focusable(),
) {
var playerSurfaceSize by remember { mutableStateOf(IntSize.Zero) }
PlayerSurface(
player = player,
surfaceType = SURFACE_TYPE_SURFACE_VIEW,
modifier = scaledModifier,
modifier =
scaledModifier.onSizeChanged {
playerSurfaceSize = it
},
)
if (presentationState.coverSurface) {
Box(
@ -415,7 +427,7 @@ fun PlaybackPageContent(
remember(subtitleSettings) { subtitleSettings.imageSubtitleOpacity / 100f }
// Subtitles
if (skipIndicatorDuration == 0L && currentItemPlayback.subtitleIndexEnabled) {
if (skipIndicatorDuration == 0L && currentItemPlayback.subtitleIndexEnabled && !presentationState.coverSurface) {
val maxSize by animateFloatAsState(if (controllerViewState.controlsVisible) .7f else 1f)
val isImageSubtitles = remember(cues) { cues.firstOrNull()?.bitmap != null }
AndroidView(
@ -426,12 +438,42 @@ fun PlaybackPageContent(
setFixedTextSize(Dimension.SP, it.fontSize.toFloat())
setBottomPaddingFraction(it.margin.toFloat() / 100f)
}
playerState.assHandler?.let { assHandler ->
if (prefs.overrides.assPlaybackMode == AssPlaybackMode.ASS_LIBASS) {
Timber.v("Adding AssSubtitleView")
addView(
AssSubtitleView(context, assHandler).apply {
layoutParams =
FrameLayout
.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
).apply { gravity = Gravity.CENTER }
},
)
}
}
}
},
update = {
it.setCues(cues)
Media3SubtitleOverride(subtitleSettings.calculateEdgeSize(density))
.apply(it)
it.children.firstOrNull { it is AssSubtitleView }?.let {
(it as? AssSubtitleView)?.apply {
val resized =
layoutParams.let { it.width != playerSurfaceSize.width || it.height != playerSurfaceSize.height }
if (resized) {
Timber.v("Resizing AssSubtitleView: $playerSurfaceSize")
layoutParams =
FrameLayout
.LayoutParams(
playerSurfaceSize.width,
playerSurfaceSize.height,
).apply { gravity = Gravity.CENTER }
}
}
}
},
onReset = {
it.setCues(null)

View file

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

View file

@ -69,6 +69,7 @@ import com.github.damontecres.wholphin.ui.playback.isDirectionalDpad
import com.github.damontecres.wholphin.ui.playback.isDpad
import com.github.damontecres.wholphin.ui.playback.isEnterKey
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.LoadingState
import org.jellyfin.sdk.model.api.MediaType
import timber.log.Timber
import kotlin.math.abs
@ -92,7 +93,20 @@ fun SlideshowPage(
),
) {
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 imageFilter by viewModel.imageFilter.observeAsState(VideoFilter())
val position by viewModel.position.observeAsState(0)
@ -389,15 +403,15 @@ fun SlideshowPage(
)
}
transformOrigin = TransformOrigin(xTransform, yTransform)
transformOrigin =
TransformOrigin(xTransform, yTransform)
}.rotate(rotateAnimation),
model =
ImageRequest
.Builder(LocalContext.current)
.data(imageState.url)
.apply {
if (isZoomed) size(Size.ORIGINAL)
}.transitionFactory(CrossFadeFactory(750.milliseconds))
.size(Size.ORIGINAL)
.transitionFactory(CrossFadeFactory(750.milliseconds))
.useExistingImageAsPlaceholder(true)
.build(),
contentDescription = null,
@ -492,3 +506,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.VideoFilter
import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.PlayerBackend
import com.github.damontecres.wholphin.services.ImageUrlService
import com.github.damontecres.wholphin.services.PlayerFactory
import com.github.damontecres.wholphin.services.ScreensaverService
@ -30,6 +31,7 @@ import com.github.damontecres.wholphin.ui.util.ThrottledLiveData
import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import com.github.damontecres.wholphin.util.LoadingState
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
@ -77,9 +79,8 @@ class SlideshowViewModel
fun create(slideshow: Destination.Slideshow): SlideshowViewModel
}
val player by lazy {
playerFactory.createVideoPlayer()
}
lateinit var player: Player
private set
private var saveFilters = true
@ -96,7 +97,6 @@ class SlideshowViewModel
var slideshowDelay by Delegates.notNull<Long>()
// private val album = MutableLiveData<BaseItem>()
private val _pager = MutableLiveData<ApiRequestPager<GetItemsRequest>>()
val pager: LiveData<List<BaseItem?>> = _pager.map { it }
val position = MutableLiveData(0)
@ -104,6 +104,8 @@ class SlideshowViewModel
private val _image = MutableLiveData<ImageState>()
val image: LiveData<ImageState> = _image
val loading = MutableStateFlow<LoadingState>(LoadingState.Pending)
val loadingState = MutableLiveData<ImageLoadingState>(ImageLoadingState.Loading)
private val _imageFilter = MutableLiveData(VideoFilter())
val imageFilter = ThrottledLiveData(_imageFilter, 500L)
@ -113,22 +115,26 @@ class SlideshowViewModel
init {
addCloseable {
screensaverService.keepScreenOn(false)
if (this@SlideshowViewModel::player.isInitialized) {
player.removeListener(this@SlideshowViewModel)
player.release()
}
player.addListener(this@SlideshowViewModel)
}
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 =
photoPrefs.slideshowDuration.takeIf { it >= AppPreference.SlideshowDuration.min }
?: AppPreference.SlideshowDuration.defaultValue
// val album =
// api.userLibraryApi
// .getItem(
// itemId = slideshowSettings.parentId,
// ).content
// .let { BaseItem(it, false) }
// this@SlideshowViewModel.album.setValueOnMain(album)
val includeItemTypes =
if (photoPrefs.slideshowPlayVideos) {
listOf(BaseItemKind.PHOTO, BaseItemKind.VIDEO)
@ -163,8 +169,13 @@ class SlideshowViewModel
ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope)
.init(slideshowSettings.index)
this@SlideshowViewModel._pager.setValueOnMain(pager)
loading.update { LoadingState.Success }
updatePosition(slideshowSettings.index)?.join()
if (slideshowSettings.startSlideshow) onMain { startSlideshow() }
} catch (ex: Exception) {
Timber.e(ex, "Error")
loading.update { LoadingState.Error(ex) }
}
}
}

View file

@ -40,14 +40,21 @@ message MpvOptions{
bool use_gpu_next = 2;
}
enum AssPlaybackMode{
ASS_LIBASS = 0;
ASS_EXO_PLAYER = 1;
ASS_TRANSCODE = 2;
}
message PlaybackOverrides{
bool ac3_supported = 1;
bool downmix_stereo = 2;
bool direct_play_ass = 3;
bool direct_play_ass = 3 [deprecated = true];
bool direct_play_pgs = 4;
MediaExtensionStatus media_extensions_enabled = 5;
bool direct_play_dolby_vision_e_l = 6;
bool decode_av1 = 7;
AssPlaybackMode ass_playback_mode = 8;
}
message PlaybackPreferences {

View file

@ -312,7 +312,7 @@
<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"><![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="downmix_stereo">Always downmix to stereo</string>
<string name="ffmpeg_extension_pref">Use FFmpeg decoder module</string>
@ -757,4 +757,18 @@
<string name="discover_movies">Discover Movies</string>
<string name="studios_in">Studios in %1$s</string>
<string name="ass_subtitle_mode_libass">Direct play with libass</string>
<string name="ass_subtitle_mode_exoplayer">Direct play with ExoPlayer built-in</string>
<string name="ass_subtitle_mode_transcode">Burn in/transcode on server</string>
<string name="ass_subtitle_playback">SSA/ASS subtitle playback</string>
<array name="ass_subtitle_modes">
<item>@string/ass_subtitle_mode_libass</item>
<item>@string/ass_subtitle_mode_exoplayer</item>
<item>@string/ass_subtitle_mode_transcode</item>
</array>
<array name="ass_subtitle_modes_summary">
<item>@string/default_track</item>
<item></item><!-- Intentionally blank -->
<item></item><!-- Intentionally blank -->
</array>
</resources>

View file

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