Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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")
Expand Down
7 changes: 7 additions & 0 deletions android/app/src/main/cpp/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -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)
46 changes: 46 additions & 0 deletions android/app/src/main/cpp/fladder_ass.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
#include <jni.h>
#include <dlfcn.h>
#include <android/log.h>

#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<AssSetFontsFn>(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<void *>(nativeRenderer), fontPath, family, 1, nullptr, 1);
env->ReleaseStringUTFChars(defaultFamily, family);
if (fontPath != nullptr) {
env->ReleaseStringUTFChars(defaultFontPath, fontPath);
}
return JNI_TRUE;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
Expand All @@ -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(
Expand Down Expand Up @@ -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)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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,
Expand All @@ -102,4 +92,4 @@ fun SubtitlePicker(
}
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<PlayableData?> = MutableStateFlow(null)

var subsInitialized = false
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 }
Expand All @@ -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])
}
Expand All @@ -201,11 +279,55 @@ fun ExoPlayer.properlySetSubAndAudioTracks(playableData: PlayableData) {
val wantedAudioIndex = indexOfAudioTrack - 1
if (wantedAudioIndex < 0) {
clearAudioTrack()
} else {
} else if (wantedAudioIndex < internalAudioTracks.size) {
clearAudioTrack(false)
setInternalAudioTrack(internalAudioTracks[wantedAudioIndex])
}
} catch (e: Exception) {
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)
}
}
}
Loading
Loading