diff --git a/app/build.gradle.kts b/app/build.gradle.kts index cd26dffce..6f5804354 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -271,9 +271,12 @@ ksp { // Hilt 2.57 can process Kotlin 2.3.0 metadata without moving to Hilt 2.59 (AGP 9). configurations.all { resolutionStrategy { - force("org.jetbrains.kotlin:kotlin-metadata-jvm:2.3.0") + force("org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.7.3") force("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3") + force("org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.7.3") force("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") + force("org.jetbrains.kotlinx:kotlinx-coroutines-jdk8:1.7.3") + force("org.jetbrains.kotlinx:kotlinx-coroutines-slf4j:1.7.3") } } @@ -364,6 +367,9 @@ ksp { // (libffmpegJNI.so) is the likely source of the Play Console warning. add("sideloadImplementation", "org.jellyfin.media3:media3-ffmpeg-decoder:1.9.0+1") + // ASS/SSA subtitle rendering for Media3. + implementation("io.github.peerless2012:ass-media:0.5.1") + // Networking - Retrofit + OkHttp implementation("com.squareup.retrofit2:retrofit:2.9.0") implementation("com.squareup.retrofit2:converter-gson:2.9.0") @@ -449,7 +455,7 @@ ksp { // Unit Testing testImplementation("junit:junit:4.13.2") - testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3") + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0") testImplementation("io.mockk:mockk:1.13.8") testImplementation("app.cash.turbine:turbine:1.0.0") // Flow testing testImplementation("com.google.truth:truth:1.1.5") // Better assertions @@ -464,7 +470,7 @@ ksp { androidTestImplementation("androidx.compose.ui:ui-test-junit4") androidTestImplementation("androidx.test.uiautomator:uiautomator:2.2.0") androidTestImplementation("io.mockk:mockk-android:1.13.8") - androidTestImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3") + androidTestImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0") } secrets { @@ -595,5 +601,3 @@ dependencies { add("sideloadImplementation", "org.mozilla:rhino:1.8.1") add("sideloadImplementation", "com.google.re2j:re2j:1.8") } - - diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerScreen.kt index 215369912..96bb7ad98 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerScreen.kt @@ -137,6 +137,8 @@ import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import androidx.media3.ui.AspectRatioFrameLayout import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.Text +import io.github.peerless2012.ass.media.kt.buildWithAssSupport +import io.github.peerless2012.ass.media.type.AssRenderType import coil.compose.AsyncImage import com.arflix.tv.ArflixApplication import com.arflix.tv.network.OkHttpProvider @@ -1152,7 +1154,10 @@ fun PlayerScreen( .build(), /* handleAudioFocus = */ true ) - .build().apply { + .buildWithAssSupport( // Enable ASS subtitle rendering for stylized subtitles + context, + AssRenderType.OVERLAY_OPEN_GL, + ).apply { // Ensure volume is at maximum volume = 1.0f @@ -6106,20 +6111,45 @@ private fun buildExternalSubtitleConfigurations(subtitles: List): List } private fun subtitleMimeTypeFromUrl(url: String): String { - // Trailing slash before the query is real-world (AIOStreams: ".../sub.vtt/?lang=…") — trim it - // so the extension check still sees ".vtt"; the SRT fallback would silently fail on WEBVTT. - val cleanUrl = url.substringBefore('?').trimEnd('/').lowercase() + // idealy the subtitle format should be retrived from the metadata of the subtitle file. + // though that would require downloading the subtitle file first, which is not ideal. + // maybe you could do some download magic where you download the first few bytes of the subtitle file to get the metadata and then determine the format from that. + // this works for now though I suppose but it should really be updated to use the metadata later. + + val lower = url.lowercase() + + val path = lower + .substringBefore('?') + .trimEnd('/') + + val query = lower + .substringAfter('?', "") + return when { - cleanUrl.endsWith(".vtt") -> MimeTypes.TEXT_VTT - cleanUrl.endsWith(".srt") || cleanUrl.endsWith(".srt.gz") -> MimeTypes.APPLICATION_SUBRIP - cleanUrl.endsWith(".ass") || cleanUrl.endsWith(".ssa") -> MimeTypes.TEXT_SSA - cleanUrl.endsWith(".ttml") || cleanUrl.endsWith(".dfxp") -> MimeTypes.APPLICATION_TTML - // OpenSubtitles serves SRT through extensionless URLs - use SRT as default - // since it's the dominant format from subtitle addons (OpenSubtitles, Comet). - // SRT and VTT are similar but SRT uses comma for milliseconds (00:01:23,456) - // while VTT uses period and requires a WEBVTT header. Using SRT avoids silent - // parse failures when the actual content is SRT. - else -> MimeTypes.APPLICATION_SUBRIP + // Detection by file name + path.endsWith(".vtt") -> + MimeTypes.TEXT_VTT + + path.endsWith(".srt") || path.endsWith(".srt.gz") -> + MimeTypes.APPLICATION_SUBRIP + + path.endsWith(".ass") || path.endsWith(".ssa") -> + MimeTypes.TEXT_SSA + + // detection by query string + query.contains("format=ass") || + query.contains("format=ssa") || + query.contains("type=ass") || + query.contains("type=ssa") || + query.contains("mime=text/x-ass") || + query.contains("mime=text/x-ssa") -> + MimeTypes.TEXT_SSA + + path.endsWith(".ttml") || path.endsWith(".dfxp") -> + MimeTypes.APPLICATION_TTML + + else -> + MimeTypes.APPLICATION_SUBRIP } }