diff --git a/android/app/build.gradle b/android/app/build.gradle index c79846f7e..5cf984daa 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -71,6 +71,19 @@ android { targetSdk = flutter.targetSdkVersion versionCode = flutter.versionCode versionName = flutter.versionName + + externalNativeBuild { + cmake { + cppFlags "" + } + } + } + + externalNativeBuild { + cmake { + path "src/main/cpp/CMakeLists.txt" + version "3.22.1" + } } composeOptions { @@ -139,6 +152,7 @@ dependencies { implementation("androidx.media3:media3-exoplayer-hls:$media3_version") implementation("org.jellyfin.media3:media3-ffmpeg-decoder:$media3_version") implementation("io.github.peerless2012:ass-media:0.3.0") + implementation("io.github.peerless2012:ass-kt:0.3.0") //UI implementation("io.github.rabehx:iconsax-compose:0.0.5") diff --git a/android/app/src/main/cpp/CMakeLists.txt b/android/app/src/main/cpp/CMakeLists.txt new file mode 100644 index 000000000..d1deeb30a --- /dev/null +++ b/android/app/src/main/cpp/CMakeLists.txt @@ -0,0 +1,7 @@ +cmake_minimum_required(VERSION 3.22.1) + +project(fladder_ass) + +add_library(fladder_ass SHARED fladder_ass.cpp) + +target_link_libraries(fladder_ass android log dl) diff --git a/android/app/src/main/cpp/fladder_ass.cpp b/android/app/src/main/cpp/fladder_ass.cpp new file mode 100644 index 000000000..b1beef94a --- /dev/null +++ b/android/app/src/main/cpp/fladder_ass.cpp @@ -0,0 +1,46 @@ +#include +#include +#include + +#define LOG_TAG "FladderAss" +#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__) + +extern "C" +JNIEXPORT jboolean JNICALL +Java_nl_jknaapen_fladder_player_AssFontConfigurator_nativeSetFonts( + JNIEnv *env, + jobject, + jlong nativeRenderer, + jstring defaultFontPath, + jstring defaultFamily +) { + if (nativeRenderer == 0) { + LOGE("nativeSetFonts missing renderer"); + return JNI_FALSE; + } + + void *library = dlopen("libass.so", RTLD_NOW); + if (library == nullptr) { + LOGE("dlopen libass.so failed: %s", dlerror()); + return JNI_FALSE; + } + + typedef void (*AssSetFontsFn)(void *, const char *, const char *, int, const char *, int); + auto assSetFonts = reinterpret_cast(dlsym(library, "ass_set_fonts")); + if (assSetFonts == nullptr) { + LOGE("dlsym ass_set_fonts failed: %s", dlerror()); + return JNI_FALSE; + } + + const char *fontPath = defaultFontPath == nullptr + ? nullptr + : env->GetStringUTFChars(defaultFontPath, nullptr); + const char *family = env->GetStringUTFChars(defaultFamily, nullptr); + // ASS_FONTPROVIDER_AUTODETECT is 1 in libass. update=true rebuilds provider after addFont(). + assSetFonts(reinterpret_cast(nativeRenderer), fontPath, family, 1, nullptr, 1); + env->ReleaseStringUTFChars(defaultFamily, family); + if (fontPath != nullptr) { + env->ReleaseStringUTFChars(defaultFontPath, fontPath); + } + return JNI_TRUE; +} diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/dialogs/AudioSelection.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/dialogs/AudioSelection.kt index 4557399f3..dbc400c9e 100644 --- a/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/dialogs/AudioSelection.kt +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/dialogs/AudioSelection.kt @@ -21,7 +21,6 @@ import nl.jknaapen.fladder.objects.Localized import nl.jknaapen.fladder.objects.Translate import nl.jknaapen.fladder.objects.VideoPlayerObject import nl.jknaapen.fladder.utility.clearAudioTrack -import nl.jknaapen.fladder.utility.setInternalAudioTrack @OptIn(UnstableApi::class) @Composable @@ -33,11 +32,11 @@ fun AudioPicker( val audioTracks by VideoPlayerObject.audioTracks.collectAsState(emptyList()) val internalAudioTracks by VideoPlayerObject.exoAudioTracks.collectAsState(emptyList()) - if (internalAudioTracks.isEmpty()) return + if (audioTracks.isEmpty()) return val focusOffTrack = remember { FocusRequester() } - val focusRequesters = remember(internalAudioTracks) { - internalAudioTracks.associateWith { FocusRequester() } + val focusRequesters = remember(audioTracks) { + audioTracks.associateWith { FocusRequester() } } val listState = rememberLazyListState() @@ -55,11 +54,8 @@ fun AudioPicker( return@LaunchedEffect } - val internalIndex = serverTrackIndex - 1 - val lazyColumnIndex = internalIndex + 1 - - listState.scrollToItem(lazyColumnIndex) - focusRequesters[internalAudioTracks[internalIndex]]?.requestFocus() + listState.scrollToItem(serverTrackIndex) + focusRequesters[audioTracks[serverTrackIndex]]?.requestFocus() } CustomModalBottomSheet( @@ -90,22 +86,20 @@ fun AudioPicker( } } - internalAudioTracks.forEachIndexed { index, track -> - val serverTrack = audioTracks.elementAtOrNull(index + 1) - val selected = serverTrack?.index?.toInt() == selectedIndex + audioTracks.drop(1).forEachIndexed { index, serverTrack -> + val selected = serverTrack.index.toInt() == selectedIndex item { TrackButton( modifier = Modifier .fillMaxWidth() - .focusRequester(focusRequesters[track]!!), + .focusRequester(focusRequesters[serverTrack]!!), onClick = { - serverTrack?.index?.let { VideoPlayerObject.setAudioTrackIndex(it.toInt()) } - player.setInternalAudioTrack(track) + VideoPlayerObject.setAudioTrackIndex(serverTrack.index.toInt()) }, selected = selected ) { - Text(serverTrack?.name ?: "") + Text(serverTrack.name) } } } diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/dialogs/SubtitlePicker.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/dialogs/SubtitlePicker.kt index b5a8d8647..2cd067302 100644 --- a/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/dialogs/SubtitlePicker.kt +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/dialogs/SubtitlePicker.kt @@ -22,7 +22,6 @@ import nl.jknaapen.fladder.objects.Localized import nl.jknaapen.fladder.objects.Translate import nl.jknaapen.fladder.objects.VideoPlayerObject import nl.jknaapen.fladder.utility.clearSubtitleTrack -import nl.jknaapen.fladder.utility.setInternalSubtitleTrack @OptIn(UnstableApi::class) @Composable @@ -32,7 +31,6 @@ fun SubtitlePicker( ) { val selectedIndex by VideoPlayerObject.currentSubtitleTrackIndex.collectAsState() val subTitles by VideoPlayerObject.subtitleTracks.collectAsState(emptyList()) - val internalSubTracks by VideoPlayerObject.exoSubTracks.collectAsState(emptyList()) if (subTitles.isEmpty()) return @@ -75,15 +73,7 @@ fun SubtitlePicker( VideoPlayerObject.setSubtitleTrackIndex(-1) player.clearSubtitleTrack() } else { - val internalTrackIndex = index - 1 - - val internalSubTrack = - internalSubTracks.elementAtOrNull(internalTrackIndex) - - if (internalSubTrack != null) { - VideoPlayerObject.setSubtitleTrackIndex(serverSub.index.toInt()) - player.setInternalSubtitleTrack(internalSubTrack) - } + VideoPlayerObject.setSubtitleTrackIndex(serverSub.index.toInt()) } }, selected = selected, @@ -102,4 +92,4 @@ fun SubtitlePicker( } } } -} \ No newline at end of file +} diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/messengers/VideoPlayerImplementation.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/messengers/VideoPlayerImplementation.kt index 88f0ed170..cdd87b910 100644 --- a/android/app/src/main/kotlin/nl/jknaapen/fladder/messengers/VideoPlayerImplementation.kt +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/messengers/VideoPlayerImplementation.kt @@ -18,6 +18,9 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.map import nl.jknaapen.fladder.objects.PlayerSettingsObject import nl.jknaapen.fladder.objects.VideoPlayerObject +import nl.jknaapen.fladder.player.FladderAssSidecarController +import nl.jknaapen.fladder.player.isAssSubtitleTrack +import nl.jknaapen.fladder.player.isAssSubtitleUrl import nl.jknaapen.fladder.utility.clearAudioTrack import nl.jknaapen.fladder.utility.clearSubtitleTrack import nl.jknaapen.fladder.utility.enableSubtitles @@ -29,7 +32,12 @@ import kotlin.time.Duration.Companion.seconds class VideoPlayerImplementation( ) : VideoPlayerApi { + private companion object { + const val MAX_PRELOADED_SIDECAR_SUBTITLES = 25 + } + var player: ExoPlayer? = null + private var assSidecarController: FladderAssSidecarController? = null val playbackData: MutableStateFlow = MutableStateFlow(null) var subsInitialized = false @@ -85,13 +93,41 @@ class VideoPlayerImplementation( ignoreCase = true ) val subTitles = playbackData.value?.subtitleTracks ?: listOf() + val playableData = playbackData.value + val useManualAssSidecars = playableData.usesManualAssSidecarPlayback() + val subtitleConfigurations = subTitles + .filter { !it.url.isNullOrEmpty() } + .let { subtitlesWithUrl -> + if (useManualAssSidecars) { + return@let subtitlesWithUrl.filterNot { it.isAssSubtitleTrack() } + } + val assTrackCount = subtitlesWithUrl.count { it.isAssSubtitleTrack() } + if ( + sidecarSubtitlesCanPreload( + subtitlesWithUrl.size, + assTrackCount, + MAX_PRELOADED_SIDECAR_SUBTITLES + ) + ) { + subtitlesWithUrl + } else { + val selectedSubtitleIndex = playbackData.value?.defaultSubtrack ?: -1 + subtitlesWithUrl.filter { it.index == selectedSubtitleIndex } + } + } + assSidecarController?.configure( + subTitles, + useManualAssSidecars, + playableData?.defaultSubtrack ?: -1, + ) val mediaItemBuilder = MediaItem.Builder() .setUri(url) .setTag(playbackData.value?.currentItem?.title) .setMediaId(playbackData.value?.currentItem?.id ?: "") .setSubtitleConfigurations( - subTitles.filter { it.external && !it.url.isNullOrEmpty() }.map { sub -> + subtitleConfigurations.map { sub -> MediaItem.SubtitleConfiguration.Builder(sub.url!!.toUri()) + .setId("fladder-sub-${sub.index}") .setMimeType(guessSubtitleMimeType(sub.url)) .setLanguage(sub.languageCode) .setLabel(sub.name) @@ -165,21 +201,63 @@ class VideoPlayerImplementation( open(playData.url, true, callback = {}) } } + + fun initAssSidecarController(controller: FladderAssSidecarController?) { + assSidecarController = controller + } + + fun applySubtitleTrack(trackIndex: Int) { + val playableData = playbackData.value + if (playableData.usesManualAssSidecarPlayback()) { + val selectedSubtitle = playableData?.subtitleTracks + ?.firstOrNull { it.index.toInt() == trackIndex } + if (trackIndex < 0 || selectedSubtitle?.isAssSubtitleTrack() == true) { + assSidecarController?.select(trackIndex.toLong()) + player?.clearSubtitleTrack() + return + } + assSidecarController?.select(-1) + } + player?.selectSubtitleTrack(playbackData.value, trackIndex) + } + + fun applyAudioTrack(trackIndex: Int) { + player?.selectAudioTrack(playbackData.value, trackIndex) + } + } fun guessSubtitleMimeType(fileName: String): String = when { fileName.contains(".srt", ignoreCase = true) -> MimeTypes.APPLICATION_SUBRIP fileName.contains(".vtt", ignoreCase = true) -> MimeTypes.TEXT_VTT fileName.contains(".ass", ignoreCase = true) -> MimeTypes.TEXT_SSA + fileName.contains(".ssa", ignoreCase = true) -> MimeTypes.TEXT_SSA else -> MimeTypes.APPLICATION_SUBRIP } +private fun sidecarSubtitlesCanPreload(total: Int, assTrackCount: Int, maxPreloaded: Int = 25): Boolean = + total <= maxPreloaded && assTrackCount <= 1 + +private fun PlayableData?.usesManualAssSidecarPlayback(maxPreloaded: Int = 25): Boolean { + if (this?.mediaInfo?.playbackType != PlaybackType.TRANSCODED) return false + val subtitlesWithUrl = subtitleTracks.filter { !it.url.isNullOrEmpty() } + return subtitlesWithUrl.size <= maxPreloaded && subtitlesWithUrl.any { + it.isAssSubtitleTrack() + } +} + fun ExoPlayer.properlySetSubAndAudioTracks(playableData: PlayableData) { if (playableData.mediaInfo.playbackType == PlaybackType.TV) { // In TV mode, do not set tracks here as they are handled differently return } try { + if (playableData.mediaInfo.playbackType == PlaybackType.TRANSCODED) { + selectSubtitleTrack(playableData, playableData.defaultSubtrack.toInt()) + clearAudioTrack(false) + return + } + val currentSubIndex = playableData.defaultSubtrack val indexOfSubtitleTrack = playableData.subtitleTracks.indexOfFirst { it.index == currentSubIndex } @@ -188,7 +266,7 @@ fun ExoPlayer.properlySetSubAndAudioTracks(playableData: PlayableData) { val wantedSubIndex = indexOfSubtitleTrack - 1 if (wantedSubIndex < 0) { clearSubtitleTrack() - } else { + } else if (wantedSubIndex < internalSubTracks.size) { enableSubtitles() setInternalSubtitleTrack(internalSubTracks[wantedSubIndex]) } @@ -201,7 +279,7 @@ fun ExoPlayer.properlySetSubAndAudioTracks(playableData: PlayableData) { val wantedAudioIndex = indexOfAudioTrack - 1 if (wantedAudioIndex < 0) { clearAudioTrack() - } else { + } else if (wantedAudioIndex < internalAudioTracks.size) { clearAudioTrack(false) setInternalAudioTrack(internalAudioTracks[wantedAudioIndex]) } @@ -209,3 +287,47 @@ fun ExoPlayer.properlySetSubAndAudioTracks(playableData: PlayableData) { e.printStackTrace() } } + +fun ExoPlayer.selectSubtitleTrack(playableData: PlayableData?, selectedSubIndex: Int) { + if (playableData == null) return + if (selectedSubIndex < 0) { + clearSubtitleTrack() + return + } + + val internalSubTracks = getSubtitleTracks() + val wantedSubIndex = when (playableData.mediaInfo.playbackType) { + PlaybackType.TRANSCODED -> { + val subtitlesWithUrl = playableData.subtitleTracks.filter { !it.url.isNullOrEmpty() } + val selectableSubtitles = if (playableData.usesManualAssSidecarPlayback()) { + subtitlesWithUrl.filterNot { it.isAssSubtitleTrack() } + } else { + subtitlesWithUrl + } + val assTrackCount = subtitlesWithUrl.count { it.isAssSubtitleTrack() } + if (sidecarSubtitlesCanPreload(subtitlesWithUrl.size, assTrackCount)) { + selectableSubtitles.indexOfFirst { it.index.toInt() == selectedSubIndex } + } else { + 0 + } + } + + else -> playableData.subtitleTracks.indexOfFirst { it.index.toInt() == selectedSubIndex } - 1 + } + + internalSubTracks.elementAtOrNull(wantedSubIndex)?.let { setInternalSubtitleTrack(it) } +} + +fun ExoPlayer.selectAudioTrack(playableData: PlayableData?, selectedAudioIndex: Int) { + if (playableData == null || playableData.mediaInfo.playbackType == PlaybackType.TRANSCODED) return + val internalAudioTracks = getAudioTracks() + val wantedAudioIndex = playableData.audioTracks.indexOfFirst { it.index.toInt() == selectedAudioIndex } - 1 + if (wantedAudioIndex < 0) { + clearAudioTrack() + } else { + internalAudioTracks.elementAtOrNull(wantedAudioIndex)?.let { + clearAudioTrack(false) + setInternalAudioTrack(it) + } + } +} diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/objects/VideoPlayerObject.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/objects/VideoPlayerObject.kt index 1f335ae51..53fead262 100644 --- a/android/app/src/main/kotlin/nl/jknaapen/fladder/objects/VideoPlayerObject.kt +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/objects/VideoPlayerObject.kt @@ -63,6 +63,7 @@ object VideoPlayerObject { fun setSubtitleTrackIndex(value: Int, init: Boolean = false) { currentSubtitleTrackIndex.value = value if (!init) { + implementation.applySubtitleTrack(value) videoPlayerControls?.swapSubtitleTrack(value.toLong(), callback = {}) } } @@ -70,6 +71,7 @@ object VideoPlayerObject { fun setAudioTrackIndex(value: Int, init: Boolean = false) { currentAudioTrackIndex.value = value if (!init) { + implementation.applyAudioTrack(value) videoPlayerControls?.swapAudioTrack(value.toLong(), callback = {}) } } @@ -77,15 +79,9 @@ object VideoPlayerObject { val subtitleTracks = implementation.playbackData.map { it?.subtitleTracks ?: listOf() } val audioTracks = implementation.playbackData.map { it?.audioTracks ?: listOf() } - val hasSubtracks: Flow = - combine(subtitleTracks, exoSubTracks.asStateFlow()) { sub, exo -> - sub.isNotEmpty() && exo.isNotEmpty() - } + val hasSubtracks: Flow = subtitleTracks.map { it.size > 1 } - val hasAudioTracks: Flow = - combine(audioTracks, exoAudioTracks.asStateFlow()) { audio, exo -> - audio.isNotEmpty() && exo.isNotEmpty() - } + val hasAudioTracks: Flow = audioTracks.map { it.size > 1 } fun setPlaybackState(state: PlaybackState) { _currentState.value = state @@ -105,4 +101,4 @@ object VideoPlayerObject { } var currentActivity: VideoPlayerActivity? = null -} \ No newline at end of file +} diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/player/AssFontConfigurator.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/player/AssFontConfigurator.kt new file mode 100644 index 000000000..aa93f0476 --- /dev/null +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/player/AssFontConfigurator.kt @@ -0,0 +1,46 @@ +package nl.jknaapen.fladder.player + +import android.util.Log +import io.github.peerless2012.ass.AssRender + +object AssFontConfigurator { + private const val TAG = "AssFontConfigurator" + + private val nativeRenderField by lazy { + AssRender::class.java.getDeclaredField("nativeRender").apply { + isAccessible = true + } + } + + init { + runCatching { System.loadLibrary("fladder_ass") } + .onFailure { error -> + Log.w(TAG, "Unable to load ASS font native helper", error) + } + } + + fun setFonts( + render: AssRender, + defaultFontPath: String? = "/system/fonts/NotoSansCJK-Regular.ttc", + defaultFamily: String = "Noto Sans CJK JP", + ): Boolean { + val nativeRender = runCatching { nativeRenderField.getLong(render) } + .onFailure { error -> + Log.w(TAG, "Unable to access native ASS renderer", error) + } + .getOrDefault(0L) + if (nativeRender == 0L) return false + + return runCatching { nativeSetFonts(nativeRender, defaultFontPath, defaultFamily) } + .onFailure { error -> + Log.w(TAG, "Unable to configure ASS fonts", error) + } + .getOrDefault(false) + } + + private external fun nativeSetFonts( + nativeRender: Long, + defaultFontPath: String?, + defaultFamily: String, + ): Boolean +} diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/player/ExoPlayer.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/player/ExoPlayer.kt index 44bb2a8d5..d04a5fc84 100644 --- a/android/app/src/main/kotlin/nl/jknaapen/fladder/player/ExoPlayer.kt +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/player/ExoPlayer.kt @@ -2,6 +2,7 @@ package nl.jknaapen.fladder.player import PlaybackState import android.app.ActivityManager +import android.content.Context import android.os.Handler import android.os.Looper import android.view.ViewGroup @@ -42,7 +43,9 @@ import androidx.media3.extractor.ts.TsExtractor import androidx.media3.ui.AspectRatioFrameLayout import androidx.media3.ui.CaptionStyleCompat import androidx.media3.ui.PlayerView -import io.github.peerless2012.ass.media.kt.buildWithAssSupport +import io.github.peerless2012.ass.media.AssHandler +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.delay @@ -55,6 +58,7 @@ import nl.jknaapen.fladder.utility.AllowedOrientations import nl.jknaapen.fladder.utility.conditional import nl.jknaapen.fladder.utility.getAudioTracks import nl.jknaapen.fladder.utility.getSubtitleTracks +import java.io.File import kotlin.time.Duration.Companion.seconds val LocalPlayer = compositionLocalOf { null } @@ -106,20 +110,48 @@ internal fun ExoPlayer( }) } + val playerView = remember { + PlayerView(context).apply { + useController = false + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) + keepScreenOn = false + } + } + val exoPlayer = remember { + val assHandler = AssHandler(AssRenderType.OVERLAY) + val assSidecarController = FladderAssSidecarController(assHandler) + val assFontConfig = installAssFonts(context, assHandler) + val assSubtitleParserFactory = AssSubtitleParserFactory(assHandler) + val assMediaSourceFactory = DefaultMediaSourceFactory( + dataSourceFactory, + extractorsFactory.withAssMkvSupport(assSubtitleParserFactory, assHandler), + ).setSubtitleParserFactory(assSubtitleParserFactory) + val assRenderersFactory = FladderAssRenderersFactory(assHandler, renderersFactory) + ExoPlayer.Builder(context, renderersFactory) .setTrackSelector(trackSelector) - .setMediaSourceFactory(DefaultMediaSourceFactory(dataSourceFactory, extractorsFactory)) + .setMediaSourceFactory(assMediaSourceFactory) + .setRenderersFactory(assRenderersFactory) .setAudioAttributes(audioAttributes, true) .setHandleAudioBecomingNoisy(true) .setPauseAtEndOfMediaItems(true) .setVideoScalingMode(C.VIDEO_SCALING_MODE_SCALE_TO_FIT) - .buildWithAssSupport( - context, - renderersFactory = renderersFactory, - extractorsFactory = extractorsFactory, - renderType = AssRenderType.LEGACY - ) + .build() + .also { player -> + playerView.subtitleView?.addView( + FladderAssSubtitleView(context, assHandler, assFontConfig, assSidecarController), + ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) + ) + assHandler.init(player) + VideoPlayerObject.implementation.initAssSidecarController(assSidecarController) + } } fun updatePlaybackState() { @@ -210,9 +242,11 @@ internal fun ExoPlayer( VideoPlayerObject.implementation.init(exoPlayer) onDispose { videoHost.videoPlayerControls?.onStop(callback = {}) + playerView.player = null VideoPlayerObject.implementation.playbackData.value = null VideoPlayerObject.tvGuide.value = null VideoPlayerObject.implementation.init(null) + VideoPlayerObject.implementation.initAssSidecarController(null) exoPlayer.release() } } @@ -234,7 +268,8 @@ internal fun ExoPlayer( displayCutoutPadding() }, factory = { - PlayerView(it).apply { + (playerView.parent as? ViewGroup)?.removeView(playerView) + playerView.apply { player = exoPlayer useController = false resizeMode = videoFit @@ -311,3 +346,54 @@ internal fun ExoPlayer( } } } + +data class AssFontConfig( + val defaultFontPath: String?, + val defaultFamily: String, +) + +private fun installAssFonts(context: Context, assHandler: AssHandler): AssFontConfig { + val bundledFallback = installBundledAssFallbackFont(context, assHandler) + val fontPaths = listOf( + "/system/fonts/DroidSans.ttf", + "/system/fonts/DroidSans-Bold.ttf", + "/system/fonts/Roboto-Regular.ttf", + "/system/fonts/Roboto-Bold.ttf", + "/system/fonts/NotoSansCJK-Regular.ttc", + "/system/fonts/NotoSerifCJK-Regular.ttc", + "/system/fonts/NotoNaskhArabic-Regular.ttf", + "/system/fonts/NotoNaskhArabic-Bold.ttf", + "/system/fonts/NotoSansHebrew-Regular.ttf", + "/system/fonts/NotoSansThai-Regular.ttf", + "/system/fonts/NotoSansDevanagari-Regular.otf", + ) + + fontPaths.forEach { path -> + runCatching { + val file = File(path) + if (!file.canRead()) { + return@forEach + } + val bytes = file.readBytes() + assHandler.ass.addFont(file.name, bytes) + } + } + + return AssFontConfig( + defaultFontPath = bundledFallback ?: "/system/fonts/NotoSansCJK-Regular.ttc", + defaultFamily = if (bundledFallback != null) "Droid Sans Fallback" else "Noto Sans CJK JP", + ) +} + +private fun installBundledAssFallbackFont(context: Context, assHandler: AssHandler): String? { + return runCatching { + val targetDir = File(context.cacheDir, "fladder-ass-fonts").apply { mkdirs() } + val target = File(targetDir, "mp-font.ttf") + context.assets.open("flutter_assets/assets/mp-font.ttf").use { input -> + target.outputStream().use { output -> input.copyTo(output) } + } + val bytes = target.readBytes() + assHandler.ass.addFont("Droid Sans Fallback", bytes) + target.absolutePath + }.getOrNull() +} diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/player/FladderAssRenderersFactory.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/player/FladderAssRenderersFactory.kt new file mode 100644 index 000000000..6e8714c04 --- /dev/null +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/player/FladderAssRenderersFactory.kt @@ -0,0 +1,66 @@ +package nl.jknaapen.fladder.player + +import android.os.Handler +import androidx.media3.exoplayer.NoSampleRenderer +import androidx.media3.exoplayer.Renderer +import androidx.media3.exoplayer.RenderersFactory +import androidx.media3.exoplayer.audio.AudioRendererEventListener +import androidx.media3.exoplayer.metadata.MetadataOutput +import androidx.media3.exoplayer.text.TextOutput +import androidx.media3.exoplayer.video.VideoRendererEventListener +import io.github.peerless2012.ass.media.AssHandler + +class FladderAssRenderersFactory( + private val assHandler: AssHandler, + private val renderersFactory: RenderersFactory, +) : RenderersFactory { + override fun createRenderers( + eventHandler: Handler, + videoRendererEventListener: VideoRendererEventListener, + audioRendererEventListener: AudioRendererEventListener, + textRendererOutput: TextOutput, + metadataRendererOutput: MetadataOutput, + ): Array = + renderersFactory.createRenderers( + eventHandler, + videoRendererEventListener, + audioRendererEventListener, + textRendererOutput, + metadataRendererOutput, + ) + FladderAssTimeRenderer(assHandler) + + override fun createSecondaryRenderer( + renderer: Renderer, + eventHandler: Handler, + videoRendererEventListener: VideoRendererEventListener, + audioRendererEventListener: AudioRendererEventListener, + textRendererOutput: TextOutput, + metadataRendererOutput: MetadataOutput, + ): Renderer? = + renderersFactory.createSecondaryRenderer( + renderer, + eventHandler, + videoRendererEventListener, + audioRendererEventListener, + textRendererOutput, + metadataRendererOutput, + ) +} + +private class FladderAssTimeRenderer( + private val assHandler: AssHandler, +) : NoSampleRenderer() { + override fun getName(): String = "FladderAssTimeRenderer" + + override fun render(positionUs: Long, elapsedRealtimeUs: Long) { + val safePositionUs = when { + positionUs >= SUBTITLE_TIME_OFFSET_US -> positionUs - SUBTITLE_TIME_OFFSET_US + else -> positionUs + }.coerceAtLeast(0L) + assHandler.videoTime = safePositionUs + } + + private companion object { + const val SUBTITLE_TIME_OFFSET_US = 1_000_000_000_000L + } +} diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/player/FladderAssSidecarController.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/player/FladderAssSidecarController.kt new file mode 100644 index 000000000..81bad07c2 --- /dev/null +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/player/FladderAssSidecarController.kt @@ -0,0 +1,182 @@ +package nl.jknaapen.fladder.player + +import SubtitleTrack +import android.os.Handler +import android.os.Looper +import io.github.peerless2012.ass.AssFrame +import io.github.peerless2012.ass.AssRender +import io.github.peerless2012.ass.AssTrack +import io.github.peerless2012.ass.media.AssHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import java.net.URL + +class FladderAssSidecarController( + private val assHandler: AssHandler, +) { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val mainHandler = Handler(Looper.getMainLooper()) + private val lock = Any() + private val subtitles = mutableMapOf() + private val subtitleBytes = mutableMapOf() + private val loading = mutableSetOf() + + private var manualMode = false + private var generation = 0L + private var wantedIndex: Long? = null + private var activeTrack: AssTrack? = null + private var manualRender: AssRender? = null + private var onActiveTrackChanged: (() -> Unit)? = null + + fun setOnActiveTrackChanged(callback: (() -> Unit)?) { + synchronized(lock) { + onActiveTrackChanged = callback + } + } + + fun configure(tracks: List, enabled: Boolean, selectedIndex: Long) { + val assTracks = tracks.filter { it.isAssSubtitleTrack() } + val currentGeneration = synchronized(lock) { + generation += 1 + generation + } + synchronized(lock) { + manualMode = enabled + subtitles.clear() + subtitleBytes.clear() + loading.clear() + activeTrack = null + manualRender = null + wantedIndex = selectedIndex.takeIf { enabled && it >= 0 } + assTracks.forEach { subtitles[it.index] = it } + } + + if (!enabled) { + notifyActiveTrackChanged() + return + } + + assTracks.forEach { preload(it.index, currentGeneration) } + select(selectedIndex) + } + + fun select(index: Long): Boolean { + val track = synchronized(lock) { + if (!manualMode) return false + wantedIndex = index.takeIf { it >= 0 } + activeTrack = null + subtitles[index] + } + + if (index < 0) { + notifyActiveTrackChanged() + return true + } + + if (track == null) { + notifyActiveTrackChanged() + return false + } + + val cachedBytes = synchronized(lock) { subtitleBytes[index] } + if (cachedBytes != null) { + activate(index, cachedBytes) + } else { + preload(index, synchronized(lock) { generation }) + notifyActiveTrackChanged() + } + return true + } + + fun renderFrame(render: AssRender, fallbackTrack: AssTrack?, videoTimeUs: Long): AssFrame? { + synchronized(lock) { + val track = if (manualMode) activeTrack else fallbackTrack + if (track == null) return null + render.setTrack(track) + return render.renderFrame(videoTimeUs / 1000L, true) + } + } + + fun renderForManualMode(): AssRender? { + synchronized(lock) { + if (!manualMode) return null + if (manualRender == null) { + manualRender = assHandler.ass.createRender() + } + return manualRender + } + } + + private fun preload(index: Long, trackGeneration: Long) { + val track = synchronized(lock) { + if ( + trackGeneration != generation || + !manualMode || + subtitleBytes.containsKey(index) || + !loading.add(index) + ) { + return + } + subtitles[index] + } ?: return + + scope.launch { + val bytes = runCatching { download(track.url!!) } + .getOrNull() + if (bytes == null) { + synchronized(lock) { loading.remove(index) } + return@launch + } + + val shouldActivate = synchronized(lock) { + loading.remove(index) + if (trackGeneration != generation) { + return@synchronized false + } + subtitleBytes[index] = bytes + wantedIndex == index + } + + if (shouldActivate) { + activate(index, bytes) + } + } + } + + private fun activate(index: Long, bytes: ByteArray) { + val changed = synchronized(lock) { + if (!manualMode || wantedIndex != index) return + activeTrack = assHandler.ass.createTrack().apply { + readBuffer(bytes, 0, bytes.size) + } + true + } + + if (changed) { + notifyActiveTrackChanged() + } + } + + private fun notifyActiveTrackChanged() { + val callback = synchronized(lock) { onActiveTrackChanged } + mainHandler.post { callback?.invoke() } + } + + private fun download(url: String): ByteArray { + val connection = URL(url).openConnection() + connection.connectTimeout = 10_000 + connection.readTimeout = 20_000 + return connection.getInputStream().use { it.readBytes() } + } +} + +fun String?.isAssSubtitleUrl(): Boolean = + this?.contains(".ass", ignoreCase = true) == true || + this?.contains(".ssa", ignoreCase = true) == true + +fun SubtitleTrack.isAssSubtitleTrack(): Boolean = + url.isAssSubtitleUrl() || + codec.equals("ass", ignoreCase = true) || + codec.equals("ssa", ignoreCase = true) diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/player/FladderAssSubtitleView.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/player/FladderAssSubtitleView.kt new file mode 100644 index 000000000..2a74dd88b --- /dev/null +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/player/FladderAssSubtitleView.kt @@ -0,0 +1,107 @@ +package nl.jknaapen.fladder.player + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.PorterDuff +import android.graphics.PorterDuffXfermode +import android.view.View +import io.github.peerless2012.ass.AssFrame +import io.github.peerless2012.ass.AssRender +import io.github.peerless2012.ass.media.AssHandler + +class FladderAssSubtitleView( + context: Context, + private val assHandler: AssHandler, + private val assFontConfig: AssFontConfig, + private val assSidecarController: FladderAssSidecarController, +) : View(context) { + private val paint = Paint().apply { + xfermode = PorterDuffXfermode(PorterDuff.Mode.SRC_OVER) + } + private var assFrame: AssFrame? = null + private var configuredRender: AssRender? = null + + init { + setWillNotDraw(false) + } + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + assHandler.renderCallback = { render: AssRender? -> + render?.let(::configureRender) + } + + assHandler.videoTimeCallback = { videoTimeUs -> + val frame = renderFrameDirect(videoTimeUs) + assFrame = when { + frame?.images?.isNotEmpty() == true -> frame + frame?.changed == 0 -> assFrame + else -> frame + } + postInvalidateOnAnimation() + } + assSidecarController.setOnActiveTrackChanged { + assFrame = null + postInvalidateOnAnimation() + } + } + + override fun onDetachedFromWindow() { + assHandler.renderCallback = null + assHandler.videoTimeCallback = null + assSidecarController.setOnActiveTrackChanged(null) + super.onDetachedFromWindow() + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + canvas.drawColor(0, PorterDuff.Mode.CLEAR) + val frame = assFrame + val images = frame?.images + if (images.isNullOrEmpty()) { + return + } + + images.forEach { tex -> + val bitmap = tex.bitmap ?: return@forEach + paint.color = toAndroidColor(tex.color) + canvas.drawBitmap(bitmap, tex.x.toFloat(), tex.y.toFloat(), paint) + } + } + + private fun renderFrameDirect(videoTimeUs: Long): AssFrame? { + val render = assSidecarController.renderForManualMode() ?: assHandler.render + val track = assHandler.track + if (render == null) return null + + return try { + configureRender(render) + if (width > 0 && height > 0) { + render.setFrameSize(width, height) + render.setStorageSize(width, height) + } + assSidecarController.renderFrame(render, track, videoTimeUs) + } catch (error: Throwable) { + null + } + } + + private fun configureRender(render: AssRender) { + if (configuredRender === render) return + configuredRender = render + val configured = AssFontConfigurator.setFonts( + render, + assFontConfig.defaultFontPath, + assFontConfig.defaultFamily, + ) + } + + private fun toAndroidColor(assColor: Int): Int { + val red = assColor shr 24 and 0xff + val green = assColor shr 16 and 0xff + val blue = assColor shr 8 and 0xff + val alpha = 255 - (assColor and 0xff) + return alpha shl 24 or (red shl 16) or (green shl 8) or blue + } +} diff --git a/lib/models/items/media_streams_model.dart b/lib/models/items/media_streams_model.dart index 97994c6dd..e1b56c1dd 100644 --- a/lib/models/items/media_streams_model.dart +++ b/lib/models/items/media_streams_model.dart @@ -8,6 +8,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:fladder/jellyfin/jellyfin_open_api.enums.swagger.dart'; import 'package:fladder/jellyfin/jellyfin_open_api.swagger.dart' as dto; import 'package:fladder/providers/api_provider.dart'; +import 'package:fladder/providers/user_provider.dart'; import 'package:fladder/util/localization_helper.dart'; import 'package:fladder/util/video_properties.dart'; @@ -71,69 +72,47 @@ class MediaStreamsModel { String? get mediaInfoTag => '${displayProfile?.value} ${resolution?.value}'; - Widget? audioIcon( - BuildContext context, - Function()? onTap, - ) { + Widget? audioIcon(BuildContext context, Function()? onTap) { final audioStream = audioStreams.firstWhereOrNull((element) => element.isDefault) ?? audioStreams.firstOrNull; if (audioStream == null) return null; - return DefaultVideoInformationBox( - onTap: onTap, - child: Text( - audioStream.title, - ), - ); + return DefaultVideoInformationBox(onTap: onTap, child: Text(audioStream.title)); } - Widget subtitleIcon( - BuildContext context, - Function()? onTap, - ) { + Widget subtitleIcon(BuildContext context, Function()? onTap) { return DefaultVideoInformationBox( onTap: onTap, - child: Icon( - subStreams.isNotEmpty ? Icons.subtitles_rounded : Icons.subtitles_off_outlined, - ), + child: Icon(subStreams.isNotEmpty ? Icons.subtitles_rounded : Icons.subtitles_off_outlined), ); } - static MediaStreamsModel fromMediaStreamsList( - List? mediaSource, - Ref ref, - ) { + static MediaStreamsModel fromMediaStreamsList(List? mediaSource, Ref ref, {String? itemId}) { return MediaStreamsModel( - defaultAudioStreamIndex: mediaSource?.firstOrNull?.defaultAudioStreamIndex, - defaultSubStreamIndex: mediaSource?.firstOrNull?.defaultSubtitleStreamIndex, - versionStreams: mediaSource?.mapIndexed( - (index, element) { - final streams = element.mediaStreams ?? []; - return VersionStreamModel( - name: element.name ?? "", - index: index, - id: element.id, - defaultAudioStreamIndex: element.defaultAudioStreamIndex, - defaultSubStreamIndex: element.defaultSubtitleStreamIndex, - videoStreams: streams - .where((element) => element.type == dto.MediaStreamType.video) - .map( - (e) => VideoStreamModel.fromMediaStream(e), - ) - .sortByExternal(), - audioStreams: streams - .where((element) => element.type == dto.MediaStreamType.audio) - .map( - (e) => AudioStreamModel.fromMediaStream(e), - ) - .sortByExternal(), - subStreams: streams - .where((element) => element.type == dto.MediaStreamType.subtitle) - .map( - (sub) => SubStreamModel.fromMediaStream(sub, ref), - ) - .sortByExternal()); - }, - ).toList() ?? - []); + defaultAudioStreamIndex: mediaSource?.firstOrNull?.defaultAudioStreamIndex, + defaultSubStreamIndex: mediaSource?.firstOrNull?.defaultSubtitleStreamIndex, + versionStreams: mediaSource?.mapIndexed((index, element) { + final streams = element.mediaStreams ?? []; + return VersionStreamModel( + name: element.name ?? "", + index: index, + id: element.id, + defaultAudioStreamIndex: element.defaultAudioStreamIndex, + defaultSubStreamIndex: element.defaultSubtitleStreamIndex, + videoStreams: streams + .where((element) => element.type == dto.MediaStreamType.video) + .map((e) => VideoStreamModel.fromMediaStream(e)) + .sortByExternal(), + audioStreams: streams + .where((element) => element.type == dto.MediaStreamType.audio) + .map((e) => AudioStreamModel.fromMediaStream(e)) + .sortByExternal(), + subStreams: streams + .where((element) => element.type == dto.MediaStreamType.subtitle) + .map((sub) => SubStreamModel.fromMediaStream(sub, ref, itemId: itemId, mediaSourceId: element.id)) + .sortByExternal(), + ); + }).toList() ?? + [], + ); } MediaStreamsModel copyWith({ @@ -407,12 +386,16 @@ class SubStreamModel extends AudioAndSubStreamModel { } } - factory SubStreamModel.fromMediaStream(dto.MediaStream stream, Ref ref) { + factory SubStreamModel.fromMediaStream(dto.MediaStream stream, Ref ref, {String? itemId, String? mediaSourceId}) { final deliveryUrl = stream.deliveryUrl; final deliveryUri = Uri.tryParse(deliveryUrl ?? ''); - final relativeSrtUrl = deliveryUri?.replace(path: deliveryUri.path.replaceAll('.vtt', '.srt')).toString(); + final subtitleExtension = _subtitleExtension(stream.codec); + final relativeSubtitleUrl = + deliveryUri?.replace(path: _subtitlePath(deliveryUri.path, subtitleExtension)).toString(); - final subStreamUrl = relativeSrtUrl == null ? null : buildServerUrl(ref, relativeUrl: relativeSrtUrl); + final subStreamUrl = relativeSubtitleUrl == null + ? _embeddedSubtitleUrl(stream, ref, itemId: itemId, mediaSourceId: mediaSourceId, extension: subtitleExtension) + : buildServerUrl(ref, relativeUrl: relativeSubtitleUrl); return SubStreamModel( name: stream.title ?? "", @@ -429,6 +412,52 @@ class SubStreamModel extends AudioAndSubStreamModel { ); } + static String _subtitleExtension(String? codec) { + return switch (codec?.toLowerCase()) { + 'ass' => 'ass', + 'ssa' => 'ssa', + 'vtt' || 'webvtt' => 'vtt', + 'subrip' || 'srt' => 'srt', + _ => 'srt', + }; + } + + static String _subtitlePath(String path, String extension) { + final subtitleExtension = RegExp(r'\.(ass|ssa|vtt|srt)$', caseSensitive: false); + final pathWithExtension = + subtitleExtension.hasMatch(path) ? path.replaceFirst(subtitleExtension, '.$extension') : '$path.$extension'; + return pathWithExtension.replaceFirstMapped( + RegExp(r'(/Subtitles/[^/]+)/(Stream\.)', caseSensitive: false), + (match) => '${match.group(1)}/0/${match.group(2)}', + ); + } + + static String? _embeddedSubtitleUrl( + dto.MediaStream stream, + Ref ref, { + String? itemId, + String? mediaSourceId, + required String extension, + }) { + final streamIndex = stream.index; + if (streamIndex == null || streamIndex < 0) { + return null; + } + if (stream.supportsExternalStream != true) { + return null; + } + if (itemId == null || itemId.isEmpty || mediaSourceId == null || mediaSourceId.isEmpty) { + return null; + } + + final token = ref.read(userProvider)?.credentials.token; + return buildServerUrl( + ref, + pathSegments: ['Videos', itemId, mediaSourceId, 'Subtitles', '$streamIndex', '0', 'Stream.$extension'], + queryParameters: {'api_key': token}, + ); + } + SubStreamModel copyWith({ String? name, String? id, diff --git a/lib/models/playback/playback_model.dart b/lib/models/playback/playback_model.dart index 77b48fac3..01f700f4c 100644 --- a/lib/models/playback/playback_model.dart +++ b/lib/models/playback/playback_model.dart @@ -48,9 +48,7 @@ import 'package:fladder/wrappers/media_control_wrapper.dart'; class Media { final String url; - const Media({ - required this.url, - }); + const Media({required this.url}); } extension PlaybackModelExtension on PlaybackModel? { @@ -143,11 +141,7 @@ class PlaybackModel { this.mediaSegments, this.chapters, this.trickPlay, - }) : playbackQueue = playbackQueue ?? - PlaybackQueueState.fromQueue( - queue, - initialItemId: item.id, - ); + }) : playbackQueue = playbackQueue ?? PlaybackQueueState.fromQueue(queue, initialItemId: item.id); } final playbackModelHelper = Provider((ref) { @@ -165,11 +159,7 @@ class PlaybackModelHelper { ref.read(videoPlayerProvider).pause(); ref.read(mediaPlaybackProvider.notifier).update((state) => state.copyWith(buffering: true)); final currentModel = ref.read(playBackModel); - final newModel = (await createPlaybackModel( - null, - newItem, - oldModel: currentModel, - )) ?? + final newModel = (await createPlaybackModel(null, newItem, oldModel: currentModel)) ?? await _createOfflinePlaybackModel( newItem, null, @@ -189,14 +179,13 @@ class PlaybackModelHelper { PlaybackModel? serverModel; try { - serverModel = await createPlaybackModel( - null, - channel, - forcedPlaybackType: PlaybackType.tv, - oldModel: currentModel, - ).timeout(const Duration(seconds: 8), onTimeout: () { - return null; - }); + serverModel = + await createPlaybackModel(null, channel, forcedPlaybackType: PlaybackType.tv, oldModel: currentModel).timeout( + const Duration(seconds: 8), + onTimeout: () { + return null; + }, + ); } catch (e) { serverModel = null; } @@ -221,7 +210,9 @@ class PlaybackModelHelper { PlaybackQueueSource? queueSource, }) async { final ItemBaseModel? syncedItemModel = syncedItem?.itemModel; - if (syncedItemModel == null || syncedItem == null || !await syncedItem.videoFile.exists()) return null; + if (syncedItemModel == null || syncedItem == null || !await syncedItem.videoFile.exists()) { + return null; + } final children = await ref.read(syncProvider.notifier).getSiblings(syncedItem); @@ -281,11 +272,7 @@ class PlaybackModelHelper { final actualStartPosition = startPosition ?? fullItem.userData.playBackPosition; - final options = { - PlaybackType.directStream, - PlaybackType.transcode, - if (firstItemIsSynced) PlaybackType.offline, - }; + final options = {PlaybackType.directStream, PlaybackType.transcode, if (firstItemIsSynced) PlaybackType.offline}; final isOffline = ref.read(connectivityStatusProvider.select((value) => value == ConnectionState.offline)); @@ -304,10 +291,7 @@ class PlaybackModelHelper { } if (((showPlaybackOptions || firstItemIsSynced) && !isOffline) && context != null) { - final playbackType = await showPlaybackTypeSelection( - context: context, - options: options, - ); + final playbackType = await showPlaybackTypeSelection(context: context, options: options); if (!context.mounted) return null; @@ -328,7 +312,7 @@ class PlaybackModelHelper { oldModel: oldModel, queueSource: effectiveQueueSource, ), - null => null + null => null, }; } else { return (await _createServerPlaybackModel( @@ -378,21 +362,22 @@ class PlaybackModelHelper { ); final audioStreamIndex = selectAudioStream( - ref.read(userProvider.select((value) => value?.userConfiguration?.rememberAudioSelections ?? true)), - oldModel?.mediaStreams?.currentAudioStream, - newStreamModel?.audioStreams, - newStreamModel?.defaultAudioStreamIndex); + ref.read(userProvider.select((value) => value?.userConfiguration?.rememberAudioSelections ?? true)), + oldModel?.mediaStreams?.currentAudioStream, + newStreamModel?.audioStreams, + newStreamModel?.defaultAudioStreamIndex, + ); final subStreamIndex = selectSubStream( - ref.read(userProvider.select((value) => value?.userConfiguration?.rememberSubtitleSelections ?? true)), - oldModel?.mediaStreams?.currentSubStream, - newStreamModel?.subStreams, - newStreamModel?.defaultSubStreamIndex); + ref.read(userProvider.select((value) => value?.userConfiguration?.rememberSubtitleSelections ?? true)), + oldModel?.mediaStreams?.currentSubStream, + newStreamModel?.subStreams, + newStreamModel?.defaultSubStreamIndex, + ); - //Native player does not allow for loading external subtitles with transcoding - final isNativePlayer = - ref.read(videoPlayerSettingsProvider.select((value) => value.wantedPlayer == PlayerOptions.nativePlayer)); - final isExternalSub = newStreamModel?.currentSubStream?.isExternal == true; + final isNativePlayer = ref.read( + videoPlayerSettingsProvider.select((value) => value.wantedPlayer == PlayerOptions.nativePlayer), + ); final Response response = await api.itemsItemIdPlaybackInfoPost( itemId: item.id, @@ -406,7 +391,7 @@ class PlaybackModelHelper { userId: userId, enableDirectPlay: type != PlaybackType.transcode, enableDirectStream: type != PlaybackType.transcode, - alwaysBurnInSubtitleWhenTranscoding: isNativePlayer && isExternalSub, + alwaysBurnInSubtitleWhenTranscoding: false, maxStreamingBitrate: qualityOptions.enabledFirst.keys.firstOrNull?.bitRate, mediaSourceId: newStreamModel?.currentVersionStream?.id, ), @@ -424,10 +409,11 @@ class PlaybackModelHelper { return null; } - final mediaStreamsWithUrls = MediaStreamsModel.fromMediaStreamsList(playbackInfo.mediaSources, ref).copyWith( - defaultAudioStreamIndex: audioStreamIndex, - defaultSubStreamIndex: subStreamIndex, - ); + final mediaStreamsWithUrls = MediaStreamsModel.fromMediaStreamsList( + playbackInfo.mediaSources, + ref, + itemId: item.id, + ).copyWith(defaultAudioStreamIndex: audioStreamIndex, defaultSubStreamIndex: subStreamIndex); final mediaSegments = await api.mediaSegmentsGet(id: item.id); @@ -555,16 +541,8 @@ class PlaybackModelHelper { final currentPosition = ref.read(mediaPlaybackProvider.select((value) => value.position)); - final audioIndex = selectAudioStream( - ref.read(userProvider.select((value) => value?.userConfiguration?.rememberAudioSelections ?? true)), - playbackModel.mediaStreams?.currentAudioStream, - playbackModel.audioStreams, - playbackModel.mediaStreams?.defaultAudioStreamIndex); - final subIndex = selectSubStream( - ref.read(userProvider.select((value) => value?.userConfiguration?.rememberSubtitleSelections ?? true)), - playbackModel.mediaStreams?.currentSubStream, - playbackModel.subStreams, - playbackModel.mediaStreams?.defaultSubStreamIndex); + final audioIndex = playbackModel.mediaStreams?.defaultAudioStreamIndex; + final subIndex = playbackModel.mediaStreams?.defaultSubStreamIndex; Response response = await api.itemsItemIdPlaybackInfoPost( itemId: item.id, @@ -576,6 +554,7 @@ class PlaybackModelHelper { subtitleStreamIndex: subIndex, enableTranscoding: true, autoOpenLiveStream: true, + alwaysBurnInSubtitleWhenTranscoding: false, deviceProfile: ref.read(videoProfileProvider), userId: userId, maxStreamingBitrate: playbackModel.bitRateOptions.enabledFirst.entries.firstOrNull?.key.bitRate, @@ -586,8 +565,9 @@ class PlaybackModelHelper { PlaybackInfoResponse playbackInfo = response.bodyOrThrow; final mediaSource = playbackInfo.mediaSources?.first; + final responseStreams = MediaStreamsModel.fromMediaStreamsList(playbackInfo.mediaSources, ref, itemId: item.id); - final mediaStreamsWithUrls = MediaStreamsModel.fromMediaStreamsList(playbackInfo.mediaSources, ref).copyWith( + final mediaStreamsWithUrls = (playbackModel.mediaStreams ?? responseStreams).copyWith( defaultAudioStreamIndex: audioIndex, defaultSubStreamIndex: subIndex, ); @@ -647,7 +627,7 @@ class PlaybackModelHelper { } if (newModel == null) return; if (newModel.runtimeType != playbackModel.runtimeType || newModel is TranscodePlaybackModel) { - ref.read(videoPlayerProvider.notifier).loadPlaybackItem(newModel, currentPosition); + ref.read(videoPlayerProvider.notifier).reloadPlaybackItem(newModel, currentPosition); } } } diff --git a/lib/providers/video_player_provider.dart b/lib/providers/video_player_provider.dart index 296b49fc0..1824db41f 100644 --- a/lib/providers/video_player_provider.dart +++ b/lib/providers/video_player_provider.dart @@ -58,22 +58,12 @@ class VideoPlayerNotifier extends StateNotifier { mediaState.update((state) => state.buffering == event ? state : state.copyWith(buffering: event)); Future updateBuffer(Duration buffer) async { - mediaState.update( - (state) => (state.buffer - buffer).inSeconds.abs() < 1 - ? state - : state.copyWith( - buffer: buffer, - ), - ); + mediaState.update((state) => (state.buffer - buffer).inSeconds.abs() < 1 ? state : state.copyWith(buffer: buffer)); } Future updateDuration(Duration duration) async { mediaState.update((state) { - return (state.duration - duration).inSeconds.abs() < 1 - ? state - : state.copyWith( - duration: duration, - ); + return (state.duration - duration).inSeconds.abs() < 1 ? state : state.copyWith(duration: duration); }); } @@ -81,9 +71,7 @@ class VideoPlayerNotifier extends StateNotifier { final currentState = playbackState; if (!state.hasPlayer || currentState.playing == event) return; if (currentState.state == VideoPlayerState.disposed) return; - mediaState.update( - (state) => state.copyWith(playing: event), - ); + mediaState.update((state) => state.copyWith(playing: event)); ref.read(playBackModel)?.updatePlaybackPosition(currentState.position, currentState.playing, ref); } @@ -102,15 +90,10 @@ class VideoPlayerNotifier extends StateNotifier { final diff = (position.inMilliseconds - lastPosition.inMilliseconds).abs(); if (diff > const Duration(seconds: 10).inMilliseconds) { - mediaState.update((value) => value.copyWith( - position: event, - lastPosition: position, - )); + mediaState.update((value) => value.copyWith(position: event, lastPosition: position)); ref.read(playBackModel)?.updatePlaybackPosition(position, playbackState.playing, ref); } else { - mediaState.update((value) => value.copyWith( - position: event, - )); + mediaState.update((value) => value.copyWith(position: event)); } } @@ -122,13 +105,15 @@ class VideoPlayerNotifier extends StateNotifier { final useMinimizedPlayer = model.item.type == FladderItemType.audio || model.mediaStreams?.videoStreams.isEmpty == true; - mediaState.update((state) => state.copyWith( - state: useMinimizedPlayer ? VideoPlayerState.minimized : VideoPlayerState.fullScreen, - fullScreen: !useMinimizedPlayer, - buffering: true, - errorPlaying: false, - skippedSegments: {}, - )); + mediaState.update( + (state) => state.copyWith( + state: useMinimizedPlayer ? VideoPlayerState.minimized : VideoPlayerState.fullScreen, + fullScreen: !useMinimizedPlayer, + buffering: true, + errorPlaying: false, + skippedSegments: {}, + ), + ); final media = model.media; PlaybackModel? newPlaybackModel = model; @@ -142,12 +127,14 @@ class VideoPlayerNotifier extends StateNotifier { await state.setAudioTrack(null, model); await state.setSubtitleTrack(null, model); - ref.read(mediaPlaybackProvider.notifier).update((state) => state.copyWith( - state: useMinimizedPlayer ? VideoPlayerState.minimized : VideoPlayerState.fullScreen, - buffering: true, - errorPlaying: false, - skippedSegments: {}, - )); + ref.read(mediaPlaybackProvider.notifier).update( + (state) => state.copyWith( + state: useMinimizedPlayer ? VideoPlayerState.minimized : VideoPlayerState.fullScreen, + buffering: true, + errorPlaying: false, + skippedSegments: {}, + ), + ); await state.play(); return true; @@ -157,6 +144,40 @@ class VideoPlayerNotifier extends StateNotifier { return false; } + Future reloadPlaybackItem(PlaybackModel model, Duration startPosition) async { + final useMinimizedPlayer = + model.item.type == FladderItemType.audio || model.mediaStreams?.videoStreams.isEmpty == true; + + state.ignoreNativeStopFor(const Duration(seconds: 6)); + ref.read(playBackModel.notifier).update((state) => model); + ref.read(playbackRateProvider.notifier).state = 1.0; + + mediaState.update( + (state) => state.copyWith( + state: useMinimizedPlayer ? VideoPlayerState.minimized : VideoPlayerState.fullScreen, + fullScreen: !useMinimizedPlayer, + buffering: true, + errorPlaying: false, + skippedSegments: {}, + ), + ); + + final media = model.media; + if (media == null) { + mediaState.update((state) => state.copyWith(errorPlaying: true)); + return false; + } + + final effectiveStartPosition = await model.resolvedStartPosition(startPosition); + ref.read(playBackModel.notifier).update((state) => model); + await state.loadVideo(model, effectiveStartPosition, true); + await state.setVolume(ref.read(videoPlayerSettingsProvider).volume); + await state.setAudioTrack(null, model); + await state.setSubtitleTrack(null, model); + await state.play(); + return true; + } + Future loadAudioPlaybackItem( PlaybackModel model, List queue, @@ -179,32 +200,32 @@ class VideoPlayerNotifier extends StateNotifier { ref.read(playBackModel.notifier).update((state) => queuedModel); ref.read(playbackRateProvider.notifier).state = 1.0; - mediaState.update((state) => state.copyWith( - state: keepFullScreenLayout ? VideoPlayerState.fullScreen : VideoPlayerState.minimized, - fullScreen: keepFullScreenLayout, - buffering: true, - errorPlaying: false, - skippedSegments: {}, - duration: queuedModel.item.overview.runTime ?? Duration.zero, - )); + mediaState.update( + (state) => state.copyWith( + state: keepFullScreenLayout ? VideoPlayerState.fullScreen : VideoPlayerState.minimized, + fullScreen: keepFullScreenLayout, + buffering: true, + errorPlaying: false, + skippedSegments: {}, + duration: queuedModel.item.overview.runTime ?? Duration.zero, + ), + ); await state.loadAudioQueue(queue, currentIndex, effectiveStartPosition, true); await state.setVolume(ref.read(videoPlayerSettingsProvider).volume); - mediaState.update((state) => state.copyWith( - buffering: false, - playing: true, - position: effectiveStartPosition, - duration: queuedModel.item.overview.runTime ?? Duration.zero, - )); + mediaState.update( + (state) => state.copyWith( + buffering: false, + playing: true, + position: effectiveStartPosition, + duration: queuedModel.item.overview.runTime ?? Duration.zero, + ), + ); return true; } - Future reorderAudioQueueSection( - AudioQueueSection section, - int oldIndex, - int newIndex, - ) async { + Future reorderAudioQueueSection(AudioQueueSection section, int oldIndex, int newIndex) async { await state.reorderAudioQueueSection(section, oldIndex, newIndex); } @@ -220,10 +241,7 @@ class VideoPlayerNotifier extends StateNotifier { await state.removeAudioQueueItem(item.id); } - Future removeAudioQueueSectionItem( - AudioQueueSection section, - int sectionIndex, - ) async { + Future removeAudioQueueSectionItem(AudioQueueSection section, int sectionIndex) async { await state.removeAudioQueueSectionItem(section, sectionIndex); } diff --git a/lib/wrappers/media_control_wrapper.dart b/lib/wrappers/media_control_wrapper.dart index 5823dfc4b..fc9eddf4f 100644 --- a/lib/wrappers/media_control_wrapper.dart +++ b/lib/wrappers/media_control_wrapper.dart @@ -21,6 +21,7 @@ import 'package:fladder/models/playback/audio_prefetch_buffer.dart'; import 'package:fladder/models/playback/audio_url_resolver.dart'; import 'package:fladder/models/playback/playback_model.dart'; import 'package:fladder/models/playback/playback_queue_state.dart'; +import 'package:fladder/models/playback/transcode_playback_model.dart'; import 'package:fladder/models/settings/video_player_settings.dart'; import 'package:fladder/providers/api_provider.dart'; import 'package:fladder/providers/live_tv_provider.dart'; @@ -73,6 +74,7 @@ class MediaControlsWrapper extends BaseAudioHandler implements VideoPlayerContro bool _isNewPlayback = false; bool _isAudioQueueMode = false; bool _audioQueueTransitioning = false; + DateTime? _ignoreNativeStopUntil; AudioPrefetchBuffer? _prefetchBuffer; List _mpvPlaylistItems = []; @@ -171,6 +173,10 @@ class MediaControlsWrapper extends BaseAudioHandler implements VideoPlayerContro } } + void ignoreNativeStopFor(Duration duration) { + _ignoreNativeStopUntil = DateTime.now().add(duration); + } + Future updateTVGuide(TVGuideModel guide) async { if (_player is NativePlayer) { (_player as NativePlayer).sendTVGuideModel(guide); @@ -250,19 +256,23 @@ class MediaControlsWrapper extends BaseAudioHandler implements VideoPlayerContro } } - subscriptions.add(_player!.stateStream.listen((value) { - playbackState.add(playbackState.value.copyWith( - bufferedPosition: value.buffer, - processingState: value.buffering ? AudioProcessingState.buffering : AudioProcessingState.ready, - updatePosition: value.position, - playing: value.playing, - )); - smtc?.setPosition(value.position); - smtc?.setPlaybackStatus(value.playing ? PlaybackStatus.playing : PlaybackStatus.paused); - if (value.completed && !_audioQueueTransitioning) { - _onAudioTrackCompleted(); - } - })); + subscriptions.add( + _player!.stateStream.listen((value) { + playbackState.add( + playbackState.value.copyWith( + bufferedPosition: value.buffer, + processingState: value.buffering ? AudioProcessingState.buffering : AudioProcessingState.ready, + updatePosition: value.position, + playing: value.playing, + ), + ); + smtc?.setPosition(value.position); + smtc?.setPlaybackStatus(value.playing ? PlaybackStatus.playing : PlaybackStatus.paused); + if (value.completed && !_audioQueueTransitioning) { + _onAudioTrackCompleted(); + } + }), + ); } @override @@ -312,11 +322,9 @@ class MediaControlsWrapper extends BaseAudioHandler implements VideoPlayerContro Future pause() async { await _player?.pause(); final position = _player?.lastState.position ?? Duration.zero; - playbackState.add(playbackState.value.copyWith( - playing: false, - updatePosition: position, - controls: [MediaControl.play], - )); + playbackState.add( + playbackState.value.copyWith(playing: false, updatePosition: position, controls: [MediaControl.play]), + ); await WakelockPlus.disable(); final playerState = _player; if (playerState != null) { @@ -380,33 +388,37 @@ class MediaControlsWrapper extends BaseAudioHandler implements VideoPlayerContro final isMusic = playBackItem is AudioModel; - mediaItem.add(MediaItem( - id: playBackItem.id, - title: playBackItem.title, - rating: Rating.newHeartRating(playBackItem.userData.isFavourite), - duration: playBackItem.overview.runTime ?? const Duration(seconds: 0), - artUri: poster != null ? _imageDataToUri(poster.path) : null, - )); - playbackState.add(PlaybackState( - playing: playing, - updatePosition: currentPosition, - bufferedPosition: _player?.lastState.buffer ?? playbackState.value.bufferedPosition, - controls: [ - if (playing) MediaControl.pause else MediaControl.play, - if (canSkipNext) MediaControl.skipToNext, - if (canSkipPrevious) MediaControl.skipToPrevious, - ], - systemActions: { - if (canSkipNext) MediaAction.skipToNext, - if (canSkipPrevious) MediaAction.skipToPrevious, - MediaAction.seek, - if (!isMusic) MediaAction.fastForward, - MediaAction.setSpeed, - if (!isMusic) MediaAction.rewind, - }, - processingState: - (_player?.lastState.buffering ?? false) ? AudioProcessingState.buffering : AudioProcessingState.ready, - )); + mediaItem.add( + MediaItem( + id: playBackItem.id, + title: playBackItem.title, + rating: Rating.newHeartRating(playBackItem.userData.isFavourite), + duration: playBackItem.overview.runTime ?? const Duration(seconds: 0), + artUri: poster != null ? _imageDataToUri(poster.path) : null, + ), + ); + playbackState.add( + PlaybackState( + playing: playing, + updatePosition: currentPosition, + bufferedPosition: _player?.lastState.buffer ?? playbackState.value.bufferedPosition, + controls: [ + if (playing) MediaControl.pause else MediaControl.play, + if (canSkipNext) MediaControl.skipToNext, + if (canSkipPrevious) MediaControl.skipToPrevious, + ], + systemActions: { + if (canSkipNext) MediaAction.skipToNext, + if (canSkipPrevious) MediaAction.skipToPrevious, + MediaAction.seek, + if (!isMusic) MediaAction.fastForward, + MediaAction.setSpeed, + if (!isMusic) MediaAction.rewind, + }, + processingState: + (_player?.lastState.buffering ?? false) ? AudioProcessingState.buffering : AudioProcessingState.ready, + ), + ); } Future windowSMTCSetup(ItemBaseModel playBackItem, Duration currentPosition, bool playing) async { @@ -415,11 +427,13 @@ class MediaControlsWrapper extends BaseAudioHandler implements VideoPlayerContro playBackItem.images?.primary ?? (playBackItem is ItemStreamModel ? playBackItem.parentImages?.primary : null); //Windows setup - smtc?.updateMetadata(MusicMetadata( - title: playBackItem.title, - artist: mainContext != null ? playBackItem.label(mainContext.localized) : null, - thumbnail: poster != null ? _imageDataToUri(poster.path).toString() : null, - )); + smtc?.updateMetadata( + MusicMetadata( + title: playBackItem.title, + artist: mainContext != null ? playBackItem.label(mainContext.localized) : null, + thumbnail: poster != null ? _imageDataToUri(poster.path).toString() : null, + ), + ); smtc?.updateTimeline( PlaybackTimeline( startTimeMs: 0, @@ -472,11 +486,7 @@ class MediaControlsWrapper extends BaseAudioHandler implements VideoPlayerContro smtc?.disableSmtc(); playbackState.add( - playbackState.value.copyWith( - playing: false, - processingState: AudioProcessingState.completed, - controls: [], - ), + playbackState.value.copyWith(playing: false, processingState: AudioProcessingState.completed, controls: []), ); return super.stop(); } @@ -485,11 +495,13 @@ class MediaControlsWrapper extends BaseAudioHandler implements VideoPlayerContro await _player?.playOrPause(); final playing = _player?.lastState.playing ?? false; final position = _player?.lastState.position ?? Duration.zero; - playbackState.add(playbackState.value.copyWith( - playing: playing, - updatePosition: position, - controls: [playing ? MediaControl.pause : MediaControl.play], - )); + playbackState.add( + playbackState.value.copyWith( + playing: playing, + updatePosition: position, + controls: [playing ? MediaControl.pause : MediaControl.play], + ), + ); if (playing) { // Only enable wakelock for video; audio can continue with screen off @@ -522,9 +534,15 @@ class MediaControlsWrapper extends BaseAudioHandler implements VideoPlayerContro bool _isMpvPlaylistInSync() { final playbackModel = ref.read(playBackModel); - if (playbackModel == null) return false; - if (_mpvPlaylistItems.isEmpty) return false; - if (_mpvPlaylistCurrentIndex < 0 || _mpvPlaylistCurrentIndex >= _mpvPlaylistItems.length) return false; + if (playbackModel == null) { + return false; + } + if (_mpvPlaylistItems.isEmpty) { + return false; + } + if (_mpvPlaylistCurrentIndex < 0 || _mpvPlaylistCurrentIndex >= _mpvPlaylistItems.length) { + return false; + } return _mpvPlaylistItems[_mpvPlaylistCurrentIndex].id == playbackModel.item.id; } @@ -556,24 +574,37 @@ class MediaControlsWrapper extends BaseAudioHandler implements VideoPlayerContro Future loadNextVideo() async { final nextVideo = ref.read(playBackModel.select((value) => value?.nextVideo)); final buffering = ref.read(mediaPlaybackProvider.select((value) => value.buffering)); - if (nextVideo != null && !buffering) ref.read(playbackModelHelper).loadNewVideo(nextVideo); + if (nextVideo != null && !buffering) { + ref.read(playbackModelHelper).loadNewVideo(nextVideo); + } } @override Future loadPreviousVideo() async { final previousVideo = ref.read(playBackModel.select((value) => value?.previousVideo)); final buffering = ref.read(mediaPlaybackProvider.select((value) => value.buffering)); - if (previousVideo != null && !buffering) ref.read(playbackModelHelper).loadNewVideo(previousVideo); + if (previousVideo != null && !buffering) { + ref.read(playbackModelHelper).loadNewVideo(previousVideo); + } } @override - void onStop() => stop(); + void onStop() { + final ignoreUntil = _ignoreNativeStopUntil; + if (ignoreUntil != null && DateTime.now().isBefore(ignoreUntil)) { + return; + } + stop(); + } @override void swapAudioTrack(int value) async { final playbackModel = ref.read(playBackModel); - final newModel = await playbackModel?.setAudio( - playbackModel.audioStreams?.firstWhere((element) => element.index == value), this); + final audioStream = playbackModel?.audioStreams?.firstWhereOrNull((element) => element.index == value); + if (audioStream == null) { + return; + } + final newModel = await playbackModel?.setAudio(audioStream, this); ref.read(playBackModel.notifier).update((state) => newModel); if (newModel != null) { await ref.read(playbackModelHelper).shouldReload(newModel); @@ -583,14 +614,39 @@ class MediaControlsWrapper extends BaseAudioHandler implements VideoPlayerContro @override void swapSubtitleTrack(int value) async { final playbackModel = ref.read(playBackModel); - final newModel = await playbackModel?.setSubtitle( - playbackModel.subStreams?.firstWhere((element) => element.index == value), this); + final subtitleStream = playbackModel?.subStreams?.firstWhereOrNull((element) => element.index == value); + if (subtitleStream == null) { + return; + } + final newModel = await playbackModel?.setSubtitle(subtitleStream, this); ref.read(playBackModel.notifier).update((state) => newModel); if (newModel != null) { + if (_player is NativePlayer && + newModel is TranscodePlaybackModel && + _canSwitchNativeTranscodedSubtitleInPlace(newModel)) { + return; + } await ref.read(playbackModelHelper).shouldReload(newModel); } } + bool _canSwitchNativeTranscodedSubtitleInPlace(TranscodePlaybackModel model) { + final subtitlesWithUrl = + model.mediaStreams?.subStreams.where((sub) => sub.url?.isNotEmpty == true).toList() ?? const []; + if (subtitlesWithUrl.length > 25) return false; + final selectedSubtitle = model.mediaStreams?.currentSubStream; + if (selectedSubtitle?.index == -1) return true; + final selectedUrl = selectedSubtitle?.url?.toLowerCase() ?? ''; + final selectedIsAss = selectedUrl.contains('.ass') || selectedUrl.contains('.ssa'); + if (selectedIsAss) return true; + + final assTrackCount = subtitlesWithUrl.where((sub) { + final url = sub.url?.toLowerCase() ?? ''; + return url.contains('.ass') || url.contains('.ssa'); + }).length; + return assTrackCount <= 1; + } + @override Future loadProgram(GuideChannel selection) async { final channelId = selection.channelId; @@ -611,16 +667,18 @@ class MediaControlsWrapper extends BaseAudioHandler implements VideoPlayerContro final context = ref.read(localizationContextProvider); return programs - .map((p) => GuideProgram( - id: p.id, - channelId: channelId, - name: p.name, - startMs: p.startDate.millisecondsSinceEpoch, - endMs: p.endDate.millisecondsSinceEpoch, - primaryPoster: p.images?.primary?.path, - overview: p.overview, - subTitle: context != null ? p.subLabel(context.localized) : null, - )) + .map( + (p) => GuideProgram( + id: p.id, + channelId: channelId, + name: p.name, + startMs: p.startDate.millisecondsSinceEpoch, + endMs: p.endDate.millisecondsSinceEpoch, + primaryPoster: p.images?.primary?.path, + overview: p.overview, + subTitle: context != null ? p.subLabel(context.localized) : null, + ), + ) .toList(); } diff --git a/lib/wrappers/players/lib_mpv.dart b/lib/wrappers/players/lib_mpv.dart index b3ee22739..b582186c2 100644 --- a/lib/wrappers/players/lib_mpv.dart +++ b/lib/wrappers/players/lib_mpv.dart @@ -67,9 +67,7 @@ class LibMPV extends BasePlayer { if (_player != null) { _controller = VideoController( _player!, - configuration: VideoControllerConfiguration( - enableHardwareAcceleration: settings.hardwareAccel, - ), + configuration: VideoControllerConfiguration(enableHardwareAcceleration: settings.hardwareAccel), ); _setupPlayerStreams(_player!); } @@ -211,15 +209,17 @@ class LibMPV extends BasePlayer { oldPlayer.dispose(); _isFading = false; - setState(lastState.update( - playing: incomingPlayer.state.playing, - buffering: incomingPlayer.state.buffering, - position: incomingPlayer.state.position, - duration: incomingPlayer.state.duration, - volume: _preferredVolume, - buffer: incomingPlayer.state.buffer, - completed: false, - )); + setState( + lastState.update( + playing: incomingPlayer.state.playing, + buffering: incomingPlayer.state.buffering, + position: incomingPlayer.state.position, + duration: incomingPlayer.state.duration, + volume: _preferredVolume, + buffer: incomingPlayer.state.buffer, + completed: false, + ), + ); } @override @@ -234,22 +234,19 @@ class LibMPV extends BasePlayer { _retryTimer?.cancel(); _retryTimer = null; - _retryTimer = RestartableTimer( - _currentRetryDuration, - () async { - await Future.delayed(const Duration(milliseconds: 150)); - if (DateTime.now().isAfter(_firstLoadAttempt.add(_maxRetryDuration))) { - log("Max retry duration reached, stopping retries."); - _retryTimer?.cancel(); - _retryTimer = null; - } else { - log("Retrying to load video $url"); - await setStartPosition(startPosition); - await _player?.open(mpv.Media(url), play: play); - _retryTimer?.reset(); - } - }, - ); + _retryTimer = RestartableTimer(_currentRetryDuration, () async { + await Future.delayed(const Duration(milliseconds: 150)); + if (DateTime.now().isAfter(_firstLoadAttempt.add(_maxRetryDuration))) { + log("Max retry duration reached, stopping retries."); + _retryTimer?.cancel(); + _retryTimer = null; + } else { + log("Retrying to load video $url"); + await setStartPosition(startPosition); + await _player?.open(mpv.Media(url), play: play); + _retryTimer?.reset(); + } + }); // Wait for the player to be ready if (_loadCompleter?.isCompleted == false) { @@ -273,14 +270,12 @@ class LibMPV extends BasePlayer { }); } - _loadCompleter?.future.then( - (value) async { - // Backup seek in case property didn't work - if (startPosition != Duration.zero && (_player?.state.position.inSeconds ?? 0) < startPosition.inSeconds - 5) { - await _player?.seek(startPosition); - } - }, - ); + _loadCompleter?.future.then((value) async { + // Backup seek in case property didn't work + if (startPosition != Duration.zero && (_player?.state.position.inSeconds ?? 0) < startPosition.inSeconds - 5) { + await _player?.seek(startPosition); + } + }); return setState(lastState.update(buffering: true)); } @@ -345,10 +340,7 @@ class LibMPV extends BasePlayer { Future setStartPosition(Duration position) async { if (_player?.platform is mpv.NativePlayer) { - await (_player?.platform as dynamic).setProperty( - 'start', - '${position.inMilliseconds / 1000}', - ); + await (_player?.platform as dynamic).setProperty('start', '${position.inMilliseconds / 1000}'); } } @@ -359,11 +351,10 @@ class LibMPV extends BasePlayer { } @override - Future open(BuildContext context) async => Navigator.of(context, rootNavigator: true).push( - MaterialPageRoute( - builder: (context) => const video_screen.VideoPlayer(), - ), - ); + Future open(BuildContext context) async => Navigator.of( + context, + rootNavigator: true, + ).push(MaterialPageRoute(builder: (context) => const video_screen.VideoPlayer())); List get subTracks => _player?.state.tracks.subtitle ?? []; mpv.SubtitleTrack get subtitleTrack => _player?.state.track.subtitle ?? mpv.SubtitleTrack.no(); @@ -438,9 +429,10 @@ class LibMPV extends BasePlayer { if (wantedAudioStream.index == AudioStreamModel.no().index) { await _player?.setAudioTrack(mpv.AudioTrack.no()); } else { - final internalTracks = audioTracks.getRange(2, audioTracks.length).toList(); - final audioTrack = - internalTracks.elementAtOrNull((playbackModel.audioStreams?.indexOf(wantedAudioStream) ?? -1) - 1); + final internalTracks = audioTracks.length > 2 ? audioTracks.sublist(2) : []; + final audioTrack = internalTracks.elementAtOrNull( + (playbackModel.audioStreams?.indexOf(wantedAudioStream) ?? -1) - 1, + ); if (audioTrack != null) { await _player?.setAudioTrack(audioTrack); } @@ -460,9 +452,11 @@ class LibMPV extends BasePlayer { return -1; } _currentSubtitleCodec = wantedSubtitle.codec; - final internalTrack = subTracks.getRange(2, subTracks.length).toList(); - final index = playbackModel.subStreams?.sublist(1).indexWhere((element) => element.id == wantedSubtitle.id); - final subTrack = internalTrack.elementAtOrNull(index ?? -1); + final internalTrack = subTracks.length > 2 ? subTracks.sublist(2) : []; + final subStreams = playbackModel.subStreams ?? []; + final index = + subStreams.length > 1 ? subStreams.sublist(1).indexWhere((element) => element.id == wantedSubtitle.id) : -1; + final subTrack = internalTrack.elementAtOrNull(index); if (wantedSubtitle.isExternal && wantedSubtitle.url != null && subTrack == null) { await _player?.setSubtitleTrack(mpv.SubtitleTrack.uri(wantedSubtitle.url!)); } else if (subTrack != null) { @@ -495,35 +489,27 @@ class LibMPV extends BasePlayer { } @override - Widget? videoWidget( - Key key, - BoxFit fit, - ) => - _controller == null - ? null - : Video( - key: key, - controller: _controller!, - wakelock: false, - fill: Colors.transparent, - fit: fit, - subtitleViewConfiguration: const SubtitleViewConfiguration(visible: false), - controls: NoVideoControls, - ); + Widget? videoWidget(Key key, BoxFit fit) => _controller == null + ? null + : Video( + key: key, + controller: _controller!, + wakelock: false, + fill: Colors.transparent, + fit: fit, + subtitleViewConfiguration: const SubtitleViewConfiguration(visible: false), + controls: NoVideoControls, + ); @override - Widget? subtitles( - bool showOverlay, { - GlobalKey? controlsKey, - }) => - _controller != null - ? _VideoSubtitles( - controller: _controller!, - showOverlay: showOverlay, - controlsKey: controlsKey, - currentSubtitleCodec: _currentSubtitleCodec, - ) - : null; + Widget? subtitles(bool showOverlay, {GlobalKey? controlsKey}) => _controller != null + ? _VideoSubtitles( + controller: _controller!, + showOverlay: showOverlay, + controlsKey: controlsKey, + currentSubtitleCodec: _currentSubtitleCodec, + ) + : null; @override Future setVolume(double volume) async { @@ -633,12 +619,7 @@ class _VideoSubtitlesState extends ConsumerState<_VideoSubtitles> { menuHeight: _cachedMenuHeight, ); - return SubtitleText( - subModel: settings, - padding: padding, - offset: offset, - text: text, - ); + return SubtitleText(subModel: settings, padding: padding, offset: offset, text: text); } void _measureMenuHeight() {