From 8f893e7a678289fe170391973473af7155d0c237 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Wed, 26 Aug 2026 18:59:28 +0200 Subject: [PATCH] fix(player): demux Android direct play with media3 again The in-app FFmpeg container demuxer classified every DTS variant as plain DTS (profile-blind MIME mapping at the demux boundary), so DTS-HD MA lost its lossless identity downstream, and it ignored container display-aspect-ratio overrides. media3's extractors get both right. Delete the FFmpeg demuxer (JNI, extractor, and its setting); ExoPlayer direct play demuxes with media3's DefaultExtractorsFactory again, and mpv - the Android default - reads container display dimensions itself. close #2124 close #2115 --- android/app/build.gradle.kts | 6 +- android/app/proguard-rules.pro | 24 +- .../app/src/androidTest/AndroidManifest.xml | 16 - .../androidTest/assets/ffmpeg/seek_cued.mkv | Bin 125794 -> 0 bytes .../assets/ffmpeg/seek_cueless.mkv | Bin 130542 -> 0 bytes .../androidTest/assets/ffmpeg/stereo_opus.mka | Bin 19537 -> 0 bytes .../assets/ffmpeg/stereo_vorbis.mka | Bin 11583 -> 0 bytes .../exoplayer/FixtureContentProvider.java | 117 --- .../FfmpegDemuxerReachabilityTest.kt | 116 --- .../FfmpegExtractorAudioCsdPlaybackTest.kt | 135 --- .../exoplayer/FfmpegExtractorReadRetryTest.kt | 240 ----- .../exoplayer/FfmpegExtractorSeekTest.kt | 357 ------- .../MatroskaLateTracksSeekDeviceTest.kt | 171 +++ android/app/src/main/cpp/CMakeLists.txt | 6 +- .../ffmpeg_demuxer_jni.cc | 983 ------------------ .../exoplayer/CuelessSeekExtractorWrapper.kt | 226 ++++ .../com/edde746/plezy/exoplayer/DoviCodecs.kt | 26 - .../edde746/plezy/exoplayer/ExoPlayerCore.kt | 141 +-- .../plezy/exoplayer/ExoPlayerPlugin.kt | 17 +- .../edde746/plezy/exoplayer/FfmpegAudioCsd.kt | 134 --- .../plezy/exoplayer/FfmpegDemuxerJni.kt | 139 --- .../plezy/exoplayer/FfmpegDemuxerPolicy.kt | 27 - .../plezy/exoplayer/FfmpegExtractor.kt | 577 ---------- .../exoplayer/FfmpegRandomAccessSource.kt | 175 ---- .../plezy/exoplayer/FfmpegSubtitleSamples.kt | 46 - .../plezy/exoplayer/MatroskaLatmSupport.kt | 65 ++ .../exoplayer/OutputWrappingExtractor.kt | 31 - .../plezy/exoplayer/StreamBitrateMeter.kt | 65 -- .../exoplayer/ZlibInflatingTrackOutput.kt | 91 ++ .../plezy/exoplayer/ZlibMatroskaExtractor.kt | 315 ++++++ .../edde746/plezy/exoplayer/DoviCodecsTest.kt | 41 - .../plezy/exoplayer/FfmpegAudioCsdTest.kt | 137 --- .../exoplayer/FfmpegDemuxerPolicyTest.kt | 29 - .../exoplayer/FfmpegRandomAccessSourceTest.kt | 245 ----- .../exoplayer/FfmpegSubtitleSamplesTest.kt | 53 - .../plezy/exoplayer/MatroskaLateTracksTest.kt | 154 +++ ...tputTest.kt => MatroskaLatmSupportTest.kt} | 115 +- .../plezy/exoplayer/StreamBitrateMeterTest.kt | 65 -- .../exoplayer/ZlibInflatingTrackOutputTest.kt | 248 +++++ .../plezy/exoplayer/ZlibTextBlockTest.kt | 80 ++ .../ZlibTextSubtitleExtractionTest.kt | 170 +++ android/libass/build.gradle.kts | 2 - .../edde746/plezy/libass/media/AssFonts.kt | 31 - .../media/extractor/AssMatroskaExtractor.kt | 169 +++ .../media/text/AssSubtitleExtractorOutput.kt | 15 +- .../plezy/libass/media/text/AssTrackOutput.kt | 128 +-- .../extractor/AssMatroskaExtractorTest.kt | 156 +++ .../libass/media/text/AssTrackOutputTest.kt | 157 --- lib/i18n/az.i18n.json | 4 - lib/i18n/bg.i18n.json | 4 - lib/i18n/da.i18n.json | 4 - lib/i18n/de.i18n.json | 4 - lib/i18n/en.i18n.json | 4 - lib/i18n/es.i18n.json | 4 - lib/i18n/fr.i18n.json | 4 - lib/i18n/hu.i18n.json | 4 - lib/i18n/it.i18n.json | 4 - lib/i18n/ja.i18n.json | 4 - lib/i18n/kk.i18n.json | 4 - lib/i18n/ko.i18n.json | 4 - lib/i18n/nb.i18n.json | 4 - lib/i18n/nl.i18n.json | 4 - lib/i18n/pl.i18n.json | 4 - lib/i18n/pt.i18n.json | 4 - lib/i18n/ru.i18n.json | 4 - lib/i18n/strings.g.dart | 2 +- lib/i18n/strings_az.g.dart | 20 +- lib/i18n/strings_bg.g.dart | 20 +- lib/i18n/strings_da.g.dart | 20 +- lib/i18n/strings_de.g.dart | 20 +- lib/i18n/strings_en.g.dart | 28 +- lib/i18n/strings_es.g.dart | 20 +- lib/i18n/strings_fr.g.dart | 20 +- lib/i18n/strings_hu.g.dart | 20 +- lib/i18n/strings_it.g.dart | 20 +- lib/i18n/strings_ja.g.dart | 20 +- lib/i18n/strings_kk.g.dart | 20 +- lib/i18n/strings_ko.g.dart | 20 +- lib/i18n/strings_nb.g.dart | 20 +- lib/i18n/strings_nl.g.dart | 20 +- lib/i18n/strings_pl.g.dart | 20 +- lib/i18n/strings_pt.g.dart | 20 +- lib/i18n/strings_ru.g.dart | 20 +- lib/i18n/strings_sv.g.dart | 20 +- lib/i18n/strings_tr.g.dart | 20 +- lib/i18n/strings_uz.g.dart | 20 +- lib/i18n/strings_zh.g.dart | 20 +- lib/i18n/strings_zh_Hant.g.dart | 20 +- lib/i18n/sv.i18n.json | 4 - lib/i18n/tr.i18n.json | 4 - lib/i18n/uz.i18n.json | 4 - lib/i18n/zh-Hant.i18n.json | 4 - lib/i18n/zh.i18n.json | 4 - lib/mpv/player/platform/player_android.dart | 6 - .../settings/playback_settings_screen.dart | 14 - lib/screens/video_player_screen.dart | 1 - lib/services/settings_service.dart | 19 +- scripts/maestro/run_maestro_ci.py | 7 +- .../performance_stats_service_test.dart | 4 +- 99 files changed, 2155 insertions(+), 4675 deletions(-) delete mode 100644 android/app/src/androidTest/AndroidManifest.xml delete mode 100644 android/app/src/androidTest/assets/ffmpeg/seek_cued.mkv delete mode 100644 android/app/src/androidTest/assets/ffmpeg/seek_cueless.mkv delete mode 100644 android/app/src/androidTest/assets/ffmpeg/stereo_opus.mka delete mode 100644 android/app/src/androidTest/assets/ffmpeg/stereo_vorbis.mka delete mode 100644 android/app/src/androidTest/java/com/edde746/plezy/exoplayer/FixtureContentProvider.java delete mode 100644 android/app/src/androidTest/kotlin/com/edde746/plezy/exoplayer/FfmpegDemuxerReachabilityTest.kt delete mode 100644 android/app/src/androidTest/kotlin/com/edde746/plezy/exoplayer/FfmpegExtractorAudioCsdPlaybackTest.kt delete mode 100644 android/app/src/androidTest/kotlin/com/edde746/plezy/exoplayer/FfmpegExtractorReadRetryTest.kt delete mode 100644 android/app/src/androidTest/kotlin/com/edde746/plezy/exoplayer/FfmpegExtractorSeekTest.kt create mode 100644 android/app/src/androidTest/kotlin/com/edde746/plezy/exoplayer/MatroskaLateTracksSeekDeviceTest.kt delete mode 100644 android/app/src/main/cpp/media3_ffmpeg_demuxer/ffmpeg_demuxer_jni.cc create mode 100644 android/app/src/main/kotlin/com/edde746/plezy/exoplayer/CuelessSeekExtractorWrapper.kt delete mode 100644 android/app/src/main/kotlin/com/edde746/plezy/exoplayer/DoviCodecs.kt delete mode 100644 android/app/src/main/kotlin/com/edde746/plezy/exoplayer/FfmpegAudioCsd.kt delete mode 100644 android/app/src/main/kotlin/com/edde746/plezy/exoplayer/FfmpegDemuxerJni.kt delete mode 100644 android/app/src/main/kotlin/com/edde746/plezy/exoplayer/FfmpegDemuxerPolicy.kt delete mode 100644 android/app/src/main/kotlin/com/edde746/plezy/exoplayer/FfmpegExtractor.kt delete mode 100644 android/app/src/main/kotlin/com/edde746/plezy/exoplayer/FfmpegRandomAccessSource.kt delete mode 100644 android/app/src/main/kotlin/com/edde746/plezy/exoplayer/FfmpegSubtitleSamples.kt create mode 100644 android/app/src/main/kotlin/com/edde746/plezy/exoplayer/MatroskaLatmSupport.kt delete mode 100644 android/app/src/main/kotlin/com/edde746/plezy/exoplayer/OutputWrappingExtractor.kt delete mode 100644 android/app/src/main/kotlin/com/edde746/plezy/exoplayer/StreamBitrateMeter.kt create mode 100644 android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ZlibInflatingTrackOutput.kt create mode 100644 android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ZlibMatroskaExtractor.kt delete mode 100644 android/app/src/test/kotlin/com/edde746/plezy/exoplayer/DoviCodecsTest.kt delete mode 100644 android/app/src/test/kotlin/com/edde746/plezy/exoplayer/FfmpegAudioCsdTest.kt delete mode 100644 android/app/src/test/kotlin/com/edde746/plezy/exoplayer/FfmpegDemuxerPolicyTest.kt delete mode 100644 android/app/src/test/kotlin/com/edde746/plezy/exoplayer/FfmpegRandomAccessSourceTest.kt delete mode 100644 android/app/src/test/kotlin/com/edde746/plezy/exoplayer/FfmpegSubtitleSamplesTest.kt create mode 100644 android/app/src/test/kotlin/com/edde746/plezy/exoplayer/MatroskaLateTracksTest.kt rename android/app/src/test/kotlin/com/edde746/plezy/exoplayer/{LatmTrackOutputTest.kt => MatroskaLatmSupportTest.kt} (58%) delete mode 100644 android/app/src/test/kotlin/com/edde746/plezy/exoplayer/StreamBitrateMeterTest.kt create mode 100644 android/app/src/test/kotlin/com/edde746/plezy/exoplayer/ZlibInflatingTrackOutputTest.kt create mode 100644 android/app/src/test/kotlin/com/edde746/plezy/exoplayer/ZlibTextBlockTest.kt create mode 100644 android/app/src/test/kotlin/com/edde746/plezy/exoplayer/ZlibTextSubtitleExtractionTest.kt delete mode 100644 android/libass/src/main/java/com/edde746/plezy/libass/media/AssFonts.kt create mode 100644 android/libass/src/main/java/com/edde746/plezy/libass/media/extractor/AssMatroskaExtractor.kt create mode 100644 android/libass/src/test/java/com/edde746/plezy/libass/media/extractor/AssMatroskaExtractorTest.kt delete mode 100644 android/libass/src/test/java/com/edde746/plezy/libass/media/text/AssTrackOutputTest.kt diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 685bea07b..9fd7b730d 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -132,7 +132,7 @@ val prepareMpvFfmpegDevelopment = tasks.register("prepareMpvFfmpegDevelopment") val aar = File(mpvDir, mpvAar) val manifest = File(mpvFfmpegDevelopmentDir, ".manifest") val abis = listOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64") - val libraries = listOf("avcodec", "avformat", "avutil", "swresample") + val libraries = listOf("avcodec", "avutil", "swresample") inputs.file(aar) inputs.property("ffmpegVersion", mpvFfmpegVersion) inputs.property("sourceUrl", mpvFfmpegSourceUrl) @@ -145,7 +145,6 @@ val prepareMpvFfmpegDevelopment = tasks.register("prepareMpvFfmpegDevelopment") outputs.files( File(mpvFfmpegDevelopmentDir, "include/libavcodec/avcodec.h"), File(mpvFfmpegDevelopmentDir, "include/libavutil/avconfig.h"), - File(mpvFfmpegDevelopmentDir, "include/libavformat/avformat.h"), File(mpvFfmpegDevelopmentDir, "include/libswresample/swresample.h"), manifest ) @@ -183,7 +182,7 @@ val prepareMpvFfmpegDevelopment = tasks.register("prepareMpvFfmpegDevelopment") } catch (error: Exception) { throw GradleException("Failed to extract FFmpeg $mpvFfmpegVersion headers", error) } - listOf("libavcodec", "libavformat", "libavutil", "libswresample").forEach { library -> + listOf("libavcodec", "libavutil", "libswresample").forEach { library -> project.copy { from(File(extractedSource, library)) { include("*.h") @@ -207,7 +206,6 @@ val prepareMpvFfmpegDevelopment = tasks.register("prepareMpvFfmpegDevelopment") from(zipTree(aar)) { include( "jni/*/libavcodec.so", - "jni/*/libavformat.so", "jni/*/libavutil.so", "jni/*/libswresample.so" ) diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro index 2a20311e7..fae3c6177 100644 --- a/android/app/proguard-rules.pro +++ b/android/app/proguard-rules.pro @@ -19,13 +19,19 @@ # growOutputBuffer's JNI descriptor names this type, so it may not be renamed either. -keep class androidx.media3.decoder.SimpleDecoderOutputBuffer { *; } -# ffmpeg_demuxer_jni.cc resolves the AVIO input proxy's callbacks by name -# (FindClass on the interface, GetMethodID for position/read/readAt/length). -# Keeping the interface members pins the names on every implementation, -# including the anonymous proxy inside FfmpegExtractor. --keep interface com.edde746.plezy.exoplayer.FfmpegDemuxerJni$Input { - long position(); - int read(byte[], int); - int readAt(long, byte[], int); - long length(); +# MatroskaExtractor.init is final and its ExtractorOutput / subtitle scratch buffer live +# in private fields, so the extractor wrappers reach both by name: AssMatroskaExtractor +# (android/libass/.../media/extractor/AssMatroskaExtractor.kt) resolves extractorOutput +# and subtitleSample with getDeclaredField to redirect ASS subtitle samples, and +# MatroskaLatmSupport (android/app/.../exoplayer/MatroskaLatmSupport.kt) resolves +# extractorOutput the same way to wrap LATM tracks. +# +# R8 renaming either field makes getDeclaredField throw; AssMatroskaExtractor resolves +# them in its companion object, so the throw surfaces as ExceptionInInitializerError +# while constructing the extractor — every MKV direct-play, release builds only. Only +# the *names* need pinning: MatroskaExtractor uses both fields itself, so they survive +# shrinking through the compile-time subclass references. +-keepclassmembernames class androidx.media3.extractor.mkv.MatroskaExtractor { + private androidx.media3.extractor.ExtractorOutput extractorOutput; + private androidx.media3.common.util.ParsableByteArray subtitleSample; } diff --git a/android/app/src/androidTest/AndroidManifest.xml b/android/app/src/androidTest/AndroidManifest.xml deleted file mode 100644 index 5bb75390b..000000000 --- a/android/app/src/androidTest/AndroidManifest.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - - - diff --git a/android/app/src/androidTest/assets/ffmpeg/seek_cued.mkv b/android/app/src/androidTest/assets/ffmpeg/seek_cued.mkv deleted file mode 100644 index 776cdc3c7a8677fd8ea0fcbb4e88eb73d33f22b8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 125794 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%0AQWAN;bPgApd*LusYX?z~f=Can2$%^t%^ zWpz_-nB*_`G5y}Ds>ZJP|8ol~8*J}=NIvs#`Pl>iAFhu&-M@6(U(wpWiQO}`OkK;G zHqG&w>}BxY&YJP8wck7?pT!(^ZXI{*6rWkJV}th`spf0;4_hV`%3ro?z2^7o`LXHW zlNuVIwN7T_5j2c4+OG01kHN%oBd6=ktn%C@A)n-R;kWKw8%)pzQgwYn9j zu3?H-iqb1x$$jK$E$jE(po5DY9yWpxZD6 z&v*OKw$0bHnR_Hl?^e0~yC$9y*j!&1Uv_+#%lqs+!6SMz4{z`NF*n1{LI2Y_i;3nN z4SqP?PrAW(WI-dlkAp(w<^}RoCEu9uzI0MxfxT(;#ScDT_TFw-?C=P>js6yB0v_3q zX9PN&#n!$uWtTa^%vQ;*zj=M~D@RVgribUYvA*4xE%J#|^IT@nyjMK47KkS@DTe*> zY1g)V94Zvy_|!P8L;1nfAG6h7qg5cXv|}YF=YQikCN*o@x}|eP z{MJa`>i9Rceq}`K*>lV#%3IDSG+1xzu43B}v;52aXBQk+EVa|meK5@^_t>uU3)k$O zc>CU&tp^u7ynr0W!0-Sx%MLHU^G`GA6eK-~o~H3Gp6z+C0CIJU4M|-j!b; z3zuy!+aK*;cm8 z;g0KFv7k52S1vH8+kdUzAGR|ztny*;@7lLHOSjwB9Isjrn$M5@zrW?{;a|I+H%Uh< z_GEz0=l@9UXkr+7^Z8{9ZJ67;%r^H4UTj<6Sov9<_2~w!HIJ&f>&0j7*}Zg&@$tWj z0jzBQOgG+YcahHtec_>cp~A^f=a{uop}GDc zrzcqfnrxdQmx}gn)?emp)EN9nuC^bz!d3kC#)Q92+aB%a-6iU7IX724;G$?>g^t)~JDFuH#>R(yCz@^lvf8AH`N!AZ z-(k-Ca_usddLHmQboCp0+*spYxc2q`+cn((3s*j>i0Dh*Ke5(w_HpyAy4!;kA0NFn z(RO8GUzl$7Oc?!NKNK=|NPg`p#b7c>G?F#nan4M%>cP zD|a)sAN?o)dCes|t#>N+-+WiyezM{AuNJwc&;^_S|5yIt;PBFGPvZamPgY#1YbyHv zXksg?|LUfC*@P9Jm)Xq~TWXmd#ky;@>BIHi-#>cjZ@l!W?D-V+!wrj?tFu^E%sg=S z^ug1a8HcLuQu4$7E}qW+vG3i6Z=0&j&+nd|y}e`E?E3hms|B+REO+@9|7fZI|Ay7_ zK;F(QmF#Hy_U$oSyyr9*d{Wt!U%89#vHYC!@SsDlve#8*yGeGF zm$?K8#s6EjJZehT6eHhq`Og+B*aGf39J5jl5xnc5q-$6tc6t5g2h8;QGT`e zN~ngb!0*%TtqX-?|9)kgYAUVeQyS{QTze{v?Z$_9McpkrZ}{5GQ)G|Uw3*8tzFG6s z<=M|i+dg%_dNJ+OBi=ACncII951pT!!}neKke=Dv?h9LmyT18*@@}X&xo_|LWi{$E zU2i|_G~u(p_PKNa&vuC3Y{_keu zZ()I75_WBq#bewUL!R#VdTPqXEzKd9xOd&1SbFD#Nz@K!tBB5u#QY}Wf1AKE=|)VMb1V)y4PrH?y}CIvBghCX|zF`&D2%Y#= zlf6uIn*n2DtlyC~zj)~cX7*dP`z(IWzj^v^dC?K6NyV4<6!3igt-Sl_oRrnI8aG)Y zCf{Ci%zc;6igWKvdRR}2+*xqnzEl67)l5sJd-gm0?C$8D`No{zs+z;wR=c!%_Oo}K zciP<5C(ru5^MP6Fya!W^SR3Z2?BjG^bK5jq@~Sgm|J7qxB+kfGTx@8lXYR_H^Y9GA zo0b&61o_ZIE1XJG=0E)~ZhjpiS6ohSyyr^f^GF79i zMV7OpENs?!Bfi;sm)snsV-Gp*nUZtc?W4%~Wv?t$x!G9~)3WE>H0~;%IqSln{chJ3 zjb8oGJ+`iY`LT$ANv+2iGCAhR`{qNp-=4TlYZBGFUY48j@Kcej5SNOv!?CcQex>zV`vvsQ#eVEH-&k?H zQ0?BMHFp#D+1DD`zGHq}JnOF1-OMV+r2>3Q{E)EX$-hwRP@=?XOph_pU4CzZ@N~YD3u4 z&$`VH(bdVU3x%d`I)C7=;)OCMYeW<2dPU=N!+3|=--`TEB z?KV-@fU@_WbD+4y@TKeQ$^Cr1rb-at>a(k{zg%v0YfY zt$1%t`#Zh)Q5*hVJ$E2%X=C?JO@@<`^iE8P`t=NSDj@GqCfnc^S1T>66}}nr3NH`y zXnbEjWkLDTo5DwqYVBHT?6S>F z7tB}x?bVe2>9lf5MXKTZn#~1{+Z}JM;5ycbxyEA*=?wg?dx9Tbz z@1Cq#r&OPLRha~Ju<5_DeI{HQF8@3vbARJv$Lo;kf5!7D)BmI#M=xK+;AFy4HERCL=Yr7ru~_j_E$*PMSJcE3BgTK9&d(ei`;rZwH#-=_HC zNX&6JKTqK_>FXKy3<}NPy(xI)dt&pEo^C7d+y*vGCPE2jS z8#QCrhW`oGKT4Y>T(8vq&8sHzY;xIU)=6D!US=|X`FmjgYQI?%Hyl08#XHrr;NYK! zD`t1aepcF9yHCcm#?n~ob;pca^^K}5p2l^;Hx)As<>sDT#mYE8h>?8FC|0AylwB;kB|wZ*Bt{ycGvqQ0lA&$AjH zG!feL#P+b7o@M_x4#`CG##swjtlw_Z=I@}_5tzHPf71l*??-||4!k#W`od6|eRV;> z8DXUxOh3*)3!km&=PVay0Hc`cdSM*`Z z{0C9`Roj<`&zc{UH0_1e*KjH2MH8iTr%#J<4T<2(Oy-KZ%P95j@4Z*qJhOw7cvBV! zE_h)S+9tks{@EW%63(C5Y%kwErSyYYeGZ#f^~<*JKY}<6Hl7sQdrOh;)uEY_uCoNs zwVV*Idi7&S>j5W!W1pULb*}3sKHvXh77OckHUIdMSeIR)ED@`6o%0(x*b_CkNS)BD zJO7QF;e?HPXivlw&ocH?sxN|4eEuAG(xa>5*S_G9P!g9tmxbRq!zz&^SN+!!KTVQ9 zv76Lgimg8B7a6!_&2_oUypN`KbMIND<~uc)UFvJ~jVA44U5kkNn5Nk~Z%^WSTYTlb z+~Fra51F1%yLR|n@DywA1wjY>e5x>~)bB!L~s>@qi zpB?>m=G&jFG|~6FqjoHQ44sRQ#yp|E_xTN`WbN9Tn!8g>!wwI-~>a5$xBGOmt8ejE6$~f)MdwJ;(&eg^1 z<@!xGrm?4O{O5+eJoj7oE>6{yKb4&%)L8ZN`LWkm_cB*1{Y}eeI1?+d=e?HH?)Zj>2i^NT z4=!|mv$yej=8T7*xL1d@_VYhD^i$)}a`)tD*%c*@-;Tz$G{v&FDD(RyZ&nvB`lF)f zwv=gE?IrcxXY-#H$2=|Jn*ZR7c1H>Sw(U2}^6O**0@L?h-TQmy^o(Ub;ho?jy1&x6SmL%@YFzY<~*UC&aL~GMlKPMKfWga z)+X7{nV%g+(iy5)LMpr5jwV@m3CsVx{UH8#dw!kCqtmzhem_0&X?Nqw++7p-<@Bx` z`u(1?rutU< zRqPHPeEUnyv->UgmV-~OpE$MF2XS(JDVut?Z~By)&i+U3>$jbF)@>WsH`)GWd?BM7 zqls_9Zrz!mr*o~c-|4wQPs9^^9h!%6|D6?I3vLDErW>oUH*IszWdU7>CcijvwUEdq zr#W3uk1d_?f!%Jp{?nK@r`u;UL|nQ1rdWa#vJ%~qzp3C&T`kY!RVQBYzT>Tsxv{9u zYW=_Tb=!X_H!FPAi8b6C;L{&bzA&Jc=~;j9u2M9 z7~vnUS>5LvetEGj^K{e&-77cO^VY=g?{ci|n?8BF%|bgFRuSpj0je(N*xtqQMb;Wk zzW7@WWzMPku*vNb#{$xnx#j==&b4sQ@Vc}&GVoha&}qlBZd{A6 zFW}iGxWBP)nY@+AGG6VFo?jln)U75R=jO>Q-PN3?_OavNho4#-mqva&{q4AK7stB= zf?~<<#W0-NSEOx*Y+ zXx@|5Wxk(ZW=ceV`z90cq1^D^>IXu*nNCXD)*ULl^GYOe`9wphJ(C|BoNe))XFTnP zb6|N?{_|(iZ`|)+s8R9K?s~A9gPp0E<$m1v%XY$Nl-FC@ z|Kdr5YeB--e;g}iov|%+&1ZMnY9+g-?=son}I@|3mf|PrbwG z<(c6=brF=-#K=Wl$Q zRNtV!H0w+fQ?ci>#L(_=HlJMYpW^D`7Uq6ArH9OL{_sMI%qKT93 z7sd#EmtQUNHb6btSm{xv)~`s3+n-oWyP>wI#z|HP-8>{ZGhmu%Z|V2nO9({xZqUz&*>{3+`Ouy|8kxB{;yA5UmaEA2|v^ONSa~C z&jmr-zr0zLXl^XR-W0}gb`o3H233}MkwpvndY!BI!e2ZTZHk<@{*QmhTs3c9dpWD` z!ZIxWHm{$3+&jV7F{MXsd+eH%&$a}1p6r&t_w%m^U&fxQjXPr>H*H+9RcGbK+>L4l zOuKHh+|GWvgl*Y=o%@!O-K)7TWNd!(OzL&|w)Q!@{7)=hea5Njb99H*v-{8gWHGy( zo86gkj`QV}y|q*L4@y_wIC6B|{)_Qmzg(X;y5BeVsGQ^X`ca#oK^4!g&(ps-1+4hD z-AVbi{j=$NZkk>>vF2^VpC`&CPu8v6JYo7h6{Gk9Nkhk_CmOtO9aoq6uq&v*O8Fs^ zz}tV5?E?+(IqxwGy1e-1R4!k32MfQ23m0%7ICJ6tncLf5b8aa9+jqYG4p)=OLDlNA zn3O*$>RyjF9SkTtD{`@6bJW2zyjsRTIja+Lv+^%LnDXe+gy$VIZ5w6vu^q6kJn-vOpW)s@2&BT_BrJo*j~^1$N1Ip_wNr#z7(4Hvr3%tWKzsJ5^e*7@Mca?*e2m9`VCH3Y8p7ipJf|F73WV_ zyjU~D<&!+mq5BSpLu&qW@f|;MK{M_B39pVCfqY;6n(~uR^#3;IE9EP2bauu)ui02% z-97QTj`p@)F@LgeIbD-8(p>(!#xSQpajy2ahZ7IJlHF9?9kt4p!#Xuj~cS zsY~-!_J?Sl-7kOq`Bi4Fx2Gpx2wSrB|A+ry*E>tq3A3m^kUuLu^H;>B;2SFyyK=i$ z3jf)A;(y9ZbrbjXE{Uge7Rmho^i^JJ>3oNYS%!L>I`+67H-F%qcBJ}$k)GA!jnV5x zf6UlnInUx#Yp~}!iHmDbS;zxsB=2AP;!>vDt+a3x$(e_sBiIQcS5Vcq-F=4H`}8IRwu z`+xCO$)1$+2EPihP?lZxV=s&Hl%cl()Ze~#S!`>;)w6Bi3llZd-`WVTA&uE@Q;mCLu9N5@Aq&3MguCU?KQ-=RiVgSV#@^4n7UCX^RxnwlT4 zky&2llX%5#r6B96+xbh2pOqyvw=#Xuw0^K7v3>WZF3}mXe0s`hGxb|KAD!m@d`RuN z-*v;#sLN>*d~Y5Jbt`{e`TyXS`OGJ^jz>MSmp*y@y;7JUq_$&QPp~v_rr|H z73ue`HY~mkU3Cx)?cSmtZGY~d>W{$0{2A(z$23YJZcV!0|0Q8+wAC@`3l*`s(+cz} z?m5gop50e@^|kcoB3mTMF9aZCmniW8Mw96$}DLLq&hQQVtHtSnSvanJ8tfLtUFr#Od~m z>sCp~%#KVYVbpq|s(U{=SB#?tfOVUB5AydHI4vKlktJ zwmHJudL!j)_KPoPYAbJVsz17WQMsMh^Qi|}V(0j*;ga4J`&9L$Hyb;zgt~10e@Owy zgym}$N-_tNIhjX?0!v5qlkF?s zojz61GkYD^fxqvSw%=2)6^#GA-nHny;5ClMJG)M$E$mP~uPFV}VevfZguWOf6k6iO_i5dT#{zWio-q8CKUS2=>{>81QUN~((_>TXRh}5~O z%ee04b6ba({`kARP5(#dx8FHa=QatJtgD;IA?EGXv-bWsL&Zm5BAeBt^Z%*7y$>v(cxv~P8&qfMbo9EW`?m3|-DeLDNd zgllClf&im$T9*y&E#ir(pXWIe` z<{6n?x%1S*(EH)b_)l|mCI4kFbNieQJ^NlTwiL3}d+Oo8F9cqx{x=41^>$=qeWSTI zp8vs>JKRrKww-9U);!)RoNZY6c0tFpnG;J}r&#}&ICIP?T{2(pbjOmCK=)rYr70}s z{VnX*4|DxwVBZ_ha9&q$zj@iuMdmlz4}=$g&%XS=_a=u!&)K6=Y#s{)yl1P0c?xqE8#~X8m&K~aHcvxEdpR(hZ4|ANh=69HW zZ0LOKdUIJs%+ANlvYBiDnjCpCWzYFZhHT5rb_L}dIL$uad1~ilt(!|`o!R3aV!d5G zG3fGZ)~o;%UAOQgHHQ?$(f4H^^wcCgbZer%D)T~ce-=`d>=Ar$%`!M zoAzJamMyGV(LLpL^ZL8yx;5JAt)lJ1f1MZ4-<{ma^6bmo&x%)7zn^AnTpVYx`?*EK zEY;)d_hwk+|8Tl2U*Iy2Stnm**Q4|64{lF+cP7Kmx$t|vh|$f`O0&*_T}?}3pRWo{ z<$5M~>FoVe9yi`cIsabzTqE(#1aS>B9sh}K@qyQmxGjI>_VLtwR<%`cXC#Q#O?fY6 zFJ7CSc<1w~$Eyna;`Bq7YUtNCow%;A)xK2IY5ulN2hRDG=N8;ACdJul(w4>8FV z2j9%w(;pL4caFzMes|qcEvL6EA8Z_1lg|V*H-^qTExE-%qN2ys;b*qZ>d6HYlq`R2 z66-tnXa4V}6DAv125j(Ndf;k(kG;Q9W4QHQGiOxyYGcg(JQivEA6&tvWcx3#;h zAA1GsG@o93=GE!KbD{V5CoRe~e*02d>{QtX=V#}d+VZ5r+do@M9@x=Z;wj(r!oYh% z+CiL$-&dZwS&^Mw^5@&yG}ALo%Y_+(!Vksj`F=UM^+V&@w1*pZ{xhAuX2KOc0eSh@ zaBldxsznim2$Sgj(;g0BH_7k&G7XP37^>jPUrZ^zEPem$B0 zqhF?dVn3}tZTC#SncfFQ-kD$iXLj*_Lfq~?nU||R{VICsAJ=T9v~ue_rk#TNfz5Mn z4x0+sp}2NW*5=&2N!2`o)nX1}52%C`f1rpG0}z7R35{9whasw;izA10q` zoKwF_G-dH+iQ3bFQ*1=6OC3u*-#CNLdfVu{Y-&$bhMktf_OGu)kGiU;sEMChaq;KF z8GTa>LpOI!<+C^teQ~ztmRV}Hvd~$qpqTKzXI|fd{m1eIUJGmZh(^3n=X?BQC8vhnzM!|P z_VIgzuh((8D>&IdIyZ^s&HWDxpQajrfBX2BL9G(w#SNdB4+O6<VrooQ;|LvRczx!_MuGlyC z%S=Cs2?ySY?_9*OYK7{F#m!PVYd*EgoY8$^X7_Q%gM;m!_rF}NI-2Lz{``Y{^~SD; zpfjf-6RA%?N5Daso$CAm9S9FP1>cqV`J&Zmr{I6il75k*_uXInWt@D&hYt(4I0`8i z-JKkw=d6>mi1C8adUhnendy@^^%bbGc;{xe zhHcEgwfckbulIAc@9fa~Bi&V6W^d7baVuYQ*yM&^AO0JCe|Pbr|E<~yItw2~`@GrY zAbh{E{IPa&^`omrfeXq#I(KP(tkpeb`KF|O4YM?FoWRl3q8q05eF?H;uWn8?3Ye7; zvXAxg8RzxS<9CXFvbQ{XX?{3p0|DghveOvn->1I}D!k_rX2E2;twv!k?`ied#tsW7 z%JE6bS_vl3kX|3Gu6A9sBni?~GYE)bED}%6kuVmJ? zEV=ivTVhk_&wp({|GmwO0G(0>IsE<}%Hj95CJZ-suNP*x%YRR5X;}oHt)bN+f$tNw zX7~hOUhuDH<%%`CXDn`LUOTIY{b{-C`uqH+^dD@j_Yu+CuyLkdgXQ;7*B8g{o!Kp` z_CzqqR(aKY<~J1!u3wq^r26xZdZ#Maqc>aPMbG3!sXH#XHubxf<(D0vlJz>QPqM=# zTsf80=iI+3@JimA=hCzJOQUy%Ype(`ZQV0ri|D%ezN^k3X1utNIScfVXon4{dX8(rEwCat+P z^YwWzes87F0K=J^*1GUAnf`gzzp2__U4J$gm;dB_b{{#;9m%>izvzD!<9fG3Ne7R& zyFXveI>YT~z@hR;Y3Hyrn`%Dm(7EQY=HpB6)xBTuah!ei zk-9x)-ZvO5eR!T}uDizBQ)81`sd9;P`hn_OE1zm_G_3boIU|1UROTg<6qFWKbhB{( z3VpyQd%UC7s8x26Lz0M!+MA>6|IRSiirLXCwLYCIDe&YDFV~X}u78(iOxkzum}tgD zwRyXLK3O6lH2Kl=6%PXZq&&_yZND7)S>xNqhPCIr?C)(m*7vu8cg6Sl7j}HTvVU93 zo=E-Oej=f7r$wFqTAn-KN#L7D1IWwF3ZP2m1^TJ@w$o<$as&h$KQ=C@F5K(Js5fVZ ze5RgJR(hMX?v|QQp87uPx<&JDbDmJF%;1@}M?2V~;l2D4&rfp&OtFl@?-dK1E5F~!s#nnNUE$5!vUq3J1v~5C)|E-mUv2q%^b8TMg`wff1 zrw&49{qLcij9<>c=%=yegL>?j&FfxDn?z1Xi!OM>epNMaL*M+`$V4GEN#}14q4NzY zYj@nyS$KTYN2cQmvLA2jac(h~TvD>+(|Hs9nH_QErt|i5m(FaMI#o$2@M-54pA^+2 zFWIZ=em-x=eRO5fLre32<(j&89>nfyf2FW*n!dx8I}KK>8y**_Z(#4;9NBQn@7ng* zrzRbH|8BFfJhW&@$vXbo&Kn~BUHZLUZ^HLgFMHNh>(1|XUGZRJnP%IkMz6)peGUq4|{(E2G-o)vYu!C^CR1K zs=IY@H@?&Qb~_oo&)<*#>m2UTPg~n=R;;`eX}eyE-J|Z$Qh%F+&7G|U&rRQ-KCnyg z?#rm{=2v%o_@rn1rck7H^O1zt~h|IZd0# z{`ln6zDZMeRNGw&(aztt#mOy*MMgGPGc$OR$A*m`FBg`1y{Z+v6WuQSF-+os;+|E@ z?Dk&VUr^V(Vo&d*Z5P+Axw9+Bgw>?LU=Np?kL{wSf;gs~D{a4d>Yj{`>TbHS!(1)x=DtS%+xGX}c_#izZzzv7u-JZL zQ`hV~|2xxKKLiElMLBV>WF6W%*Xx>Z7Y7w*p~vu&y6+1_4q`{d=D&Ef7b z##33vnAu%!{yMPvTyEYTd6>Si#$eQK&^EUpF=h={jHKJlt(gKX)C7hbpin9r;=cj>~F zr?m`CxMZuYtW$ir<8;pX>nR!@SvJ2;?^4}6qh}k#R`*M*)vm1k^7U4{PeO#_--(&; z&lq0NOqh5^>__L1$;Xx7GQOYrJvy;q1^>C?TIph)29=IaoAQ%pEm^R1r(~t=V|lL7 zlY1liZW{Ys4@gux_QJ8{qWZZn3r@%8o#C7;&R4NT^W^r8GkbWpG@HaOJYbmm?cDb} zoF!+hg)fbx-De@jg`&VU*&v$*D{J z-sSmqwpS_LsvYfc| zj?@g-#ux5S=b7k#$X3tXnvv2YW)Zh}eu&V$*hdC`64$G=xUWCf^jmu#%NMuzwmG>r zQb{v!bjs?~*-@NX156)}6{j|H3 zDarit^Xpg6FJkL+wMw<_yLeo&*6noCyN}1b#kbCF{I;&TkmpL?Y=JLco)6CWs(Uwm zYG0goMsH&}_nk5a>xq#Qd)!|iP>x&da@5jheVwCgl10s`Z0?QOGhSBQaee!(d3*ag zm3zTjUhAL6Zr(FnW5SE%mCrxO>zv25#z;^iOHuaF z(qqX28=jvtR!ZsBdK&ay@aU2r4r``!bwQW@D_vXR_rmstePZ9&zLhI>1>WsRY<96@ z%=@zRIp_UJMqEdxUi|U(+n4vh7+zjE;GE$a%A^(*Y%(dBLjG#<{osQ2K%Y}o|3W){bnz> zX|WePE3z+Y=Hlm%_>X(^S@_RB^zG#$UFogb>Dp_-Z10_h-FY zJnK)*+wO4Tr2!vae-&NNF1kcE?@QIeA7{2@K6-Uo$%9?oS~&K}$`^qX4PUWbKb#hO zv0hhHUgc`(pZyP(glX%~^wl_Sdf9IO`5TqjyoH}0R89Dvc`e#^vBOp9%Jj3K0|Vg0 z8wCo?k*i)`_MP=eQytWO$S4e(wBvoR;v=_g-#n$G!AotEnm2SvoZF?aDEnKJjOs$R znJa5oq_2G}Vs^1~_q)hdS10W^*w%CCap;9#FV;@n+G-!U^z7n^bxGfNZstpW>3Of#7b&>g#o?8Cqlf$vbT}nKf z%7VRH9(K&U_Ux0=aRJBa0}|HuEH~^_(_Ao9 zR`zU**6I(g?R^T%Ki^fI?^zh@U2E*~x8}++|D-3$JkwRLPZ60lC2C*6!_Fx`QX9Qg zWGVwwYx0sdGtB?Awb>-PGKVkLjw5c>?Ir4!?K+17uTFT*-86Sw(7QJVXB~dj@f_pk zESKEr!S{TZSnX5hxZJNNON@@y+j>u`WnObZcgKMQ)|uzqs$Hk}>uxXRbb5B?je})A z+}z&Jc`2~9t7J#bJo(1veOC&8>|eL|Xwf~z+lx0Au{-9Unf6xx zQS-t&wfovpb^kiG8Cj~c43#Asj&RmZoH76TdQs1ZN#~e;#O}STPU|r$ngI@xtbm>*~Ty zIVG?4`=-{)9Y~uzNnBO>e%aQ7#Y-SNa~Nb8=PhwTI?`Xtg|GbB;U8NrEIyOpu-M@w zbP4@I#32K{r9bx{zQDBGVv?8c|ML6xTI;yHewG#m6q{FOiWylbNeLyehl!rm3sPSx z_9K4oG5!9RdV8;NI53~MxQ2mg^&5A)=?|uD`p%xSH}!n|N`dstlt1fA-oh;izvgGA`!?io*7QS%r7oKCCxqPzL!i0(2Pd=KsMkwUF zgw2~}Y{ynlijG$jUvi&&X2B}C%wE=sHXr7z2~Ip7C8|&tvUuKJ(Cu@MjL?ykpP(}Z zU^xhUvj1)MJC)rTa+`O(Dg6_9-h(B+!uy%@mrDCDUuMb1JlXSly_XKd(VLTQ{m}}& z>?bJF)0?{Dx$}>gYb=$+J($ltx>CdHxW12TyVduS&)*B=;!0;8k2`eI<@aYU^|mXQ z6!Z?5gxzvyKRnK z+L==rFK}P`X6`a0=H(}%O219kNv^s0Teod<+3CoyzXUqBzCSjp;{Y$GcksrC^5#c5 za^Em1hHjbj_HbY9mhyG>C#SCY_Ipl)RsOGeNy>pT3`u$4m~zu6uP|8b@EWp3hT$>j z%mGLkWc$9#Kh0qFzip4N_Wf0>K4s5Nm9D#0Y5!m+=VaCUa<`&htWeY{i~X#w!Wa@* zY^e3$v8{OL!v^&qyniM<5H*fuOuQiA*KjGs@76WWC+w;5m5cxEdDC)%=iqBs^LLZj zCbcxqoto-1b&u0j?$*E${q9B_KhLvX)CyaqkdU;0USE=z)R&`i_7c~+qA;+ zKcWQ-gg!GZ*8W@gB-X1$+<9+4M@;S>?mOO}I(9ERa^`e;7V9;od$t<8F0ZYt`80cq z@dwY(TBX^W4A1Rx_FN+^>bfdqf9O^DdyD@zYp#|pUW968+~|{7yr5~|1WFYwf(W_Y3sLD^WW8m9GH8y zVfja!8L1TouJ2`J_Ad})uQLz6vgQ1{-JP=)-^kaecPd@JAaPZC^Nbax`qI7CJ?*L@ ziWfbFrVF^wUUp_$&dJ{DBhT6zABjFvmurg6;wWqE@M7~Rk+AyyvGm^NCp(&t>hPUN z>RMp1iQ_Gb?R69!A=&p!p)ymR$pFz!r$m?z)3Ut{XZwI{1ouCZEmR0Dvoyx1cl;1>Kwc& zCUtuCu14M)a(_N}B#J!`xN%aQUnu42>K}m@Z=cw_ymt9xW62r%o31iUP3!$7oa@ND zs8u*|EAN73g<2I6GdLSsx-5>r+W5cc&o`ET636_FVr(AQ@3jqJb^Y*Cr~Rs>ox)LD z-FJKiS>G<39Ah|gH01x!{8b{;W7GFOY%;xZoaeAX*1n>ZQJUuM22&Vzs~v5*^lwY& z+Zbi;+v5Lf-uKM#y{8+fbMt1%f2pO1XRY?&Tj7jTtQp>M&b5gC3u=c%4 zcWget4*#4u^Te)H8}sIc>gKY+`}Y1>b@J>nHQze1GYijeGEBMH`+2`Y<+163o#Jw` z(zhqoUp=zhw#;L>(uv7-l8=rbacDb`_+gFBx6XvmhR&ZoI4gNol$<#}KdiaSxZeDf zYE^#5`+cg@g|k>v9&0>F+K_9tT_gKa)0Z`hOLsmr+F@2I^6lh@zRKVGr^nSyo!s>H zG{~I=S;5)DoTj^bYW|l|F_oW@A9=g zn^=FHIXl}_EB|X4R^KZ- zw?S;}YU@jV?0;9z;ky6o%*ms#t2B=tXjt&%CF4InoB7_^UoV9!HHth9I<#)%AqTdL zz4cSGOBHRhKTAD4vg^tTe^ZVlTPM^e9sRrTnJx3qiVw3FgWg-b7|fuy{n$vL9*}z1H0q#s%6dYtkd4V zI}~l0cR*5?RWbJISGk`VPr5oI@}mm`!_KYBtgc;}y8q@ikKNzCOr6O8@w4KT7~hK@ zUR5;toSAEU;gjzw@BSG{N4fOB*&h3UC0^lJtNTlL*@pg0f?8$@Vv*f_S^v(xJL&UG zIfaL->D>|;yNdet6L#jsn|=S1Xrk1*VcF*!>pR`U?zon{|NQ#QTa~%l;L8D)b~pTv zlTle38O5%ve|qMltere!qKn=g^F5|4l^k-g(r$mB#;p<$nKx&y?dLLjzSTx@#!SZb z`~8K(-%XHNoKjtp)O%k)yLhpa7^Dxv#D{fvg~`nr5~mx&RH07=Pft$qj;09 z1!`*#sq`34bW{7`SW&rAT&UT^v~gKhTOjq>MPHS6b=RNW}(;|Oez*?spk&-0Vl)qTJ4@>r!r_{7{~ z+rZYhApM7TL~Xq4w3Kb0^Zs93^K#YF@;nO(GpR{$r_?t6TXjT8oZXW*>TzggdeM>{ zg70|V$&@MxUlxBEy(%v9&>>qs(>;4GuJ&1MAHU&;?6s!goio?2cwiQ~eaV3bh9vDQ>brF~aVhP?Wt_RQ(+grK7==Y%U6 zKH2~8o?NcRlP&3OJ}GDGvDCO7rOhRL5h3x z0a8^tn%PyOs)#XnoOlzCc_1f7oS++neH2#9s7PCX&|D9y} zAGU7lft!3wq{P=1w3ka2+*rIEdd7e}WZwnD?D;7S(_=*VAD4${AJtX}*I;G+d~&;3 z*y1_Rp%xKF$Wa5ZI{}z?OpmRK!2<&(nF-pl16Kavi|j3k#8`xc)+)B;JFPz^QOZ@d-0a`O_mYa8ctp=wp%AhRhEjEUTHdf z?4ahp+iq`dO>kWVP#0XgR49J2V<@C=%INDjYo&<~bT0=H7Xi%lNHSX!a4Soax5lPx zt3}cSxK%QDU;Fpi$mhe8rSe-0>z+PdtGreM@R{GFa^0J+s;L5rDQgrC{d&Q2Zm!k* zX&)F9wuJ2d)I2f&*sKI;#*?W$A3pcT{7EYHUp%q7{v5m56}_TGk`hOFC+3TJ-P!Z$ z(IcT9zpK>6{@&Xt81QZ3O9eyu;GEx6g7<`e_P8dn=XIe@@&1N{O|^=~>%_z+Z+dp} zA25s_w87frsAb47}-IwSudw-noUMp66`nl{G8HL3`>9Ueu!RMqaG(&AY zDtaC~`hG2_cDvixZ_AhuSPJ+qcGcKd`+j%U4t#e2ti2weD&Dx*(+zUE0jJ&Qii6QR z07k+c0K;X)!R)wwCz~|Cxbm4Vm!4K1az<{w+Jps{G#&o&nY=oG_x~lKxskJ(8qJaq zhb)wCk^4FK?Tft&T9>C<$QD}p-#T`Ap`v``H>;*642`c%8+<;jlD)#RSX1v6pPX%L z_7Z8XHEQ9nQtS6EbzHa3+sS=mT>9+I&aYRlm;7E@P~H5X4=D=eRBW^#4s?m%Bsy*WcxU&P^AxnzR{S*Ozd}P1(`JzcV)IP(|g!^d7bYS6;7L ztej=?-aM+g+^X$qY1cE>-8YnY{~SHy+h3scHQN8IV0y--xXzx<-`+pO@7@#*tKC1Wi+m7B_n9qbBB*`K%V6BsA{tOSRa)uDLW5do%D_EhU}WNtNE||DSgJecA=E6^9AeLslL9qO^65=-lH~9}ncrKk@gY zR8wiO#HBhrOFjGl2balxxe;qt$aCOGW$Nyn-2VGIhlA4$<+GQZd+nco{8;#PnZlNDo!ZCSwd&%p%Dr5-{`M1_vN+#M ziE1vBp7BTZMbF)6`Xye%Tv%tW@9bM;n?xbk4y3Nh1YbMw^bP3Rf!kZm5!Vi6t>3au zztgX)Z#BAG$3{KI$;#UUE|qH7bC^3XKe*W8E_5IJRpccFxGo)d^v*G*neBJL##L;J zYxhK5)OT;%YQAvp?@78do6O@JS1PI`ZJhJe*ZX(M>yFkp1uwH^#N6xJw^mYDZ|l^r z>TEM_lrdVBn%gCVZkV`MYZ8@~-esI}_WzyOr-{ z%7s76wx3A(@x^Sv{_Z2ks&fOYqx2`Sim~%HWb*C#e5S3Ncaq+xTQ3hh^Dq=#{$jhL zKVx*@ah`q`*-~L~W&dgaB$v)v_F(b9{7q}tCVzIEkYCVm`06=J`{H)_^I=X(J^B5f zzuzx%&SANLinzkB>OyWy7i8qBMtuIyi=AW{FQxNbRb zO2(J<`3|$aZ3Ki)yEj-aT=OThw{n~OzoQp69Al5KxOQ1T@4+wc@cRd&-o4Va

qr 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

qx=hkZa;e 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 mYdg;^wB@1enl?2B=V>2TbTD$S?qQ&diqp diff --git a/android/app/src/androidTest/assets/ffmpeg/seek_cueless.mkv b/android/app/src/androidTest/assets/ffmpeg/seek_cueless.mkv deleted file mode 100644 index 85ce735662ad04c040f7b96bf3b85268a7533865..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 130542 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 diff --git a/android/app/src/androidTest/assets/ffmpeg/stereo_opus.mka b/android/app/src/androidTest/assets/ffmpeg/stereo_opus.mka deleted file mode 100644 index a868c100687e14679cc4cbe3ab0ceea8b9b444e2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 19537 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 diff --git a/android/app/src/androidTest/assets/ffmpeg/stereo_vorbis.mka b/android/app/src/androidTest/assets/ffmpeg/stereo_vorbis.mka deleted file mode 100644 index c9b24341773f9161e85a6a3650b3aff83b878029..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 11583 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 M@naL;jt`BT0OhRU!2kdN diff --git a/android/app/src/androidTest/java/com/edde746/plezy/exoplayer/FixtureContentProvider.java b/android/app/src/androidTest/java/com/edde746/plezy/exoplayer/FixtureContentProvider.java deleted file mode 100644 index e0b07474a..000000000 --- a/android/app/src/androidTest/java/com/edde746/plezy/exoplayer/FixtureContentProvider.java +++ /dev/null @@ -1,117 +0,0 @@ -package com.edde746.plezy.exoplayer; - -import android.content.ContentProvider; -import android.content.ContentValues; -import android.content.Context; -import android.database.Cursor; -import android.net.Uri; -import android.os.ParcelFileDescriptor; -import java.io.File; -import java.io.FileNotFoundException; -import java.io.FileOutputStream; -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; - -/** - * Serves a test asset over a {@code content://} URI so instrumentation can exercise media3's - * {@code ContentDataSource} read path. - * - *

A download stored through SAF reaches the player as {@code content://} and is read with - * {@code openAssetFileDescriptor} — a different reopen story than a plain file: - * {@code FfmpegRandomAccessSource} has to open a second descriptor on the document while - * the loader still holds one. Nothing else in the app produces that shape on demand, and MediaStore - * would leave rows on the device. - * - *

The provider owns the whole fixture: a test body runs in the app's process and cannot - * write into this APK's data directory, so handing it a path could never work. It copies the named - * asset into its own cache and returns a read-only descriptor, which crosses the uid boundary fine. - * - *

Java rather than Kotlin on purpose: hosting this provider starts a process for the test APK - * alone, and the Kotlin stdlib the instrumentation normally borrows from the app is not on that - * process's classpath. - */ -public final class FixtureContentProvider extends ContentProvider { - - public static final String AUTHORITY = "com.edde746.plezy.test.fixtures"; - - /** @param asset path inside this APK's assets, e.g. {@code ffmpeg/seek_cued.mkv}. */ - public static Uri uriFor(String asset) { - return new Uri.Builder().scheme("content").authority(AUTHORITY).path(asset).build(); - } - - @Override - public boolean onCreate() { - return true; - } - - @Override - public ParcelFileDescriptor openFile(Uri uri, String mode) throws FileNotFoundException { - String path = uri.getPath(); - if (path == null || path.length() <= 1) { - throw new FileNotFoundException("no asset in " + uri); - } - String asset = path.substring(1); - Context context = getContext(); - if (context == null) { - throw new FileNotFoundException("provider has no context"); - } - File cached = new File(context.getCacheDir(), asset.replace('/', '-')); - if (!cached.isFile()) { - copyAsset(context, asset, cached); - } - return ParcelFileDescriptor.open(cached, ParcelFileDescriptor.MODE_READ_ONLY); - } - - private static void copyAsset(Context context, String asset, File target) throws FileNotFoundException { - File staging = null; - try { - context.getCacheDir().mkdirs(); - staging = File.createTempFile("fixture-", null, context.getCacheDir()); - try (InputStream source = context.getAssets().open(asset); - OutputStream sink = new FileOutputStream(staging)) { - byte[] buffer = new byte[64 * 1024]; - for (int read = source.read(buffer); read != -1; read = source.read(buffer)) { - sink.write(buffer, 0, read); - } - } - // Publish atomically so a concurrent open never sees a short file. - if (!staging.renameTo(target) && !target.isFile()) { - throw new IOException("could not publish " + target); - } - } catch (IOException e) { - FileNotFoundException failure = new FileNotFoundException("could not stage asset " + asset); - failure.initCause(e); - throw failure; - } finally { - if (staging != null && staging.isFile()) { - staging.delete(); - } - } - } - - @Override - public String getType(Uri uri) { - return "video/x-matroska"; - } - - @Override - public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) { - return null; - } - - @Override - public Uri insert(Uri uri, ContentValues values) { - return null; - } - - @Override - public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) { - return 0; - } - - @Override - public int delete(Uri uri, String selection, String[] selectionArgs) { - return 0; - } -} diff --git a/android/app/src/androidTest/kotlin/com/edde746/plezy/exoplayer/FfmpegDemuxerReachabilityTest.kt b/android/app/src/androidTest/kotlin/com/edde746/plezy/exoplayer/FfmpegDemuxerReachabilityTest.kt deleted file mode 100644 index 88a2180ca..000000000 --- a/android/app/src/androidTest/kotlin/com/edde746/plezy/exoplayer/FfmpegDemuxerReachabilityTest.kt +++ /dev/null @@ -1,116 +0,0 @@ -package com.edde746.plezy.exoplayer - -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.platform.app.InstrumentationRegistry -import java.io.File -import java.io.RandomAccessFile -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Test -import org.junit.runner.RunWith - -/** - * Asserts the demuxer's JNI byte source stays reachable under R8. - * - * `ffmpeg_demuxer_jni.cc` resolves [FfmpegDemuxerJni.Input] by name — `FindClass` - * on the interface, then `GetMethodID` for `position`, `read`, `readAt` and - * `length`. Nothing in Kotlin calls those methods, so R8 may rename or remove - * them while every debug check still passes; the AVIO callbacks would then fail - * on the first read and every container would fall back to media3's extractors. - * That is the same failure shape as #1703, which shipped. - * - * Run against the `minified` build type (`-Pplezy.testBuildType=minified`); on an - * unminified variant it can only ever pass. Deliberately touches no media3 API, - * so the only keep rules it depends on are the ones under test. - */ -@RunWith(AndroidJUnit4::class) -class FfmpegDemuxerReachabilityTest { - - @Test - fun avioCallbacksResolveAndServeBothSequentialAndIndexReads() { - assertTrue("ffmpeg demuxer JNI library is unavailable", FfmpegDemuxerJni.available) - - val fixture = copyFixture() - val input = FileInput(fixture) - try { - // nativeOpen drives the whole handshake: FindClass on Input, GetMethodID - // for all four methods, then avio reads served back through them. A - // shrunk or renamed member makes this fail instead of returning streams. - val code = FfmpegDemuxerJni.nativeOpen(input) - - assertEquals("nativeOpen failed (${input.lastError ?: "no java error"})", 0, code) - assertTrue("demuxer reported no streams", FfmpegDemuxerJni.nativeStreamCount() > 0) - assertTrue("position was never called", input.positionCalls > 0) - - // Header parsing is sequential, so it only proves read()/position()/length(). - // readAt() is the member nothing in the app calls except this interface's - // own implementation, and it is what an index seek runs on. - val durationUs = FfmpegDemuxerJni.nativeDurationUs() - assertTrue("fixture duration unknown", durationUs > 0) - val seek = FfmpegDemuxerJni.nativeSeek(durationUs / 2) - - assertTrue("nativeSeek failed with $seek (${input.lastError ?: "no java error"})", seek >= 0) - assertTrue("readAt was never called for the index read", input.readAtCalls > 0) - } finally { - FfmpegDemuxerJni.nativeClose() - input.close() - fixture.delete() - } - } - - private fun copyFixture(): File { - val instrumentation = InstrumentationRegistry.getInstrumentation() - val cacheDir = instrumentation.targetContext.cacheDir.also { it.mkdirs() } - val output = File.createTempFile("ffmpeg-reachability-", ".mkv", cacheDir) - instrumentation.context.assets.open(FIXTURE).use { source -> - output.outputStream().use(source::copyTo) - } - return output - } - - /** Byte source with no media3 involvement, so R8 has nothing else to keep. */ - private class FileInput(file: File) : FfmpegDemuxerJni.Input { - private val handle = RandomAccessFile(file, "r") - private val total = file.length() - - var readAtCalls = 0 - private set - - var positionCalls = 0 - private set - - override var lastError: String? = null - - override fun position(): Long { - positionCalls++ - return handle.filePointer - } - - override fun read(buf: ByteArray, length: Int): Int { - val read = handle.read(buf, 0, length) - return if (read < 0) 0 else read - } - - override fun readAt(position: Long, buf: ByteArray, length: Int): Int { - readAtCalls++ - val restore = handle.filePointer - return try { - if (position >= total) return 0 - handle.seek(position) - val read = handle.read(buf, 0, length) - if (read < 0) 0 else read - } finally { - handle.seek(restore) - } - } - - override fun length(): Long = total - - fun close() = handle.close() - } - - private companion object { - /** The cued fixture from [FfmpegExtractorSeekTest]: a SeekHead plus Cues at the end. */ - const val FIXTURE = "ffmpeg/seek_cued.mkv" - } -} diff --git a/android/app/src/androidTest/kotlin/com/edde746/plezy/exoplayer/FfmpegExtractorAudioCsdPlaybackTest.kt b/android/app/src/androidTest/kotlin/com/edde746/plezy/exoplayer/FfmpegExtractorAudioCsdPlaybackTest.kt deleted file mode 100644 index 3acebb25c..000000000 --- a/android/app/src/androidTest/kotlin/com/edde746/plezy/exoplayer/FfmpegExtractorAudioCsdPlaybackTest.kt +++ /dev/null @@ -1,135 +0,0 @@ -package com.edde746.plezy.exoplayer - -import android.content.Context -import android.net.Uri -import android.os.Handler -import android.os.HandlerThread -import androidx.media3.common.MediaItem -import androidx.media3.common.PlaybackException -import androidx.media3.common.Player -import androidx.media3.datasource.DataSpec -import androidx.media3.datasource.DefaultDataSource -import androidx.media3.exoplayer.ExoPlayer -import androidx.media3.exoplayer.source.ProgressiveMediaSource -import androidx.media3.extractor.ExtractorsFactory -import androidx.media3.extractor.text.DefaultSubtitleParserFactory -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.platform.app.InstrumentationRegistry -import com.edde746.plezy.libass.media.AssHandler -import java.io.File -import java.util.concurrent.CountDownLatch -import java.util.concurrent.TimeUnit -import java.util.concurrent.atomic.AtomicReference -import org.junit.Assert.assertNotNull -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Test -import org.junit.runner.RunWith - -/** - * End-to-end regression coverage for #2088: audio demuxed by FfmpegExtractor - * must reach MediaCodec with the per-codec csd shapes media3's own extractors - * emit. With raw ffmpeg extradata as the only csd buffer, Android's Opus - * decoders consume the first two real packets as codec delay / seek pre-roll - * and then silently discard every decoded sample — the player never leaves - * STATE_BUFFERING, which this test observes as a playback timeout. - * - * The extractors factory contains only FfmpegExtractor, so a fixture that - * falls back to media3's extractors fails instead of passing vacuously. - */ -@RunWith(AndroidJUnit4::class) -class FfmpegExtractorAudioCsdPlaybackTest { - - @Test - fun opusPlaysToEnd() { - playToEnd("ffmpeg/stereo_opus.mka") - } - - @Test - fun vorbisPlaysToEnd() { - playToEnd("ffmpeg/stereo_vorbis.mka") - } - - @Test - fun flacPlaysToEnd() { - playToEnd("ffmpeg/stereo.flac") - } - - private fun playToEnd(fixture: String) { - val instrumentation = InstrumentationRegistry.getInstrumentation() - val context = instrumentation.targetContext - val fixtureFile = copyFixture(instrumentation.context, context, fixture) - val playbackThread = HandlerThread("ffmpeg-csd-test").apply { start() } - val handler = Handler(playbackThread.looper) - val completed = CountDownLatch(1) - val playerReference = AtomicReference() - val errorReference = AtomicReference() - - handler.post { - try { - val fixtureUri = Uri.fromFile(fixtureFile) - val ffmpegOnlyExtractors = ExtractorsFactory { - val extractor = FfmpegExtractor.create( - { FfmpegDemuxerPolicy.Preference.FFMPEG }, - DvConversionMode.DISABLED, - DefaultSubtitleParserFactory(), - AssHandler(), - FfmpegRandomAccessSource(DefaultDataSource.Factory(context)) { DataSpec(fixtureUri) } - ) - assertNotNull("FFmpeg demuxer JNI library is unavailable", extractor) - arrayOf(extractor!!) - } - val player = ExoPlayer.Builder(context, PlezyRenderersFactory(context)) - .setLooper(playbackThread.looper) - .build() - playerReference.set(player) - player.addListener(object : Player.Listener { - override fun onPlayerError(error: PlaybackException) { - errorReference.set(error) - completed.countDown() - } - - override fun onPlaybackStateChanged(playbackState: Int) { - if (playbackState == Player.STATE_ENDED) { - completed.countDown() - } - } - }) - val source = ProgressiveMediaSource.Factory( - DefaultDataSource.Factory(context), - ffmpegOnlyExtractors - ).createMediaSource(MediaItem.fromUri(fixtureUri)) - player.setMediaSource(source) - player.prepare() - player.play() - } catch (error: Throwable) { - errorReference.set(error) - completed.countDown() - } - } - - val finished = completed.await(20, TimeUnit.SECONDS) - val released = CountDownLatch(1) - handler.post { - playerReference.get()?.release() - playbackThread.quitSafely() - released.countDown() - } - val teardownFinished = released.await(5, TimeUnit.SECONDS) - playbackThread.join(5_000) - val fixtureDeleted = fixtureFile.delete() - - assertTrue("Player teardown timed out for $fixture", teardownFinished) - assertTrue("Playback timed out for $fixture", finished) - assertNull("Playback failed for $fixture", errorReference.get()) - assertTrue("Fixture cleanup failed for $fixture", fixtureDeleted) - } - - private fun copyFixture(instrumentationContext: Context, targetContext: Context, fixture: String): File { - val output = File.createTempFile("ffmpeg-csd-fixture-", null, targetContext.cacheDir) - instrumentationContext.assets.open(fixture).use { input -> - output.outputStream().use(input::copyTo) - } - return output - } -} diff --git a/android/app/src/androidTest/kotlin/com/edde746/plezy/exoplayer/FfmpegExtractorReadRetryTest.kt b/android/app/src/androidTest/kotlin/com/edde746/plezy/exoplayer/FfmpegExtractorReadRetryTest.kt deleted file mode 100644 index d1e82ea23..000000000 --- a/android/app/src/androidTest/kotlin/com/edde746/plezy/exoplayer/FfmpegExtractorReadRetryTest.kt +++ /dev/null @@ -1,240 +0,0 @@ -package com.edde746.plezy.exoplayer - -import android.content.Context -import android.net.Uri -import androidx.media3.common.C -import androidx.media3.common.DataReader -import androidx.media3.common.Format -import androidx.media3.common.ParserException -import androidx.media3.common.util.ParsableByteArray -import androidx.media3.datasource.DataSource -import androidx.media3.datasource.DataSpec -import androidx.media3.datasource.DefaultDataSource -import androidx.media3.datasource.TransferListener -import androidx.media3.extractor.DefaultExtractorInput -import androidx.media3.extractor.Extractor -import androidx.media3.extractor.ExtractorInput -import androidx.media3.extractor.ExtractorOutput -import androidx.media3.extractor.PositionHolder -import androidx.media3.extractor.SeekMap -import androidx.media3.extractor.TrackOutput -import androidx.media3.extractor.text.DefaultSubtitleParserFactory -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.platform.app.InstrumentationRegistry -import com.edde746.plezy.libass.media.AssHandler -import java.io.File -import java.io.IOException -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertThrows -import org.junit.Assert.assertTrue -import org.junit.Test -import org.junit.runner.RunWith - -/** - * Regression coverage for #2113: a transient byte-source failure mid-stream - * must reach media3 as a plain retryable IOException, and the demuxer must - * deliver samples again once the retried read succeeds. - * - * 2.17.1 got both halves wrong. The AVIO callback converted the proxy's - * stored IOException into a bare AVERROR(EIO), which the extractor classified - * as "malformed container" — a ParserException that - * DefaultLoadErrorHandlingPolicy never retries — so one dropped connection - * sent an otherwise healthy ExoPlayer session to the MPV fallback. And even - * with the classification fixed, a failed refill latches - * AVIOContext.error/eof_reached, so the retry would re-fail on the stale - * error without the native side clearing the latch on re-entry. - */ -@RunWith(AndroidJUnit4::class) -class FfmpegExtractorReadRetryTest { - - private companion object { - /** 30 s at 10 fps: video samples are exact multiples of 100 ms. */ - const val FIXTURE = "ffmpeg/seek_cued.mkv" - const val SAMPLE_INTERVAL_US = 100_000L - const val TOTAL_SAMPLES = 300 - } - - @Test - fun transientLoaderReadFailureIsRetryableAndDeliveryResumesGaplessly() { - withHarness(FIXTURE) { harness -> - assertTrue("no samples before the fault", harness.pumpUntil { harness.sampleTimestamps.size >= 10 }) - - harness.failNextLoaderRead() - val thrown = assertThrows(IOException::class.java) { - harness.pumpUntil { harness.sampleTimestamps.size >= TOTAL_SAMPLES } - } - // The regression: a byte-source failure classified as malformed content - // is terminal to media3, which retries only plain IOExceptions. - assertFalse("byte-source failure classified as malformed: $thrown", thrown is ParserException) - assertEquals("injected loader failure", thrown.message) - - // What ExtractingLoadable does on a load-error retry: reopen the source - // where the input stood and call read() again. - harness.reopenForRetry() - assertTrue( - "no samples after the retried read", - harness.pumpUntil { harness.sampleTimestamps.size >= TOTAL_SAMPLES / 2 } - ) - - // Decode order is not presentation order with B-frames; what must hold - // is that the delivered set stays gapless across the failure point. - val sorted = harness.sampleTimestamps.sorted() - assertEquals( - "sample delivery must resume gaplessly after the transient failure", - sorted.indices.map { it * SAMPLE_INTERVAL_US }, - sorted - ) - } - } - - private fun withHarness(fixture: String, block: (Harness) -> Unit) { - val instrumentation = InstrumentationRegistry.getInstrumentation() - val context = instrumentation.targetContext - val cacheDir = context.cacheDir.also { it.mkdirs() } - val file = File.createTempFile("ffmpeg-retry-fixture-", ".mkv", cacheDir) - instrumentation.context.assets.open(fixture).use { input -> - file.outputStream().use(input::copyTo) - } - val harness = Harness(context, Uri.fromFile(file)) - try { - harness.prepare() - block(harness) - } finally { - harness.close() - file.delete() - } - } - - /** - * Stand-in for ProgressiveMediaPeriod's loader, like FfmpegExtractorSeekTest's, - * plus the piece under test here: the loader's DataSource can be made to fail - * exactly one read, and [reopenForRetry] performs the load-error retry the - * way ExtractingLoadable.load() does. - */ - private class Harness(context: Context, private val uri: Uri) { - private val factory = DefaultDataSource.Factory(context) - private val io = FfmpegRandomAccessSource(factory) { DataSpec(uri) } - private val output = CapturingOutput() - private val positionHolder = PositionHolder() - private val extractor = requireNotNull( - FfmpegExtractor.create( - { FfmpegDemuxerPolicy.Preference.FFMPEG }, - DvConversionMode.DISABLED, - DefaultSubtitleParserFactory(), - AssHandler(), - io - ) - ) { "native ffmpeg demuxer unavailable" } - - private var loaderSource: FlakySource? = null - private var input: ExtractorInput? = null - - val sampleTimestamps: List - get() = output.sampleTimestamps - - fun prepare() { - openAt(0) - assertTrue("fixture should be sniffed by the ffmpeg demuxer", extractor.sniff(input!!)) - input!!.resetPeekPosition() - extractor.init(output) - } - - /** Arms the current loader handle; only its next read throws. */ - fun failNextLoaderRead() { - loaderSource!!.failNextRead = true - } - - fun reopenForRetry() { - openAt(input!!.position) - } - - fun pumpUntil(condition: () -> Boolean): Boolean { - repeat(200_000) { - if (condition()) return true - when (extractor.read(input!!, positionHolder)) { - Extractor.RESULT_SEEK -> openAt(positionHolder.position) - Extractor.RESULT_END_OF_INPUT -> return condition() - else -> Unit - } - } - return condition() - } - - private fun openAt(position: Long) { - loaderSource?.close() - val source = FlakySource(factory.createDataSource()) - val remaining = source.open(DataSpec.Builder().setUri(uri).setPosition(position).build()) - loaderSource = source - input = DefaultExtractorInput( - source, - position, - if (remaining == C.LENGTH_UNSET.toLong()) C.LENGTH_UNSET.toLong() else position + remaining - ) - } - - fun close() { - extractor.release() - loaderSource?.close() - } - } - - /** Wraps only the loader's source, so the fault never lands on an index read. */ - private class FlakySource(private val inner: DataSource) : DataSource { - var failNextRead = false - - override fun open(dataSpec: DataSpec): Long = inner.open(dataSpec) - - override fun read(buffer: ByteArray, offset: Int, length: Int): Int { - if (failNextRead) { - failNextRead = false - throw IOException("injected loader failure") - } - return inner.read(buffer, offset, length) - } - - override fun addTransferListener(transferListener: TransferListener) = inner.addTransferListener(transferListener) - - override fun getUri(): Uri? = inner.uri - - override fun getResponseHeaders(): Map> = inner.responseHeaders - - override fun close() = inner.close() - } - - private class CapturingOutput : ExtractorOutput { - val sampleTimestamps = mutableListOf() - - override fun track(id: Int, type: Int): TrackOutput = CapturingTrack(type, sampleTimestamps) - - override fun endTracks() = Unit - - override fun seekMap(seekMap: SeekMap) = Unit - } - - private class CapturingTrack(private val type: Int, private val timestamps: MutableList) : TrackOutput { - private val scratch = ByteArray(64 * 1024) - - override fun format(format: Format) = Unit - - override fun sampleData(input: DataReader, length: Int, allowEndOfInput: Boolean): Int = - sampleData(input, length, allowEndOfInput, TrackOutput.SAMPLE_DATA_PART_MAIN) - - override fun sampleData(input: DataReader, length: Int, allowEndOfInput: Boolean, sampleDataPart: Int): Int { - val read = input.read(scratch, 0, minOf(length, scratch.size)) - return if (read == C.RESULT_END_OF_INPUT) 0 else read - } - - override fun sampleData(data: ParsableByteArray, length: Int) { - sampleData(data, length, TrackOutput.SAMPLE_DATA_PART_MAIN) - } - - override fun sampleData(data: ParsableByteArray, length: Int, sampleDataPart: Int) { - data.skipBytes(length) - } - - override fun sampleMetadata(timeUs: Long, flags: Int, size: Int, offset: Int, cryptoData: TrackOutput.CryptoData?) { - if (type == C.TRACK_TYPE_VIDEO) timestamps.add(timeUs) - } - } -} diff --git a/android/app/src/androidTest/kotlin/com/edde746/plezy/exoplayer/FfmpegExtractorSeekTest.kt b/android/app/src/androidTest/kotlin/com/edde746/plezy/exoplayer/FfmpegExtractorSeekTest.kt deleted file mode 100644 index 596f7503b..000000000 --- a/android/app/src/androidTest/kotlin/com/edde746/plezy/exoplayer/FfmpegExtractorSeekTest.kt +++ /dev/null @@ -1,357 +0,0 @@ -package com.edde746.plezy.exoplayer - -import android.content.Context -import android.net.Uri -import androidx.media3.common.C -import androidx.media3.common.DataReader -import androidx.media3.common.Format -import androidx.media3.common.util.ParsableByteArray -import androidx.media3.datasource.DataSource -import androidx.media3.datasource.DataSpec -import androidx.media3.datasource.DefaultDataSource -import androidx.media3.datasource.TransferListener -import androidx.media3.extractor.DefaultExtractorInput -import androidx.media3.extractor.Extractor -import androidx.media3.extractor.ExtractorInput -import androidx.media3.extractor.ExtractorOutput -import androidx.media3.extractor.PositionHolder -import androidx.media3.extractor.SeekMap -import androidx.media3.extractor.TrackOutput -import androidx.media3.extractor.text.DefaultSubtitleParserFactory -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.platform.app.InstrumentationRegistry -import com.edde746.plezy.libass.media.AssHandler -import java.io.File -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Test -import org.junit.runner.RunWith - -/** - * Regression coverage for #2096 and for the seam that caused it. - * - * libavformat resolves seeks through its own index, reading it via - * [FfmpegRandomAccessSource]. When that read cannot be served, matroskadec - * burns its one-shot Cues parse and every later seek degrades to libavformat's - * linear generic scan — which is what buffered a resume for minutes. The cued - * fixture here has the same geometry as the file from the report (front - * SeekHead, Cues at 99.5% of the file), so a scan is trivially distinguishable - * from an index seek: it has to read most of the file. - */ -@RunWith(AndroidJUnit4::class) -class FfmpegExtractorSeekTest { - - private companion object { - // Regenerate with: - // ffmpeg -f lavfi -i "testsrc=size=160x120:rate=10:duration=30" \ - // -c:v libx264 -g 10 -pix_fmt yuv420p -preset veryfast seek_cued.mkv - // mkvmerge --no-cues -o seek_cueless.mkv seek_cued.mkv - // The cued fixture keeps ffmpeg's default layout: a SeekHead at byte 52 - // pointing at Cues written after the clusters, at 99.5% of the file. - const val CUED = "ffmpeg/seek_cued.mkv" - const val CUELESS = "ffmpeg/seek_cueless.mkv" - - /** The fixtures are 30 s at 10 fps with a keyframe every second. */ - const val TARGET_US = 25_000_000L - const val KEYFRAME_TOLERANCE_US = 1_500_000L - } - - @Test - fun cuedSeekLandsOnTheTargetWithoutWalkingTheFile() { - withHarness(CUED) { harness -> - harness.pumpUntilSampleDelivered() - assertTrue( - "playback should start at the beginning, got ${harness.lastSampleUs}us", - harness.lastSampleUs < 500_000L - ) - - harness.resetByteCounter() - harness.seekTo(TARGET_US) - val landed = harness.pumpUntilSampleAtLeast(TARGET_US - KEYFRAME_TOLERANCE_US) - - assertTrue("no sample delivered after seeking to ${TARGET_US}us", landed) - assertTrue( - "seek landed at ${harness.lastSampleUs}us, expected within " + - "${KEYFRAME_TOLERANCE_US}us of ${TARGET_US}us", - harness.lastSampleUs <= TARGET_US + KEYFRAME_TOLERANCE_US - ) - // An index seek reads the Cues plus the target cluster. A generic scan - // has to read everything up to 83% of the file to get here. - val budget = harness.fixtureSize / 3 - assertTrue( - "seek read ${harness.bytesRead} bytes of a ${harness.fixtureSize}-byte file; " + - "expected under $budget (index seek, not a scan)", - harness.bytesRead < budget - ) - // Reopening per read would mean one request per 64 KB in steady state. - assertTrue("random-access opens after seek: ${harness.randomAccessOpens}", harness.randomAccessOpens <= 4) - } - } - - /** - * Without Cues libavformat falls back to its generic scan, exactly as media3's - * own MatroskaExtractor scans clusters. That is slow but it must still land on - * the target rather than stalling or silently delivering from the wrong place. - */ - @Test - fun cuelessSeekStillReachesTheTarget() { - withHarness(CUELESS) { harness -> - harness.pumpUntilSampleDelivered() - harness.seekTo(TARGET_US) - val landed = harness.pumpUntilSampleAtLeast(TARGET_US - KEYFRAME_TOLERANCE_US) - - assertTrue("cueless seek to ${TARGET_US}us delivered nothing", landed) - assertTrue( - "cueless seek landed at ${harness.lastSampleUs}us", - harness.lastSampleUs <= TARGET_US + KEYFRAME_TOLERANCE_US - ) - } - } - - /** - * A download stored through SAF arrives as `content://`, which media3 reads - * with `openAssetFileDescriptor`. The demuxer's index reads therefore have to - * open a second descriptor on the same document while the loader still holds - * one — the case a `file://` fixture cannot prove. - */ - @Test - fun cuedSeekLandsOnTheTargetThroughAContentUri() { - withHarness(CUED, contentUri = true) { harness -> - harness.pumpUntilSampleDelivered() - - harness.resetByteCounter() - harness.seekTo(TARGET_US) - val landed = harness.pumpUntilSampleAtLeast(TARGET_US - KEYFRAME_TOLERANCE_US) - - assertTrue("no sample delivered after a content:// seek to ${TARGET_US}us", landed) - assertTrue( - "content:// seek landed at ${harness.lastSampleUs}us", - harness.lastSampleUs <= TARGET_US + KEYFRAME_TOLERANCE_US - ) - assertTrue( - "content:// index read opened no descriptor of its own", - harness.randomAccessOpens > 0 - ) - val budget = harness.fixtureSize / 3 - assertTrue( - "content:// seek read ${harness.bytesRead} bytes of a ${harness.fixtureSize}-byte file; " + - "expected under $budget (index seek, not a scan)", - harness.bytesRead < budget - ) - } - } - - @Test - fun playbackFromTheStartDeliversEverySampleFromTheBeginning() { - withHarness(CUED) { harness -> - val timestamps = harness.pumpSamples(count = 40) - - assertTrue("expected samples from the start, got ${timestamps.size}", timestamps.size >= 40) - assertTrue("first sample at ${timestamps.first()}us", timestamps.first() < 500_000L) - // Delivery is in decode order, which is not presentation order once the - // encoder emits B-frames; what must hold is that the set of samples is - // gapless from zero (this fixture is a steady 10 fps). - val sorted = timestamps.sorted() - assertEquals(sorted.indices.map { it * 100_000L }, sorted) - } - } - - private fun withHarness(fixture: String, contentUri: Boolean = false, block: (Harness) -> Unit) { - val instrumentation = InstrumentationRegistry.getInstrumentation() - val context = instrumentation.targetContext - val file = copyFixture(instrumentation.context, context, fixture) - // The content:// case reads the same asset back through the provider, which - // keeps its own copy: this process cannot write into the test APK's dirs. - val uri = if (contentUri) FixtureContentProvider.uriFor(fixture) else Uri.fromFile(file) - val harness = Harness(context, file, uri) - try { - harness.prepare() - block(harness) - } finally { - harness.close() - file.delete() - } - } - - private fun copyFixture(instrumentationContext: Context, targetContext: Context, fixture: String): File { - val cacheDir = targetContext.cacheDir.also { it.mkdirs() } - val output = File.createTempFile("ffmpeg-seek-fixture-", ".mkv", cacheDir) - instrumentationContext.assets.open(fixture).use { input -> - output.outputStream().use(input::copyTo) - } - return output - } - - /** - * Minimal stand-in for ProgressiveMediaPeriod's loader: it opens the data - * source, feeds an ExtractorInput, and honours RESULT_SEEK by re-opening — - * the same contract media3 implements in ExtractingLoadable.load(). - */ - private class Harness(context: Context, fixture: File, private val uri: Uri) { - val fixtureSize = fixture.length() - private val counting = CountingDataSourceFactory(DefaultDataSource.Factory(context)) - private val io = FfmpegRandomAccessSource(counting) { DataSpec(uri) } - private val output = CapturingOutput() - private val positionHolder = PositionHolder() - private val extractor = requireNotNull( - FfmpegExtractor.create( - { FfmpegDemuxerPolicy.Preference.FFMPEG }, - DvConversionMode.DISABLED, - DefaultSubtitleParserFactory(), - AssHandler(), - io - ) - ) { "native ffmpeg demuxer unavailable" } - - private var dataSource: DataSource? = null - private var input: ExtractorInput? = null - private var opensBaseline = 0 - - val lastSampleUs: Long - get() = output.lastSampleUs - - val bytesRead: Long - get() = counting.bytesRead - - val randomAccessOpens: Int - get() = io.openCount - opensBaseline - - fun prepare() { - openAt(0) - assertTrue("fixture should be sniffed by the ffmpeg demuxer", extractor.sniff(input!!)) - input!!.resetPeekPosition() - extractor.init(output) - } - - fun resetByteCounter() { - counting.reset() - opensBaseline = io.openCount - } - - fun seekTo(timeUs: Long) { - // media3 restarts the load at the SeekMap's byte hint, then calls seek() - // on the loader thread before the next read. - val hint = output.seekMap?.getSeekPoints(timeUs)?.first?.position ?: 0L - openAt(hint) - extractor.seek(hint, timeUs) - } - - fun pumpUntilSampleDelivered(): Boolean { - val before = output.sampleCount - return pump { output.sampleCount > before } - } - - fun pumpUntilSampleAtLeast(timeUs: Long): Boolean = pump { output.lastSampleUs >= timeUs } - - fun pumpSamples(count: Int): List { - pump { output.sampleTimestamps.size >= count } - return output.sampleTimestamps.toList() - } - - private fun pump(until: () -> Boolean): Boolean { - repeat(200_000) { - if (until()) return true - when (extractor.read(input!!, positionHolder)) { - Extractor.RESULT_SEEK -> openAt(positionHolder.position) - Extractor.RESULT_END_OF_INPUT -> return until() - else -> Unit - } - } - return until() - } - - private fun openAt(position: Long) { - dataSource?.close() - val source = counting.createDataSource() - val remaining = source.open(DataSpec.Builder().setUri(uri).setPosition(position).build()) - dataSource = source - input = DefaultExtractorInput( - source, - position, - if (remaining == C.LENGTH_UNSET.toLong()) C.LENGTH_UNSET.toLong() else position + remaining - ) - } - - fun close() { - extractor.release() - dataSource?.close() - } - } - - private class CapturingOutput : ExtractorOutput { - val sampleTimestamps = mutableListOf() - var seekMap: SeekMap? = null - private set - - val sampleCount: Int - get() = sampleTimestamps.size - - val lastSampleUs: Long - get() = sampleTimestamps.lastOrNull() ?: C.TIME_UNSET - - override fun track(id: Int, type: Int): TrackOutput = CapturingTrack(type, sampleTimestamps) - - override fun endTracks() = Unit - - override fun seekMap(seekMap: SeekMap) { - this.seekMap = seekMap - } - } - - private class CapturingTrack(private val type: Int, private val timestamps: MutableList) : TrackOutput { - private val scratch = ByteArray(64 * 1024) - - override fun format(format: Format) = Unit - - override fun sampleData(input: DataReader, length: Int, allowEndOfInput: Boolean): Int = sampleData(input, length, allowEndOfInput, TrackOutput.SAMPLE_DATA_PART_MAIN) - - override fun sampleData(input: DataReader, length: Int, allowEndOfInput: Boolean, sampleDataPart: Int): Int { - val read = input.read(scratch, 0, minOf(length, scratch.size)) - return if (read == C.RESULT_END_OF_INPUT) 0 else read - } - - override fun sampleData(data: ParsableByteArray, length: Int) { - sampleData(data, length, TrackOutput.SAMPLE_DATA_PART_MAIN) - } - - override fun sampleData(data: ParsableByteArray, length: Int, sampleDataPart: Int) { - data.skipBytes(length) - } - - override fun sampleMetadata(timeUs: Long, flags: Int, size: Int, offset: Int, cryptoData: TrackOutput.CryptoData?) { - if (type == C.TRACK_TYPE_VIDEO) timestamps.add(timeUs) - } - } - - /** Counts every byte the demuxer pulls, through either read path. */ - private class CountingDataSourceFactory(private val delegate: DataSource.Factory) : DataSource.Factory { - @Volatile private var counter = 0L - - val bytesRead: Long - get() = counter - - fun reset() { - counter = 0L - } - - override fun createDataSource(): DataSource = Counting(delegate.createDataSource()) - - private inner class Counting(private val inner: DataSource) : DataSource { - override fun open(dataSpec: DataSpec): Long = inner.open(dataSpec) - - override fun read(buffer: ByteArray, offset: Int, length: Int): Int { - val read = inner.read(buffer, offset, length) - if (read > 0) counter += read - return read - } - - override fun addTransferListener(transferListener: TransferListener) = inner.addTransferListener(transferListener) - - override fun getUri(): Uri? = inner.uri - - override fun getResponseHeaders(): Map> = inner.responseHeaders - - override fun close() = inner.close() - } - } -} diff --git a/android/app/src/androidTest/kotlin/com/edde746/plezy/exoplayer/MatroskaLateTracksSeekDeviceTest.kt b/android/app/src/androidTest/kotlin/com/edde746/plezy/exoplayer/MatroskaLateTracksSeekDeviceTest.kt new file mode 100644 index 000000000..070ad98be --- /dev/null +++ b/android/app/src/androidTest/kotlin/com/edde746/plezy/exoplayer/MatroskaLateTracksSeekDeviceTest.kt @@ -0,0 +1,171 @@ +package com.edde746.plezy.exoplayer + +import android.net.Uri +import android.os.Handler +import android.os.HandlerThread +import android.util.Log +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import androidx.media3.datasource.DefaultDataSource +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.source.ProgressiveMediaSource +import androidx.media3.extractor.Extractor +import androidx.media3.extractor.ExtractorsFactory +import androidx.media3.extractor.mkv.MatroskaExtractor +import androidx.media3.extractor.text.DefaultSubtitleParserFactory +import androidx.test.platform.app.InstrumentationRegistry +import com.edde746.plezy.libass.media.AssHandler +import com.edde746.plezy.libass.media.parser.AssSubtitleParserFactory +import java.io.File +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicReference +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * On-device coverage for MKV files whose SeekHead references Tracks after the Clusters (#1969). + * + * media3 1.11.0 builds the Matroska seek map at the end of the Cues element, which for these files + * is before the Tracks element parsed, so the map permanently reports unseekable and ExoPlayer + * coerces every seek to t=0 (ProgressiveMediaPeriod; tracking issue androidx/media #3377). The + * first test is a canary asserting the upstream defect against a stock MatroskaExtractor — when it + * fails after a media3 upgrade, the TrackAwareSeekMap repair in [CuelessSeekExtractorWrapper] can + * be retired. The second test drives the production wrapper stack and requires the seek to + * actually land. + */ +class MatroskaLateTracksSeekDeviceTest { + private companion object { + const val TAG = "MkvLateTracksSeek" + const val SEEK_TARGET_MS = 500L + const val SETTLE_MS = 2_000L + } + + private class Session( + val handler: Handler, + val thread: HandlerThread, + val player: AtomicReference, + val fixture: File + ) + + @Test + fun stockMatroskaExtractorSnapsSeeksToStart() { + val result = runSeekScenario("stock") { MatroskaExtractor(DefaultSubtitleParserFactory()) } + assertFalse( + "upstream media3 now reports tracks-after-clusters MKVs seekable — " + + "the TrackAwareSeekMap repair in CuelessSeekExtractorWrapper can be retired", + result.seekable + ) + assertTrue( + "unseekable media must snap the seek to the start, position=${result.positionAfterSeekMs}ms", + result.positionAfterSeekMs < SEEK_TARGET_MS / 2 + ) + } + + @Test + fun wrappedExtractorKeepsSeekPosition() { + val result = runSeekScenario("wrapped") { + val assHandler = AssHandler() + CuelessSeekExtractorWrapper(ZlibMatroskaExtractor(AssSubtitleParserFactory(assHandler), assHandler)) + } + assertTrue("wrapped extractor must report the item seekable", result.seekable) + assertTrue( + "seek to ${SEEK_TARGET_MS}ms must hold, position=${result.positionAfterSeekMs}ms", + result.positionAfterSeekMs >= SEEK_TARGET_MS / 2 + ) + } + + private class ScenarioResult(val seekable: Boolean, val positionAfterSeekMs: Long) + + private fun runSeekScenario(label: String, extractorFactory: () -> Extractor): ScenarioResult { + val session = openPaused(label, extractorFactory) + try { + val seekable = onPlayerThread(session) { it.isCurrentMediaItemSeekable } + session.handler.post { session.player.get()?.seekTo(SEEK_TARGET_MS) } + // The masked position is the seek target; the snap surfaces once the period resolves the + // seek, so give the load a moment before sampling the settled position. + Thread.sleep(SETTLE_MS) + val positionAfterSeekMs = onPlayerThread(session) { it.currentPosition } + Log.i(TAG, "==== $label: seekable=$seekable position=${positionAfterSeekMs}ms ====") + return ScenarioResult(seekable, positionAfterSeekMs) + } finally { + teardown(session) + } + } + + private fun openPaused(label: String, extractorFactory: () -> Extractor): Session { + val instrumentation = InstrumentationRegistry.getInstrumentation() + val context = instrumentation.targetContext + val fixture = copyFixture(context) + val thread = HandlerThread("mkv-late-tracks-$label").apply { start() } + val handler = Handler(thread.looper) + val player = AtomicReference(null) + val ready = CountDownLatch(1) + val failed = AtomicBoolean(false) + + handler.post { + val exo = ExoPlayer.Builder(context).build() + player.set(exo) + exo.addListener( + object : Player.Listener { + override fun onPlaybackStateChanged(playbackState: Int) { + if (playbackState == Player.STATE_READY) ready.countDown() + } + + override fun onPlayerError(error: androidx.media3.common.PlaybackException) { + Log.e(TAG, "$label player error", error) + failed.set(true) + ready.countDown() + } + } + ) + val source = ProgressiveMediaSource.Factory( + DefaultDataSource.Factory(context), + ExtractorsFactory { arrayOf(extractorFactory()) } + ).createMediaSource(MediaItem.fromUri(Uri.fromFile(fixture))) + exo.setMediaSource(source) + exo.playWhenReady = false + exo.prepare() + } + + val session = Session(handler, thread, player, fixture) + if (!ready.await(30, TimeUnit.SECONDS) || failed.get()) { + teardown(session) + error("$label playback never became ready") + } + return session + } + + private fun onPlayerThread(session: Session, read: (ExoPlayer) -> T): T { + val value = AtomicReference() + val done = CountDownLatch(1) + session.handler.post { + session.player.get()?.let { value.set(read(it)) } + done.countDown() + } + assertTrue("player thread stalled", done.await(5, TimeUnit.SECONDS)) + return checkNotNull(value.get()) + } + + private fun teardown(session: Session) { + val done = CountDownLatch(1) + session.handler.post { + session.player.get()?.release() + done.countDown() + } + done.await(10, TimeUnit.SECONDS) + session.thread.quitSafely() + session.thread.join(5_000) + session.fixture.delete() + } + + private fun copyFixture(context: android.content.Context): File { + val output = File.createTempFile("mkv-late-tracks-", ".mkv", context.cacheDir) + InstrumentationRegistry.getInstrumentation().context.assets + .open("ffmpeg/matroska_tracks_at_end.mkv") + .use { input -> output.outputStream().use { input.copyTo(it) } } + return output + } +} diff --git a/android/app/src/main/cpp/CMakeLists.txt b/android/app/src/main/cpp/CMakeLists.txt index 7c1d96008..a9ef5873a 100644 --- a/android/app/src/main/cpp/CMakeLists.txt +++ b/android/app/src/main/cpp/CMakeLists.txt @@ -19,22 +19,20 @@ else() target_link_libraries(dovi_bridge log) endif() -foreach(ffmpeg_library avcodec avformat avutil swresample) +foreach(ffmpeg_library avcodec avutil swresample) add_library(${ffmpeg_library} SHARED IMPORTED) set_target_properties(${ffmpeg_library} PROPERTIES IMPORTED_LOCATION "${MPV_FFMPEG_ROOT}/native/${ANDROID_ABI}/lib${ffmpeg_library}.so") endforeach() add_library(ffmpegJNI SHARED - media3_ffmpeg_decoder/ffmpeg_jni.cc - media3_ffmpeg_demuxer/ffmpeg_demuxer_jni.cc) + media3_ffmpeg_decoder/ffmpeg_jni.cc) target_compile_features(ffmpegJNI PRIVATE cxx_std_17) target_include_directories(ffmpegJNI PRIVATE "${MPV_FFMPEG_ROOT}/include" "${CMAKE_CURRENT_SOURCE_DIR}/media3_ffmpeg_decoder") target_link_libraries(ffmpegJNI avcodec - avformat avutil swresample android diff --git a/android/app/src/main/cpp/media3_ffmpeg_demuxer/ffmpeg_demuxer_jni.cc b/android/app/src/main/cpp/media3_ffmpeg_demuxer/ffmpeg_demuxer_jni.cc deleted file mode 100644 index 03da40bf1..000000000 --- a/android/app/src/main/cpp/media3_ffmpeg_demuxer/ffmpeg_demuxer_jni.cc +++ /dev/null @@ -1,983 +0,0 @@ -/* - * Copyright (c) 2026 Plezy - * - * libavformat demuxing for media3. Wraps an AVFormatContext whose AVIOContext - * is served by a media3 ExtractorInput, so FFmpeg parses the container while - * media3 keeps owning decoders, renderers, and the whole playback surface. - * - * The seam used to be seeking. media3's Extractor is a pull API: an extractor - * cannot move the input; it returns RESULT_SEEK and the loader re-opens the - * data source. libavformat expects random access and seeks whenever it pleases - * (MP4 moov at end of file, AVI idx1, MKV Cues, MPEG-PS binary search), so - * serving it from the loader alone meant unwinding the in-flight libavformat - * call on every backward jump and replaying the whole open — which a - * one-shot lazy parse like matroska's Cues cannot survive (#2096). - * - * libavformat now gets what it actually requires: the Kotlin side hands this - * shim a random-access byte source (FfmpegRandomAccessSource), so every AVIO - * read succeeds at the position libavformat asked for and nothing is ever - * unwound. Two paths serve reads, chosen per read: - * - * - The loader's ExtractorInput when it already stands at that position. This - * is the steady state for sample delivery, and it keeps media3's byte - * accounting, back-pressure and load-error policy in charge of streaming. - * - The random-access source otherwise (header, index, post-seek jumps). - * - * Kotlin nudges the loader back into step after a jump by returning - * RESULT_SEEK to nativeLogicalPosition(); that is an optimization, never a - * correctness requirement, so no budgets or retries guard it. - */ -#include -#include - -#include -#include -#include - -extern "C" { -#ifdef __cplusplus -#define __STDC_CONSTANT_MACROS -#ifdef _STDINT_H -#undef _STDINT_H -#endif -#include -#endif -#include -#include -#include -#include -#include -#include -#include -#include -} - -#define LOG_TAG "ffmpeg_demuxer" -#define LOGE(...) ((void)__android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)) -#define LOGW(...) ((void)__android_log_print(ANDROID_LOG_WARN, LOG_TAG, __VA_ARGS__)) -#define LOGD(...) ((void)__android_log_print(ANDROID_LOG_DEBUG, LOG_TAG, __VA_ARGS__)) - -namespace { - -// readPacket() result codes written to out[0]. Negative values are raw -// AVERROR codes; ERR_JAVA signals the byte source threw and Kotlin must -// rethrow the stored IOException. -const jint CODE_PACKET = 0; -const jint CODE_EOF = 1; -const jint CODE_GROW = 2; -const jint ERR_NOT_OPEN = -101; -const jint ERR_JAVA = -102; - -// streamInfo() long[] layout. Keep in sync with FfmpegDemuxerJni.kt. -const int INFO_TRACK_TYPE = 0; // 0 video, 1 audio, 2 text, -1 unsupported -const int INFO_WIDTH = 1; -const int INFO_HEIGHT = 2; -const int INFO_PAR_NUM = 3; -const int INFO_PAR_DEN = 4; -const int INFO_FPS_NUM = 5; -const int INFO_FPS_DEN = 6; -const int INFO_SAMPLE_RATE = 7; -const int INFO_CHANNELS = 8; -const int INFO_PCM_ENCODING = 9; // media3 C.ENCODING_* value, -1 when n/a -const int INFO_ROTATION = 10; -const int INFO_SELECTION_FLAGS = 11; -const int INFO_ROLE_FLAGS = 12; -const int INFO_BITRATE = 13; -const int INFO_DOVI_PROFILE = 14; // DV profile from dvcC/dvvC side data, -1 when none -const int INFO_DOVI_LEVEL = 15; -const int INFO_LATM = 16; // 1 when the audio stream is LOAS/LATM-framed AAC -const int INFO_LENGTH = 17; - -// Android AudioFormat encoding constants (media3 C.ENCODING_* mirror them). -const jint PCM_ENCODING_NONE = -1; -const jint PCM_ENCODING_8BIT = 3; -const jint PCM_ENCODING_16BIT = 2; -const jint PCM_ENCODING_FLOAT = 4; -const jint PCM_ENCODING_24BIT_PACKED = 21; -const jint PCM_ENCODING_32BIT = 22; -const jint PCM_ENCODING_ALAW = 11; -const jint PCM_ENCODING_ULAW = 12; - -// media3 C.SELECTION_FLAG_* and C.ROLE_FLAG_* values. -const jint SELECTION_FLAG_DEFAULT = 1; -const jint SELECTION_FLAG_FORCED = 2; -const jint ROLE_FLAG_MAIN = 1; -const jint ROLE_FLAG_DESCRIPTION = 64; // C.ROLE_FLAG_DESCRIPTION -const jint ROLE_FLAG_HARD_OF_HEARING = 512; // C.ROLE_FLAG_HARD_OF_HEARING - -constexpr jint kReadChunkBytes = 64 * 1024; - -struct DemuxState { - JavaVM* vm = nullptr; - jobject input = nullptr; - jmethodID midPosition = nullptr; - jmethodID midRead = nullptr; - jmethodID midReadAt = nullptr; - jmethodID midLength = nullptr; - jbyteArray readBuffer = nullptr; - - AVFormatContext* format = nullptr; - AVIOContext* avio = nullptr; - bool opened = false; - - // Byte position libavformat will read next. Authoritative: the AVIO seek - // callback moves it, and both read paths serve exactly this offset. - int64_t logicalPos = 0; - // format->start_time in microseconds; subtracted from delivered pts so - // media3's zero-based timeline matches the duration we report (MPEG-PS/TS - // containers commonly start at a nonzero PCR). - int64_t startTimeUs = 0; - - AVPacket* packet = nullptr; - AVPacket* filteredPacket = nullptr; - // A packet that outgrew the Java-side buffer: already read and filtered, - // waiting for redelivery after CODE_GROW. Without this flag the retry's - // av_read_frame would silently drop it. - bool packetPending = false; - - // Annex-B conversion for H264/HEVC streams stored length-prefixed - // (matroska/mp4 family); one context per stream, null when none needed. - AVBSFContext** bsf = nullptr; - unsigned nbStreams = 0; - - bool javaError = false; -}; - -// One demuxer at a time: a player session owns exactly one progressive source -// and the extractor releases its state before another can open. -DemuxState* gState = nullptr; -JavaVM* gVm = nullptr; - -// Serializes JNI entry points against closeState: the fallback path can -// release the player while the loader thread is inside a native call, and -// deleting DemuxState under it is a use-after-free (observed as a scudo -// invalid-chunk abort during MPV fallback teardown). -pthread_mutex_t gStateMutex = PTHREAD_MUTEX_INITIALIZER; - -struct StateLock { - StateLock() { pthread_mutex_lock(&gStateMutex); } - ~StateLock() { pthread_mutex_unlock(&gStateMutex); } -}; - -JNIEnv* envFor() { - JNIEnv* env = nullptr; - if (gVm->GetEnv(reinterpret_cast(&env), JNI_VERSION_1_6) != JNI_OK) return nullptr; - return env; -} - -bool javaPending(JNIEnv* env) { return env->ExceptionCheck() == JNI_TRUE; } - -void javaClear(JNIEnv* env) { - if (env->ExceptionCheck() == JNI_TRUE) env->ExceptionClear(); -} - -jlong callPosition(JNIEnv* env, DemuxState* s) { return env->CallLongMethod(s->input, s->midPosition); } - -jlong callLength(JNIEnv* env, DemuxState* s) { return env->CallLongMethod(s->input, s->midLength); } - -// Returns bytes read, 0 at end of input, or -1 when the input failed (the -// Kotlin proxy stored the IOException message). -jint callRead(JNIEnv* env, DemuxState* s, jint length) { - jint n = env->CallIntMethod(s->input, s->midRead, s->readBuffer, length); - if (javaPending(env)) { - javaClear(env); - s->javaError = true; - return -1; - } - return n; -} - -// Same contract as callRead, but at an absolute position: the Kotlin source -// reopens its handle when the position is not where it stands. -jint callReadAt(JNIEnv* env, DemuxState* s, int64_t position, jint length) { - jint n = env->CallIntMethod(s->input, s->midReadAt, static_cast(position), s->readBuffer, length); - if (javaPending(env)) { - javaClear(env); - s->javaError = true; - return -1; - } - return n; -} - -int avioReadPacket(void* opaque, uint8_t* buf, int bufSize) { - DemuxState* s = static_cast(opaque); - JNIEnv* env = envFor(); - if (env == nullptr) return AVERROR(EIO); - if (bufSize <= 0) return 0; - // After the byte source has failed once in this native call, fail every - // further read immediately: libavformat's internal recovery (matroska - // resync) would drive the dead source again, clobbering the stored - // IOException message — and were the source half-alive, could silently - // resync past the failed range. Failing fast makes the whole entry return - // ERR_JAVA, and media3's retry resumes at the exact failed position. - if (s->javaError) return AVERROR(EIO); - - const jint want = bufSize < kReadChunkBytes ? bufSize : kReadChunkBytes; - // Prefer the loader's input when it already stands here: those bytes then - // count as media3's own load progress, which is what drives its buffering - // and back-pressure. Anywhere else, read at the absolute position. - const int64_t inputPos = callPosition(env, s); - if (javaPending(env)) { - javaClear(env); - s->javaError = true; - return AVERROR(EIO); - } - const jint n = inputPos == s->logicalPos ? callRead(env, s, want) : callReadAt(env, s, s->logicalPos, want); - if (n < 0 || s->javaError) { - // A negative count is the Input contract for a byte source that threw: - // the Kotlin proxy caught the IOException and stored its message. Mark - // javaError so the JNI entry point returns ERR_JAVA and media3's - // load-error policy retries the load; a bare AVERROR would read as a - // malformed container, which media3 never retries (#2113). - s->javaError = true; - return AVERROR(EIO); - } - if (n == 0) return AVERROR_EOF; - - env->GetByteArrayRegion(s->readBuffer, 0, n, reinterpret_cast(buf)); - s->logicalPos += n; - return n; -} - -int64_t avioSeek(void* opaque, int64_t offset, int whence) { - DemuxState* s = static_cast(opaque); - JNIEnv* env = envFor(); - if (env == nullptr) return AVERROR(EIO); - - whence &= ~AVSEEK_FORCE; - if (whence == AVSEEK_SIZE) { - int64_t length = callLength(env, s); - if (javaPending(env)) { - javaClear(env); - s->javaError = true; - return -1; - } - return length < 0 ? -1 : length; - } - - int64_t target; - if (whence == SEEK_CUR) { - target = s->logicalPos + offset; - } else if (whence == SEEK_END) { - int64_t length = callLength(env, s); - if (javaPending(env)) { - javaClear(env); - s->javaError = true; - return AVERROR(EIO); - } - if (length < 0) return AVERROR(EIO); - target = length + offset; - } else { - target = offset; - } - - // Pure bookkeeping: the next read serves this position from whichever path - // can, so a seek can no longer fail or need reconciling. avio invalidates - // its own buffers after this successful callback. - s->logicalPos = target; - return target; -} - -// A failed byte-source read latches AVIOContext error state: fill_buffer -// stores the AVERROR in pb->error, sets pb->eof_reached, and never drives the -// read callback again while either is set (aviobuf.c). The Kotlin side maps -// ERR_JAVA to an IOException that media3's load-error policy retries with the -// source reopened, so the latch must be dropped when the retry re-enters — -// otherwise the first retry re-fails on the stale error without ever reaching -// the byte source (#2113). A genuine end of file latches eof_reached with -// error == 0 and is deliberately left alone. -void clearLatchedIoError(DemuxState* s) { - if (s->format == nullptr || s->format->pb == nullptr) return; - if (s->format->pb->error >= 0) return; - s->format->pb->error = 0; - s->format->pb->eof_reached = 0; -} - -// Probing (ffio_ensure_seekback) can swap the AVIO buffer for a larger -// allocation and free the original, so the pointer handed to -// avio_alloc_context may be stale. Free whichever buffer the context -// currently holds, exactly once. -void freeAvio(DemuxState* s) { - if (s->avio == nullptr) return; - av_freep(&s->avio->buffer); - avio_context_free(&s->avio); -} - -void closeState() { - DemuxState* s = gState; - if (s == nullptr) return; // Close without (or after) an open is a no-op. - JNIEnv* env = envFor(); - if (s->filteredPacket != nullptr) { - av_packet_free(&s->filteredPacket); - } - if (s->bsf != nullptr) { - for (unsigned i = 0; i < s->nbStreams; i++) { - if (s->bsf[i] != nullptr) av_bsf_free(&s->bsf[i]); - } - av_freep(&s->bsf); - } - if (s->packet != nullptr) { - av_packet_free(&s->packet); - } - if (s->format != nullptr) { - avformat_close_input(&s->format); - } - freeAvio(s); - if (env != nullptr && s->input != nullptr) { - if (s->readBuffer != nullptr) env->DeleteGlobalRef(s->readBuffer); - env->DeleteGlobalRef(s->input); - } - delete s; - gState = nullptr; -} - -const char* videoMime(AVCodecID id) { - switch (id) { - case AV_CODEC_ID_H264: - return "video/avc"; - case AV_CODEC_ID_HEVC: - return "video/hevc"; - case AV_CODEC_ID_MPEG4: - return "video/mp4v-es"; - case AV_CODEC_ID_MPEG2VIDEO: - return "video/mpeg2"; - case AV_CODEC_ID_MJPEG: - return "video/mjpeg"; - case AV_CODEC_ID_VP8: - return "video/x-vnd.on2.vp8"; - case AV_CODEC_ID_VP9: - return "video/x-vnd.on2.vp9"; - case AV_CODEC_ID_AV1: - return "video/av01"; - case AV_CODEC_ID_H263: - return "video/3gpp"; - default: - return nullptr; - } -} - -const char* audioMime(AVCodecID id, jint* pcmEncoding) { - *pcmEncoding = PCM_ENCODING_NONE; - switch (id) { - case AV_CODEC_ID_AAC: - return "audio/mp4a-latm"; - case AV_CODEC_ID_AAC_LATM: - // LOAS/LATM framing. Kotlin wraps the track with LatmTrackOutput, which - // unwraps to raw AAC access units and emits the real decoder Format; - // this mime only marks the stream as supported. - return "audio/mp4a-latm"; - case AV_CODEC_ID_MP3: - return "audio/mpeg"; - case AV_CODEC_ID_MP2: - return "audio/mpeg-L2"; - case AV_CODEC_ID_AC3: - return "audio/ac3"; - case AV_CODEC_ID_EAC3: - return "audio/eac3"; - case AV_CODEC_ID_DTS: - return "audio/vnd.dts"; - case AV_CODEC_ID_TRUEHD: - return "audio/true-hd"; - case AV_CODEC_ID_FLAC: - return "audio/flac"; - case AV_CODEC_ID_OPUS: - return "audio/opus"; - case AV_CODEC_ID_VORBIS: - return "audio/vorbis"; - case AV_CODEC_ID_ALAC: - return "audio/alac"; - case AV_CODEC_ID_AMR_NB: - return "audio/amr"; - case AV_CODEC_ID_AMR_WB: - return "audio/amr-wb"; - case AV_CODEC_ID_PCM_S16LE: - *pcmEncoding = PCM_ENCODING_16BIT; - return "audio/raw"; - case AV_CODEC_ID_PCM_S16BE: - *pcmEncoding = PCM_ENCODING_16BIT; - return "audio/raw"; - case AV_CODEC_ID_PCM_S8: - *pcmEncoding = PCM_ENCODING_8BIT; - return "audio/raw"; - case AV_CODEC_ID_PCM_U8: - *pcmEncoding = PCM_ENCODING_8BIT; - return "audio/raw"; - case AV_CODEC_ID_PCM_S24LE: - *pcmEncoding = PCM_ENCODING_24BIT_PACKED; - return "audio/raw"; - case AV_CODEC_ID_PCM_S32LE: - *pcmEncoding = PCM_ENCODING_32BIT; - return "audio/raw"; - case AV_CODEC_ID_PCM_F32LE: - *pcmEncoding = PCM_ENCODING_FLOAT; - return "audio/raw"; - case AV_CODEC_ID_PCM_F32BE: - *pcmEncoding = PCM_ENCODING_FLOAT; - return "audio/raw"; - case AV_CODEC_ID_PCM_ALAW: - *pcmEncoding = PCM_ENCODING_ALAW; - return "audio/raw"; - case AV_CODEC_ID_PCM_MULAW: - *pcmEncoding = PCM_ENCODING_ULAW; - return "audio/raw"; - default: - return nullptr; - } -} - -const char* subtitleMime(AVCodecID id) { - switch (id) { - case AV_CODEC_ID_SUBRIP: - return "application/x-subrip"; - case AV_CODEC_ID_ASS: - case AV_CODEC_ID_SSA: - return "text/x-ssa"; - case AV_CODEC_ID_WEBVTT: - return "text/vtt"; - case AV_CODEC_ID_HDMV_PGS_SUBTITLE: - return "application/pgs"; - case AV_CODEC_ID_DVD_SUBTITLE: - // media3's VobsubParser takes the .idx text (Matroska CodecPrivate, - // surfaced verbatim as extradata) as initialization data and the raw - // SPU payloads as samples — exactly what this path delivers. - return "application/vobsub"; - default: - // DVB subtitles are deliberately dropped: media3's DvbParser expects - // the TS/PMT 5-byte config, not Matroska CodecPrivate, and DVB rides - // TS in practice — which stays on media3's extractor. - return nullptr; - } -} - -// Maps an attachment ordinal to its stream index. Attachment payloads -// (embedded fonts) ride extradata and are fully parsed during -// avformat_open_input; these streams never produce packets. -int attachmentStreamIndex(AVFormatContext* format, jint ordinal) { - jint seen = 0; - for (unsigned i = 0; i < format->nb_streams; i++) { - AVCodecParameters* params = format->streams[i]->codecpar; - if (params->codec_type != AVMEDIA_TYPE_ATTACHMENT) continue; - if (params->extradata == nullptr || params->extradata_size <= 0) continue; - if (seen++ == ordinal) return static_cast(i); - } - return -1; -} - -} // namespace - -extern "C" { - -JNIEXPORT jstring JNICALL -Java_com_edde746_plezy_exoplayer_FfmpegDemuxerJni_nativeProbeFormat(JNIEnv* env, jobject thiz, jbyteArray header) { - StateLock lock; - jsize size = env->GetArrayLength(header); - std::vector padded(static_cast(size) + AVPROBE_PADDING_SIZE, 0); - env->GetByteArrayRegion(header, 0, size, reinterpret_cast(padded.data())); - - AVProbeData probe{}; - probe.buf = padded.data(); - probe.buf_size = size; - probe.filename = ""; - int score = 0; - const AVInputFormat* format = av_probe_input_format2(&probe, 1, &score); - if (format == nullptr || score <= 0) return nullptr; - return env->NewStringUTF(format->name); -} - -// Returns 0 on success or a negative AVERROR (ERR_JAVA when the byte source -// itself threw). The stream count is NOT the return value: positive counts -// would share the result-code namespace, so callers use nativeStreamCount(). -JNIEXPORT jint JNICALL -Java_com_edde746_plezy_exoplayer_FfmpegDemuxerJni_nativeOpen(JNIEnv* env, jobject thiz, jobject input) { - StateLock lock; - if (gVm == nullptr) { - env->GetJavaVM(&gVm); - av_log_set_level(AV_LOG_ERROR); - } - if (gState == nullptr) { - gState = new DemuxState(); - gState->vm = gVm; - } - DemuxState* s = gState; - - // FindClass on the declared interface (not GetObjectClass on the concrete - // proxy): JNI dispatches interface method IDs virtually, and the named - // class is what the shrinker keep in proguard-rules.pro pins. - jclass inputClass = env->FindClass("com/edde746/plezy/exoplayer/FfmpegDemuxerJni$Input"); - if (inputClass == nullptr) { - javaClear(env); - return ERR_NOT_OPEN; - } - jmethodID midPosition = env->GetMethodID(inputClass, "position", "()J"); - jmethodID midRead = env->GetMethodID(inputClass, "read", "([BI)I"); - jmethodID midReadAt = env->GetMethodID(inputClass, "readAt", "(J[BI)I"); - jmethodID midLength = env->GetMethodID(inputClass, "length", "()J"); - env->DeleteLocalRef(inputClass); - if (midPosition == nullptr || midRead == nullptr || midReadAt == nullptr || midLength == nullptr) { - javaClear(env); - return ERR_NOT_OPEN; - } - - // A fresh open replaces any previous proxy binding. - if (s->input != nullptr) { - if (s->readBuffer != nullptr) env->DeleteGlobalRef(s->readBuffer); - env->DeleteGlobalRef(s->input); - s->input = nullptr; - s->readBuffer = nullptr; - } - s->input = env->NewGlobalRef(input); - s->midPosition = midPosition; - s->midRead = midRead; - s->midReadAt = midReadAt; - s->midLength = midLength; - jbyteArray readBuffer = env->NewByteArray(kReadChunkBytes); - s->readBuffer = static_cast(env->NewGlobalRef(readBuffer)); - env->DeleteLocalRef(readBuffer); - - if (s->packet == nullptr) { - s->packet = av_packet_alloc(); - s->filteredPacket = av_packet_alloc(); - if (s->packet == nullptr || s->filteredPacket == nullptr) return AVERROR(ENOMEM); - } - - // One AVIOContext per item: a stale buffer or a sticky error from the - // previous item must not leak into this open, and reallocating is cheaper - // than reasoning about which of libavformat's internal fields to reset. - freeAvio(s); - constexpr int kAvioBufferSize = 64 * 1024; - unsigned char* avioBuffer = static_cast(av_malloc(kAvioBufferSize)); - if (avioBuffer == nullptr) return AVERROR(ENOMEM); - s->avio = avio_alloc_context( - avioBuffer, kAvioBufferSize, /*write_flag=*/0, s, avioReadPacket, - /*write_packet=*/nullptr, avioSeek); - if (s->avio == nullptr) { - av_freep(&avioBuffer); - return AVERROR(ENOMEM); - } - - s->javaError = false; - s->packetPending = false; - s->logicalPos = 0; - // Nothing is ever resumed across calls now: every read succeeds at the - // position libavformat asked for, so open_input and find_stream_info each - // run once, to completion. - avformat_close_input(&s->format); - s->opened = false; - - s->format = avformat_alloc_context(); - if (s->format == nullptr) return AVERROR(ENOMEM); - s->format->pb = s->avio; - // Generate missing PTS: some raw streams (MPEG-TS after a seek) present - // packets without timestamps that media3 cannot schedule. - s->format->flags |= AVFMT_FLAG_GENPTS; - - int err = avformat_open_input(&s->format, "", nullptr, nullptr); - if (s->javaError) return ERR_JAVA; - if (err < 0) { - LOGW("avformat_open_input failed: %d", err); - return err; - } - - err = avformat_find_stream_info(s->format, nullptr); - if (s->javaError) return ERR_JAVA; - if (err < 0) { - LOGW("avformat_find_stream_info failed: %d", err); - return err; - } - // Zero-base the timeline: delivered pts subtract this so media3 sees - // [0, duration] even when the container starts at a nonzero PCR/PTS. - s->startTimeUs = s->format->start_time != AV_NOPTS_VALUE ? s->format->start_time : 0; - s->opened = true; - - bool anySupported = false; - for (unsigned i = 0; i < s->format->nb_streams; i++) { - AVStream* stream = s->format->streams[i]; - AVCodecID codecId = stream->codecpar->codec_id; - jint pcmEncoding; - bool supported = videoMime(codecId) != nullptr || audioMime(codecId, &pcmEncoding) != nullptr || - subtitleMime(codecId) != nullptr; - // ID3/MKV attached pictures surface as video streams; media3 cannot - // render them as tracks. - if (stream->attached_pic.size > 0) supported = false; - if (!supported) { - LOGW("Dropping stream %u: unsupported codec %s", i, avcodec_get_name(codecId)); - stream->discard = AVDISCARD_ALL; - } else { - anySupported = true; - } - } - if (!anySupported) { - // Fail fast so the session falls back (mpv decodes WMV and friends) - // instead of buffering an empty track list forever. - LOGW("No supported streams; refusing container"); - avformat_close_input(&s->format); - s->opened = false; - return AVERROR(ENOSYS); - } - // MediaCodec requires Annex-B start-code framing for H264/HEVC, while the - // matroska/mp4 family stores length-prefixed NAL units and libavformat - // hands those through verbatim (ffmpeg muxers insert the same - // *_mp4toannexb bitstream filters in this situation). Filter those streams - // here so every packet leaving the shim is decoder-ready, and adopt the - // filter's rewritten parameters so extradata surfaces as Annex-B csd too. - if (s->bsf != nullptr) { - for (unsigned i = 0; i < s->nbStreams; i++) { - if (s->bsf[i] != nullptr) av_bsf_free(&s->bsf[i]); - } - av_freep(&s->bsf); - } - s->nbStreams = s->format->nb_streams; - s->bsf = static_cast(av_calloc(s->nbStreams, sizeof(*s->bsf))); - if (s->bsf == nullptr) return AVERROR(ENOMEM); - for (unsigned i = 0; i < s->nbStreams; i++) { - AVCodecID codecId = s->format->streams[i]->codecpar->codec_id; - const char* filterName = codecId == AV_CODEC_ID_H264 ? "h264_mp4toannexb" - : codecId == AV_CODEC_ID_HEVC ? "hevc_mp4toannexb" - // AVI-family MPEG-4 ASP commonly packs several VOPs into one chunk; - // hardware decoders silently drop those stacks (observed as a decoder - // hang with zero output), so unpack them like every ffmpeg pipeline. - : codecId == AV_CODEC_ID_MPEG4 ? "mpeg4_unpack_bframes" - : nullptr; - const AVBitStreamFilter* filter = av_bsf_get_by_name(filterName); - AVBSFContext* ctx = nullptr; - if (filter == nullptr || av_bsf_alloc(filter, &ctx) < 0 || - avcodec_parameters_copy(ctx->par_in, s->format->streams[i]->codecpar) < 0) { - if (ctx != nullptr) av_bsf_free(&ctx); - continue; - } - ctx->time_base_in = s->format->streams[i]->time_base; - if (av_bsf_init(ctx) < 0) { - av_bsf_free(&ctx); - continue; - } - avcodec_parameters_copy(s->format->streams[i]->codecpar, ctx->par_out); - s->format->streams[i]->time_base = ctx->time_base_out; - s->bsf[i] = ctx; - } - - return 0; // Success; prepareTracks() reads the count via nativeStreamCount(). -} - -JNIEXPORT void JNICALL Java_com_edde746_plezy_exoplayer_FfmpegDemuxerJni_nativeClose(JNIEnv* env, jobject thiz) { - StateLock lock; - closeState(); -} - -JNIEXPORT jint JNICALL Java_com_edde746_plezy_exoplayer_FfmpegDemuxerJni_nativeStreamCount(JNIEnv* env, jobject thiz) { - StateLock lock; - if (gState == nullptr || gState->format == nullptr) return ERR_NOT_OPEN; - return static_cast(gState->format->nb_streams); -} - -JNIEXPORT jlong JNICALL Java_com_edde746_plezy_exoplayer_FfmpegDemuxerJni_nativeDurationUs(JNIEnv* env, jobject thiz) { - StateLock lock; - if (gState == nullptr || gState->format == nullptr) return -1; - int64_t duration = gState->format->duration; - return duration == AV_NOPTS_VALUE ? -1 : duration; -} - -// Returns true when the stream is usable and filled the out arrays. -JNIEXPORT jboolean JNICALL Java_com_edde746_plezy_exoplayer_FfmpegDemuxerJni_nativeStreamInfo( - JNIEnv* env, jobject thiz, jint index, jlongArray numbers, jobjectArray strings) { - StateLock lock; - if (gState == nullptr || gState->format == nullptr || index < 0 || - index >= static_cast(gState->format->nb_streams)) { - return JNI_FALSE; - } - AVStream* stream = gState->format->streams[index]; - AVCodecParameters* params = stream->codecpar; - - jint trackType; - const char* mime = nullptr; - jint pcmEncoding = PCM_ENCODING_NONE; - switch (params->codec_type) { - case AVMEDIA_TYPE_VIDEO: - trackType = 0; - mime = videoMime(params->codec_id); - break; - case AVMEDIA_TYPE_AUDIO: - trackType = 1; - mime = audioMime(params->codec_id, &pcmEncoding); - break; - case AVMEDIA_TYPE_SUBTITLE: - trackType = 2; - mime = subtitleMime(params->codec_id); - break; - default: - return JNI_FALSE; - } - if (mime == nullptr) return JNI_FALSE; - - jlong info[INFO_LENGTH] = {}; - info[INFO_TRACK_TYPE] = trackType; - info[INFO_WIDTH] = params->width; - info[INFO_HEIGHT] = params->height; - AVRational par = params->sample_aspect_ratio; - if (par.num <= 0 || par.den <= 0) par = stream->sample_aspect_ratio; - info[INFO_PAR_NUM] = par.num > 0 ? par.num : 1; - info[INFO_PAR_DEN] = par.num > 0 ? par.den : 1; - AVRational fps = stream->avg_frame_rate; - info[INFO_FPS_NUM] = fps.num > 0 ? fps.num : 0; - info[INFO_FPS_DEN] = fps.num > 0 ? fps.den : 0; - info[INFO_SAMPLE_RATE] = params->sample_rate > 0 ? params->sample_rate : -1; - info[INFO_CHANNELS] = params->ch_layout.nb_channels > 0 ? params->ch_layout.nb_channels : -1; - info[INFO_PCM_ENCODING] = pcmEncoding; - info[INFO_LATM] = params->codec_id == AV_CODEC_ID_AAC_LATM ? 1 : 0; - - jint rotation = 0; - AVPacketSideData* sideData = nullptr; - for (int i = 0; i < params->nb_coded_side_data; i++) { - if (params->coded_side_data[i].type == AV_PKT_DATA_DISPLAYMATRIX) { - sideData = ¶ms->coded_side_data[i]; - break; - } - } - if (sideData != nullptr) { - double angle = av_display_rotation_get(reinterpret_cast(sideData->data)); - if (!std::isnan(angle)) rotation = static_cast(-lround(angle)); - } - info[INFO_ROTATION] = rotation; - - jint selectionFlags = 0; - jint roleFlags = 0; - if (stream->disposition & AV_DISPOSITION_DEFAULT) selectionFlags |= SELECTION_FLAG_DEFAULT; - if (stream->disposition & AV_DISPOSITION_FORCED) selectionFlags |= SELECTION_FLAG_FORCED; - if (stream->disposition & AV_DISPOSITION_HEARING_IMPAIRED) roleFlags |= ROLE_FLAG_HARD_OF_HEARING; - if (stream->disposition & AV_DISPOSITION_VISUAL_IMPAIRED) roleFlags |= ROLE_FLAG_DESCRIPTION; - if (selectionFlags == 0 && trackType != 2) roleFlags |= ROLE_FLAG_MAIN; - info[INFO_SELECTION_FLAGS] = selectionFlags; - info[INFO_ROLE_FLAGS] = roleFlags; - info[INFO_BITRATE] = params->bit_rate > 0 ? params->bit_rate : -1; - - // Dolby Vision configuration (dvcC/dvvC), surfaced by libavformat as an - // unpacked AVDOVIDecoderConfigurationRecord — one byte per field, not the - // bit-packed ISOBMFF box layout. Kotlin mirrors media3's DolbyVisionConfig - // to publish video/dolby-vision plus the RFC 6381 codecs string. - info[INFO_DOVI_PROFILE] = -1; - info[INFO_DOVI_LEVEL] = -1; - for (int i = 0; i < params->nb_coded_side_data; i++) { - AVPacketSideData* sd = ¶ms->coded_side_data[i]; - if (sd->type == AV_PKT_DATA_DOVI_CONF && sd->size >= sizeof(AVDOVIDecoderConfigurationRecord)) { - const AVDOVIDecoderConfigurationRecord* dovi = - reinterpret_cast(sd->data); - info[INFO_DOVI_PROFILE] = dovi->dv_profile; - info[INFO_DOVI_LEVEL] = dovi->dv_level; - break; - } - } - - env->SetLongArrayRegion(numbers, 0, INFO_LENGTH, info); - - const char* language = nullptr; - const char* label = nullptr; - if (stream->metadata != nullptr) { - AVDictionaryEntry* entry = av_dict_get(stream->metadata, "language", nullptr, 0); - if (entry != nullptr) language = entry->value; - entry = av_dict_get(stream->metadata, "title", nullptr, 0); - if (entry != nullptr) label = entry->value; - } - env->SetObjectArrayElement(strings, 0, env->NewStringUTF(mime)); - env->SetObjectArrayElement(strings, 1, language != nullptr ? env->NewStringUTF(language) : nullptr); - env->SetObjectArrayElement(strings, 2, label != nullptr ? env->NewStringUTF(label) : nullptr); - return JNI_TRUE; -} - -JNIEXPORT jbyteArray JNICALL -Java_com_edde746_plezy_exoplayer_FfmpegDemuxerJni_nativeStreamExtradata(JNIEnv* env, jobject thiz, jint index) { - StateLock lock; - if (gState == nullptr || gState->format == nullptr || index < 0 || - index >= static_cast(gState->format->nb_streams)) { - return nullptr; - } - AVCodecParameters* params = gState->format->streams[index]->codecpar; - // libavformat carries the AVI stream's WAVEFORMATEX header as MP3 - // extradata. MediaCodec mp3 decoders expect either no csd or the two-byte - // ISO header, and report a WAVEFORMATEX blob as corrupt input - // (C2_CORRUPTED on the first work item), so drop it. - if (params->codec_id == AV_CODEC_ID_MP3) return nullptr; - if (params->extradata == nullptr || params->extradata_size <= 0) return nullptr; - jbyteArray result = env->NewByteArray(params->extradata_size); - env->SetByteArrayRegion(result, 0, params->extradata_size, reinterpret_cast(params->extradata)); - return result; -} - -JNIEXPORT jint JNICALL -Java_com_edde746_plezy_exoplayer_FfmpegDemuxerJni_nativeAttachmentCount(JNIEnv* env, jobject thiz) { - StateLock lock; - if (gState == nullptr || gState->format == nullptr) return 0; - jint count = 0; - while (attachmentStreamIndex(gState->format, count) >= 0) count++; - return count; -} - -// Fills strings with [filename, mimetype] and returns the payload size in -// bytes, or -1 when the ordinal is out of range. -JNIEXPORT jlong JNICALL Java_com_edde746_plezy_exoplayer_FfmpegDemuxerJni_nativeAttachmentInfo( - JNIEnv* env, jobject thiz, jint ordinal, jobjectArray strings) { - StateLock lock; - if (gState == nullptr || gState->format == nullptr) return -1; - int index = attachmentStreamIndex(gState->format, ordinal); - if (index < 0) return -1; - AVStream* stream = gState->format->streams[index]; - const char* filename = nullptr; - const char* mimetype = nullptr; - if (stream->metadata != nullptr) { - AVDictionaryEntry* entry = av_dict_get(stream->metadata, "filename", nullptr, 0); - if (entry != nullptr) filename = entry->value; - entry = av_dict_get(stream->metadata, "mimetype", nullptr, 0); - if (entry != nullptr) mimetype = entry->value; - } - env->SetObjectArrayElement(strings, 0, filename != nullptr ? env->NewStringUTF(filename) : nullptr); - env->SetObjectArrayElement(strings, 1, mimetype != nullptr ? env->NewStringUTF(mimetype) : nullptr); - return stream->codecpar->extradata_size; -} - -JNIEXPORT jbyteArray JNICALL -Java_com_edde746_plezy_exoplayer_FfmpegDemuxerJni_nativeAttachmentData(JNIEnv* env, jobject thiz, jint ordinal) { - StateLock lock; - if (gState == nullptr || gState->format == nullptr) return nullptr; - int index = attachmentStreamIndex(gState->format, ordinal); - if (index < 0) return nullptr; - AVCodecParameters* params = gState->format->streams[index]->codecpar; - jbyteArray result = env->NewByteArray(params->extradata_size); - if (result == nullptr) return nullptr; - env->SetByteArrayRegion(result, 0, params->extradata_size, reinterpret_cast(params->extradata)); - return result; -} - -// Reads one packet. out[0] carries the result code; on CODE_PACKET the packet -// bytes were written into the byte array and out carries -// [1]=streamIndex [2]=ptsUs (start-time normalized; packets without any -// timestamp are dropped, media3 cannot schedule them) [3]=flags [4]=size -// [5]=packet position in input coordinates [6]=durationUs (-1 unknown). -JNIEXPORT jint JNICALL Java_com_edde746_plezy_exoplayer_FfmpegDemuxerJni_nativeReadPacket( - JNIEnv* env, jobject thiz, jbyteArray buffer, jlongArray out) { - StateLock lock; - DemuxState* s = gState; - if (s == nullptr || !s->opened || s->format == nullptr) return ERR_NOT_OPEN; - - jint capacity = env->GetArrayLength(buffer); - jlong result[7] = {}; - s->javaError = false; - clearLatchedIoError(s); - while (true) { - if (s->packetPending) { - // Oversized packet held across a CODE_GROW round trip: deliver it now - // instead of reading (and losing) a new frame. - s->packetPending = false; - } else { - av_packet_unref(s->packet); - int err = av_read_frame(s->format, s->packet); - if (err == AVERROR_EOF) { - result[0] = CODE_EOF; - env->SetLongArrayRegion(out, 0, 7, result); - return CODE_EOF; - } - if (err < 0 || s->javaError) { - result[0] = s->javaError ? ERR_JAVA : err; - env->SetLongArrayRegion(out, 0, 7, result); - return s->javaError ? ERR_JAVA : err; - } - if (s->packet->size <= 0) continue; - - if (s->format->streams[s->packet->stream_index]->discard >= AVDISCARD_ALL) continue; - - AVBSFContext* bsf = s->bsf != nullptr && s->packet->stream_index < static_cast(s->nbStreams) - ? s->bsf[s->packet->stream_index] - : nullptr; - if (bsf != nullptr) { - // send consumes the packet reference; move the filtered result back so - // the delivery code below keeps a single code path. - int filterErr = av_bsf_send_packet(bsf, s->packet); - if (filterErr >= 0) { - av_packet_unref(s->packet); - filterErr = av_bsf_receive_packet(bsf, s->filteredPacket); - if (filterErr == AVERROR(EAGAIN)) continue; - if (filterErr < 0) { - result[0] = filterErr; - env->SetLongArrayRegion(out, 0, 7, result); - return filterErr; - } - av_packet_move_ref(s->packet, s->filteredPacket); - } else { - result[0] = filterErr; - env->SetLongArrayRegion(out, 0, 7, result); - return filterErr; - } - } - } - int size = s->packet->size; - if (size > capacity) { - s->packetPending = true; - result[0] = CODE_GROW; - result[4] = size; - env->SetLongArrayRegion(out, 0, 7, result); - return CODE_GROW; - } - env->SetByteArrayRegion(buffer, 0, size, reinterpret_cast(s->packet->data)); - - AVStream* stream = s->format->streams[s->packet->stream_index]; - int64_t ptsUs; - if (s->packet->pts != AV_NOPTS_VALUE) { - ptsUs = av_rescale_q(s->packet->pts, stream->time_base, AVRational{1, 1000000}); - } else if (s->packet->dts != AV_NOPTS_VALUE) { - ptsUs = av_rescale_q(s->packet->dts, stream->time_base, AVRational{1, 1000000}); - } else { - // No timestamp even after GENPTS: media3 cannot schedule the sample, - // and delivering data without metadata would corrupt the sample queue. - continue; - } - ptsUs -= s->startTimeUs; - - result[0] = CODE_PACKET; - result[1] = s->packet->stream_index; - result[2] = ptsUs; - result[3] = (s->packet->flags & AV_PKT_FLAG_KEY) != 0 ? 1 : 0; - result[4] = size; - result[5] = s->packet->pos >= 0 ? s->packet->pos : 0; - result[6] = - s->packet->duration > 0 ? av_rescale_q(s->packet->duration, stream->time_base, AVRational{1, 1000000}) : -1; - env->SetLongArrayRegion(out, 0, 7, result); - return CODE_PACKET; - } -} - -JNIEXPORT jlong JNICALL -Java_com_edde746_plezy_exoplayer_FfmpegDemuxerJni_nativeLogicalPosition(JNIEnv* env, jobject thiz) { - StateLock lock; - DemuxState* s = gState; - if (s == nullptr || !s->opened) return -1; - return static_cast(s->logicalPos); -} - -// Seeks to a presentation time (media3 timeline microseconds, start-time -// normalized). Called on the loader thread, where blocking IO belongs: -// libavformat reads its own index through the random-access source, so this -// either lands on the target keyframe or reports that the container has no -// usable index. -JNIEXPORT jint JNICALL -Java_com_edde746_plezy_exoplayer_FfmpegDemuxerJni_nativeSeek(JNIEnv* env, jobject thiz, jlong timeUs) { - StateLock lock; - DemuxState* s = gState; - if (s == nullptr || !s->opened || s->format == nullptr) return ERR_NOT_OPEN; - s->javaError = false; - clearLatchedIoError(s); - // Any packet parked for a CODE_GROW retry belongs to the pre-seek stream. - s->packetPending = false; - if (s->bsf != nullptr) { - for (unsigned i = 0; i < s->nbStreams; i++) { - if (s->bsf[i] != nullptr) av_bsf_flush(s->bsf[i]); - } - } - const int64_t ts = timeUs + s->startTimeUs; - const int err = avformat_seek_file(s->format, -1, INT64_MIN, ts, ts, 0); - if (s->javaError) return ERR_JAVA; - if (err < 0) { - LOGW("seek to %lld failed: %d", (long long)ts, err); - return err; - } - LOGD("seek to %lld: logical=%lld", (long long)ts, (long long)s->logicalPos); - return 0; -} - -} // extern "C" diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/CuelessSeekExtractorWrapper.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/CuelessSeekExtractorWrapper.kt new file mode 100644 index 000000000..bc9bfbc09 --- /dev/null +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/CuelessSeekExtractorWrapper.kt @@ -0,0 +1,226 @@ +package com.edde746.plezy.exoplayer + +import android.util.Log +import androidx.media3.common.C +import androidx.media3.extractor.Extractor +import androidx.media3.extractor.ExtractorInput +import androidx.media3.extractor.ExtractorOutput +import androidx.media3.extractor.PositionHolder +import androidx.media3.extractor.SeekMap +import androidx.media3.extractor.SeekPoint +import androidx.media3.extractor.TrackAwareSeekMap +import androidx.media3.extractor.TrackOutput +import java.util.concurrent.CopyOnWriteArrayList + +/** + * Extractor wrapper that repairs seeking for MKV files media3 reports as unseekable. + * + * Two repairs, in priority order: + * - [SeekMap.Unseekable] with a known duration (no Cues element at all): replaced with a + * proportional byte-position estimate, resynced to the nearest Cluster boundary after seeking. + * - A [TrackAwareSeekMap] whose [SeekMap.isSeekable] is false (media3 1.11.0 builds the Matroska + * seek map while parsing Cues, which for files whose Tracks element follows the Clusters is + * before any track is known — the primary seek track stays unset even though the Cues parsed): + * wrapped so seeks resolve through the per-track cue lookups using the track IDs observed on + * this output. + */ +@androidx.media3.common.util.UnstableApi +class CuelessSeekExtractorWrapper( + private val delegate: Extractor +) : Extractor { + + companion object { + private const val TAG = "CuelessSeek" + + // MKV Cluster element ID: 0x1F43B675 (4-byte EBML Class-D ID) + private val CLUSTER_ID = byteArrayOf(0x1F, 0x43, 0xB6.toByte(), 0x75) + private const val SCAN_BUFFER_SIZE = 8192 + + // Max bytes to scan for a Cluster boundary before giving up + private const val MAX_SCAN_BYTES = 1024 * 1024 // 1 MB + } + + private var inputLength: Long = C.LENGTH_UNSET.toLong() + private var needsClusterResync = false + private var isApproximateSeeking = false + private var pendingSeekTimeUs: Long = C.TIME_UNSET + + /** Track IDs and types observed on the output, consulted by [TrackCueSeekMap]. */ + private val registeredTracks = CopyOnWriteArrayList>() + + override fun sniff(input: ExtractorInput): Boolean = delegate.sniff(input) + + override fun init(output: ExtractorOutput) { + delegate.init(SeekInterceptingOutput(output)) + } + + override fun read(input: ExtractorInput, seekPosition: PositionHolder): Int { + if (inputLength == C.LENGTH_UNSET.toLong()) { + inputLength = input.length + } + if (needsClusterResync) { + needsClusterResync = false + return scanForCluster(input, seekPosition) + } + return delegate.read(input, seekPosition) + } + + override fun seek(position: Long, timeUs: Long) { + if (isApproximateSeeking && position > 0) { + needsClusterResync = true + pendingSeekTimeUs = timeUs + } + delegate.seek(position, timeUs) + } + + override fun release() = delegate.release() + + /** + * Scan forward from the current input position to find the next MKV Cluster + * element ID (0x1F43B675). Returns [Extractor.RESULT_SEEK] with the Cluster's + * byte position so ExoPlayer repositions the DataSource there. + */ + private fun scanForCluster(input: ExtractorInput, seekPosition: PositionHolder): Int { + val buffer = ByteArray(SCAN_BUFFER_SIZE) + var totalScanned = 0L + // Carry over last 3 bytes across buffer boundaries to detect split IDs + var carry = ByteArray(0) + + while (totalScanned < MAX_SCAN_BYTES) { + val toRead = minOf(SCAN_BUFFER_SIZE, (MAX_SCAN_BYTES - totalScanned).toInt()) + val bytesRead: Int + try { + bytesRead = input.read(buffer, 0, toRead) + } catch (_: Exception) { + break + } + if (bytesRead == C.RESULT_END_OF_INPUT) break + + // Combine carry + new data for scanning + val scanData = if (carry.isNotEmpty()) carry + buffer.copyOf(bytesRead) else buffer.copyOf(bytesRead) + + for (i in 0..scanData.size - 4) { + if (scanData[i] == CLUSTER_ID[0] && + scanData[i + 1] == CLUSTER_ID[1] && + scanData[i + 2] == CLUSTER_ID[2] && + scanData[i + 3] == CLUSTER_ID[3] + ) { + // Compute the absolute byte position of this Cluster + val clusterPosition = input.position - bytesRead - carry.size + i + Log.d(TAG, "Found Cluster at byte $clusterPosition (scanned ${totalScanned + i} bytes)") + seekPosition.position = clusterPosition + delegate.seek(clusterPosition, pendingSeekTimeUs) + pendingSeekTimeUs = C.TIME_UNSET + return Extractor.RESULT_SEEK + } + } + + // Keep last 3 bytes as carry for next iteration + carry = if (scanData.size >= 3) scanData.copyOfRange(scanData.size - 3, scanData.size) else scanData.copyOf() + totalScanned += bytesRead + } + + // Failed to find a Cluster — fall back to position 0 + Log.w(TAG, "No Cluster found after scanning $totalScanned bytes, resetting to start") + seekPosition.position = 0 + delegate.seek(0, 0) + return Extractor.RESULT_SEEK + } + + /** + * ExtractorOutput wrapper that intercepts [seekMap] calls to replace + * [SeekMap.Unseekable] with an approximate proportional SeekMap. + */ + private inner class SeekInterceptingOutput( + private val delegate: ExtractorOutput + ) : ExtractorOutput { + + override fun track(id: Int, type: Int): TrackOutput { + if (registeredTracks.none { it.first == id }) { + registeredTracks.add(id to type) + } + return delegate.track(id, type) + } + + override fun endTracks() = delegate.endTracks() + + override fun seekMap(seekMap: SeekMap) { + if (seekMap is SeekMap.Unseekable) { + val durationUs = seekMap.durationUs + if (durationUs != C.TIME_UNSET && durationUs > 0) { + Log.i(TAG, "Replacing Unseekable with approximate SeekMap (duration=${durationUs / 1_000_000}s)") + isApproximateSeeking = true + delegate.seekMap(ApproximateSeekMap(durationUs)) + return + } + } else if (!seekMap.isSeekable && seekMap is TrackAwareSeekMap) { + Log.i(TAG, "Wrapping unseekable TrackAwareSeekMap with per-track cue seeking") + isApproximateSeeking = false + delegate.seekMap(TrackCueSeekMap(seekMap)) + return + } + // File has real Cues or unknown duration — pass through + isApproximateSeeking = false + delegate.seekMap(seekMap) + } + } + + /** + * Approximate SeekMap that estimates byte positions proportionally. + * Used when the MKV has no Cues but has a known duration. + */ + private inner class ApproximateSeekMap( + private val durationUs: Long + ) : SeekMap { + + override fun isSeekable(): Boolean = true + + override fun getDurationUs(): Long = durationUs + + override fun getSeekPoints(timeUs: Long): SeekMap.SeekPoints { + val length = inputLength + if (length == C.LENGTH_UNSET.toLong() || durationUs <= 0) { + return SeekMap.SeekPoints(SeekPoint(0, 0)) + } + val clampedTimeUs = timeUs.coerceIn(0, durationUs) + val position = (clampedTimeUs.toDouble() / durationUs * length).toLong().coerceIn(0, length) + return SeekMap.SeekPoints(SeekPoint(clampedTimeUs, position)) + } + } + + /** + * Routes seeks through [TrackAwareSeekMap]'s per-track cue lookups when the delegate reports + * unseekable overall. The per-track queries read the live cue data (populated once the Cues + * element parsed), so they resolve correctly even when the map was constructed before the + * Tracks element — the media3 1.11.0 tracks-after-clusters case (androidx/media #3377). + */ + private inner class TrackCueSeekMap( + private val delegate: TrackAwareSeekMap + ) : SeekMap { + + override fun isSeekable(): Boolean = delegate.isSeekable || seekableTrackId() != null + + override fun getDurationUs(): Long = delegate.durationUs + + override fun getSeekPoints(timeUs: Long): SeekMap.SeekPoints { + if (delegate.isSeekable) return delegate.getSeekPoints(timeUs) + val trackId = seekableTrackId() ?: return delegate.getSeekPoints(timeUs) + return delegate.getSeekPoints(timeUs, trackId) + } + + /** Mirrors media3's primary-track priority: video first, then audio, then anything with cues. */ + private fun seekableTrackId(): Int? { + var audio: Int? = null + var fallback: Int? = null + for ((id, type) in registeredTracks) { + if (!delegate.isSeekable(id)) continue + when (type) { + C.TRACK_TYPE_VIDEO -> return id + C.TRACK_TYPE_AUDIO -> if (audio == null) audio = id + else -> if (fallback == null) fallback = id + } + } + return audio ?: fallback + } + } +} diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/DoviCodecs.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/DoviCodecs.kt deleted file mode 100644 index 779149313..000000000 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/DoviCodecs.kt +++ /dev/null @@ -1,26 +0,0 @@ -package com.edde746.plezy.exoplayer - -/** - * RFC 6381 codecs strings for Dolby Vision tracks, mirroring media3's - * `DolbyVisionConfig.parse` profile table so the FFmpeg demux path publishes - * the same format media3's own MP4/Matroska extractors would: recognized - * profiles get `video/dolby-vision` plus this string, unrecognized profiles - * keep the base codec's format untouched. - */ -internal object DoviCodecs { - - /** - * Codecs string for a DOVIDecoderConfigurationRecord's [profile]/[level], - * or null when media3 does not recognize the profile (the track then plays - * as its base codec). - */ - fun rfc6381(profile: Int, level: Int): String? { - val prefix = when (profile) { - 4, 5, 7, 8 -> "dvhe" - 9 -> "dvav" - 10 -> "dav1" - else -> return null - } - return "%s.%02d.%02d".format(prefix, profile, level) - } -} diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt index f50d5d5c3..31b5200eb 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt @@ -64,7 +64,6 @@ import androidx.media3.extractor.DefaultExtractorsFactory import androidx.media3.extractor.mkv.MatroskaExtractor import androidx.media3.extractor.mp4.FragmentedMp4Extractor import androidx.media3.extractor.mp4.Mp4Extractor -import androidx.media3.extractor.text.SubtitleTranscodingExtractorOutput import androidx.media3.extractor.ts.TsExtractor import androidx.media3.ui.AspectRatioFrameLayout import androidx.media3.ui.CaptionStyleCompat @@ -72,7 +71,6 @@ import androidx.media3.ui.SubtitleView import com.edde746.plezy.AndroidRuntimeDiagnostics import com.edde746.plezy.libass.media.AssHandler import com.edde746.plezy.libass.media.parser.AssSubtitleParserFactory -import com.edde746.plezy.libass.media.text.AssSubtitleExtractorOutput import com.edde746.plezy.libass.media.widget.AssSubtitleSurfaceView import com.edde746.plezy.shared.AudioFocusManager import com.edde746.plezy.shared.DeviceQuirks @@ -563,18 +561,6 @@ class ExoPlayerCore(private val activity: Activity) : @Volatile private var activeDoviMp4Wrapper: DoviExtractorWrapper? = null - // The FFmpeg extractor from the current session; getStats reads its - // measured audio bitrate for the playing item. - @Volatile private var activeFfmpegExtractor: FfmpegExtractor? = null - - // Container-demuxer placement; read per media item when extractors are built. - @Volatile private var demuxerPreference: FfmpegDemuxerPolicy.Preference = FfmpegDemuxerPolicy.Preference.FFMPEG - - fun setDemuxerMode(mode: String) { - demuxerPreference = FfmpegDemuxerPolicy.fromWire(mode) - emitLog("info", "init", "demuxer mode: ${demuxerPreference.wireName}") - } - private fun getConfiguredDvMode(): DvConversionMode { val override = debugDvModeOverride if (override != null) return override @@ -590,10 +576,7 @@ class ExoPlayerCore(private val activity: Activity) : // Read-ahead depth, as the wire name Dart sends. Kept a String because `LoadControlPolicy` // is internal and this function is not; unrecognised names resolve to Auto, which is also // the default (#1816). - bufferTier: String = "auto", - // Whether FFmpeg demuxes ahead of media3's extractors. Wire values from - // [FfmpegDemuxerPolicy.Preference]; unrecognised resolves to FFmpeg. - demuxerMode: String = "ffmpeg" + bufferTier: String = "auto" ): Boolean { if (isInitialized) { Log.d(TAG, "Already initialized") @@ -602,7 +585,6 @@ class ExoPlayerCore(private val activity: Activity) : tunnelingUserEnabled = tunnelingEnabled this.audioPassthroughEnabled = audioPassthroughEnabled - demuxerPreference = FfmpegDemuxerPolicy.fromWire(demuxerMode) this.dvMode = getConfiguredDvMode() DoviBridge.logSupportSummary(activity) Log.i( @@ -744,20 +726,10 @@ class ExoPlayerCore(private val activity: Activity) : } httpDataSourceFactory = httpFactory dataSourceFactory = DefaultDataSource.Factory(activity, httpFactory) - // libavformat owns its own seeking, so the demuxer reads through a - // random-access source (FfmpegRandomAccessSource). Recording the DataSpec - // media3 opens lets it clone that spec — same URI, cache key, flags and - // request headers — instead of re-deriving one. - val recordingFactory = RecordingDataSourceFactory(dataSourceFactory!!) val extractorsFactory = DefaultExtractorsFactory() // High-bitrate Plex DVR MPEG-TS recordings can have sparse PCR packets; the default // 600-packet window may leave duration unknown and seeking disabled. .setTsExtractorTimestampSearchBytes(TS_TIMESTAMP_SEARCH_PACKETS * TsExtractor.TS_PACKET_SIZE) - // Raw subtitle samples from MatroskaExtractor: the external text - // pipeline below feeds libass dialogue first and then re-adds cue - // transcoding — with the internal wrapper enabled the dialogue bytes - // would already be consumed before our wrapper could see them. - .setMatroskaExtractorFlags(MatroskaExtractor.FLAG_EMIT_RAW_SUBTITLE_DATA) // Inline buildWithAssSupport to retain AssHandler reference for font scale control. val handler = AssHandler() @@ -767,82 +739,42 @@ class ExoPlayerCore(private val activity: Activity) : // composition object and blanks palette-only fade updates (#1953). val subtitleParserFactory = PgsSubtitleParserFactory(AssSubtitleParserFactory(handler)) - // Wrap extractors: FFmpeg demuxes the containers media3 is weak at - // (primary, before the list) and anything media3 cannot sniff (catch-all, - // behind it); give MatroskaExtractor the same libass/transcoding text - // pipeline so the media3 fallback (ffmpeg unavailable, media3-only - // preference) keeps ASS rendering; wrap MKV/MP4 extractors with the DV - // converter when enabled. - // Reads this.dvMode and this.demuxerPreference each time (not captured) so - // DV7→8.1 retry and demuxer-mode changes can reload without - // reinitializing the player. - val wrappedExtractorsFactory = object : androidx.media3.extractor.ExtractorsFactory { - // media3's progressive path always supplies the item URI. Without one - // the demuxer cannot be handed random access, so FFmpeg stays out - // rather than failing later on its first index jump. - override fun createExtractors(): Array { - Log.w(TAG, "[init] extractors: no item uri, ffmpeg demuxer unavailable") - return buildExtractors(null) - } - - override fun createExtractors( - uri: Uri, - responseHeaders: Map> - ): Array = buildExtractors(uri) - - private fun buildExtractors(uri: Uri?): Array { - val currentDvMode = this@ExoPlayerCore.dvMode - val doviEnabled = currentDvMode != DvConversionMode.DISABLED - val currentDemuxerPreference = this@ExoPlayerCore.demuxerPreference - Log.i(TAG, "[init] extractors: demuxer=${currentDemuxerPreference.wireName}") - // media3 invokes the factory once per item and reuses the extractor - // instances, so the sniff reads the live preference instead of a - // captured one. - val liveDemuxerPreference = { this@ExoPlayerCore.demuxerPreference } - val ffmpegFirst = listOfNotNull( - uri?.let { itemUri -> - val io = FfmpegRandomAccessSource(dataSourceFactory!!) { recordingFactory.specFor(itemUri) } - FfmpegExtractor.create( - liveDemuxerPreference, - currentDvMode, - subtitleParserFactory, - handler, - io - )?.also { activeFfmpegExtractor = it } + // Wrap extractors: replace MatroskaExtractor with the ASS+zlib+LATM + // variant, wrap MP4 extractors with the DV converter when enabled. + // Reads this.dvMode each time (not captured) so DV7→8.1 retry can + // change mode and reload without reinitializing the player. + val wrappedExtractorsFactory = androidx.media3.extractor.ExtractorsFactory { + val currentDvMode = this.dvMode + val doviEnabled = currentDvMode != DvConversionMode.DISABLED + extractorsFactory.createExtractors().map { extractor -> + when { + extractor is MatroskaExtractor -> { + val assExtractor = ZlibMatroskaExtractor(subtitleParserFactory, handler) + val inner = if (doviEnabled) { + DoviExtractorWrapper(assExtractor, currentDvMode) { level, prefix, message -> + emitLog(level, prefix, message) + }.also { + activeDoviMkvWrapper = it + } + } else { + assExtractor + } + // Wrap with approximate seeking for MKV files without Cues + CuelessSeekExtractorWrapper(inner) } - ) - return ( - ffmpegFirst + extractorsFactory.createExtractors().map { extractor -> - when { - extractor is MatroskaExtractor -> { - val withTextPipeline = OutputWrappingExtractor(extractor) { out -> - AssSubtitleExtractorOutput(SubtitleTranscodingExtractorOutput(out, subtitleParserFactory), handler) - } - if (doviEnabled) { - DoviExtractorWrapper(withTextPipeline, currentDvMode) { level, prefix, message -> - emitLog(level, prefix, message) - }.also { - activeDoviMkvWrapper = it - } - } else { - withTextPipeline - } - } - doviEnabled && (extractor is Mp4Extractor || extractor is FragmentedMp4Extractor) -> { - DoviExtractorWrapper(extractor, currentDvMode) { level, prefix, message -> - emitLog(level, prefix, message) - }.also { - activeDoviMp4Wrapper = it - } - } - else -> extractor + doviEnabled && (extractor is Mp4Extractor || extractor is FragmentedMp4Extractor) -> { + DoviExtractorWrapper(extractor, currentDvMode) { level, prefix, message -> + emitLog(level, prefix, message) + }.also { + activeDoviMp4Wrapper = it } } - ).toTypedArray() - } + else -> extractor + } + }.toTypedArray() } - val mediaSourceFactory = DefaultMediaSourceFactory(recordingFactory, wrappedExtractorsFactory) + val mediaSourceFactory = DefaultMediaSourceFactory(dataSourceFactory!!, wrappedExtractorsFactory) .setSubtitleParserFactory(subtitleParserFactory) playbackMediaSourceFactory = mediaSourceFactory @@ -3791,7 +3723,6 @@ class ExoPlayerCore(private val activity: Activity) : dv7RetryAttempted = override != null activeDoviMkvWrapper = null activeDoviMp4Wrapper = null - activeFfmpegExtractor = null val debugMode = override?.name ?: "AUTO" emitLog("info", "dv-debug", "P7 DV conversion mode set to $debugMode (active=$dvMode)") reloadCurrentMediaForDvMode() @@ -3828,7 +3759,6 @@ class ExoPlayerCore(private val activity: Activity) : lastDvPlaybackInfo = null activeDoviMkvWrapper = null activeDoviMp4Wrapper = null - activeFfmpegExtractor = null stopFrameWatchdog() cancelDecoderHangCheck() cancelResumeStallWatchdog() @@ -4295,12 +4225,7 @@ class ExoPlayerCore(private val activity: Activity) : "audioMimeType" to audioFormat?.sampleMimeType, "audioSampleRate" to audioFormat?.sampleRate, "audioChannels" to audioFormat?.channelCount, - // Container-declared bitrate when present; otherwise the FFmpeg - // demuxer's measured value (Matroska rarely carries BPS tags — #2063). - "audioBitrate" to ( - audioFormat?.bitrate?.takeIf { it > 0 } - ?: audioFormat?.id?.let { activeFfmpegExtractor?.measuredAudioBitrateBps(it) } - ), + "audioBitrate" to audioFormat?.bitrate?.takeIf { it > 0 }, "audioDecoderName" to audioDecoderInitName, "audioOutputEncoding" to audioTrackConfig?.encoding, "audioOutputChannels" to audioTrackConfig?.channelConfig?.let { Integer.bitCount(it) }, diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt index e302f5246..d758c33e8 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt @@ -258,7 +258,6 @@ class ExoPlayerPlugin : "setBoxFitMode" -> handleSetBoxFitMode(call, result) "setVideoZoom" -> handleSetVideoZoom(call, result) "setDvConversionMode" -> handleSetDvConversionMode(call, result) - "setDemuxerMode" -> handleSetDemuxerMode(call, result) "setAudioNormalization" -> handleSetAudioNormalization(call, result) "setAudioPassthrough" -> handleSetAudioPassthrough(call, result) "setAudioDownmix" -> handleSetAudioDownmix(call, result) @@ -300,7 +299,6 @@ class ExoPlayerPlugin : // ExoPlayer-only: mpv's read-ahead is owned by the mpv.conf editor, so there is no // fallback replay for this one. Resolved in the core; unrecognised means Auto (#1816). val bufferTier = call.argument("bufferTier") ?: "auto" - val demuxerMode = call.argument("demuxerMode") ?: "ffmpeg" // Seed the request here rather than waiting for Dart's separate setAudioPassthrough // call, so a fallback raised before that arrives still derives audio-spdif correctly. audioPassthroughRequested = audioPassthroughEnabled @@ -342,8 +340,7 @@ class ExoPlayerPlugin : val success = core.initialize( tunnelingEnabled = tunnelingEnabled, audioPassthroughEnabled = audioPassthroughEnabled, - bufferTier = bufferTier, - demuxerMode = demuxerMode + bufferTier = bufferTier ) if (!success) { if (playerCore === core) playerCore = null @@ -1148,18 +1145,6 @@ class ExoPlayerPlugin : } ?: result.error("NO_ACTIVITY", "Activity not available", null) } - private fun handleSetDemuxerMode(call: MethodCall, result: MethodChannel.Result) { - val mode = call.argument("mode") - if (mode == null) { - result.error("INVALID_ARGS", "Missing 'mode'", null) - return - } - activity?.runOnUiThread { - playerCore?.setDemuxerMode(mode) - result.success(null) - } ?: result.error("NO_ACTIVITY", "Activity not available", null) - } - private fun handleSetAudioNormalization(call: MethodCall, result: MethodChannel.Result) { val enabled = call.argument("enabled") if (enabled == null) { diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/FfmpegAudioCsd.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/FfmpegAudioCsd.kt deleted file mode 100644 index 3d8084278..000000000 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/FfmpegAudioCsd.kt +++ /dev/null @@ -1,134 +0,0 @@ -package com.edde746.plezy.exoplayer - -import androidx.annotation.OptIn -import androidx.media3.common.MimeTypes -import androidx.media3.common.ParserException -import androidx.media3.common.util.UnstableApi -import androidx.media3.container.OpusUtil - -/** - * Shapes ffmpeg codec extradata into the per-codec initializationData layout - * media3's audio decode paths consume. MediaCodecAudioRenderer maps the list - * entries verbatim to MediaCodec csd-0/1/2 and the bundled FfmpegAudioDecoder - * rebuilds avcodec extradata from the same entries, so both expect the shapes - * media3's own extractors emit — not raw ffmpeg extradata: - * - * - Opus: [OpusHead, codec delay ns, seek pre-roll ns]. Android's Opus - * decoders consume their first three input buffers as exactly this - * configuration; given only OpusHead they read the first two real packets - * as delay/pre-roll and then silently discard every decoded sample, which - * stalled the audio clock at 0 until the MPV fallback fired (#2088). - * - Vorbis: [identification header, setup header], split out of the - * Xiph-laced blob the way MatroskaExtractor's parseVorbisCodecPrivate does. - * - FLAC: the "fLaC"-marked STREAMINFO stream MediaCodec documents; ffmpeg - * surfaces a bare or block-headed STREAMINFO depending on the container. - * - * Everything else (AAC ASC, ALAC cookie, passthrough codecs) already matches - * media3's single-entry shape and passes through. MP3's WAVEFORMATEX blob is - * dropped on the JNI side before it reaches this shaping. - */ -@OptIn(UnstableApi::class) -internal object FfmpegAudioCsd { - - // RFC 7845 section 5.1: the identification header is at least 19 bytes; - // OpusUtil reads the pre-skip field at offsets 10-11. - private const val OPUS_HEADER_MIN_BYTES = 19 - - private val FLAC_STREAM_MARKER = byteArrayOf(0x66, 0x4C, 0x61, 0x43) // "fLaC" - private const val FLAC_STREAM_INFO_SIZE = 34 - private const val FLAC_BLOCK_HEADER_SIZE = 4 - private const val FLAC_LAST_BLOCK_FLAG = 0x80 - - fun initializationData(mime: String, extradata: ByteArray): List = when (mime) { - MimeTypes.AUDIO_OPUS -> opus(extradata) - MimeTypes.AUDIO_VORBIS -> vorbis(extradata) - MimeTypes.AUDIO_FLAC -> flac(extradata) - else -> listOf(extradata) - } - - private fun opus(extradata: ByteArray): List { - if (extradata.size < OPUS_HEADER_MIN_BYTES) { - throw malformed("Opus extradata is not an identification header: ${extradata.size} bytes") - } - // [OpusHead, pre-skip ns, 80 ms default seek pre-roll ns] — the same - // shape media3's Matroska/Ogg/MP4 extractors publish. - return OpusUtil.buildInitializationData(extradata) - } - - /** - * Splits the Xiph-laced header blob (identification, comment, setup) into - * media3's [identification header, setup header] pair, mirroring media3 - * 1.11.0 MatroskaExtractor.parseVorbisCodecPrivate. - */ - private fun vorbis(extradata: ByteArray): List { - try { - if (extradata[0].toInt() != 0x02) throw malformed("Vorbis extradata is not Xiph-laced headers") - var offset = 1 - var idHeaderLength = 0 - while (extradata[offset].toInt() and 0xFF == 0xFF) { - idHeaderLength += 0xFF - offset++ - } - idHeaderLength += extradata[offset++].toInt() and 0xFF - var commentHeaderLength = 0 - while (extradata[offset].toInt() and 0xFF == 0xFF) { - commentHeaderLength += 0xFF - offset++ - } - commentHeaderLength += extradata[offset++].toInt() and 0xFF - - if (extradata[offset].toInt() != 0x01) throw malformed("Vorbis identification header not found") - val idHeader = extradata.copyOfRange(offset, offset + idHeaderLength) - offset += idHeaderLength - if (extradata[offset].toInt() != 0x03) throw malformed("Vorbis comment header not found") - offset += commentHeaderLength - if (extradata[offset].toInt() != 0x05) throw malformed("Vorbis setup header not found") - return listOf(idHeader, extradata.copyOfRange(offset, extradata.size)) - } catch (e: IndexOutOfBoundsException) { - throw malformed("Vorbis extradata truncated") - } - } - - /** - * Normalizes to the "fLaC"-marked metadata stream. MediaCodec's decoder - * feeds csd-0 straight into libFLAC, which requires the stream marker; - * ffmpeg's canonical FLAC extradata is the bare 34-byte STREAMINFO, and - * some demuxers keep the 4-byte metadata block header in front of it. - */ - private fun flac(extradata: ByteArray): List { - if (startsWithFlacMarker(extradata)) return listOf(extradata) - if (extradata.size == FLAC_STREAM_INFO_SIZE) { - val blockHeader = byteArrayOf( - (FLAC_LAST_BLOCK_FLAG or 0).toByte(), // last-metadata-block, type 0 = STREAMINFO - 0x00, - 0x00, - FLAC_STREAM_INFO_SIZE.toByte() - ) - return listOf(FLAC_STREAM_MARKER + blockHeader + extradata) - } - val declaredLength = if (extradata.size >= FLAC_BLOCK_HEADER_SIZE) { - ((extradata[1].toInt() and 0xFF) shl 16) or - ((extradata[2].toInt() and 0xFF) shl 8) or - (extradata[3].toInt() and 0xFF) - } else { - -1 - } - if (extradata[0].toInt() and 0x7F == 0 && - declaredLength == FLAC_STREAM_INFO_SIZE && - extradata.size >= FLAC_BLOCK_HEADER_SIZE + FLAC_STREAM_INFO_SIZE - ) { - return listOf(FLAC_STREAM_MARKER + extradata) - } - throw malformed("FLAC extradata is not a STREAMINFO block: ${extradata.size} bytes") - } - - private fun startsWithFlacMarker(extradata: ByteArray): Boolean { - if (extradata.size < FLAC_STREAM_MARKER.size) return false - for (i in FLAC_STREAM_MARKER.indices) { - if (extradata[i] != FLAC_STREAM_MARKER[i]) return false - } - return true - } - - private fun malformed(message: String): ParserException = ParserException.createForMalformedContainer(message, null) -} diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/FfmpegDemuxerJni.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/FfmpegDemuxerJni.kt deleted file mode 100644 index db63ad9bf..000000000 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/FfmpegDemuxerJni.kt +++ /dev/null @@ -1,139 +0,0 @@ -package com.edde746.plezy.exoplayer - -import androidx.annotation.OptIn -import androidx.media3.common.util.UnstableApi - -/** - * JNI surface for the libavformat demuxer shim (`cpp/media3_ffmpeg_demuxer`). - * - * One demuxer exists per process, owned by whichever [FfmpegExtractor] is - * currently open — Plezy plays at most one progressive source at a time, and - * every other playback mode (HLS/DASH, music) stays on media3's own sources. - * - * The long[] layouts mirror the constants documented in - * `ffmpeg_demuxer_jni.cc`; keep both sides in sync. - */ -@OptIn(UnstableApi::class) -internal object FfmpegDemuxerJni { - /** Byte source the AVIO callbacks read through while a native call is in flight. */ - interface Input { - /** Current byte position of the loader's [androidx.media3.extractor.ExtractorInput]. */ - fun position(): Long - - /** - * Reads up to [length] bytes from the loader's input at its current - * position. Returns the number of bytes read, `0` at end of input, or `-1` - * after storing the failure in [lastError]. - */ - fun read(buf: ByteArray, length: Int): Int - - /** - * Reads up to [length] bytes at absolute [position], wherever the loader - * stands. Same return contract as [read]. This is what lets libavformat - * seek its own index without the shim unwinding the in-flight call. - */ - fun readAt(position: Long, buf: ByteArray, length: Int): Int - - /** Total input length in bytes, or `-1` when unknown. */ - fun length(): Long - - var lastError: String? - } - - // readPacket out[] indices. - const val OUT_CODE = 0 - const val OUT_STREAM_INDEX = 1 - const val OUT_PTS_US = 2 - const val OUT_FLAGS = 3 - const val OUT_SIZE = 4 - const val OUT_POSITION = 5 - const val OUT_DURATION_US = 6 - const val OUT_LENGTH = 7 - - // Result codes written to out[OUT_CODE]; negative values are raw AVERRORs. - const val CODE_PACKET = 0 - const val CODE_EOF = 1 - const val CODE_GROW = 2 - const val ERR_JAVA = -102 - - // streamInfo long[] layout. - const val INFO_TRACK_TYPE = 0 - const val INFO_WIDTH = 1 - const val INFO_HEIGHT = 2 - const val INFO_PAR_NUM = 3 - const val INFO_PAR_DEN = 4 - const val INFO_FPS_NUM = 5 - const val INFO_FPS_DEN = 6 - const val INFO_SAMPLE_RATE = 7 - const val INFO_CHANNELS = 8 - const val INFO_PCM_ENCODING = 9 - const val INFO_ROTATION = 10 - const val INFO_SELECTION_FLAGS = 11 - const val INFO_ROLE_FLAGS = 12 - const val INFO_BITRATE = 13 - const val INFO_DOVI_PROFILE = 14 - const val INFO_DOVI_LEVEL = 15 - const val INFO_LATM = 16 - const val INFO_LENGTH = 17 - - val available: Boolean = try { - System.loadLibrary("ffmpegJNI") - true - } catch (_: UnsatisfiedLinkError) { - false - } - - /** Probes container bytes; returns the ffmpeg short name ("avi", "asf", …) or null. */ - external fun nativeProbeFormat(header: ByteArray): String? - - /** - * Opens the demuxer against [input]. Returns 0 on success or a negative - * AVERROR ([ERR_JAVA] when the input itself failed). The stream count is read - * separately via [nativeStreamCount]; it must not ride the same namespace as - * result codes. - */ - external fun nativeOpen(input: Input): Int - - external fun nativeClose() - - external fun nativeStreamCount(): Int - - /** Total duration in microseconds, or -1 when unknown. */ - external fun nativeDurationUs(): Long - - /** Fills [numbers]/[strings] for [index]; false when the stream is unusable. */ - external fun nativeStreamInfo(index: Int, numbers: LongArray, strings: Array): Boolean - - /** Codec initialization data (extradata), or null. */ - external fun nativeStreamExtradata(index: Int): ByteArray? - - /** Number of attachment streams (embedded fonts and friends) with payloads. */ - external fun nativeAttachmentCount(): Int - - /** - * Fills [strings] with [filename, mimetype] for attachment [ordinal] and - * returns the payload size in bytes, or -1 when out of range. Lets Kotlin - * enforce font budgets before copying any bytes across the boundary. - */ - external fun nativeAttachmentInfo(ordinal: Int, strings: Array): Long - - /** Attachment payload bytes, or null. */ - external fun nativeAttachmentData(ordinal: Int): ByteArray? - - /** - * Reads one packet into [buffer], filling [out]. See the shim header - * comment for the result contract. - */ - external fun nativeReadPacket(buffer: ByteArray, out: LongArray): Int - - /** Byte position libavformat will read next, or -1 when not open. */ - external fun nativeLogicalPosition(): Long - - /** - * Seeks to [timeUs] (media3 timeline microseconds, i.e. start-time - * normalized). The demuxer resolves the byte position itself through its own - * index. Returns 0 on success, or a negative AVERROR when the container has - * no usable index and libavformat refused the seek. - */ - external fun nativeSeek(timeUs: Long): Int -} diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/FfmpegDemuxerPolicy.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/FfmpegDemuxerPolicy.kt deleted file mode 100644 index 2ba1fcb2f..000000000 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/FfmpegDemuxerPolicy.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.edde746.plezy.exoplayer - -/** - * Decides whether the FFmpeg demuxer sits ahead of media3's own extractors. - * - * FFmpeg demuxes every progressive container by default. media3's bundled - * extractors were weak exactly where this app kept accumulating container - * patches — AVI (XviD packed-bitstream timestamps, missing VOL csd — issue - * #2052), ASF/WMV (no extractor at all), MPEG-PS/VOB (no extractor), and - * Matroska (zlib-compressed subtitles, LOAS/LATM audio as A_MS/ACM, cueless - * seeking, font attachments) — and libavformat handles all of it natively. - * media3's extractors stay behind FFmpeg in the list, so a container FFmpeg - * fails to sniff still reaches media3's parsers. [Preference.MEDIA3_ONLY] is - * the user-facing escape hatch in case a file misbehaves on the FFmpeg path. - */ -internal object FfmpegDemuxerPolicy { - enum class Preference(val wireName: String) { - FFMPEG("ffmpeg"), - MEDIA3_ONLY("media3") - } - - /** Unknown wire values (including the retired "auto") resolve to FFmpeg. */ - fun fromWire(value: String?): Preference = Preference.entries.firstOrNull { it.wireName == value } ?: Preference.FFMPEG - - /** Whether the FFmpeg extractor placed before media3's list demuxes at all. */ - fun enabled(preference: Preference): Boolean = preference == Preference.FFMPEG -} diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/FfmpegExtractor.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/FfmpegExtractor.kt deleted file mode 100644 index e68cbc6ad..000000000 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/FfmpegExtractor.kt +++ /dev/null @@ -1,577 +0,0 @@ -package com.edde746.plezy.exoplayer - -import android.util.Log -import androidx.annotation.OptIn -import androidx.media3.common.C -import androidx.media3.common.Format -import androidx.media3.common.MimeTypes -import androidx.media3.common.ParserException -import androidx.media3.common.util.ParsableByteArray -import androidx.media3.common.util.UnstableApi -import androidx.media3.extractor.Extractor -import androidx.media3.extractor.ExtractorInput -import androidx.media3.extractor.ExtractorOutput -import androidx.media3.extractor.PositionHolder -import androidx.media3.extractor.SeekMap -import androidx.media3.extractor.SeekPoint -import androidx.media3.extractor.TrackOutput -import androidx.media3.extractor.text.SubtitleParser -import androidx.media3.extractor.text.SubtitleTranscodingExtractorOutput -import com.edde746.plezy.libass.media.AssFonts -import com.edde746.plezy.libass.media.AssHandler -import com.edde746.plezy.libass.media.text.AssSubtitleExtractorOutput -import java.io.IOException - -/** - * Demuxes progressive containers with libavformat and feeds the packets to - * media3's renderers, so FFmpeg's container coverage replaces media3's on the - * paths where it is weak (AVI/XviD timestamps and VOL csd — #2052 — ASF/WMV, - * MPEG-PS) and where the app accumulated Matroska patches (zlib-compressed - * subtitles, LOAS/LATM audio, cueless seeking) without giving up any of the - * renderer-side work (passthrough carriers, DV sanitizing, libass rendering - * with embedded fonts, subtitle latency calibration). - * - * Text tracks are emitted in the exact sample shape media3's - * MatroskaExtractor writes (see [FfmpegSubtitleSamples]); the output chain - * composed in [init] then feeds libass dialogue and transcodes cues the same - * way the media3 Matroska path does. - * - * Seeking: libavformat resolves seeks itself, reading its own index through - * [FfmpegRandomAccessSource]. media3's byte hints are ignored, nothing is ever - * unwound, and the loader is merely nudged to follow along so that sample - * delivery keeps streaming on its connection (see [alignLoader]). - * - * The instance sits before media3's extractors and sniffs everything FFmpeg - * can probe while [FfmpegDemuxerPolicy] enables it; media3's list behind it - * is the fallback for anything FFmpeg cannot open. - */ -@OptIn(UnstableApi::class) -internal class FfmpegExtractor private constructor( - private val preferenceSupplier: () -> FfmpegDemuxerPolicy.Preference, - private val dvMode: DvConversionMode, - private val subtitleParserFactory: SubtitleParser.Factory, - private val assHandler: AssHandler, - private val io: FfmpegRandomAccessSource -) : Extractor { - - companion object { - private const val TAG = "FfmpegExtractor" - private const val SNIFF_BYTES = 64 * 1024 - private const val INITIAL_PACKET_BUFFER_BYTES = 2 * 1024 * 1024 - - /** Null when the native library is unavailable. */ - fun create( - preferenceSupplier: () -> FfmpegDemuxerPolicy.Preference, - dvMode: DvConversionMode, - subtitleParserFactory: SubtitleParser.Factory, - assHandler: AssHandler, - io: FfmpegRandomAccessSource - ): FfmpegExtractor? = if (FfmpegDemuxerJni.available) { - FfmpegExtractor(preferenceSupplier, dvMode, subtitleParserFactory, assHandler, io) - } else { - null - } - } - - private lateinit var output: ExtractorOutput - private var currentInput: ExtractorInput? = null - private val inputProxy = object : FfmpegDemuxerJni.Input { - override fun position(): Long = currentInput?.position ?: 0L - - override fun read(buf: ByteArray, length: Int): Int { - val input = currentInput ?: return 0 - lastError = null - return try { - val read = input.read(buf, 0, length) - if (read == C.RESULT_END_OF_INPUT) 0 else read - } catch (e: IOException) { - lastError = e.message ?: "demuxer input failed" - -1 - } - } - - override fun readAt(position: Long, buf: ByteArray, length: Int): Int { - lastError = null - return try { - io.readAt(position, buf, length) - } catch (e: IOException) { - lastError = e.message ?: "demuxer random access read failed" - -1 - } - } - - override fun length(): Long { - val fromLoader = currentInput?.length ?: C.LENGTH_UNSET.toLong() - if (fromLoader != C.LENGTH_UNSET.toLong()) return fromLoader - // Length-less loader open (progressive live stream): fall back to - // whatever a random-access open already resolved. - val resolved = io.length() - return if (resolved == C.LENGTH_UNSET.toLong()) -1L else resolved - } - - override var lastError: String? = null - } - - private var opened = false - private var doviOutputWrapper: DoviExtractorOutputWrapper? = null - private var durationUs = C.TIME_UNSET - private var trackOutputs: Array = emptyArray() - private var packetBytes = ByteArray(INITIAL_PACKET_BUFFER_BYTES) - private val packetParsable = ParsableByteArray() - private val packetOut = LongArray(FfmpegDemuxerJni.OUT_LENGTH) - private val prefixParsable = ParsableByteArray() - - // Per-stream text handling picked in prepareTracks; parallel to trackOutputs. - private var subtitleKinds: Array = emptyArray() - private val latmOutputs = ArrayList() - - // Per-audio-stream measured bitrate, parallel to trackOutputs. Written by - // the loader thread, read by ExoPlayerCore.getStats on another thread. - @Volatile private var bitrateMeters: Array = emptyArray() - - // Set by seek(), applied by the next read(): media3 calls both on the loader - // thread, but only read() is allowed to throw IOException and the demuxer's - // seek reads its index. - private var pendingSeekUs = C.TIME_UNSET - private var deliveredAnyPacket = false - - private val sniffScratch = ByteArray(SNIFF_BYTES) - - override fun sniff(input: ExtractorInput): Boolean { - // media3 materializes the extractor array once per player session, so the - // policy must be read live at sniff time — a preference captured at - // construction would freeze for the session. - if (!FfmpegDemuxerPolicy.enabled(preferenceSupplier())) return false - val probed = probe(input) ?: return false - Log.i(TAG, "sniff accepted $probed") - return true - } - - private fun probe(input: ExtractorInput): String? { - // create() only builds instances when the native library loaded. - input.resetPeekPosition() - return try { - val filled = - input.peekFully( - sniffScratch, - /* offset= */ - 0, - sniffScratch.size, - /* allowEndOfInput= */ - true - ) - if (!filled) return null - FfmpegDemuxerJni.nativeProbeFormat(sniffScratch)?.lowercase() - } catch (_: Throwable) { - // A sniffer must never take playback down: media3 escalates anything - // that is not an IOException into a fatal loader error. - null - } finally { - input.resetPeekPosition() - } - } - - override fun init(output: ExtractorOutput) { - // media3 reuses extractor instances across sources; drop the previous - // item's demuxer and byte source so this open starts clean. - releaseDemuxer() - // Same composition as the MP4/MKV paths: DoviConvertingTrackOutput - // inspects each video track's codecs string and passes non-DV through. - val doviWrapped = if (dvMode != DvConversionMode.DISABLED) { - // The wrapper forwards emitLog into each converting track output; the - // playback-info hook stays wired to the MKV/MP4 wrappers, so nothing to - // capture here. - DoviExtractorOutputWrapper( - output, - dvMode, - emitLog = { level, prefix, message -> - Log.println(if (level == "error") Log.ERROR else Log.INFO, TAG, "$prefix $message") - }, - onVideoTrackWrapped = { _ -> } - ).also { doviOutputWrapper = it } - } else { - doviOutputWrapper = null - output - } - // Text pipeline, extractor-outward: raw media3-shape samples first hit - // AssTrackOutput (libass dialogue feed), then the transcoding wrapper - // parses them into cue samples. media3 does NOT wrap third-party - // extractors' outputs; without this wrapper no text track would ever - // reach the renderer as cues. - this.output = AssSubtitleExtractorOutput( - SubtitleTranscodingExtractorOutput(doviWrapped, subtitleParserFactory), - assHandler - ) - } - - override fun read(input: ExtractorInput, seekPosition: PositionHolder): Int { - currentInput = input - try { - if (!opened) openStreams() - applyPendingSeek() - alignLoader(input)?.let { position -> - seekPosition.position = position - return Extractor.RESULT_SEEK - } - // Aligned: the loader serves the samples, so nothing needs a second - // handle open until the demuxer jumps again. - if (io.isOpen) io.close() - return readPacket() - } catch (e: Throwable) { - Log.w(TAG, "read threw at input=${input.position}", e) - throw e - } finally { - currentInput = null - } - } - - /** - * Byte position the loader should move to, or null when it already stands - * where libavformat reads. - * - * Reads outside the loader's window are served by [io] regardless, so this - * is an optimization, never a correctness requirement: it keeps sample - * delivery on media3's own connection, which is what drives its byte - * accounting, back-pressure (ProgressiveMediaPeriod watches the input - * position) and load-error policy. Nothing here needs a retry budget. - */ - private fun alignLoader(input: ExtractorInput): Long? { - val demuxerPosition = FfmpegDemuxerJni.nativeLogicalPosition() - if (demuxerPosition < 0 || demuxerPosition == input.position) return null - val length = input.length - // Opening a range at or past the end is a failed request, not an - // alignment; let the read report end of input instead. - if (length != C.LENGTH_UNSET.toLong() && demuxerPosition >= length) return null - return demuxerPosition - } - - /** - * Records the target; [read] performs it. media3's byte position is its - * SeekMap guess and is deliberately ignored — libavformat picks the byte - * position from its own index. - */ - override fun seek(position: Long, timeUs: Long) { - Log.i(TAG, "extractor seek timeUs=$timeUs opened=$opened") - doviOutputWrapper?.resetTracks() - // Buffered LOAS bytes belong to the pre-seek stream; the StreamMuxConfig - // survives so audio resumes without waiting for the next config. - for (latm in latmOutputs) latm.reset() - pendingSeekUs = timeUs - } - - private fun applyPendingSeek() { - val target = pendingSeekUs - if (target == C.TIME_UNSET) return - pendingSeekUs = C.TIME_UNSET - // media3 issues seek(0) before the first read of every item; a demuxer - // that has not delivered a packet yet is already at the start, and - // seeking would throw away find_stream_info's buffered packets and - // re-read the header region. - if (target == 0L && !deliveredAnyPacket) return - val code = FfmpegDemuxerJni.nativeSeek(target) - if (code == FfmpegDemuxerJni.ERR_JAVA) { - throw IOException(inputProxy.lastError ?: "demuxer input failed") - } - if (code != 0) { - // No usable index in this container: libavformat keeps delivering from - // where it stands and the renderers decode-discard toward the target, - // which is what media3's own extractors do for a cueless file. - Log.w(TAG, "demuxer refused seek to ${target}us: $code") - } - } - - override fun release() { - releaseDemuxer() - } - - private fun releaseDemuxer() { - if (opened) { - opened = false - FfmpegDemuxerJni.nativeClose() - } - io.close() - pendingSeekUs = C.TIME_UNSET - deliveredAnyPacket = false - // media3's extractors may demux a later item while this instance keeps - // the previous one's stream layout; a stale measurement must not leak - // into stats. - bitrateMeters = emptyArray() - } - - /** - * Measured average bitrate (bps) of the audio stream whose index matches - * [formatId] (this extractor sets `Format.id` to the stream index), or null - * when this instance is not demuxing that stream. Fallback for containers - * that declare no per-track bitrate (#2063). - */ - fun measuredAudioBitrateBps(formatId: String?): Int? { - val index = formatId?.toIntOrNull() ?: return null - return bitrateMeters.getOrNull(index)?.bitrateBps() - } - - private fun openStreams() { - when (val code = FfmpegDemuxerJni.nativeOpen(inputProxy)) { - 0 -> { - prepareTracks() - opened = true - Log.i(TAG, "ffmpeg demuxer ready: ${trackOutputs.count { it != null }} tracks") - } - FfmpegDemuxerJni.ERR_JAVA -> throw IOException(inputProxy.lastError ?: "demuxer input failed") - else -> throw malformed("ffmpeg demuxer open failed: $code") - } - } - - private enum class SubtitleKind { NONE, SSA, SUBRIP, VTT } - - private fun prepareTracks() { - val count = FfmpegDemuxerJni.nativeStreamCount() - durationUs = FfmpegDemuxerJni.nativeDurationUs().takeIf { it >= 0 } ?: C.TIME_UNSET - trackOutputs = arrayOfNulls(count) - subtitleKinds = Array(count) { SubtitleKind.NONE } - bitrateMeters = arrayOfNulls(count) - latmOutputs.clear() - // Fonts go in before any text track exists so AssHandler's store flushes - // them into libass when the first ASS track is created. - deliverFontAttachments() - val numbers = LongArray(FfmpegDemuxerJni.INFO_LENGTH) - val strings = arrayOfNulls(3) - var primaryVideo = -1 - var primaryAudio = -1 - - for (index in 0 until count) { - if (!FfmpegDemuxerJni.nativeStreamInfo(index, numbers, strings)) continue - val mime = strings[0] ?: continue - val trackType = when (numbers[FfmpegDemuxerJni.INFO_TRACK_TYPE]) { - 0L -> C.TRACK_TYPE_VIDEO - 1L -> C.TRACK_TYPE_AUDIO - 2L -> C.TRACK_TYPE_TEXT - else -> continue - } - - val builder = Format.Builder() - .setId(index.toString()) - .setSampleMimeType(mime) - .setLanguage(strings[1]) - .setLabel(strings[2]) - .setSelectionFlags(numbers[FfmpegDemuxerJni.INFO_SELECTION_FLAGS].toInt()) - .setRoleFlags(numbers[FfmpegDemuxerJni.INFO_ROLE_FLAGS].toInt()) - .setAverageBitrate(numbers[FfmpegDemuxerJni.INFO_BITRATE].toInt()) - - val extradata = FfmpegDemuxerJni.nativeStreamExtradata(index) - if (extradata != null) { - // Audio csd is codec-specific in media3 (see FfmpegAudioCsd); raw - // extradata is only correct for video (already Annex-B via the JNI - // bitstream filter) and text tracks. - builder.setInitializationData( - if (trackType == C.TRACK_TYPE_AUDIO) { - FfmpegAudioCsd.initializationData(mime, extradata) - } else { - listOf(extradata) - } - ) - } - - when (trackType) { - C.TRACK_TYPE_VIDEO -> { - // A Dolby Vision config record rides the stream as side data. - // Mirror media3's MP4/Matroska extractors: recognized profiles - // publish video/dolby-vision with the RFC 6381 codecs string, so - // DoviConvertingTrackOutput's P7 conversion, the P8 passthrough - // path, and the renderer's HEVC fallback engage exactly as they do - // on the media3 demux path. - val doviProfile = numbers[FfmpegDemuxerJni.INFO_DOVI_PROFILE].toInt() - if (doviProfile >= 0) { - val doviLevel = numbers[FfmpegDemuxerJni.INFO_DOVI_LEVEL].toInt().coerceAtLeast(0) - val doviCodecs = DoviCodecs.rfc6381(doviProfile, doviLevel) - if (doviCodecs != null) { - builder.setCodecs(doviCodecs) - builder.setSampleMimeType(MimeTypes.VIDEO_DOLBY_VISION) - } - Log.i(TAG, "video track $index: doviProfile=$doviProfile doviLevel=$doviLevel codecs=$doviCodecs baseMime=$mime") - } - val width = numbers[FfmpegDemuxerJni.INFO_WIDTH].toInt() - val height = numbers[FfmpegDemuxerJni.INFO_HEIGHT].toInt() - builder - .setWidth(width) - .setHeight(height) - .setRotationDegrees(numbers[FfmpegDemuxerJni.INFO_ROTATION].toInt()) - val parNum = numbers[FfmpegDemuxerJni.INFO_PAR_NUM] - val parDen = numbers[FfmpegDemuxerJni.INFO_PAR_DEN] - if (parNum > 0 && parDen > 0 && parNum != parDen) { - builder.setPixelWidthHeightRatio(parNum.toFloat() / parDen.toFloat()) - } - val fpsNum = numbers[FfmpegDemuxerJni.INFO_FPS_NUM] - val fpsDen = numbers[FfmpegDemuxerJni.INFO_FPS_DEN] - if (fpsNum > 0 && fpsDen > 0) { - builder.setFrameRate(fpsNum.toFloat() / fpsDen.toFloat()) - } - if (primaryVideo < 0) { - primaryVideo = index - // libass needs the storage size before the first render; the - // Matroska path published it from the Tracks element the same way. - if (width > 0 && height > 0) assHandler.setVideoSize(width, height) - } - } - C.TRACK_TYPE_AUDIO -> { - builder - .setSampleRate(numbers[FfmpegDemuxerJni.INFO_SAMPLE_RATE].toInt()) - .setChannelCount(numbers[FfmpegDemuxerJni.INFO_CHANNELS].toInt()) - val pcmEncoding = numbers[FfmpegDemuxerJni.INFO_PCM_ENCODING].toInt() - if (pcmEncoding >= 0) builder.setPcmEncoding(pcmEncoding) - if (primaryAudio < 0) primaryAudio = index - bitrateMeters[index] = StreamBitrateMeter() - } - C.TRACK_TYPE_TEXT -> { - val kind = when (mime) { - MimeTypes.TEXT_SSA -> SubtitleKind.SSA - MimeTypes.APPLICATION_SUBRIP -> SubtitleKind.SUBRIP - MimeTypes.TEXT_VTT -> SubtitleKind.VTT - else -> SubtitleKind.NONE - } - subtitleKinds[index] = kind - if (kind == SubtitleKind.SSA) { - // Embedded ASS reaches libass as per-sample dialogue, never as a - // whole file: AssSubtitleParserFactory keys embedded handling off - // the Matroska container mime, and AssHeaderParser reads the - // header from initializationData[1] — the exact layout media3's - // MatroskaExtractor publishes. - builder.setContainerMimeType(MimeTypes.VIDEO_MATROSKA) - if (extradata != null) { - builder.setInitializationData( - listOf(FfmpegSubtitleSamples.SSA_DIALOGUE_FORMAT, extradata) - ) - } - } - } - } - - val rawOutput = output.track(index, trackType) - val trackOutput = if (trackType == C.TRACK_TYPE_AUDIO && numbers[FfmpegDemuxerJni.INFO_LATM] == 1L) { - // LOAS/LATM-framed AAC: unwrap to raw access units; the wrapper - // swallows this placeholder Format and emits the real one parsed from - // the StreamMuxConfig. - LatmTrackOutput(rawOutput, index).also { latmOutputs.add(it) } - } else { - rawOutput - } - trackOutput.format(builder.build()) - trackOutputs[index] = trackOutput - } - - output.endTracks() - output.seekMap(SeekMapImpl()) - } - - /** Hands embedded font attachments to libass, under the shared budgets. */ - private fun deliverFontAttachments() { - val count = FfmpegDemuxerJni.nativeAttachmentCount() - if (count <= 0) return - var acceptedBytes = 0L - val meta = arrayOfNulls(2) - for (ordinal in 0 until count) { - val size = FfmpegDemuxerJni.nativeAttachmentInfo(ordinal, meta) - if (size <= 0) continue - val name = meta[0] ?: continue - val mime = meta[1] ?: continue - if (mime !in AssFonts.fontMimeTypes) continue - val rejectionReason = AssFonts.rejectionReason(size, acceptedBytes) - if (rejectionReason != null) { - Log.w(TAG, "Skipping embedded font: $rejectionReason (bytes=$size, accepted=$acceptedBytes)") - continue - } - val data = FfmpegDemuxerJni.nativeAttachmentData(ordinal) ?: continue - acceptedBytes += size - assHandler.addFont(name, data) - } - } - - private fun readPacket(): Int { - while (true) { - val code = FfmpegDemuxerJni.nativeReadPacket(packetBytes, packetOut) - when (code) { - FfmpegDemuxerJni.CODE_PACKET -> { - val streamIndex = packetOut[FfmpegDemuxerJni.OUT_STREAM_INDEX].toInt() - val size = packetOut[FfmpegDemuxerJni.OUT_SIZE].toInt() - val trackOutput = trackOutputs.getOrNull(streamIndex) ?: continue - val ptsUs = packetOut[FfmpegDemuxerJni.OUT_PTS_US] - val isKeyframe = packetOut[FfmpegDemuxerJni.OUT_FLAGS] and 1L != 0L - val subtitleKind = subtitleKinds.getOrNull(streamIndex) ?: SubtitleKind.NONE - if (subtitleKind != SubtitleKind.NONE) { - // Text samples carry their duration inside the sample text, in - // media3's MatroskaExtractor shape; a sample without a duration - // cannot be displayed (same skip media3 performs). - val sampleDurationUs = packetOut[FfmpegDemuxerJni.OUT_DURATION_US] - if (sampleDurationUs <= 0) { - Log.w(TAG, "Skipping subtitle sample with no duration (stream=$streamIndex)") - continue - } - val prefix = when (subtitleKind) { - SubtitleKind.SSA -> FfmpegSubtitleSamples.ssaPrefix(sampleDurationUs) - SubtitleKind.SUBRIP -> FfmpegSubtitleSamples.subripPrefix(sampleDurationUs) - else -> FfmpegSubtitleSamples.vttPrefix(sampleDurationUs) - } - prefixParsable.reset(prefix, prefix.size) - trackOutput.sampleData(prefixParsable, prefix.size) - packetParsable.reset(packetBytes, size) - trackOutput.sampleData(packetParsable, size) - trackOutput.sampleMetadata( - ptsUs, - C.BUFFER_FLAG_KEY_FRAME, - prefix.size + size, - /* offset= */ - 0, - /* cryptoData= */ - null - ) - return Extractor.RESULT_CONTINUE - } - packetParsable.reset(packetBytes, size) - trackOutput.sampleData(packetParsable, size) - // Native drops packets without any timestamp, so ptsUs is always real. - trackOutput.sampleMetadata( - ptsUs, - if (isKeyframe) C.BUFFER_FLAG_KEY_FRAME else 0, - size, - /* offset= */ - 0, - /* cryptoData= */ - null - ) - deliveredAnyPacket = true - bitrateMeters.getOrNull(streamIndex)?.onPacket(ptsUs, size) - return Extractor.RESULT_CONTINUE - } - FfmpegDemuxerJni.CODE_EOF -> return Extractor.RESULT_END_OF_INPUT - FfmpegDemuxerJni.CODE_GROW -> { - val required = packetOut[FfmpegDemuxerJni.OUT_SIZE].toInt() - packetBytes = ByteArray(required + 64 * 1024) - } - FfmpegDemuxerJni.ERR_JAVA -> - throw IOException(inputProxy.lastError ?: "demuxer input failed") - // Byte-source failures surface as ERR_JAVA above and become retryable - // IOExceptions; a raw AVERROR here is libavformat's verdict on the - // bytes themselves, which media3 rightly treats as terminal (#2113). - else -> throw malformed("ffmpeg demuxer read failed: $code") - } - } - } - - private fun malformed(message: String): ParserException = ParserException.createForMalformedContainer(message, null) - - private inner class SeekMapImpl : SeekMap { - override fun isSeekable(): Boolean = this@FfmpegExtractor.durationUs != C.TIME_UNSET - - /** - * The demuxer owns seek resolution: it reads its own index and lands on - * the right keyframe by itself, so there is no second index here to keep - * consistent. The byte position exists only to satisfy the interface — - * [alignLoader] moves the loader to wherever libavformat actually ended - * up, on the read that follows the seek. - */ - override fun getSeekPoints(timeUs: Long): SeekMap.SeekPoints = SeekMap.SeekPoints(SeekPoint(timeUs, 0)) - - // Qualified on purpose: bare `durationUs` binds to the inherited Java - // getter as a synthetic property (this.getDurationUs()) and recurses. - override fun getDurationUs(): Long = this@FfmpegExtractor.durationUs - } -} diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/FfmpegRandomAccessSource.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/FfmpegRandomAccessSource.kt deleted file mode 100644 index d0286545e..000000000 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/FfmpegRandomAccessSource.kt +++ /dev/null @@ -1,175 +0,0 @@ -package com.edde746.plezy.exoplayer - -import android.net.Uri -import androidx.annotation.OptIn -import androidx.media3.common.C -import androidx.media3.common.util.UnstableApi -import androidx.media3.datasource.DataSource -import androidx.media3.datasource.DataSpec -import androidx.media3.datasource.TransferListener -import java.io.IOException - -/** - * Random-access byte source for the libavformat demuxer. - * - * libavformat demuxers own their own IO: they jump to the end of the file for an - * index (matroska Cues, MP4 moov, AVI idx1), back to the header, and binary - * search (MPEG-PS) whenever they please. media3's [ - * androidx.media3.extractor.ExtractorInput] is forward-only — an extractor may - * only ask the loader to re-open elsewhere and be invoked again — so serving - * libavformat from the loader alone means every backward jump has to unwind the - * in-flight libavformat call and replay it, which is what the demuxer shim used - * to do (and what made a matroska Cues read unrecoverable, #2096). - * - * This class removes the mismatch instead of working around it: reads outside - * the loader's window open a second [DataSource] from the same factory media3 - * uses, positioned exactly where libavformat asked. Cronet, download caches, - * SAF and the item's own request headers therefore still apply, because the - * [DataSpec] is cloned from the one media3 opened for this item. - * - * Sequential sample delivery deliberately does not come through here: it stays - * on the loader's connection so media3 keeps its byte accounting, back-pressure - * and load-error policy (see [FfmpegExtractor]). This source is opened for - * header/index bursts and closed again as soon as the loader is back in step. - */ -@OptIn(UnstableApi::class) -internal class FfmpegRandomAccessSource( - private val factory: DataSource.Factory, - private val specProvider: () -> DataSpec? -) { - private var source: DataSource? = null - - /** Absolute position the open handle returns next; [C.INDEX_UNSET] when closed. */ - private var handlePosition = C.INDEX_UNSET.toLong() - - private var resolvedLength = C.LENGTH_UNSET.toLong() - - private var opens = 0 - - /** Handles opened so far; the tests assert an index seek does not walk the file. */ - val openCount: Int - get() = synchronized(this) { opens } - - val isOpen: Boolean - get() = synchronized(this) { source != null } - - /** - * Reads up to [length] bytes at absolute [position]. Returns the number of - * bytes read, or 0 at end of input. Reopens the underlying source whenever - * the requested position is not where the current handle stands, which is the - * only way to move backwards on a byte stream. - */ - fun readAt(position: Long, buffer: ByteArray, length: Int): Int = synchronized(this) { - if (length <= 0) return 0 - val handle = handleAt(position) - val read = try { - handle.read(buffer, 0, length) - } catch (e: Throwable) { - // The handle is dead but handlePosition still matches the request, so a - // load-error retry of the same read would be handed the same broken - // handle forever. Drop it; the retry reopens at the position (#2113). - close() - throw e - } - if (read == C.RESULT_END_OF_INPUT) return 0 - handlePosition = position + read - read - } - - /** - * Total resource length once some open has resolved it, else - * [C.LENGTH_UNSET]. Never performs IO of its own: the extractor prefers the - * length media3 already resolved for the loader. - */ - fun length(): Long = synchronized(this) { resolvedLength } - - fun close() = synchronized(this) { - closeHandle() - handlePosition = C.INDEX_UNSET.toLong() - } - - private fun handleAt(position: Long): DataSource { - val existing = source - if (existing != null && handlePosition == position) return existing - closeHandle() - val spec = specProvider() ?: throw IOException("ffmpeg demuxer has no data spec for random access") - // Clone rather than subrange: the recorded spec carries the loader's own - // position, and subrange() is relative to it. - val positioned = spec.buildUpon() - .setPosition(position) - .setLength(C.LENGTH_UNSET.toLong()) - .build() - val created = factory.createDataSource() - val remaining = try { - created.open(positioned) - } catch (e: Throwable) { - closeQuietly(created) - throw e - } - source = created - handlePosition = position - opens++ - if (remaining != C.LENGTH_UNSET.toLong()) resolvedLength = position + remaining - return created - } - - private fun closeHandle() { - source?.let { closeQuietly(it) } - source = null - } - - private fun closeQuietly(dataSource: DataSource) { - try { - dataSource.close() - } catch (_: IOException) { - // Closing a handle we are abandoning cannot fail the read that replaces it. - } - } -} - -/** - * [DataSource.Factory] that remembers the [DataSpec] media3 opened for each - * item, so [FfmpegRandomAccessSource] can clone it — same URI, cache key, flags - * and request headers — instead of re-deriving one and silently losing, say, a - * download's cache key. - * - * Keyed by URI because ExoPlayer prepares the next item's period while the - * current one is still loading; the demuxer must never read the next item's - * bytes. - */ -@OptIn(UnstableApi::class) -internal class RecordingDataSourceFactory(private val delegate: DataSource.Factory) : DataSource.Factory { - - private val specs = object : LinkedHashMap(8, 0.75f, false) { - override fun removeEldestEntry(eldest: MutableMap.MutableEntry): Boolean = size > MAX_TRACKED_ITEMS - } - - fun specFor(uri: Uri): DataSpec? = synchronized(specs) { specs[uri] } - - override fun createDataSource(): DataSource = RecordingDataSource(delegate.createDataSource()) - - private fun record(dataSpec: DataSpec) { - synchronized(specs) { specs[dataSpec.uri] = dataSpec } - } - - private inner class RecordingDataSource(private val inner: DataSource) : DataSource { - override fun open(dataSpec: DataSpec): Long { - record(dataSpec) - return inner.open(dataSpec) - } - - override fun read(buffer: ByteArray, offset: Int, length: Int): Int = inner.read(buffer, offset, length) - - override fun addTransferListener(transferListener: TransferListener) = inner.addTransferListener(transferListener) - - override fun getUri(): Uri? = inner.uri - - override fun getResponseHeaders(): Map> = inner.responseHeaders - - override fun close() = inner.close() - } - - private companion object { - const val MAX_TRACKED_ITEMS = 4 - } -} diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/FfmpegSubtitleSamples.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/FfmpegSubtitleSamples.kt deleted file mode 100644 index 8328c8333..000000000 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/FfmpegSubtitleSamples.kt +++ /dev/null @@ -1,46 +0,0 @@ -package com.edde746.plezy.exoplayer - -import java.util.Locale - -/** - * Builds text subtitle samples in the exact shape media3's MatroskaExtractor - * writes, so the same downstream consumers work behind the FFmpeg demuxer: - * the bundled SsaParser/SubripParser/WebvttParser (via - * SubtitleTranscodingExtractorOutput) and AssTrackOutput's dialogue feed. - * - * MatroskaExtractor prefixes each raw payload with a synthetic header whose - * *second* timecode carries the sample duration (BlockDuration), while the - * first stays zero; parsers reconstruct absolute times as sampleTimeUs + - * parsedStart. FFmpeg hands us the same raw payloads (an ASS packet is the - * MKV block: "ReadOrder,Layer,Style,...,Text"; SubRip/WebVTT packets are the - * bare cue text), so only the prefix needs synthesizing. - */ -internal object FfmpegSubtitleSamples { - - val SSA_DIALOGUE_FORMAT: ByteArray = - "Format: Start, End, ReadOrder, Layer, Style, Name, MarginL, MarginR, MarginV, Effect, Text" - .toByteArray(Charsets.UTF_8) - - /** `Dialogue: 0:00:00:00,H:MM:SS:CC,` — centiseconds, ':' separator (media3's SSA shape). */ - fun ssaPrefix(durationUs: Long): ByteArray = "Dialogue: 0:00:00:00,${timecode(durationUs, "%01d:%02d:%02d:%02d", 10_000)}," - .toByteArray(Charsets.UTF_8) - - /** `1\n00:00:00,000 --> HH:MM:SS,mmm\n` — a complete SRT cue header. */ - fun subripPrefix(durationUs: Long): ByteArray = "1\n00:00:00,000 --> ${timecode(durationUs, "%02d:%02d:%02d,%03d", 1_000)}\n" - .toByteArray(Charsets.UTF_8) - - /** `WEBVTT\n\n00:00:00.000 --> HH:MM:SS.mmm\n` — a one-cue WebVTT document header. */ - fun vttPrefix(durationUs: Long): ByteArray = "WEBVTT\n\n00:00:00.000 --> ${timecode(durationUs, "%02d:%02d:%02d.%03d", 1_000)}\n" - .toByteArray(Charsets.UTF_8) - - private fun timecode(timeUs: Long, format: String, lastValueDivisor: Long): String { - val hours = timeUs / 3_600_000_000L - var remainder = timeUs - hours * 3_600_000_000L - val minutes = remainder / 60_000_000L - remainder -= minutes * 60_000_000L - val seconds = remainder / 1_000_000L - remainder -= seconds * 1_000_000L - val lastValue = remainder / lastValueDivisor - return String.format(Locale.US, format, hours, minutes, seconds, lastValue) - } -} diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/MatroskaLatmSupport.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/MatroskaLatmSupport.kt new file mode 100644 index 000000000..01976aada --- /dev/null +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/MatroskaLatmSupport.kt @@ -0,0 +1,65 @@ +package com.edde746.plezy.exoplayer + +import androidx.media3.extractor.ExtractorOutput +import androidx.media3.extractor.SeekMap +import androidx.media3.extractor.TrackOutput +import androidx.media3.extractor.mkv.MatroskaExtractor + +/** MKV CodecID for Microsoft ACM compatibility mode. */ +private const val CODEC_ID_ACM = "A_MS/ACM" + +/** + * MatroskaExtractor.init is final and extractorOutput private; subclasses swap + * in wrapping outputs via reflection once the Segment element starts (shared + * with ZlibMatroskaExtractor). + */ +internal val matroskaExtractorOutputField by lazy { + MatroskaExtractor::class.java.getDeclaredField("extractorOutput").apply { + isAccessible = true + } +} + +/** WAVEFORMATEX format tag for LOAS/LATM-wrapped AAC (WAVE_FORMAT_MPEG_LOAS). */ +private const val WAVE_FORMAT_MPEG_LOAS = 0x1602 + +/** + * Returns whether a track is LOAS/LATM AAC muxed as A_MS/ACM — ffmpeg's + * fallback mapping for aac_latm, which Matroska has no native codec ID for. + * The WAVEFORMATEX wFormatTag is the first 2 bytes (LE) of CodecPrivate. + */ +fun isLoasAcmTrack(codecId: String?, codecPrivate: ByteArray?): Boolean = codecId == CODEC_ID_ACM && + codecPrivate != null && + codecPrivate.size >= 2 && + ((codecPrivate[0].toInt() and 0xFF) or ((codecPrivate[1].toInt() and 0xFF) shl 8)) == WAVE_FORMAT_MPEG_LOAS + +/** + * ExtractorOutput wrapper that wraps marked tracks with [LatmTrackOutput]. + * Call [markNextTrackLatm] before the parent extractor creates the track + * (i.e. before super.endMasterElement(ID_TRACK_ENTRY)). + */ +class LatmExtractorOutputWrapper( + private val delegate: ExtractorOutput +) : ExtractorOutput { + + private var nextTrackIsLatm = false + private val latmOutputs = mutableListOf() + + fun markNextTrackLatm() { + nextTrackIsLatm = true + } + + /** Resets LATM parser state after an extractor seek. */ + fun resetTracks() { + latmOutputs.forEach { it.reset() } + } + + override fun track(id: Int, type: Int): TrackOutput { + val original = delegate.track(id, type) + if (!nextTrackIsLatm) return original + nextTrackIsLatm = false + return LatmTrackOutput(original, id).also { latmOutputs.add(it) } + } + + override fun endTracks() = delegate.endTracks() + override fun seekMap(seekMap: SeekMap) = delegate.seekMap(seekMap) +} diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/OutputWrappingExtractor.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/OutputWrappingExtractor.kt deleted file mode 100644 index 4e05b95c3..000000000 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/OutputWrappingExtractor.kt +++ /dev/null @@ -1,31 +0,0 @@ -package com.edde746.plezy.exoplayer - -import androidx.media3.common.util.UnstableApi -import androidx.media3.extractor.Extractor -import androidx.media3.extractor.ExtractorInput -import androidx.media3.extractor.ExtractorOutput -import androidx.media3.extractor.PositionHolder - -/** - * Delegates everything to [delegate] but hands it a wrapped [ExtractorOutput]. - * Used to compose the text pipeline (libass dialogue feed + subtitle cue - * transcoding) around media3's raw-subtitle MatroskaExtractor without - * subclassing or reflection. - */ -@UnstableApi -internal class OutputWrappingExtractor( - private val delegate: Extractor, - private val wrap: (ExtractorOutput) -> ExtractorOutput -) : Extractor { - override fun sniff(input: ExtractorInput): Boolean = delegate.sniff(input) - - override fun init(output: ExtractorOutput) = delegate.init(wrap(output)) - - override fun read(input: ExtractorInput, seekPosition: PositionHolder): Int = delegate.read(input, seekPosition) - - override fun seek(position: Long, timeUs: Long) = delegate.seek(position, timeUs) - - override fun release() = delegate.release() - - override fun getUnderlyingImplementation(): Extractor = delegate.underlyingImplementation -} diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/StreamBitrateMeter.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/StreamBitrateMeter.kt deleted file mode 100644 index b296e4f03..000000000 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/StreamBitrateMeter.kt +++ /dev/null @@ -1,65 +0,0 @@ -package com.edde746.plezy.exoplayer - -/** - * Rolling average bitrate of one demuxed stream, measured from packet sizes - * over their presentation-time span — the same source of truth mpv uses for - * its `audio-bitrate` property. It exists because containers frequently - * declare no per-track bitrate (Matroska without BPS statistics tags), which - * left the performance overlay with nothing to show (#2063). - * - * Fed from the loader thread ([onPacket]) and read from the stats path - * ([bitrateBps]); both are synchronized. The measurement follows the demux - * position, which runs ahead of the playhead by the buffered duration — - * acceptable for a diagnostics overlay. - */ -internal class StreamBitrateMeter(private val windowUs: Long = DEFAULT_WINDOW_US) { - - private val ptsUsQueue = ArrayDeque() - private val sizeQueue = ArrayDeque() - private var windowBytes = 0L - - @Synchronized - fun onPacket(ptsUs: Long, sizeBytes: Int) { - if (sizeBytes <= 0) return - // A backwards pts jump is a seek; packets on either side of it do not - // form a contiguous span, so the window restarts. - val last = ptsUsQueue.lastOrNull() - if (last != null && ptsUs < last) reset() - ptsUsQueue.addLast(ptsUs) - sizeQueue.addLast(sizeBytes) - windowBytes += sizeBytes - while (ptsUsQueue.size > 1 && ptsUsQueue.last() - ptsUsQueue.first() > windowUs) { - ptsUsQueue.removeFirst() - windowBytes -= sizeQueue.removeFirst() - } - } - - /** - * Average bitrate in bits per second over the current window, or null until - * enough packets span a measurable interval. - */ - @Synchronized - fun bitrateBps(): Int? { - if (ptsUsQueue.size < MIN_PACKETS) return null - val spanUs = ptsUsQueue.last() - ptsUsQueue.first() - if (spanUs <= 0) return null - // The pts span covers the durations of every packet except the last one, - // whose bytes are therefore excluded — a constant-rate stream measures - // exactly its rate. - val bytes = windowBytes - sizeQueue.last() - if (bytes <= 0) return null - return (bytes * 8_000_000L / spanUs).toInt() - } - - @Synchronized - fun reset() { - ptsUsQueue.clear() - sizeQueue.clear() - windowBytes = 0L - } - - companion object { - private const val DEFAULT_WINDOW_US = 10_000_000L - private const val MIN_PACKETS = 16 - } -} diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ZlibInflatingTrackOutput.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ZlibInflatingTrackOutput.kt new file mode 100644 index 000000000..156a28c78 --- /dev/null +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ZlibInflatingTrackOutput.kt @@ -0,0 +1,91 @@ +package com.edde746.plezy.exoplayer + +import androidx.media3.common.ParserException +import androidx.media3.extractor.TrackOutput +import java.util.zip.DataFormatException +import java.util.zip.Inflater + +/** + * TrackOutput wrapper that inflates zlib-compressed sample data (MKV ContentCompAlgo 0). + * Each MKV block is independently zlib-compressed; this wrapper decompresses per-sample + * between sampleData() and sampleMetadata() calls. + * + * All buffers are reused across samples to minimize GC pressure on the hot path. + */ +class ZlibInflatingTrackOutput( + delegate: TrackOutput +) : BufferedTransformingTrackOutput( + delegate, + INITIAL_BUFFER_SIZE, + INFLATE_CHUNK, + MAX_COMPRESSED_SAMPLE_SIZE +) { + + companion object { + private const val INITIAL_BUFFER_SIZE = 256 * 1024 + private const val INFLATE_CHUNK = 64 * 1024 + private const val MAX_COMPRESSED_SAMPLE_SIZE = 16 * 1024 * 1024 + private const val MAX_INFLATED_SAMPLE_SIZE = 16 * 1024 * 1024 + private const val MAX_COMPRESSION_RATIO = 1024L + private const val MIN_RATIO_ALLOWANCE = 1024L * 1024 + } + + var active = false + + private val inflater = Inflater() + private var inflateBuf = ByteArray(INITIAL_BUFFER_SIZE) + private val overflowProbe = ByteArray(1) + + override val transformEnabled: Boolean + get() = active + + override val transformedBuffer: ByteArray + get() = inflateBuf + + override fun transformSample(inputLength: Int, flags: Int): Int { + inflater.reset() + inflater.setInput(inputBuffer, 0, inputLength) + var written = 0 + val ratioBound = maxOf(MIN_RATIO_ALLOWANCE, inputLength.toLong() * MAX_COMPRESSION_RATIO) + + try { + while (true) { + if (written == inflateBuf.size) { + if (inflateBuf.size < MAX_INFLATED_SAMPLE_SIZE) { + val nextSize = minOf(MAX_INFLATED_SAMPLE_SIZE, inflateBuf.size * 2) + inflateBuf = inflateBuf.copyOf(nextSize) + } else { + val overflow = inflater.inflate(overflowProbe, 0, 1) + if (overflow > 0) { + throw malformed("Inflated sample exceeds the maximum size") + } + if (inflater.finished()) return written + throw stalledInflate() + } + } + + val count = inflater.inflate(inflateBuf, written, inflateBuf.size - written) + written += count + if (written.toLong() > ratioBound) { + throw malformed("Inflated sample exceeds the maximum compression ratio") + } + if (inflater.finished()) return written + if (count == 0) throw stalledInflate() + } + } catch (_: DataFormatException) { + // Preserve playback for corrupt subtitle blocks; the raw sample remains bounded + // by MAX_COMPRESSED_SAMPLE_SIZE and matches the pre-hardening fallback. + if (inflateBuf.size < inputLength) inflateBuf = inflateBuf.copyOf(inputLength) + inputBuffer.copyInto(inflateBuf, endIndex = inputLength) + return inputLength + } + } + + private fun stalledInflate(): ParserException = when { + inflater.needsDictionary() -> malformed("Zlib-compressed sample requires a dictionary") + inflater.needsInput() -> malformed("Truncated zlib-compressed sample") + else -> malformed("Zlib decompressor made no progress") + } + + private fun malformed(message: String, cause: Throwable? = null): ParserException = ParserException.createForMalformedContainer(message, cause) +} diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ZlibMatroskaExtractor.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ZlibMatroskaExtractor.kt new file mode 100644 index 000000000..4d7139fc8 --- /dev/null +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ZlibMatroskaExtractor.kt @@ -0,0 +1,315 @@ +package com.edde746.plezy.exoplayer + +import android.util.Log +import androidx.media3.common.C +import androidx.media3.common.DataReader +import androidx.media3.extractor.DefaultExtractorInput +import androidx.media3.extractor.ExtractorInput +import androidx.media3.extractor.ExtractorOutput +import androidx.media3.extractor.SeekMap +import androidx.media3.extractor.TrackOutput +import androidx.media3.extractor.text.SubtitleParser +import com.edde746.plezy.libass.media.AssHandler +import com.edde746.plezy.libass.media.extractor.AssMatroskaExtractor +import java.io.EOFException +import java.util.zip.DataFormatException +import java.util.zip.Inflater + +/** + * Extends AssMatroskaExtractor to add support for MKV quirks media3 rejects: + * + * ContentCompAlgo 0 (zlib) — media3 only supports ContentCompAlgo 3 (header + * stripping). This subclass intercepts the compression algorithm during track + * header parsing: + * - Tells the parent it's header stripping (algo 3) to avoid the ParserException + * - Skips ContentCompSettings for zlib tracks (not applicable) + * - For text subtitle tracks, inflates the block payload *before* the parent + * parses it (see below). For every other zlib track, wraps TrackOutputs with + * ZlibInflatingTrackOutput to decompress per-sample data. + * + * Text subtitle tracks (SRT/ASS/SSA/VTT) cannot be inflated at the TrackOutput + * level: MatroskaExtractor rewrites their samples in-place before any TrackOutput + * runs — it prepends a plaintext timecode prefix ("Dialogue: 0:00:00:00,…,") to + * the still-compressed payload and truncates the sample at the first NUL byte, + * which deflate streams routinely contain (#2023). AssTrackOutput additionally + * feeds that same internal buffer straight to libass. So for zlib text tracks the + * frame payload is inflated at the block level, before the parent's sample + * assembly, and the TrackOutput wrapper is left inactive. + * + * LOAS/LATM AAC as A_MS/ACM — media3 sets audio/x-unknown for non-PCM ACM + * tracks (silent playback). Detected tracks are wrapped with LatmTrackOutput, + * which unwraps LOAS frames to raw AAC for direct-playing Matroska files. + */ +class ZlibMatroskaExtractor( + subtitleParserFactory: SubtitleParser.Factory, + assHandler: AssHandler +) : AssMatroskaExtractor(subtitleParserFactory, assHandler) { + + companion object { + private const val TAG = "ZlibMkvExtractor" + + // Matroska EBML element IDs + private const val ID_SEGMENT = 0x18538067 + private const val ID_TRACK_ENTRY = 0xAE + private const val ID_CONTENT_COMPRESSION_ALGORITHM = 0x4254 + private const val ID_CONTENT_COMPRESSION_SETTINGS = 0x4255 + private const val ID_CONTENT_COMPRESSION = 0x5034 + private const val ID_SIMPLE_BLOCK = 0xA3 + private const val ID_BLOCK = 0xA1 + + /** + * Codec IDs whose samples MatroskaExtractor rewrites in-place (timecode prefix + * plus NUL truncation) before any TrackOutput runs — the authority is the + * prefix list in MatroskaExtractor.writeSampleData. + */ + private val TEXT_SUBTITLE_CODEC_IDS = setOf("S_TEXT/UTF8", "S_TEXT/ASS", "S_TEXT/SSA", "S_TEXT/WEBVTT") + + /** Subtitle blocks are tiny; anything above this passes through untouched. */ + private const val MAX_TEXT_BLOCK_BYTES = 16 * 1024 * 1024 + } + + private var zlibOutput: ZlibExtractorOutputWrapper? = null + private var latmOutput: LatmExtractorOutputWrapper? = null + private var currentTrackUsesZlib = false + + /** Track numbers whose blocks are inflated before the parent parses them. */ + private val zlibTextTrackNumbers = mutableSetOf() + private val blockInflater = Inflater() + private var blockBuf = ByteArray(0) + private val peekBuf = ByteArray(8) + + override fun startMasterElement(id: Int, contentPosition: Long, contentSize: Long) { + super.startMasterElement(id, contentPosition, contentSize) + + // ContentCompAlgo DEFAULTS to 0 (zlib), so mkvmerge omits the element for + // zlib tracks entirely — the mere presence of ContentCompression means zlib + // until an explicit ContentCompAlgo says otherwise. media3 ignores an empty + // ContentCompression and would silently emit compressed samples. + if (id == ID_CONTENT_COMPRESSION) { + currentTrackUsesZlib = true + Log.i(TAG, "Track has ContentCompression, assuming ContentCompAlgo 0 (zlib) until told otherwise") + } + + // After super installs AssSubtitleExtractorOutput, wrap it with our zlib + + // LATM layers (zlib outermost so inflation runs before LATM parsing). + if (id == ID_SEGMENT && zlibOutput == null) { + val currentOutput = matroskaExtractorOutputField.get(this) as ExtractorOutput + val latmWrapper = LatmExtractorOutputWrapper(currentOutput) + latmOutput = latmWrapper + val wrapper = ZlibExtractorOutputWrapper(latmWrapper) + zlibOutput = wrapper + matroskaExtractorOutputField.set(this, wrapper) + Log.d(TAG, "Installed zlib+LATM ExtractorOutput wrapper") + } + } + + override fun integerElement(id: Int, value: Long) { + if (id == ID_CONTENT_COMPRESSION_ALGORITHM) { + if (value == 0L) { + currentTrackUsesZlib = true + Log.i(TAG, "Track uses explicit ContentCompAlgo 0 (zlib), will inflate samples") + // Tell parent it's header stripping (algo 3) to avoid ParserException + super.integerElement(id, 3) + return + } + // Explicit non-zlib algorithm: header stripping (3) is handled by the + // parent; anything else makes the parent throw, matching stock behavior. + currentTrackUsesZlib = false + } + super.integerElement(id, value) + } + + override fun binaryElement(id: Int, contentSize: Int, input: ExtractorInput) { + if (id == ID_CONTENT_COMPRESSION_SETTINGS && currentTrackUsesZlib) { + // Skip ContentCompSettings for zlib tracks — parent would store these as + // sampleStrippedBytes and prepend them to every sample, corrupting output. + input.skipFully(contentSize) + return + } + if ((id == ID_SIMPLE_BLOCK || id == ID_BLOCK) && + zlibTextTrackNumbers.isNotEmpty() && + contentSize in MIN_BLOCK_BYTES..MAX_TEXT_BLOCK_BYTES && + peekBlockTrackNumber(input) in zlibTextTrackNumbers + ) { + inflateTextBlock(id, contentSize, input) + return + } + super.binaryElement(id, contentSize, input) + } + + override fun endMasterElement(id: Int) { + var zlibTextTrackNumber: Int? = null + if (id == ID_TRACK_ENTRY) { + // Must inspect before super — the track output is created inside super's + // endMasterElement, and the current track is cleared afterwards. + val track = getCurrentTrack(id) + if (isLoasAcmTrack(track.codecId, track.codecPrivate)) { + Log.i(TAG, "Track ${track.number} is LOAS/LATM AAC, unwrapping to raw AAC") + latmOutput?.markNextTrackLatm() + } + if (currentTrackUsesZlib && track.codecId in TEXT_SUBTITLE_CODEC_IDS) { + zlibTextTrackNumber = track.number + } + } + + val wasZlib = currentTrackUsesZlib + super.endMasterElement(id) + + if (id == ID_TRACK_ENTRY && wasZlib) { + currentTrackUsesZlib = false + if (zlibTextTrackNumber != null) { + // Text subtitle samples are rewritten inside the parent before any + // TrackOutput runs, so the TrackOutput wrapper stays inactive and the + // block payload is inflated in binaryElement instead. + zlibTextTrackNumbers.add(zlibTextTrackNumber) + Log.i(TAG, "Track $zlibTextTrackNumber is a zlib text subtitle track, inflating at block level") + } else { + zlibOutput?.activateLast() + Log.i(TAG, "Activated zlib inflation for track") + } + } + } + + override fun seek(position: Long, timeUs: Long) { + latmOutput?.resetTracks() + zlibOutput?.resetTracks() + super.seek(position, timeUs) + } + + /** + * Peeks the EBML varint at the block start — the block's track number — without + * consuming input. Returns null for malformed varints or truncated input; the + * parent then produces the canonical failure for the untouched stream. + */ + private fun peekBlockTrackNumber(input: ExtractorInput): Int? { + try { + input.peekFully(peekBuf, 0, 1) + val first = peekBuf[0].toInt() and 0xFF + if (first == 0) return null + val length = Integer.numberOfLeadingZeros(first) - 23 + var value = (first and (0xFF ushr length)).toLong() + if (length > 1) { + input.peekFully(peekBuf, 1, length - 1) + for (i in 1 until length) { + value = (value shl 8) or (peekBuf[i].toLong() and 0xFF) + } + } + return if (value <= Int.MAX_VALUE) value.toInt() else null + } catch (_: EOFException) { + return null + } finally { + input.resetPeekPosition() + } + } + + /** + * Buffers one text-subtitle block, inflates its frame payload, and hands the + * parent a block whose payload is plaintext. A block that cannot be rewritten + * (laced, corrupt, or over-bound) passes through byte-identical. + */ + private fun inflateTextBlock(id: Int, contentSize: Int, input: ExtractorInput) { + val basePosition = input.position + if (blockBuf.size < contentSize) blockBuf = ByteArray(maxOf(contentSize, blockBuf.size * 2)) + input.readFully(blockBuf, 0, contentSize) + val rewritten = rewriteZlibTextBlock(blockBuf, contentSize, blockInflater) + if (rewritten == null) { + Log.w(TAG, "Passing zlib text block through uninflated (laced, corrupt, or over-bound)") + super.binaryElement(id, contentSize, bufferedInput(blockBuf, contentSize, basePosition)) + } else { + super.binaryElement(id, rewritten.size, bufferedInput(rewritten, rewritten.size, basePosition)) + } + } + + private fun bufferedInput(data: ByteArray, limit: Int, position: Long): ExtractorInput = DefaultExtractorInput(ByteRangeDataReader(data, limit), position, position + limit) + + private class ByteRangeDataReader(private val data: ByteArray, private val limit: Int) : DataReader { + private var position = 0 + + override fun read(buffer: ByteArray, offset: Int, length: Int): Int { + if (position == limit) return C.RESULT_END_OF_INPUT + val count = minOf(length, limit - position) + System.arraycopy(data, position, buffer, offset, count) + position += count + return count + } + } + + /** + * ExtractorOutput wrapper that wraps all TrackOutputs with ZlibInflatingTrackOutput. + * Tracks are created inactive; activateLast() enables inflation for the most recently + * created track (called when we know a track uses zlib compression). + */ + private class ZlibExtractorOutputWrapper( + private val delegate: ExtractorOutput + ) : ExtractorOutput { + + private var lastCreatedWrapper: ZlibInflatingTrackOutput? = null + private val trackOutputs = mutableListOf() + + override fun track(id: Int, type: Int): TrackOutput { + val original = delegate.track(id, type) + return ZlibInflatingTrackOutput(original).also { + trackOutputs.add(it) + lastCreatedWrapper = it + } + } + + fun activateLast() { + lastCreatedWrapper?.active = true + } + + fun resetTracks() { + trackOutputs.forEach { it.resetBufferedData() } + } + + override fun endTracks() = delegate.endTracks() + override fun seekMap(seekMap: SeekMap) = delegate.seekMap(seekMap) + } +} + +/** Smallest well-formed unlaced block: 1-byte varint + 2-byte timecode + flags + 1 payload byte. */ +internal const val MIN_BLOCK_BYTES = 5 + +/** + * Rewrites one unlaced Matroska block whose frame payload is zlib-compressed: + * returns the unchanged block header followed by the inflated payload, or null + * when the block must pass through unchanged — lacing (never produced for text + * subtitle tracks), a truncated or corrupt deflate stream, or an inflated size + * beyond the bounds shared with [ZlibInflatingTrackOutput]'s hardening. + */ +internal fun rewriteZlibTextBlock(data: ByteArray, limit: Int, inflater: Inflater): ByteArray? { + if (limit < MIN_BLOCK_BYTES) return null + val first = data[0].toInt() and 0xFF + if (first == 0) return null // track-number varint longer than 8 bytes + val varintLength = Integer.numberOfLeadingZeros(first) - 23 + val headerLength = varintLength + 3 // varint + 2-byte timecode + flags + if (limit <= headerLength) return null + if (data[headerLength - 1].toInt() and 0x06 != 0) return null // laced + + val payloadLength = limit - headerLength + val ratioBound = maxOf(1024L * 1024, payloadLength.toLong() * 1024) + val maxInflatedBytes = 16 * 1024 * 1024 + inflater.reset() + inflater.setInput(data, headerLength, payloadLength) + var buf = ByteArray(maxOf(4096, payloadLength * 4)) + var written = 0 + try { + while (true) { + if (written == buf.size) { + if (buf.size >= maxInflatedBytes) return null + buf = buf.copyOf(minOf(maxInflatedBytes, buf.size * 2)) + } + val count = inflater.inflate(buf, written, buf.size - written) + written += count + if (written > ratioBound) return null + if (inflater.finished()) break + if (count == 0) return null // truncated stream or preset-dictionary request + } + } catch (_: DataFormatException) { + return null + } + return ByteArray(headerLength + written).also { + System.arraycopy(data, 0, it, 0, headerLength) + System.arraycopy(buf, 0, it, headerLength, written) + } +} diff --git a/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/DoviCodecsTest.kt b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/DoviCodecsTest.kt deleted file mode 100644 index 528cf0799..000000000 --- a/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/DoviCodecsTest.kt +++ /dev/null @@ -1,41 +0,0 @@ -package com.edde746.plezy.exoplayer - -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNull -import org.junit.Test - -class DoviCodecsTest { - @Test - fun hevcProfilesUseDvhePrefix() { - // Profile 8 level 6 is the stream from #2096: the record's dv_profile and - // dv_level bytes must surface verbatim, matching media3's - // DolbyVisionConfig output for the same file (dvhe.08.06). - assertEquals("dvhe.08.06", DoviCodecs.rfc6381(8, 6)) - assertEquals("dvhe.04.09", DoviCodecs.rfc6381(4, 9)) - assertEquals("dvhe.05.06", DoviCodecs.rfc6381(5, 6)) - // Profile 7 keys DoviConvertingTrackOutput's P7 conversion. - assertEquals("dvhe.07.06", DoviCodecs.rfc6381(7, 6)) - } - - @Test - fun avcAndAv1ProfilesUseTheirOwnPrefixes() { - assertEquals("dvav.09.05", DoviCodecs.rfc6381(9, 5)) - assertEquals("dav1.10.04", DoviCodecs.rfc6381(10, 4)) - } - - @Test - fun twoDigitLevelsAreNotZeroPadded() { - assertEquals("dvhe.08.10", DoviCodecs.rfc6381(8, 10)) - } - - @Test - fun unrecognizedProfilesReturnNullSoTheBaseCodecFormatSurvives() { - // Media3's DolbyVisionConfig table: anything it rejects must keep the - // track's plain HEVC/AVC format instead of publishing an unplayable - // video/dolby-vision variant. - assertNull(DoviCodecs.rfc6381(0, 6)) - assertNull(DoviCodecs.rfc6381(2, 6)) - assertNull(DoviCodecs.rfc6381(6, 6)) - assertNull(DoviCodecs.rfc6381(20, 6)) - } -} diff --git a/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/FfmpegAudioCsdTest.kt b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/FfmpegAudioCsdTest.kt deleted file mode 100644 index f2647d954..000000000 --- a/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/FfmpegAudioCsdTest.kt +++ /dev/null @@ -1,137 +0,0 @@ -package com.edde746.plezy.exoplayer - -import androidx.media3.common.MimeTypes -import androidx.media3.common.ParserException -import java.nio.ByteBuffer -import java.nio.ByteOrder -import org.junit.Assert.assertArrayEquals -import org.junit.Assert.assertEquals -import org.junit.Assert.assertSame -import org.junit.Assert.assertThrows -import org.junit.Test - -/** - * Spec for the per-codec initializationData shapes the ffmpeg demuxer must - * publish so MediaCodec csd-0/1/2 and FfmpegAudioDecoder's extradata - * reconstruction both see what media3's own extractors emit. Regression - * coverage for #2088: Opus with a bare OpusHead csd made Android's decoders - * eat the first two real packets as configuration and discard all output. - */ -class FfmpegAudioCsdTest { - - private fun opusHead(preSkip: Int): ByteArray { - val header = ByteArray(19) - "OpusHead".toByteArray(Charsets.US_ASCII).copyInto(header) - header[8] = 1 // version - header[9] = 2 // channel count - header[10] = (preSkip and 0xFF).toByte() - header[11] = (preSkip ushr 8).toByte() - return header - } - - private fun nativeOrderLong(bytes: ByteArray): Long = ByteBuffer.wrap(bytes).order(ByteOrder.nativeOrder()).long - - @Test - fun opusBuildsThreeCsdBuffers() { - val header = opusHead(preSkip = 312) - val csd = FfmpegAudioCsd.initializationData(MimeTypes.AUDIO_OPUS, header) - assertEquals(3, csd.size) - assertArrayEquals(header, csd[0]) - // Pre-skip converted to nanoseconds at the Opus 48 kHz clock. - assertEquals(312L * 1_000_000_000L / 48_000L, nativeOrderLong(csd[1])) - // media3's default seek pre-roll: 3840 samples = 80 ms. - assertEquals(80_000_000L, nativeOrderLong(csd[2])) - } - - @Test - fun opusRejectsTruncatedHeader() { - assertThrows(ParserException::class.java) { - FfmpegAudioCsd.initializationData(MimeTypes.AUDIO_OPUS, ByteArray(11)) - } - } - - private fun xiphLace(length: Int): ByteArray { - val bytes = ArrayList() - var remaining = length - while (remaining >= 0xFF) { - bytes.add(0xFF.toByte()) - remaining -= 0xFF - } - bytes.add(remaining.toByte()) - return bytes.toByteArray() - } - - private fun vorbisHeader(type: Int, payloadLength: Int): ByteArray = byteArrayOf(type.toByte()) + "vorbis".toByteArray(Charsets.US_ASCII) + ByteArray(payloadLength) - - @Test - fun vorbisSplitsLacedHeadersIntoIdentificationAndSetup() { - val id = vorbisHeader(0x01, payloadLength = 23) // 30 bytes, the real identification header size - val comment = vorbisHeader(0x03, payloadLength = 300) // > 255 exercises the 0xFF lacing - val setup = vorbisHeader(0x05, payloadLength = 40) - val extradata = byteArrayOf(0x02) + xiphLace(id.size) + xiphLace(comment.size) + id + comment + setup - - val csd = FfmpegAudioCsd.initializationData(MimeTypes.AUDIO_VORBIS, extradata) - - assertEquals(2, csd.size) - assertArrayEquals(id, csd[0]) - assertArrayEquals(setup, csd[1]) - } - - @Test - fun vorbisRejectsNonLacedExtradata() { - assertThrows(ParserException::class.java) { - FfmpegAudioCsd.initializationData(MimeTypes.AUDIO_VORBIS, byteArrayOf(0x01, 0x02, 0x03)) - } - } - - @Test - fun vorbisRejectsTruncatedExtradata() { - val id = vorbisHeader(0x01, payloadLength = 23) - val truncated = byteArrayOf(0x02) + xiphLace(id.size) + xiphLace(64) + id // headers end early - assertThrows(ParserException::class.java) { - FfmpegAudioCsd.initializationData(MimeTypes.AUDIO_VORBIS, truncated) - } - } - - @Test - fun flacWrapsBareStreamInfoInMarkedStream() { - val streamInfo = ByteArray(34) { it.toByte() } - val csd = FfmpegAudioCsd.initializationData(MimeTypes.AUDIO_FLAC, streamInfo) - assertEquals(1, csd.size) - assertArrayEquals( - "fLaC".toByteArray(Charsets.US_ASCII) + byteArrayOf(0x80.toByte(), 0x00, 0x00, 34) + streamInfo, - csd[0] - ) - } - - @Test - fun flacPrependsMarkerToBlockHeadedStreamInfo() { - val block = byteArrayOf(0x00, 0x00, 0x00, 34) + ByteArray(34) { it.toByte() } - val csd = FfmpegAudioCsd.initializationData(MimeTypes.AUDIO_FLAC, block) - assertEquals(1, csd.size) - assertArrayEquals("fLaC".toByteArray(Charsets.US_ASCII) + block, csd[0]) - } - - @Test - fun flacKeepsMarkedStreamVerbatim() { - val marked = "fLaC".toByteArray(Charsets.US_ASCII) + byteArrayOf(0x80.toByte(), 0x00, 0x00, 34) + ByteArray(34) - val csd = FfmpegAudioCsd.initializationData(MimeTypes.AUDIO_FLAC, marked) - assertEquals(1, csd.size) - assertSame(marked, csd[0]) - } - - @Test - fun flacRejectsUnrecognizedExtradata() { - assertThrows(ParserException::class.java) { - FfmpegAudioCsd.initializationData(MimeTypes.AUDIO_FLAC, ByteArray(10)) - } - } - - @Test - fun otherCodecsPassExtradataThroughAsSingleEntry() { - val asc = byteArrayOf(0x12, 0x10) // AAC-LC 44.1 kHz stereo AudioSpecificConfig - val csd = FfmpegAudioCsd.initializationData(MimeTypes.AUDIO_AAC, asc) - assertEquals(1, csd.size) - assertSame(asc, csd[0]) - } -} diff --git a/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/FfmpegDemuxerPolicyTest.kt b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/FfmpegDemuxerPolicyTest.kt deleted file mode 100644 index fccd981d3..000000000 --- a/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/FfmpegDemuxerPolicyTest.kt +++ /dev/null @@ -1,29 +0,0 @@ -package com.edde746.plezy.exoplayer - -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test - -class FfmpegDemuxerPolicyTest { - @Test - fun wireValuesRoundTrip() { - assertEquals(FfmpegDemuxerPolicy.Preference.FFMPEG, FfmpegDemuxerPolicy.fromWire("ffmpeg")) - assertEquals(FfmpegDemuxerPolicy.Preference.MEDIA3_ONLY, FfmpegDemuxerPolicy.fromWire("media3")) - } - - @Test - fun unknownWireValuesResolveToFfmpeg() { - assertEquals(FfmpegDemuxerPolicy.Preference.FFMPEG, FfmpegDemuxerPolicy.fromWire(null)) - assertEquals(FfmpegDemuxerPolicy.Preference.FFMPEG, FfmpegDemuxerPolicy.fromWire("")) - assertEquals(FfmpegDemuxerPolicy.Preference.FFMPEG, FfmpegDemuxerPolicy.fromWire("bogus")) - // Retired wire value from the container-list era maps to the default. - assertEquals(FfmpegDemuxerPolicy.Preference.FFMPEG, FfmpegDemuxerPolicy.fromWire("auto")) - } - - @Test - fun ffmpegDemuxesByDefaultAndMedia3OnlyDisables() { - assertTrue(FfmpegDemuxerPolicy.enabled(FfmpegDemuxerPolicy.Preference.FFMPEG)) - assertFalse(FfmpegDemuxerPolicy.enabled(FfmpegDemuxerPolicy.Preference.MEDIA3_ONLY)) - } -} diff --git a/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/FfmpegRandomAccessSourceTest.kt b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/FfmpegRandomAccessSourceTest.kt deleted file mode 100644 index 95aeee143..000000000 --- a/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/FfmpegRandomAccessSourceTest.kt +++ /dev/null @@ -1,245 +0,0 @@ -package com.edde746.plezy.exoplayer - -import android.net.Uri -import androidx.media3.common.C -import androidx.media3.datasource.DataSource -import androidx.media3.datasource.DataSpec -import androidx.media3.datasource.TransferListener -import java.io.IOException -import org.junit.Assert.assertArrayEquals -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNull -import org.junit.Assert.assertSame -import org.junit.Assert.assertThrows -import org.junit.Assert.assertTrue -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner - -/** - * The demuxer's correctness now rests on this source answering any absolute - * position, so these pin the contract libavformat depends on: a read lands on - * the requested byte, a backward jump reopens rather than returning the wrong - * bytes, and sequential reads do not reopen (which would turn steady-state - * playback into one request per 64 KB). - */ -@RunWith(RobolectricTestRunner::class) -class FfmpegRandomAccessSourceTest { - - private val content = ByteArray(4096) { (it % 251).toByte() } - private val uri: Uri = Uri.parse("https://example.test/movie.mkv") - - private class FakeDataSource(private val content: ByteArray) : DataSource { - var openedAt: Long = -1 - var closed = false - var failNextRead = false - private var position = 0 - - override fun open(dataSpec: DataSpec): Long { - openedAt = dataSpec.position - position = dataSpec.position.toInt() - return (content.size - position).toLong() - } - - override fun read(buffer: ByteArray, offset: Int, length: Int): Int { - if (failNextRead) { - failNextRead = false - throw IOException("injected read failure") - } - if (position >= content.size) return C.RESULT_END_OF_INPUT - val count = minOf(length, content.size - position) - content.copyInto(buffer, offset, position, position + count) - position += count - return count - } - - override fun addTransferListener(transferListener: TransferListener) = Unit - - override fun getUri(): Uri? = null - - override fun getResponseHeaders(): Map> = emptyMap() - - override fun close() { - closed = true - } - } - - private inner class FakeFactory : DataSource.Factory { - val created = mutableListOf() - - override fun createDataSource(): DataSource = FakeDataSource(content).also { created.add(it) } - } - - private fun sourceFor(factory: DataSource.Factory, spec: DataSpec? = DataSpec(uri)) = FfmpegRandomAccessSource(factory) { spec } - - @Test - fun readsLandOnTheRequestedAbsolutePosition() { - val factory = FakeFactory() - val source = sourceFor(factory) - val buffer = ByteArray(16) - - assertEquals(16, source.readAt(1000, buffer, 16)) - - assertArrayEquals(content.copyOfRange(1000, 1016), buffer) - assertEquals(1000L, factory.created.single().openedAt) - } - - @Test - fun sequentialReadsReuseOneHandle() { - val factory = FakeFactory() - val source = sourceFor(factory) - val buffer = ByteArray(64) - - source.readAt(0, buffer, 64) - source.readAt(64, buffer, 64) - source.readAt(128, buffer, 64) - - assertEquals(1, source.openCount) - assertArrayEquals(content.copyOfRange(128, 192), buffer) - } - - @Test - fun backwardJumpReopensAndReturnsTheRequestedBytes() { - val factory = FakeFactory() - val source = sourceFor(factory) - val buffer = ByteArray(32) - - source.readAt(2048, buffer, 32) - // The jump libavformat makes for an index at the end of the file, then back. - assertEquals(32, source.readAt(16, buffer, 32)) - - assertArrayEquals(content.copyOfRange(16, 48), buffer) - assertEquals(2, source.openCount) - assertTrue("abandoned handle must be closed", factory.created.first().closed) - } - - @Test - fun failedReadDropsTheHandleSoTheRetryReopens() { - val factory = FakeFactory() - val source = sourceFor(factory) - val buffer = ByteArray(32) - - source.readAt(0, buffer, 32) - factory.created.single().failNextRead = true - assertThrows(IOException::class.java) { source.readAt(32, buffer, 32) } - assertTrue("failed handle must be closed", factory.created.single().closed) - - // media3's load-error policy retries the read with the source reopened; a - // dead handle whose position happens to match must not serve it (#2113). - assertEquals(32, source.readAt(32, buffer, 32)) - assertArrayEquals(content.copyOfRange(32, 64), buffer) - assertEquals(2, source.openCount) - } - - @Test - fun endOfInputReportsZeroWithoutAdvancing() { - val factory = FakeFactory() - val source = sourceFor(factory) - val buffer = ByteArray(8) - - assertEquals(0, source.readAt(content.size.toLong(), buffer, 8)) - } - - @Test - fun lengthResolvesFromAnOpenAndStaysKnown() { - val factory = FakeFactory() - val source = sourceFor(factory) - - assertEquals(C.LENGTH_UNSET.toLong(), source.length()) - source.readAt(512, ByteArray(8), 8) - - assertEquals(content.size.toLong(), source.length()) - } - - @Test - fun clonedSpecKeepsCacheKeyAndFlagsButTakesOurPosition() { - val recorded = DataSpec.Builder() - .setUri(uri) - .setPosition(9999) - .setLength(1234) - .setKey("download-cache-key") - .setFlags(DataSpec.FLAG_ALLOW_CACHE_FRAGMENTATION) - .setHttpRequestHeaders(mapOf("X-Test" to "1")) - .build() - var openedSpec: DataSpec? = null - val factory = DataSource.Factory { - object : DataSource { - override fun open(dataSpec: DataSpec): Long { - openedSpec = dataSpec - return C.LENGTH_UNSET.toLong() - } - - override fun read(buffer: ByteArray, offset: Int, length: Int): Int = C.RESULT_END_OF_INPUT - - override fun addTransferListener(transferListener: TransferListener) = Unit - - override fun getUri(): Uri? = null - - override fun getResponseHeaders(): Map> = emptyMap() - - override fun close() = Unit - } - } - - sourceFor(factory, recorded).readAt(4242, ByteArray(4), 4) - - val spec = requireNotNull(openedSpec) - assertEquals(4242L, spec.position) - assertEquals(C.LENGTH_UNSET.toLong(), spec.length) - assertEquals("download-cache-key", spec.key) - assertEquals(DataSpec.FLAG_ALLOW_CACHE_FRAGMENTATION, spec.flags) - assertEquals("1", spec.httpRequestHeaders["X-Test"]) - assertEquals(uri, spec.uri) - } - - @Test - fun readWithoutARecordedSpecFails() { - val source = sourceFor(FakeFactory(), spec = null) - - assertThrows(IOException::class.java) { source.readAt(0, ByteArray(4), 4) } - } - - @Test - fun closeReleasesTheHandle() { - val factory = FakeFactory() - val source = sourceFor(factory) - source.readAt(0, ByteArray(4), 4) - - assertTrue(source.isOpen) - source.close() - - assertFalse(source.isOpen) - assertTrue(factory.created.single().closed) - } - - @Test - fun recordingFactoryReportsTheSpecMedia3OpenedPerUri() { - val delegate = FakeFactory() - val recorder = RecordingDataSourceFactory(delegate) - val other = Uri.parse("https://example.test/next-episode.mkv") - - assertNull(recorder.specFor(uri)) - recorder.createDataSource().open(DataSpec.Builder().setUri(uri).setPosition(64).setKey("a").build()) - recorder.createDataSource().open(DataSpec.Builder().setUri(other).setPosition(128).build()) - - // Preloading the next item must not redirect the current demuxer's reads. - assertEquals(64L, recorder.specFor(uri)?.position) - assertEquals("a", recorder.specFor(uri)?.key) - assertEquals(128L, recorder.specFor(other)?.position) - } - - @Test - fun recordingFactoryDelegatesReads() { - val delegate = FakeFactory() - val recorder = RecordingDataSourceFactory(delegate) - val buffer = ByteArray(16) - - val dataSource = recorder.createDataSource() - dataSource.open(DataSpec.Builder().setUri(uri).setPosition(32).build()) - assertEquals(16, dataSource.read(buffer, 0, 16)) - - assertArrayEquals(content.copyOfRange(32, 48), buffer) - assertSame(delegate.created.single(), delegate.created.first()) - } -} diff --git a/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/FfmpegSubtitleSamplesTest.kt b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/FfmpegSubtitleSamplesTest.kt deleted file mode 100644 index 8ac723ee6..000000000 --- a/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/FfmpegSubtitleSamplesTest.kt +++ /dev/null @@ -1,53 +0,0 @@ -package com.edde746.plezy.exoplayer - -import org.junit.Assert.assertEquals -import org.junit.Test - -/** - * Byte-exact spec for the media3-shaped subtitle sample prefixes. The shapes - * mirror media3 1.11.0 MatroskaExtractor's SSA_PREFIX/SUBRIP_PREFIX/VTT_PREFIX - * with the duration patched into the second timecode; SsaParser, - * SubripParser, WebvttParser, and AssTrackOutput all parse these exact bytes. - */ -class FfmpegSubtitleSamplesTest { - - private fun string(bytes: ByteArray): String = bytes.toString(Charsets.UTF_8) - - @Test - fun ssaPrefixMatchesMatroskaExtractorShape() { - // 1h 2m 3s 450ms -> centiseconds, ':' separator, single-digit hour. - assertEquals( - "Dialogue: 0:00:00:00,1:02:03:45,", - string(FfmpegSubtitleSamples.ssaPrefix(3_723_450_000L)) - ) - assertEquals("Dialogue: 0:00:00:00,0:00:05:00,", string(FfmpegSubtitleSamples.ssaPrefix(5_000_000L))) - // Sub-centisecond remainders truncate, matching integer division. - assertEquals("Dialogue: 0:00:00:00,0:00:00:99,", string(FfmpegSubtitleSamples.ssaPrefix(999_999L))) - } - - @Test - fun subripPrefixIsACompleteCueHeader() { - assertEquals( - "1\n00:00:00,000 --> 01:02:03,450\n", - string(FfmpegSubtitleSamples.subripPrefix(3_723_450_000L)) - ) - assertEquals("1\n00:00:00,000 --> 00:00:05,000\n", string(FfmpegSubtitleSamples.subripPrefix(5_000_000L))) - } - - @Test - fun vttPrefixIsAOneCueDocumentHeader() { - assertEquals( - "WEBVTT\n\n00:00:00.000 --> 01:02:03.450\n", - string(FfmpegSubtitleSamples.vttPrefix(3_723_450_000L)) - ) - assertEquals("WEBVTT\n\n00:00:00.000 --> 00:00:05.000\n", string(FfmpegSubtitleSamples.vttPrefix(5_000_000L))) - } - - @Test - fun ssaDialogueFormatMatchesMedia3Constant() { - assertEquals( - "Format: Start, End, ReadOrder, Layer, Style, Name, MarginL, MarginR, MarginV, Effect, Text", - string(FfmpegSubtitleSamples.SSA_DIALOGUE_FORMAT) - ) - } -} diff --git a/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/MatroskaLateTracksTest.kt b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/MatroskaLateTracksTest.kt new file mode 100644 index 000000000..b578a8a7d --- /dev/null +++ b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/MatroskaLateTracksTest.kt @@ -0,0 +1,154 @@ +package com.edde746.plezy.exoplayer + +import androidx.media3.common.C +import androidx.media3.common.DataReader +import androidx.media3.common.Format +import androidx.media3.common.util.ParsableByteArray +import androidx.media3.extractor.DefaultExtractorInput +import androidx.media3.extractor.Extractor +import androidx.media3.extractor.ExtractorInput +import androidx.media3.extractor.ExtractorOutput +import androidx.media3.extractor.PositionHolder +import androidx.media3.extractor.SeekMap +import androidx.media3.extractor.TrackOutput +import com.edde746.plezy.libass.media.AssHandler +import com.edde746.plezy.libass.media.parser.AssSubtitleParserFactory +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** Regression coverage for Matroska files whose SeekHead references Tracks after the Clusters. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class MatroskaLateTracksTest { + + private class CapturedTrack(val type: Int) : TrackOutput { + val timesUs = mutableListOf() + var format: Format? = null + + val sampleCount: Int + get() = timesUs.size + + override fun format(format: Format) { + this.format = format + } + + override fun sampleData(input: DataReader, length: Int, allowEndOfInput: Boolean, sampleDataPart: Int): Int = input.read(ByteArray(length), 0, length) + + override fun sampleData(data: ParsableByteArray, length: Int, sampleDataPart: Int) { + data.skipBytes(length) + } + + override fun sampleMetadata(timeUs: Long, flags: Int, size: Int, offset: Int, cryptoData: TrackOutput.CryptoData?) { + timesUs.add(timeUs) + } + } + + private class CapturingExtractorOutput : ExtractorOutput { + val tracks = mutableMapOf() + val seekMaps = mutableListOf() + + override fun track(id: Int, type: Int): TrackOutput = tracks.getOrPut(id) { CapturedTrack(type) } + override fun endTracks() = Unit + override fun seekMap(seekMap: SeekMap) { + seekMaps.add(seekMap) + } + } + + private class ByteArrayDataReader(private val data: ByteArray) : DataReader { + var position = 0L + + override fun read(buffer: ByteArray, offset: Int, length: Int): Int { + if (position >= data.size) return C.RESULT_END_OF_INPUT + val bytesRead = minOf(length, data.size - position.toInt()) + data.copyInto(buffer, offset, position.toInt(), position.toInt() + bytesRead) + position += bytesRead + return bytesRead + } + } + + private fun fixtureData(): ByteArray = checkNotNull(javaClass.getResourceAsStream("/matroska_tracks_at_end.mkv")) { + "fixture matroska_tracks_at_end.mkv missing from test resources" + }.use { it.readBytes() } + + private fun readToEnd(extractor: Extractor, reader: ByteArrayDataReader, length: Long) { + var input: ExtractorInput = DefaultExtractorInput(reader, reader.position, length) + val seekPosition = PositionHolder() + repeat(100_000) { + when (extractor.read(input, seekPosition)) { + Extractor.RESULT_END_OF_INPUT -> return + Extractor.RESULT_SEEK -> { + reader.position = seekPosition.position + input = DefaultExtractorInput(reader, seekPosition.position, length) + } + } + } + error("extractor did not reach end of input") + } + + @Test + fun extractsSamplesWhenSeekHeadReferencesTracksAfterClusters() { + val assHandler = AssHandler() + val extractor = ZlibMatroskaExtractor(AssSubtitleParserFactory(assHandler), assHandler) + val output = CapturingExtractorOutput() + extractor.init(output) + + val data = fixtureData() + readToEnd(extractor, ByteArrayDataReader(data), data.size.toLong()) + + assertEquals(2, output.tracks.size) + assertEquals(setOf(C.TRACK_TYPE_VIDEO, C.TRACK_TYPE_AUDIO), output.tracks.values.map { it.type }.toSet()) + output.tracks.values.forEach { track -> + assertNotNull(track.format) + assertTrue("expected extracted samples for track type ${track.type}", track.sampleCount > 0) + } + } + + /** + * media3 1.11.0 builds the Matroska seek map at the end of the Cues element, which for + * tracks-after-clusters files is before the Tracks element parsed — the map then permanently + * reports unseekable and resolves every seek to byte 0, snapping playback to the start + * (#1969; upstream androidx/media #3377). + * The production stack must repair it through the per-track cue lookups. + */ + @Test + fun seeksViaCuesWhenSeekMapIsBuiltBeforeTracks() { + val assHandler = AssHandler() + val extractor = CuelessSeekExtractorWrapper(ZlibMatroskaExtractor(AssSubtitleParserFactory(assHandler), assHandler)) + val output = CapturingExtractorOutput() + extractor.init(output) + + val data = fixtureData() + val reader = ByteArrayDataReader(data) + readToEnd(extractor, reader, data.size.toLong()) + + assertEquals(1, output.seekMaps.size) + val seekMap = output.seekMaps.single() + assertTrue("tracks-after-clusters file with Cues must be seekable", seekMap.isSeekable) + + val points = seekMap.getSeekPoints(500_000L) + // Cue-based resolution snaps to the fixture's only cue point (t=0); a byte-proportional + // estimate would return the requested time instead. + assertEquals(0L, points.first.timeUs) + val position = points.first.position + val clusterId = byteArrayOf(0x1F, 0x43, 0xB6.toByte(), 0x75) + assertTrue( + "seek position $position must point at a Cluster element", + data.copyOfRange(position.toInt(), position.toInt() + 4).contentEquals(clusterId) + ) + + output.tracks.values.forEach { it.timesUs.clear() } + extractor.seek(position, points.first.timeUs) + reader.position = position + readToEnd(extractor, reader, data.size.toLong()) + + output.tracks.values.forEach { track -> + assertTrue("expected extracted samples after seek for track type ${track.type}", track.sampleCount > 0) + assertEquals(points.first.timeUs, track.timesUs.first()) + } + } +} diff --git a/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/LatmTrackOutputTest.kt b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/MatroskaLatmSupportTest.kt similarity index 58% rename from android/app/src/test/kotlin/com/edde746/plezy/exoplayer/LatmTrackOutputTest.kt rename to android/app/src/test/kotlin/com/edde746/plezy/exoplayer/MatroskaLatmSupportTest.kt index fbc23f1c2..d918227ef 100644 --- a/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/LatmTrackOutputTest.kt +++ b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/MatroskaLatmSupportTest.kt @@ -5,7 +5,14 @@ import androidx.media3.common.DataReader import androidx.media3.common.Format import androidx.media3.common.MimeTypes import androidx.media3.common.util.ParsableByteArray +import androidx.media3.extractor.DefaultExtractorInput +import androidx.media3.extractor.Extractor +import androidx.media3.extractor.ExtractorOutput +import androidx.media3.extractor.PositionHolder +import androidx.media3.extractor.SeekMap import androidx.media3.extractor.TrackOutput +import com.edde746.plezy.libass.media.AssHandler +import com.edde746.plezy.libass.media.parser.AssSubtitleParserFactory import java.io.EOFException import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -17,17 +24,16 @@ import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config /** - * Drives LatmTrackOutput with LOAS frames from the committed fixture (1s 440Hz - * sine, AAC-LC 48kHz stereo, LATM/LOAS) and verifies it unwraps them to raw - * AAC with a synthesized AudioSpecificConfig. Both demuxer paths deliver LOAS - * this way: the FFmpeg extractor wraps aac_latm tracks directly. + * Extracts the committed fixture (1s 440Hz sine, AAC-LC 48kHz stereo, LATM/LOAS + * muxed into MKV as A_MS/ACM tag 0x1602) and verifies direct Matroska playback + * unwraps LOAS frames to raw AAC with a synthesized AudioSpecificConfig. * - * Robolectric provides real android.util.* implementations for media3's - * internals. + * Robolectric provides real android.util.* implementations — MatroskaExtractor + * stores tracks in a SparseArray, which is a no-op stub on plain JVM. */ @RunWith(RobolectricTestRunner::class) @Config(sdk = [34]) -class LatmTrackOutputTest { +class MatroskaLatmSupportTest { private class CapturedSample(val timeUs: Long, val flags: Int, val data: ByteArray) @@ -65,6 +71,14 @@ class LatmTrackOutputTest { } } + private class FakeExtractorOutput : ExtractorOutput { + val tracks = mutableMapOf() + + override fun track(id: Int, type: Int): TrackOutput = tracks.getOrPut(id) { FakeTrackOutput() } + override fun endTracks() {} + override fun seekMap(seekMap: SeekMap) {} + } + private class ByteArrayDataReader(private val data: ByteArray) : DataReader { var position = 0L @@ -104,46 +118,76 @@ class LatmTrackOutputTest { return frames } - private fun placeholderFormat(): Format = Format.Builder() - .setId("1") - .setSampleMimeType(MimeTypes.AUDIO_AAC) - .setLabel("LATM track") - .setLanguage("eng") - .build() + private fun extractFixture(): FakeExtractorOutput { + val data = fixtureData() + + val assHandler = AssHandler() + val extractor = ZlibMatroskaExtractor(AssSubtitleParserFactory(assHandler), assHandler) + val output = FakeExtractorOutput() + extractor.init(output) + + val reader = ByteArrayDataReader(data) + var input = DefaultExtractorInput(reader, 0, data.size.toLong()) + val seekPosition = PositionHolder() + while (true) { + when (extractor.read(input, seekPosition)) { + Extractor.RESULT_END_OF_INPUT -> return output + Extractor.RESULT_SEEK -> { + reader.position = seekPosition.position + input = DefaultExtractorInput(reader, seekPosition.position, data.size.toLong()) + } + else -> {} + } + } + } @Test - fun unwrapsLoasFramesToRawAac() { - val delegate = FakeTrackOutput() - val output = LatmTrackOutput(delegate, 1) - output.format(placeholderFormat()) + fun unwrapsLoasAcmTrackToRawAac() { + val output = extractFixture() - val frames = loasFrames(40) - var timeUs = 0L - for (frame in frames) { - output.sampleData(ParsableByteArray(frame), frame.size, TrackOutput.SAMPLE_DATA_PART_MAIN) - output.sampleMetadata(timeUs, C.BUFFER_FLAG_KEY_FRAME, frame.size, 0, null) - timeUs += 21_333 - } + assertEquals(1, output.tracks.size) + val track = output.tracks.values.first() - // The placeholder format must be swallowed; the LATM-derived AAC format - // carries the AudioSpecificConfig and the original track metadata. - val format = delegate.formats.last() + // No audio/x-unknown format may reach the queue; the LATM-derived AAC + // format must carry the AudioSpecificConfig for MediaCodec. + assertFalse(track.formats.any { it.sampleMimeType == MimeTypes.AUDIO_UNKNOWN }) + val format = track.formats.last() assertEquals(MimeTypes.AUDIO_AAC, format.sampleMimeType) assertEquals(48000, format.sampleRate) assertEquals(2, format.channelCount) - assertEquals("1", format.id) - assertEquals("LATM track", format.label) assertTrue(format.initializationData.isNotEmpty()) assertTrue(format.initializationData[0].isNotEmpty()) - assertTrue("expected ~40 samples, got ${delegate.samples.size}", delegate.samples.size in 35..40) - for (sample in delegate.samples) { + // 1s at 48kHz / 1024 samples per AAC frame ≈ 47 frames + assertTrue("expected ~47 samples, got ${track.samples.size}", track.samples.size in 40..55) + + // Raw AAC payloads: smaller than the LOAS wrapping, no LOAS syncword, + // keyframe-flagged, monotonic timestamps spanning ~1s. + var prevTimeUs = Long.MIN_VALUE + for (sample in track.samples) { assertTrue(sample.data.isNotEmpty()) val isLoasSync = sample.data.size >= 2 && (sample.data[0].toInt() and 0xFF) == 0x56 && (sample.data[1].toInt() and 0xE0) == 0xE0 assertFalse("sample still LOAS-framed", isLoasSync) + assertEquals(C.BUFFER_FLAG_KEY_FRAME, sample.flags and C.BUFFER_FLAG_KEY_FRAME) + assertTrue(sample.timeUs >= prevTimeUs) + prevTimeUs = sample.timeUs } + val spanUs = track.samples.last().timeUs - track.samples.first().timeUs + assertTrue("expected ~1s span, got ${spanUs}us", spanUs in 800_000..1_200_000) + } + + @Test + fun detectsLoasAcmCodecPrivate() { + val loas = byteArrayOf(0x02, 0x16, 0, 0, 0, 0) + assertTrue(isLoasAcmTrack("A_MS/ACM", loas)) + // Wrong tag (PCM), wrong codec, or missing private data must not match + assertFalse(isLoasAcmTrack("A_MS/ACM", byteArrayOf(0x01, 0x00, 0, 0))) + assertFalse(isLoasAcmTrack("A_AAC", loas)) + assertFalse(isLoasAcmTrack("A_MS/ACM", null)) + assertFalse(isLoasAcmTrack("A_MS/ACM", byteArrayOf(0x02))) + assertFalse(isLoasAcmTrack(null, loas)) } @Test @@ -161,14 +205,19 @@ class LatmTrackOutputTest { } @Test - fun preservesStreamMuxConfigAcrossReset() { + fun preservesStreamMuxConfigAcrossExtractorSeek() { val frames = loasFrames(2) assertEquals(0, frames[0][3].toInt() and 0x80) assertEquals(0x80, frames[1][3].toInt() and 0x80) val delegate = FakeTrackOutput() val output = LatmTrackOutput(delegate, 1) - output.format(placeholderFormat()) + output.format( + Format.Builder() + .setId("1") + .setSampleMimeType(MimeTypes.AUDIO_UNKNOWN) + .build() + ) output.sampleData(ParsableByteArray(frames[0]), frames[0].size, TrackOutput.SAMPLE_DATA_PART_MAIN) output.sampleMetadata(0, C.BUFFER_FLAG_KEY_FRAME, frames[0].size, 0, null) diff --git a/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/StreamBitrateMeterTest.kt b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/StreamBitrateMeterTest.kt deleted file mode 100644 index 58ee52753..000000000 --- a/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/StreamBitrateMeterTest.kt +++ /dev/null @@ -1,65 +0,0 @@ -package com.edde746.plezy.exoplayer - -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNull -import org.junit.Test - -class StreamBitrateMeterTest { - - /** Feeds [count] packets of [sizeBytes] every [intervalUs], starting at [startUs]. */ - private fun StreamBitrateMeter.feed(count: Int, sizeBytes: Int, intervalUs: Long, startUs: Long = 0L) { - for (i in 0 until count) onPacket(startUs + i * intervalUs, sizeBytes) - } - - @Test - fun `constant packet stream measures its bitrate`() { - val meter = StreamBitrateMeter() - // 1200-byte packets every 32 ms: 1200 * 8 / 0.032 = 300 kbps. - meter.feed(count = 32, sizeBytes = 1200, intervalUs = 32_000) - assertEquals(300_000, meter.bitrateBps()) - } - - @Test - fun `too few packets report nothing`() { - val meter = StreamBitrateMeter() - meter.feed(count = 15, sizeBytes = 1200, intervalUs = 32_000) - assertNull(meter.bitrateBps()) - } - - @Test - fun `zero pts span reports nothing`() { - val meter = StreamBitrateMeter() - repeat(32) { meter.onPacket(0L, 1200) } - assertNull(meter.bitrateBps()) - } - - @Test - fun `window drops old packets so a bitrate change is followed`() { - val meter = StreamBitrateMeter(windowUs = 1_000_000) - meter.feed(count = 100, sizeBytes = 1200, intervalUs = 32_000) - // Continue at four times the packet size; after well over a window of - // new packets, only the new rate remains. - meter.feed(count = 100, sizeBytes = 4800, intervalUs = 32_000, startUs = 100 * 32_000L) - assertEquals(1_200_000, meter.bitrateBps()) - } - - @Test - fun `backwards pts jump resets the window`() { - val meter = StreamBitrateMeter() - meter.feed(count = 32, sizeBytes = 1200, intervalUs = 32_000) - // Seek back: the single post-seek packet must not combine with the - // pre-seek span. - meter.onPacket(0L, 1200) - assertNull(meter.bitrateBps()) - meter.feed(count = 31, sizeBytes = 1200, intervalUs = 32_000, startUs = 32_000) - assertEquals(300_000, meter.bitrateBps()) - } - - @Test - fun `empty packets are ignored`() { - val meter = StreamBitrateMeter() - meter.feed(count = 32, sizeBytes = 1200, intervalUs = 32_000) - meter.onPacket(32 * 32_000L, 0) - assertEquals(300_000, meter.bitrateBps()) - } -} diff --git a/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/ZlibInflatingTrackOutputTest.kt b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/ZlibInflatingTrackOutputTest.kt new file mode 100644 index 000000000..35c29815f --- /dev/null +++ b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/ZlibInflatingTrackOutputTest.kt @@ -0,0 +1,248 @@ +package com.edde746.plezy.exoplayer + +import androidx.media3.common.C +import androidx.media3.common.DataReader +import androidx.media3.common.Format +import androidx.media3.common.ParserException +import androidx.media3.common.util.ParsableByteArray +import androidx.media3.extractor.TrackOutput +import java.io.ByteArrayOutputStream +import java.util.zip.Deflater +import java.util.zip.DeflaterOutputStream +import org.junit.After +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class ZlibInflatingTrackOutputTest { + @Before + fun enableMedia3TestBoundsChecks() { + ParsableByteArray.setShouldEnforceLimitOnLegacyMethods(true) + } + + @After + fun resetMedia3TestBoundsChecks() { + ParsableByteArray.setShouldEnforceLimitOnLegacyMethods(null) + } + + @Test + fun activeTransformInflatesChunkedSampleAndPreservesMetadata() { + val original = ByteArray(512 * 1024) { (it % 251).toByte() } + val compressed = deflate(original) + val delegate = RecordingTrackOutput(retainBytes = true) + val output = ZlibInflatingTrackOutput(delegate).apply { active = true } + val split = compressed.size / 2 + + output.sampleData( + ParsableByteArray(compressed.copyOfRange(0, split)), + split, + TrackOutput.SAMPLE_DATA_PART_MAIN + ) + output.sampleData( + ParsableByteArray(compressed.copyOfRange(split, compressed.size)), + compressed.size - split, + TrackOutput.SAMPLE_DATA_PART_MAIN + ) + output.sampleMetadata(42L, C.BUFFER_FLAG_KEY_FRAME, compressed.size, 0, null) + + assertArrayEquals(original, delegate.retained.toByteArray()) + assertEquals(42L, delegate.timeUs) + assertEquals(C.BUFFER_FLAG_KEY_FRAME, delegate.flags) + assertEquals(original.size, delegate.sampleSize) + assertEquals(0, delegate.offset) + assertEquals(1, delegate.metadataCount) + } + + @Test + fun activeTransformUsesMetadataBoundariesForBufferedSamples() { + val first = ByteArray(4096) { (it % 19).toByte() } + val second = ByteArray(2048) { (it % 23).toByte() } + val firstCompressed = deflate(first) + val secondCompressed = deflate(second) + val combined = firstCompressed + secondCompressed + val delegate = RecordingTrackOutput(retainBytes = true) + val output = ZlibInflatingTrackOutput(delegate).apply { active = true } + + output.sampleData(ParsableByteArray(combined), combined.size, TrackOutput.SAMPLE_DATA_PART_MAIN) + output.sampleMetadata(1, 0, firstCompressed.size, secondCompressed.size, null) + output.sampleMetadata(2, 0, secondCompressed.size, 0, null) + + assertArrayEquals(first + second, delegate.retained.toByteArray()) + assertEquals(2, delegate.metadataCount) + } + + @Test + fun exactlySixteenMiBInflatedSampleIsAccepted() { + val size = 16 * 1024 * 1024 + val compressed = deflateRepeated(size) + val delegate = RecordingTrackOutput() + val output = ZlibInflatingTrackOutput(delegate).apply { active = true } + + feed(output, compressed) + + assertEquals(size, delegate.byteCount) + assertEquals(size, delegate.sampleSize) + assertEquals(1, delegate.metadataCount) + assertTrue(compressed.size < 64 * 1024) + } + + @Test + fun oneByteOverInflatedLimitFailsBeforeDelegation() { + val compressed = deflateRepeated(16 * 1024 * 1024 + 1) + val delegate = RecordingTrackOutput() + val output = ZlibInflatingTrackOutput(delegate).apply { active = true } + + assertThrows(ParserException::class.java) { feed(output, compressed) } + + assertEquals(0, delegate.byteCount) + assertEquals(0, delegate.metadataCount) + assertTrue(compressed.size < 64 * 1024) + } + + @Test + fun excessiveCompressionRatioFailsBeforeInflatedSizeLimit() { + val compressed = deflate(ByteArray(8 * 1024 * 1024)) + val delegate = RecordingTrackOutput() + val output = ZlibInflatingTrackOutput(delegate).apply { active = true } + + assertThrows(ParserException::class.java) { feed(output, compressed) } + + assertEquals(0, delegate.byteCount) + assertEquals(0, delegate.metadataCount) + } + + @Test + fun corruptStreamPassesTheBoundedSampleThroughUnchanged() { + val corrupt = byteArrayOf(0, 1, 2, 3) + val delegate = RecordingTrackOutput(retainBytes = true) + val output = ZlibInflatingTrackOutput(delegate).apply { active = true } + + feed(output, corrupt) + + assertArrayEquals(corrupt, delegate.retained.toByteArray()) + assertEquals(corrupt.size, delegate.sampleSize) + assertEquals(1, delegate.metadataCount) + } + + @Test + fun truncatedAndDictionaryStreamsFailClosed() { + val valid = deflate(ByteArray(4096) { 7 }) + val dictionary = "shared-zlib-dictionary".toByteArray() + val cases = listOf( + valid.copyOf(valid.size - 2), + deflate(ByteArray(4096) { 3 }, dictionary) + ) + + for (compressed in cases) { + val delegate = RecordingTrackOutput() + val output = ZlibInflatingTrackOutput(delegate).apply { active = true } + + assertThrows(ParserException::class.java) { feed(output, compressed) } + assertEquals(0, delegate.byteCount) + assertEquals(0, delegate.metadataCount) + } + } + + @Test + fun inactiveWrapperDelegatesBytesAndMetadataUnchanged() { + val bytes = byteArrayOf(9, 8, 7, 6) + val trailing = byteArrayOf(5, 4) + val allBytes = bytes + trailing + val delegate = RecordingTrackOutput(retainBytes = true) + val output = ZlibInflatingTrackOutput(delegate) + + output.sampleData(ParsableByteArray(allBytes), allBytes.size, TrackOutput.SAMPLE_DATA_PART_MAIN) + output.sampleMetadata(99L, 3, bytes.size, trailing.size, null) + + assertArrayEquals(allBytes, delegate.retained.toByteArray()) + assertEquals(bytes.size, delegate.sampleSize) + assertEquals(trailing.size, delegate.offset) + assertEquals(99L, delegate.timeUs) + } + + private fun feed(output: ZlibInflatingTrackOutput, compressed: ByteArray) { + output.sampleData( + ParsableByteArray(compressed), + compressed.size, + TrackOutput.SAMPLE_DATA_PART_MAIN + ) + output.sampleMetadata(1L, C.BUFFER_FLAG_KEY_FRAME, compressed.size, 0, null) + } + + private fun deflate(bytes: ByteArray, dictionary: ByteArray? = null): ByteArray { + val target = ByteArrayOutputStream() + val deflater = Deflater().apply { + if (dictionary != null) setDictionary(dictionary) + } + DeflaterOutputStream(target, deflater).use { it.write(bytes) } + return target.toByteArray() + } + + private fun deflateRepeated(size: Int): ByteArray { + val target = ByteArrayOutputStream() + val chunk = ByteArray(8192) { (it % 7).toByte() } + DeflaterOutputStream(target).use { stream -> + var remaining = size + while (remaining > 0) { + val count = minOf(remaining, chunk.size) + stream.write(chunk, 0, count) + remaining -= count + } + } + return target.toByteArray() + } + + private class RecordingTrackOutput( + private val retainBytes: Boolean = false + ) : TrackOutput { + val retained = ByteArrayOutputStream() + var byteCount = 0 + var metadataCount = 0 + var timeUs = C.TIME_UNSET + var flags = 0 + var sampleSize = -1 + var offset = -1 + + override fun format(format: Format) = Unit + + override fun sampleData( + input: DataReader, + length: Int, + allowEndOfInput: Boolean, + sampleDataPart: Int + ): Int { + val buffer = ByteArray(length) + val read = input.read(buffer, 0, length) + if (read > 0) record(buffer, read) + return read + } + + override fun sampleData(data: ParsableByteArray, length: Int, sampleDataPart: Int) { + val buffer = ByteArray(length) + data.readBytes(buffer, 0, length) + record(buffer, length) + } + + private fun record(buffer: ByteArray, length: Int) { + byteCount += length + if (retainBytes) retained.write(buffer, 0, length) + } + + override fun sampleMetadata( + timeUs: Long, + flags: Int, + size: Int, + offset: Int, + cryptoData: TrackOutput.CryptoData? + ) { + metadataCount++ + this.timeUs = timeUs + this.flags = flags + sampleSize = size + this.offset = offset + } + } +} diff --git a/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/ZlibTextBlockTest.kt b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/ZlibTextBlockTest.kt new file mode 100644 index 000000000..8fdc31396 --- /dev/null +++ b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/ZlibTextBlockTest.kt @@ -0,0 +1,80 @@ +package com.edde746.plezy.exoplayer + +import java.io.ByteArrayOutputStream +import java.util.zip.Deflater +import java.util.zip.DeflaterOutputStream +import java.util.zip.Inflater +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** + * Unit spec for [rewriteZlibTextBlock]: the block header (track-number varint, + * timecode, flags) must survive byte-identically, the payload must inflate, and + * every block the rewriter cannot prove safe must pass through unchanged (null). + */ +class ZlibTextBlockTest { + + private val inflater = Inflater() + + private fun block(header: ByteArray, payload: ByteArray): ByteArray = header + payload + + private fun deflate(bytes: ByteArray): ByteArray { + val target = ByteArrayOutputStream() + DeflaterOutputStream(target, Deflater()).use { it.write(bytes) } + return target.toByteArray() + } + + // Track 3 (varint 0x83), relative timecode 0x0102, no lacing. + private val unlacedHeader = byteArrayOf(0x83.toByte(), 0x01, 0x02, 0x00) + + @Test + fun inflatesUnlacedBlockPayloadAndPreservesHeader() { + val dialogue = "42,,Default,,0,0,0,,SUBTITLE LINE 1 (0:00:00.00)".toByteArray() + val data = block(unlacedHeader, deflate(dialogue)) + + val rewritten = rewriteZlibTextBlock(data, data.size, inflater)!! + + assertArrayEquals(unlacedHeader + dialogue, rewritten) + } + + @Test + fun twoByteTrackNumberVarintIsPreserved() { + val dialogue = "1,,Default,,0,0,0,,hello".toByteArray() + val header = byteArrayOf(0x41, 0x2A, 0x01, 0x02, 0x00) // varint 0x412A = track 298 + val data = block(header, deflate(dialogue)) + + val rewritten = rewriteZlibTextBlock(data, data.size, inflater)!! + + assertArrayEquals(header + dialogue, rewritten) + } + + @Test + fun lacedBlockPassesThrough() { + val laced = byteArrayOf(0x83.toByte(), 0x01, 0x02, 0x06) // EBML lacing bits set + val data = block(laced, deflate("payload".toByteArray())) + + assertNull(rewriteZlibTextBlock(data, data.size, inflater)) + } + + @Test + fun corruptStreamPassesThrough() { + val data = block(unlacedHeader, byteArrayOf(0x44, 0x69, 0x61, 0x6C)) // "Dial", not zlib + + assertNull(rewriteZlibTextBlock(data, data.size, inflater)) + } + + @Test + fun truncatedStreamPassesThrough() { + val compressed = deflate("a longer payload that spans several deflate symbols".toByteArray()) + val truncated = compressed.copyOf(compressed.size - 4) + val data = block(unlacedHeader, truncated) + + assertNull(rewriteZlibTextBlock(data, data.size, inflater)) + } + + @Test + fun headerOnlyBlockPassesThrough() { + assertNull(rewriteZlibTextBlock(unlacedHeader, unlacedHeader.size, inflater)) + } +} diff --git a/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/ZlibTextSubtitleExtractionTest.kt b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/ZlibTextSubtitleExtractionTest.kt new file mode 100644 index 000000000..af4b81d41 --- /dev/null +++ b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/ZlibTextSubtitleExtractionTest.kt @@ -0,0 +1,170 @@ +package com.edde746.plezy.exoplayer + +import androidx.media3.common.C +import androidx.media3.common.DataReader +import androidx.media3.common.MimeTypes +import androidx.media3.common.util.ParsableByteArray +import androidx.media3.extractor.DefaultExtractorInput +import androidx.media3.extractor.Extractor +import androidx.media3.extractor.ExtractorOutput +import androidx.media3.extractor.PositionHolder +import androidx.media3.extractor.SeekMap +import androidx.media3.extractor.TrackOutput +import androidx.media3.extractor.text.CueDecoder +import androidx.media3.extractor.text.DefaultSubtitleParserFactory +import com.edde746.plezy.libass.media.AssHandler +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Extracts the committed fixtures — one ASS subtitle track muxed by mkvmerge with + * `--compression 0:zlib` (ContentCompAlgo 0, the anime-release convention that + * broke #2023) and an identical uncompressed mux — and verifies the zlib block + * payloads are inflated *before* MatroskaExtractor's subtitle sample assembly. + * + * Regenerate with: + * mkvmerge -o zlib_ssa.mkv --compression 0:zlib repro.ass + * mkvmerge -o plain_ssa.mkv --compression 0:none repro.ass + * where repro.ass holds 45 two-second "SUBTITLE LINE N (H:MM:SS.00)" events. + * + * Robolectric provides real android.util/android.os implementations — + * MatroskaExtractor uses SparseArray and CueDecoder uses Parcel, both of which + * are no-op stubs on plain JVM. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class ZlibTextSubtitleExtractionTest { + + private class CapturedSample(val timeUs: Long, val data: ByteArray) + + private class FakeTrackOutput : TrackOutput { + val formats = mutableListOf() + val samples = mutableListOf() + private var buf = ByteArray(64 * 1024) + private var bufLen = 0 + + override fun format(format: androidx.media3.common.Format) { + formats.add(format) + } + + override fun sampleData(input: DataReader, length: Int, allowEndOfInput: Boolean, sampleDataPart: Int): Int { + ensureCapacity(bufLen + length) + val read = input.read(buf, bufLen, length) + if (read > 0) bufLen += read + return read + } + + override fun sampleData(data: ParsableByteArray, length: Int, sampleDataPart: Int) { + ensureCapacity(bufLen + length) + data.readBytes(buf, bufLen, length) + bufLen += length + } + + override fun sampleMetadata(timeUs: Long, flags: Int, size: Int, offset: Int, cryptoData: TrackOutput.CryptoData?) { + val start = bufLen - offset - size + samples.add(CapturedSample(timeUs, buf.copyOfRange(start, start + size))) + if (offset == 0) bufLen = 0 + } + + private fun ensureCapacity(needed: Int) { + if (buf.size < needed) buf = buf.copyOf(maxOf(needed, buf.size * 2)) + } + } + + private class FakeExtractorOutput : ExtractorOutput { + val tracks = mutableMapOf() + + override fun track(id: Int, type: Int): TrackOutput = tracks.getOrPut(id) { FakeTrackOutput() } + override fun endTracks() {} + override fun seekMap(seekMap: SeekMap) {} + } + + private class ByteArrayDataReader(private val data: ByteArray) : DataReader { + var position = 0L + + override fun read(buffer: ByteArray, offset: Int, length: Int): Int { + if (position >= data.size) return C.RESULT_END_OF_INPUT + val toRead = minOf(length, data.size - position.toInt()) + System.arraycopy(data, position.toInt(), buffer, offset, toRead) + position += toRead + return toRead + } + } + + private fun extract(resource: String): FakeExtractorOutput { + val data = checkNotNull(javaClass.getResourceAsStream(resource)) { + "fixture $resource missing from test resources" + }.use { it.readBytes() } + + // DefaultSubtitleParserFactory (not AssSubtitleParserFactory) so the SSA track + // transcodes to decodable media3 cues instead of loading native libass. + val extractor = ZlibMatroskaExtractor(DefaultSubtitleParserFactory(), AssHandler()) + val output = FakeExtractorOutput() + extractor.init(output) + + val reader = ByteArrayDataReader(data) + var input = DefaultExtractorInput(reader, 0, data.size.toLong()) + val seekPosition = PositionHolder() + while (true) { + when (extractor.read(input, seekPosition)) { + Extractor.RESULT_END_OF_INPUT -> return output + Extractor.RESULT_SEEK -> { + reader.position = seekPosition.position + input = DefaultExtractorInput(reader, seekPosition.position, data.size.toLong()) + } + else -> {} + } + } + } + + private class DecodedCue(val startTimeUs: Long, val durationUs: Long, val text: String) + + private fun decodeCues(output: FakeExtractorOutput): List { + val track = output.tracks.values.single() + val format = track.formats.last() + assertEquals(MimeTypes.APPLICATION_MEDIA3_CUES, format.sampleMimeType) + assertEquals(MimeTypes.TEXT_SSA, format.codecs) + val decoder = CueDecoder() + return track.samples.map { sample -> + val cues = decoder.decode(sample.timeUs, sample.data, 0, sample.data.size) + DecodedCue( + cues.startTimeUs, + cues.durationUs, + cues.cues.joinToString("\n") { it.text.toString() } + ) + } + } + + @Test + fun zlibCompressedAssDialogueIsInflatedToParseableCues() { + val cues = decodeCues(extract("/zlib_ssa.mkv")) + + assertEquals(45, cues.size) + assertEquals("SUBTITLE LINE 1 (0:00:00.00)", cues.first().text) + assertEquals(0L, cues.first().startTimeUs) + assertEquals(2_000_000L, cues.first().durationUs) + assertEquals("SUBTITLE LINE 45 (0:01:28.00)", cues.last().text) + assertEquals(88_000_000L, cues.last().startTimeUs) + cues.forEachIndexed { index, cue -> + assertEquals(index * 2_000_000L, cue.startTimeUs) + assertTrue("cue $index text: ${cue.text}", cue.text.startsWith("SUBTITLE LINE ${index + 1} ")) + } + } + + @Test + fun zlibAndPlainMuxesProduceIdenticalCues() { + val zlib = decodeCues(extract("/zlib_ssa.mkv")) + val plain = decodeCues(extract("/plain_ssa.mkv")) + + assertEquals(plain.size, zlib.size) + plain.zip(zlib).forEachIndexed { index, (expected, actual) -> + assertEquals("startTimeUs of cue $index", expected.startTimeUs, actual.startTimeUs) + assertEquals("durationUs of cue $index", expected.durationUs, actual.durationUs) + assertEquals("text of cue $index", expected.text, actual.text) + } + } +} diff --git a/android/libass/build.gradle.kts b/android/libass/build.gradle.kts index bbf92103a..6998c8234 100644 --- a/android/libass/build.gradle.kts +++ b/android/libass/build.gradle.kts @@ -58,6 +58,4 @@ dependencies { implementation("androidx.media3:media3-ui:1.11.0") testImplementation("junit:junit:4.13.2") - // media3 Format/MimeTypes touch android.util in static initializers. - testImplementation("org.robolectric:robolectric:4.16.1") } diff --git a/android/libass/src/main/java/com/edde746/plezy/libass/media/AssFonts.kt b/android/libass/src/main/java/com/edde746/plezy/libass/media/AssFonts.kt deleted file mode 100644 index 66f9c1710..000000000 --- a/android/libass/src/main/java/com/edde746/plezy/libass/media/AssFonts.kt +++ /dev/null @@ -1,31 +0,0 @@ -package com.edde746.plezy.libass.media - -/** - * Shared policy for embedded font attachments delivered to libass: which - * attachment mime types are fonts, and the per-font/aggregate byte budgets - * that keep a hostile container from ballooning the font store. - */ -object AssFonts { - const val MAX_FONT_BYTES = 16L * 1024 * 1024 - const val MAX_TOTAL_FONT_BYTES = 32L * 1024 * 1024 - - val fontMimeTypes = listOf( - "font/ttf", - "font/otf", - "font/sfnt", - "font/woff", - "font/woff2", - "application/font-sfnt", - "application/font-woff", - "application/x-truetype-font", - "application/vnd.ms-opentype", - "application/x-font-ttf" - ) - - /** Null to accept the font, or a human-readable rejection reason. */ - fun rejectionReason(size: Long, acceptedBytes: Long): String? = when { - size > MAX_FONT_BYTES -> "per-font limit" - size > MAX_TOTAL_FONT_BYTES - acceptedBytes -> "aggregate limit" - else -> null - } -} diff --git a/android/libass/src/main/java/com/edde746/plezy/libass/media/extractor/AssMatroskaExtractor.kt b/android/libass/src/main/java/com/edde746/plezy/libass/media/extractor/AssMatroskaExtractor.kt new file mode 100644 index 000000000..9a7bdb48e --- /dev/null +++ b/android/libass/src/main/java/com/edde746/plezy/libass/media/extractor/AssMatroskaExtractor.kt @@ -0,0 +1,169 @@ +package com.edde746.plezy.libass.media.extractor + +import android.util.Log +import androidx.annotation.OptIn +import androidx.media3.common.ParserException +import androidx.media3.common.util.ParsableByteArray +import androidx.media3.common.util.UnstableApi +import androidx.media3.extractor.ExtractorInput +import androidx.media3.extractor.ExtractorOutput +import androidx.media3.extractor.mkv.EbmlProcessor +import androidx.media3.extractor.mkv.MatroskaExtractor +import androidx.media3.extractor.text.SubtitleParser +import com.edde746.plezy.libass.media.AssHandler +import com.edde746.plezy.libass.media.text.AssSubtitleExtractorOutput + +@OptIn(UnstableApi::class) +open class AssMatroskaExtractor( + subtitleParserFactory: SubtitleParser.Factory, + private val assHandler: AssHandler, + flags: Int = 0 +) : MatroskaExtractor(subtitleParserFactory, flags) { + + private var currentAttachmentName: String? = null + private var currentAttachmentMime: String? = null + internal var acceptedFontBytes = 0L + private set + + internal val subtitleSample = subtitleSampleField.get(this) as ParsableByteArray + + override fun getElementType(id: Int): Int = when (id) { + ID_ATTACHMENTS -> EbmlProcessor.ELEMENT_TYPE_MASTER + ID_ATTACHED_FILE -> EbmlProcessor.ELEMENT_TYPE_MASTER + ID_FILE_NAME -> EbmlProcessor.ELEMENT_TYPE_STRING + ID_FILE_MIME_TYPE -> EbmlProcessor.ELEMENT_TYPE_STRING + ID_FILE_DATA -> EbmlProcessor.ELEMENT_TYPE_BINARY + else -> super.getElementType(id) + } + + override fun isLevel1Element(id: Int): Boolean = super.isLevel1Element(id) || id == ID_ATTACHMENTS + + override fun startMasterElement(id: Int, contentPosition: Long, contentSize: Long) { + when (id) { + ID_EBML -> { + val currentExtractor = extractorOutput.get(this) as ExtractorOutput + if (currentExtractor !is AssSubtitleExtractorOutput) { + extractorOutput.set( + this, + AssSubtitleExtractorOutput(currentExtractor, assHandler, this) + ) + } + super.startMasterElement(id, contentPosition, contentSize) + } + ID_ATTACHED_FILE -> clearAttachment() + else -> super.startMasterElement(id, contentPosition, contentSize) + } + } + + override fun endMasterElement(id: Int) { + when (id) { + ID_VIDEO -> { + // We need to get the video dimensions very early + val track = getCurrentTrack(id) + assHandler.setVideoSize(track.width, track.height) + super.endMasterElement(id) + } + ID_ATTACHED_FILE -> clearAttachment() + else -> super.endMasterElement(id) + } + } + + override fun stringElement(id: Int, value: String) { + when (id) { + ID_FILE_NAME -> currentAttachmentName = value + ID_FILE_MIME_TYPE -> currentAttachmentMime = value + else -> super.stringElement(id, value) + } + } + + override fun binaryElement(id: Int, contentSize: Int, input: ExtractorInput) { + when (id) { + ID_FILE_DATA -> { + if (contentSize < 0) { + throw ParserException.createForMalformedContainer( + "Negative Matroska attachment size", + null + ) + } + + val attachmentName = requireNotNull(currentAttachmentName) + val attachmentMime = requireNotNull(currentAttachmentMime) + if (attachmentMime !in fontMimeTypes) { + input.skipFully(contentSize) + return + } + if (contentSize == 0) { + input.skipFully(0) + return + } + + val size = contentSize.toLong() + val rejectionReason = when { + size > MAX_FONT_BYTES -> "per-font limit" + size > MAX_TOTAL_FONT_BYTES - acceptedFontBytes -> "aggregate limit" + else -> null + } + if (rejectionReason != null) { + onFontRejected(contentSize, acceptedFontBytes, rejectionReason) + input.skipFully(contentSize) + return + } + + val data = ByteArray(contentSize) + input.readFully(data, 0, contentSize) + acceptedFontBytes += size + assHandler.addFont(attachmentName, data) + } + else -> super.binaryElement(id, contentSize, input) + } + } + + protected open fun onFontRejected( + contentSize: Int, + acceptedBytes: Long, + reason: String + ) { + Log.w( + TAG, + "Skipping embedded font: $reason (bytes=$contentSize, accepted=$acceptedBytes)" + ) + } + + private fun clearAttachment() { + currentAttachmentName = null + currentAttachmentMime = null + } + + companion object { + private const val TAG = "AssMatroskaExtractor" + internal const val MAX_FONT_BYTES = 16L * 1024 * 1024 + internal const val MAX_TOTAL_FONT_BYTES = 32L * 1024 * 1024 + const val ID_EBML = 0x1A45DFA3 + const val ID_VIDEO = 0xE0 + const val ID_ATTACHMENTS = 0x1941A469 + const val ID_ATTACHED_FILE = 0x61A7 + const val ID_FILE_NAME = 0x466E + const val ID_FILE_MIME_TYPE = 0x4660 + const val ID_FILE_DATA = 0x465C + + val fontMimeTypes = listOf( + "font/ttf", + "font/otf", + "font/sfnt", + "font/woff", + "font/woff2", + "application/font-sfnt", + "application/font-woff", + "application/x-truetype-font", + "application/vnd.ms-opentype", + "application/x-font-ttf" + ) + + val extractorOutput = MatroskaExtractor::class.java.getDeclaredField("extractorOutput").apply { + isAccessible = true + } + val subtitleSampleField = MatroskaExtractor::class.java.getDeclaredField("subtitleSample").apply { + isAccessible = true + } + } +} diff --git a/android/libass/src/main/java/com/edde746/plezy/libass/media/text/AssSubtitleExtractorOutput.kt b/android/libass/src/main/java/com/edde746/plezy/libass/media/text/AssSubtitleExtractorOutput.kt index 68c68be8e..ed4836cfd 100644 --- a/android/libass/src/main/java/com/edde746/plezy/libass/media/text/AssSubtitleExtractorOutput.kt +++ b/android/libass/src/main/java/com/edde746/plezy/libass/media/text/AssSubtitleExtractorOutput.kt @@ -5,22 +5,21 @@ import androidx.media3.common.util.UnstableApi import androidx.media3.extractor.ExtractorOutput import androidx.media3.extractor.TrackOutput import com.edde746.plezy.libass.media.AssHandler +import com.edde746.plezy.libass.media.extractor.AssMatroskaExtractor /** - * Wraps every text track with [AssTrackOutput] so embedded ASS dialogue - * reaches libass. Install it on the raw-subtitle side of the output chain — - * between the extractor and the SubtitleTranscodingExtractorOutput — so the - * dialogue bytes are still unparsed when they pass through. + * This class is only used by the overlay renderer. It's needed to get the start time of the subtitles. */ @UnstableApi class AssSubtitleExtractorOutput( private val delegate: ExtractorOutput, - private val assHandler: AssHandler + private val assHandler: AssHandler, + private val extractor: AssMatroskaExtractor ) : ExtractorOutput by delegate { override fun track(id: Int, type: Int): TrackOutput = if (type == C.TRACK_TYPE_TEXT) { - // We can't know at this time if the subtitle track is ASS or another - // format, so we wrap every subtitle track; non-ASS tracks pass through. - AssTrackOutput(delegate.track(id, type), assHandler::readTrackDialogue) + // We can't know at this time if the subtitle track is ASS or other format, so we wrap + // every subtitle track + AssTrackOutput(delegate.track(id, type), assHandler, extractor) } else { delegate.track(id, type) } diff --git a/android/libass/src/main/java/com/edde746/plezy/libass/media/text/AssTrackOutput.kt b/android/libass/src/main/java/com/edde746/plezy/libass/media/text/AssTrackOutput.kt index b2138e2a0..b68d6c05b 100644 --- a/android/libass/src/main/java/com/edde746/plezy/libass/media/text/AssTrackOutput.kt +++ b/android/libass/src/main/java/com/edde746/plezy/libass/media/text/AssTrackOutput.kt @@ -1,49 +1,28 @@ package com.edde746.plezy.libass.media.text import androidx.media3.common.C -import androidx.media3.common.DataReader import androidx.media3.common.Format import androidx.media3.common.MimeTypes -import androidx.media3.common.util.ParsableByteArray import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.Util import androidx.media3.extractor.TrackOutput -import java.io.EOFException +import com.edde746.plezy.libass.media.AssHandler +import com.edde746.plezy.libass.media.extractor.AssMatroskaExtractor import java.util.regex.Pattern /** - * Feeds embedded ASS dialogue to libass while forwarding every sample - * unchanged. Only used by the overlay renderer, which needs the start time and - * duration of each dialogue line. - * - * Samples must arrive in the media3 MatroskaExtractor subtitle shape - * `Dialogue: 0:00:00:00,,` — the - * second field carries the sample *duration* (MatroskaExtractor patches - * BlockDuration into it; the FFmpeg extractor writes the packet duration). - * The extractor beneath must therefore emit raw subtitle samples: media3's - * MatroskaExtractor with FLAG_EMIT_RAW_SUBTITLE_DATA, or the FFmpeg demuxer - * path. Cue transcoding happens downstream of this wrapper. + * This class is only used by the overlay renderer. It's needed to get the start time of the subtitles. */ @UnstableApi class AssTrackOutput( private val delegate: TrackOutput, - private val dialogueSink: DialogueSink -) : TrackOutput { - - /** Receives one dialogue line; mirrors AssHandler.readTrackDialogue. */ - fun interface DialogueSink { - fun onDialogue(trackId: String?, startMs: Long, durationMs: Long, data: ByteArray, offset: Int, length: Int) - } + private val assHandler: AssHandler, + private val extractor: AssMatroskaExtractor +) : TrackOutput by delegate { private var isAss = false - private var trackId: String? = null - // Sample bytes buffered between sampleData and sampleMetadata; only - // populated for ASS tracks. - private var buf = ByteArray(INITIAL_BUFFER_SIZE) - private var bufLen = 0 - private var readBuf = ByteArray(INITIAL_BUFFER_SIZE) - private val forwardParsable = ParsableByteArray() + private var trackId: String? = null override fun format(format: Format) { if (format.sampleMimeType == MimeTypes.TEXT_SSA || format.codecs == MimeTypes.TEXT_SSA) { @@ -53,34 +32,6 @@ class AssTrackOutput( delegate.format(format) } - override fun sampleData( - input: DataReader, - length: Int, - allowEndOfInput: Boolean, - sampleDataPart: Int - ): Int { - if (!isAss) return delegate.sampleData(input, length, allowEndOfInput, sampleDataPart) - if (readBuf.size < length) readBuf = ByteArray(length) - val bytesRead = input.read(readBuf, 0, length) - if (bytesRead == C.RESULT_END_OF_INPUT) { - if (!allowEndOfInput) throw EOFException() - return C.RESULT_END_OF_INPUT - } - append(readBuf, bytesRead) - forwardParsable.reset(readBuf, bytesRead) - delegate.sampleData(forwardParsable, bytesRead, sampleDataPart) - return bytesRead - } - - override fun sampleData(data: ParsableByteArray, length: Int, sampleDataPart: Int) { - if (isAss) { - ensureCapacity(bufLen + length) - System.arraycopy(data.data, data.position, buf, bufLen, length) - bufLen += length - } - delegate.sampleData(data, length, sampleDataPart) - } - override fun sampleMetadata( timeUs: Long, flags: Int, @@ -88,37 +39,26 @@ class AssTrackOutput( offset: Int, cryptoData: TrackOutput.CryptoData? ) { - if (isAss) { - // offset counts bytes buffered after this sample; the slice math matches - // media3's TrackOutput contract even though subtitle samples are - // normally delivered one at a time with offset 0. - val start = bufLen - offset - size - if (timeUs != C.TIME_UNSET && start >= 0) { - feedDialogue(timeUs, start, size) - } - if (offset == 0) bufLen = 0 + if (isAss && timeUs.isValidTs) { + val sample = extractor.subtitleSample + val endIndex = findTokenIndex(sample.data, 1) + val lineIndex = findTokenIndex(sample.data, 2) + + val rawDuration = sample.data.decodeToString(endIndex, lineIndex - 1) + val durationUs = parseTimecodeUs(rawDuration) + + assHandler.readTrackDialogue( + trackId = trackId, + start = timeUs / 1000, + duration = durationUs / 1000, + data = sample.data, + offset = lineIndex, + length = sample.limit() - lineIndex + ) } delegate.sampleMetadata(timeUs, flags, size, offset, cryptoData) } - private fun feedDialogue(timeUs: Long, start: Int, size: Int) { - val end = start + size - val durationIndex = findTokenIndex(start, end, 1) - val lineIndex = findTokenIndex(start, end, 2) - if (durationIndex <= start || lineIndex <= durationIndex) return - val rawDuration = buf.decodeToString(durationIndex, lineIndex - 1) - val durationUs = parseTimecodeUs(rawDuration) - if (durationUs == C.TIME_UNSET) return - dialogueSink.onDialogue( - trackId, - timeUs / 1000, - durationUs / 1000, - buf, - lineIndex, - end - lineIndex - ) - } - private fun parseTimecodeUs(timeString: String): Long { val matcher = SSA_TIMECODE_PATTERN.matcher(timeString.trim { it <= ' ' }) if (!matcher.matches()) { @@ -132,32 +72,24 @@ class AssTrackOutput( return timestampUs } - /** Index just past the [tokenNumber]th comma inside [from, to), or [from]. */ - private fun findTokenIndex(from: Int, to: Int, tokenNumber: Int): Int { + private fun findTokenIndex(array: ByteArray, tokenNumber: Int): Int { + if (tokenNumber == 0) return 0 var tokensFound = 0 - for (index in from until to) { - if (buf[index] == COMMA && ++tokensFound == tokenNumber) { + array.forEachIndexed { index, byte -> + if (byte == COMMA && ++tokensFound == tokenNumber) { return index + 1 } } - return from + return 0 } - private fun append(src: ByteArray, length: Int) { - ensureCapacity(bufLen + length) - System.arraycopy(src, 0, buf, bufLen, length) - bufLen += length - } - - private fun ensureCapacity(needed: Int) { - if (buf.size < needed) buf = buf.copyOf(maxOf(needed, buf.size * 2)) - } + private val Long.isValidTs + get() = this != C.TIME_UNSET private companion object { val SSA_TIMECODE_PATTERN: Pattern = Pattern.compile("""(?:(\d+):)?(\d+):(\d+)[:.](\d+)""") const val COMMA = ','.code.toByte() - const val INITIAL_BUFFER_SIZE = 1024 } } diff --git a/android/libass/src/test/java/com/edde746/plezy/libass/media/extractor/AssMatroskaExtractorTest.kt b/android/libass/src/test/java/com/edde746/plezy/libass/media/extractor/AssMatroskaExtractorTest.kt new file mode 100644 index 000000000..9a8e2ee75 --- /dev/null +++ b/android/libass/src/test/java/com/edde746/plezy/libass/media/extractor/AssMatroskaExtractorTest.kt @@ -0,0 +1,156 @@ +package com.edde746.plezy.libass.media.extractor + +import androidx.media3.common.C +import androidx.media3.common.DataReader +import androidx.media3.common.ParserException +import androidx.media3.extractor.DefaultExtractorInput +import androidx.media3.extractor.ExtractorInput +import androidx.media3.extractor.text.DefaultSubtitleParserFactory +import com.edde746.plezy.libass.media.AssHandler +import java.io.EOFException +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test + +class AssMatroskaExtractorTest { + + @Test + fun smallFontIsDeliveredAndNonFontDoesNotConsumeBudget() { + val handler = AssHandler() + val extractor = extractor(handler) + + attachment(extractor, "font/ttf") + extractor.fileData(4, input(4, seed = 11)) + attachment(extractor, "application/octet-stream") + extractor.fileData(9, input(9, seed = 22)) + + val pending = handler.fontStore.pendingSnapshot() + assertEquals(1, pending.size) + assertEquals("fixture-font", pending.single().first) + assertArrayEquals(byteArrayOf(11, 12, 13, 14), pending.single().second) + assertEquals(4L, extractor.acceptedFontBytes) + } + + @Test + fun perFontLimitAcceptsExactBoundaryAndSkipsOneByteOver() { + val handler = AssHandler() + val extractor = extractor(handler) + val limit = AssMatroskaExtractor.MAX_FONT_BYTES.toInt() + + attachment(extractor, "font/otf") + extractor.fileData(limit, input(limit)) + attachment(extractor, "font/otf") + extractor.fileData(limit + 1, input(limit + 1)) + + assertEquals(limit.toLong(), extractor.acceptedFontBytes) + assertEquals(1, handler.fontStore.pendingSnapshot().size) + } + + @Test + fun aggregateLimitIsDeterministicAcrossAttachmentEntries() { + val handler = AssHandler() + val extractor = extractor(handler) + val perFont = AssMatroskaExtractor.MAX_FONT_BYTES.toInt() + + repeat(2) { + attachment(extractor, "font/ttf") + extractor.fileData(perFont, input(perFont, seed = it)) + extractor.endAttachment() + } + attachment(extractor, "font/ttf") + extractor.fileData(1, input(1)) + + assertEquals(AssMatroskaExtractor.MAX_TOTAL_FONT_BYTES, extractor.acceptedFontBytes) + assertEquals(2, handler.fontStore.pendingSnapshot().size) + } + + @Test + fun negativeAndZeroSizesAllocateAndDeliverNothing() { + val handler = AssHandler() + val extractor = extractor(handler) + attachment(extractor, "font/woff2") + + assertThrows(ParserException::class.java) { + extractor.fileData(-1, input(0)) + } + attachment(extractor, "font/woff2") + extractor.fileData(0, input(0)) + + assertEquals(0L, extractor.acceptedFontBytes) + assertEquals(0, handler.fontStore.pendingSnapshot().size) + } + + @Test + fun failedReadDoesNotChargeAggregateBudgetOrDeliverPartialFont() { + val handler = AssHandler() + val extractor = extractor(handler) + attachment(extractor, "font/ttf") + + assertThrows(EOFException::class.java) { + extractor.fileData(1024, input(1024, available = 4)) + } + assertEquals(0L, extractor.acceptedFontBytes) + assertEquals(0, handler.fontStore.pendingSnapshot().size) + + attachment(extractor, "font/ttf") + extractor.fileData(1024, input(1024)) + assertEquals(1024L, extractor.acceptedFontBytes) + assertEquals(1, handler.fontStore.pendingSnapshot().size) + } + + private fun extractor(handler: AssHandler) = TestExtractor(handler) + + private fun attachment(extractor: TestExtractor, mime: String) { + extractor.setAttachment(mime) + } + + private class TestExtractor(handler: AssHandler) : + AssMatroskaExtractor( + DefaultSubtitleParserFactory(), + handler + ) { + fun setAttachment(mime: String) { + startMasterElement(ID_ATTACHED_FILE, 0, 0) + stringElement(ID_FILE_NAME, "fixture-font") + stringElement(ID_FILE_MIME_TYPE, mime) + } + + fun fileData(contentSize: Int, input: ExtractorInput) { + binaryElement(ID_FILE_DATA, contentSize, input) + } + + fun endAttachment() { + endMasterElement(ID_ATTACHED_FILE) + } + + override fun onFontRejected(contentSize: Int, acceptedBytes: Long, reason: String) = Unit + } + + private fun input( + declared: Int, + available: Int = declared, + seed: Int = 0 + ): ExtractorInput = DefaultExtractorInput( + PatternDataReader(available, seed), + 0, + declared.toLong() + ) + + private class PatternDataReader( + private val size: Int, + private val seed: Int + ) : DataReader { + private var position = 0 + + override fun read(buffer: ByteArray, offset: Int, length: Int): Int { + if (position >= size) return C.RESULT_END_OF_INPUT + val count = minOf(length, size - position) + for (index in 0 until count) { + buffer[offset + index] = ((seed + position + index) and 0xFF).toByte() + } + position += count + return count + } + } +} diff --git a/android/libass/src/test/java/com/edde746/plezy/libass/media/text/AssTrackOutputTest.kt b/android/libass/src/test/java/com/edde746/plezy/libass/media/text/AssTrackOutputTest.kt deleted file mode 100644 index ac9e1a6a6..000000000 --- a/android/libass/src/test/java/com/edde746/plezy/libass/media/text/AssTrackOutputTest.kt +++ /dev/null @@ -1,157 +0,0 @@ -package com.edde746.plezy.libass.media.text - -import androidx.media3.common.C -import androidx.media3.common.Format -import androidx.media3.common.MimeTypes -import androidx.media3.common.util.ParsableByteArray -import androidx.media3.extractor.TrackOutput -import org.junit.Assert.assertArrayEquals -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config - -/** - * Spec for the dialogue feed both demuxer paths rely on: samples arrive in - * media3's MatroskaExtractor shape ("Dialogue: 0:00:00:00,,") - * and the sink must receive the sample start, the parsed duration, and the - * payload after the second comma — while every byte is still forwarded - * unchanged to the delegate. - */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34]) -class AssTrackOutputTest { - - private class CapturedDialogue(val trackId: String?, val startMs: Long, val durationMs: Long, val line: ByteArray) - - private class RecordingSink : AssTrackOutput.DialogueSink { - val dialogues = mutableListOf() - - override fun onDialogue(trackId: String?, startMs: Long, durationMs: Long, data: ByteArray, offset: Int, length: Int) { - dialogues.add(CapturedDialogue(trackId, startMs, durationMs, data.copyOfRange(offset, offset + length))) - } - } - - private class RecordingTrackOutput : TrackOutput { - val formats = mutableListOf() - val samples = mutableListOf() - val sampleTimes = mutableListOf() - private var buf = ByteArray(1024) - private var bufLen = 0 - - override fun format(format: Format) { - formats.add(format) - } - - override fun sampleData(input: androidx.media3.common.DataReader, length: Int, allowEndOfInput: Boolean, sampleDataPart: Int): Int { - if (buf.size < bufLen + length) buf = buf.copyOf(bufLen + length) - val read = input.read(buf, bufLen, length) - if (read > 0) bufLen += read - return read - } - - override fun sampleData(data: ParsableByteArray, length: Int, sampleDataPart: Int) { - if (buf.size < bufLen + length) buf = buf.copyOf(bufLen + length) - data.readBytes(buf, bufLen, length) - bufLen += length - } - - override fun sampleMetadata(timeUs: Long, flags: Int, size: Int, offset: Int, cryptoData: TrackOutput.CryptoData?) { - val start = bufLen - offset - size - samples.add(buf.copyOfRange(start, start + size)) - sampleTimes.add(timeUs) - if (offset == 0) bufLen = 0 - } - } - - private fun ssaFormat(id: String = "3"): Format = Format.Builder() - .setId(id) - .setSampleMimeType(MimeTypes.TEXT_SSA) - .build() - - private fun deliver(output: AssTrackOutput, sample: String, timeUs: Long) { - val bytes = sample.toByteArray(Charsets.UTF_8) - output.sampleData(ParsableByteArray(bytes), bytes.size, TrackOutput.SAMPLE_DATA_PART_MAIN) - output.sampleMetadata(timeUs, C.BUFFER_FLAG_KEY_FRAME, bytes.size, 0, null) - } - - @Test - fun feedsDialogueAndForwardsSampleUnchanged() { - val sink = RecordingSink() - val delegate = RecordingTrackOutput() - val output = AssTrackOutput(delegate, sink) - output.format(ssaFormat()) - - val sample = "Dialogue: 0:00:00:00,0:00:05:00,7,0,Default,,0,0,0,,Hello, world" - deliver(output, sample, 90_000_000L) - - assertEquals(1, sink.dialogues.size) - val dialogue = sink.dialogues.first() - assertEquals("3", dialogue.trackId) - assertEquals(90_000L, dialogue.startMs) - assertEquals(5_000L, dialogue.durationMs) - // Payload after the second comma: the raw MKV/FFmpeg block, commas in the - // text field intact. - assertEquals("7,0,Default,,0,0,0,,Hello, world", dialogue.line.toString(Charsets.UTF_8)) - - // The delegate still receives the full untouched sample. - assertEquals(1, delegate.samples.size) - assertArrayEquals(sample.toByteArray(Charsets.UTF_8), delegate.samples.first()) - assertEquals(90_000_000L, delegate.sampleTimes.first()) - } - - @Test - fun ignoresNonAssTracksAndMalformedSamples() { - val sink = RecordingSink() - val delegate = RecordingTrackOutput() - val output = AssTrackOutput(delegate, sink) - - // SubRip track: forwarded, never fed to libass. - output.format(Format.Builder().setId("1").setSampleMimeType(MimeTypes.APPLICATION_SUBRIP).build()) - deliver(output, "1\n00:00:00,000 --> 00:00:05,000\nplain text", 1_000_000L) - assertTrue(sink.dialogues.isEmpty()) - assertEquals(1, delegate.samples.size) - - // ASS track with an unparseable duration: forwarded, not fed. - val assOutput = AssTrackOutput(RecordingTrackOutput(), sink) - assOutput.format(ssaFormat()) - deliver(assOutput, "Dialogue: bogus", 1_000_000L) - assertTrue(sink.dialogues.isEmpty()) - } - - @Test - fun handlesChunkedSampleDataAndDataReaderPath() { - val sink = RecordingSink() - val delegate = RecordingTrackOutput() - val output = AssTrackOutput(delegate, sink) - output.format(ssaFormat("0")) - - val sample = "Dialogue: 0:00:00:00,0:00:01:50,1,0,Style,,0,0,0,,Line" - val bytes = sample.toByteArray(Charsets.UTF_8) - val split = bytes.size / 2 - output.sampleData(ParsableByteArray(bytes.copyOfRange(0, split)), split, TrackOutput.SAMPLE_DATA_PART_MAIN) - val rest = bytes.copyOfRange(split, bytes.size) - var consumed = 0 - val reader = androidx.media3.common.DataReader { buffer, offset, length -> - if (consumed >= rest.size) { - C.RESULT_END_OF_INPUT - } else { - val toRead = minOf(length, rest.size - consumed) - System.arraycopy(rest, consumed, buffer, offset, toRead) - consumed += toRead - toRead - } - } - while (consumed < rest.size) { - output.sampleData(reader, rest.size - consumed, false, TrackOutput.SAMPLE_DATA_PART_MAIN) - } - output.sampleMetadata(0L, C.BUFFER_FLAG_KEY_FRAME, bytes.size, 0, null) - - assertEquals(1, sink.dialogues.size) - assertEquals(1_500L, sink.dialogues.first().durationMs) - assertEquals("1,0,Style,,0,0,0,,Line", sink.dialogues.first().line.toString(Charsets.UTF_8)) - assertArrayEquals(bytes, delegate.samples.first()) - } -} diff --git a/lib/i18n/az.i18n.json b/lib/i18n/az.i18n.json index f6f116606..43a464174 100644 --- a/lib/i18n/az.i18n.json +++ b/lib/i18n/az.i18n.json @@ -332,10 +332,6 @@ "dvConversionNativeDescription": "Daxili DV7-ni məcburi et", "dvConversionDv81Description": "Dolby Vision profile 8.1-ə çevrilməni məcburi et", "dvConversionHevcStripDescription": "Dolby Vision təbəqələrini sil və sadə HEVC kimi təqdim et", - "demuxer": "Konteyner demukseri", - "demuxerDescription": "Birbaşa oxudulan faylları hansı demukserin təhlil edəcəyi. Fayl düzgün işləmirsə, media3-ə keçin.", - "demuxerFfmpeg": "FFmpeg (Tövsiyə olunan)", - "demuxerMedia3": "media3", "requireProfileSelectionOnOpen": "Açılışda profil soruş", "requireProfileSelectionOnOpenDescription": "Tətbiq hər dəfə açıldıqda profil seçimini göstər", "forceTvMode": "TV rejimini məcburi et", diff --git a/lib/i18n/bg.i18n.json b/lib/i18n/bg.i18n.json index 52a0d7845..216beafea 100644 --- a/lib/i18n/bg.i18n.json +++ b/lib/i18n/bg.i18n.json @@ -332,10 +332,6 @@ "dvConversionNativeDescription": "Принуждава директно възпроизвеждане на DV7 и изключва повторния опит за преобразуване", "dvConversionDv81Description": "Принуждава директно преобразуване на RPU към Dolby Vision Profile 8.1", "dvConversionHevcStripDescription": "Премахва слоевете Dolby Vision RPU/EL и подава обикновен HEVC поток", - "demuxer": "Демултиплексор на контейнера", - "demuxerDescription": "Кой демултиплексор анализира файловете при директно възпроизвеждане. Превключете на media3, ако даден файл създава проблеми.", - "demuxerFfmpeg": "FFmpeg (препоръчително)", - "demuxerMedia3": "media3", "requireProfileSelectionOnOpen": "Питай за профил при отваряне на приложението", "requireProfileSelectionOnOpenDescription": "Показвай избор на профил всеки път при отваряне на приложението", "forceTvMode": "Принуди TV режим", diff --git a/lib/i18n/da.i18n.json b/lib/i18n/da.i18n.json index 9a028b9fb..cdaf3a5f5 100644 --- a/lib/i18n/da.i18n.json +++ b/lib/i18n/da.i18n.json @@ -332,10 +332,6 @@ "dvConversionNativeDescription": "Gennemtving indbygget DV7-understøttelse, og undlad at forsøge DV-konvertering igen", "dvConversionDv81Description": "Tving inline RPU-konvertering til Dolby Vision profil 8.1", "dvConversionHevcStripDescription": "Fjern Dolby Vision RPU/EL-lag og brug almindelig HEVC", - "demuxer": "Container-demuxer", - "demuxerDescription": "Hvilken demuxer der læser direkte afspillede filer. Skift til media3, hvis en fil driller.", - "demuxerFfmpeg": "FFmpeg (anbefalet)", - "demuxerMedia3": "media3", "requireProfileSelectionOnOpen": "Spørg om profil ved åbning", "requireProfileSelectionOnOpenDescription": "Vis profilvalg hver gang appen åbnes", "forceTvMode": "Gennemtving TV-tilstand", diff --git a/lib/i18n/de.i18n.json b/lib/i18n/de.i18n.json index fae89e334..2080814e4 100644 --- a/lib/i18n/de.i18n.json +++ b/lib/i18n/de.i18n.json @@ -332,10 +332,6 @@ "dvConversionNativeDescription": "Natives DV7 erzwingen und einen erneuten DV-Konvertierungsversuch unterdrücken", "dvConversionDv81Description": "Inline-RPU-Konvertierung in Dolby-Vision-Profil 8.1 erzwingen", "dvConversionHevcStripDescription": "Dolby-Vision-RPU/EL-Schichten entfernen und reines HEVC ausgeben", - "demuxer": "Container-Demuxer", - "demuxerDescription": "Welcher Demuxer direkt abgespielte Dateien einliest. Wechsle zu media3, wenn eine Datei Probleme macht.", - "demuxerFfmpeg": "FFmpeg (empfohlen)", - "demuxerMedia3": "media3", "requireProfileSelectionOnOpen": "Profil beim Öffnen abfragen", "requireProfileSelectionOnOpenDescription": "Profilauswahl bei jedem Öffnen der App anzeigen", "forceTvMode": "TV-Modus erzwingen", diff --git a/lib/i18n/en.i18n.json b/lib/i18n/en.i18n.json index d5b39dbcd..3b97cd2ba 100644 --- a/lib/i18n/en.i18n.json +++ b/lib/i18n/en.i18n.json @@ -332,10 +332,6 @@ "dvConversionNativeDescription": "Force native DV7 and suppress DV conversion retry", "dvConversionDv81Description": "Force inline RPU conversion to Dolby Vision profile 8.1", "dvConversionHevcStripDescription": "Strip Dolby Vision RPU/EL layers and present plain HEVC", - "demuxer": "Container Demuxer", - "demuxerDescription": "Which demuxer parses direct-played files. Switch to media3 if a file misbehaves.", - "demuxerFfmpeg": "FFmpeg (Recommended)", - "demuxerMedia3": "media3", "requireProfileSelectionOnOpen": "Ask for profile on app open", "requireProfileSelectionOnOpenDescription": "Show profile selection every time the app is opened", "forceTvMode": "Force TV mode", diff --git a/lib/i18n/es.i18n.json b/lib/i18n/es.i18n.json index 9087e35b1..7a7e59477 100644 --- a/lib/i18n/es.i18n.json +++ b/lib/i18n/es.i18n.json @@ -332,10 +332,6 @@ "dvConversionNativeDescription": "Forzar DV7 nativo y suprimir el reintento de conversión DV", "dvConversionDv81Description": "Forzar la conversión de RPU en línea al perfil 8.1 de Dolby Vision", "dvConversionHevcStripDescription": "Eliminar las capas RPU/EL de Dolby Vision y presentar HEVC convencional", - "demuxer": "Demultiplexor de contenedor", - "demuxerDescription": "Qué demultiplexor analiza los archivos de reproducción directa. Cambia a media3 si un archivo da problemas.", - "demuxerFfmpeg": "FFmpeg (recomendado)", - "demuxerMedia3": "media3", "requireProfileSelectionOnOpen": "Pedir perfil al abrir la aplicación", "requireProfileSelectionOnOpenDescription": "Mostrar selección de perfil cada vez que se abre la aplicación", "forceTvMode": "Forzar modo TV", diff --git a/lib/i18n/fr.i18n.json b/lib/i18n/fr.i18n.json index 34305840a..303e8e686 100644 --- a/lib/i18n/fr.i18n.json +++ b/lib/i18n/fr.i18n.json @@ -332,10 +332,6 @@ "dvConversionNativeDescription": "Forcer le DV7 natif et bloquer la nouvelle tentative de conversion DV", "dvConversionDv81Description": "Forcer la conversion RPU intégrée vers le profil 8.1 de Dolby Vision", "dvConversionHevcStripDescription": "Supprimer les couches RPU/EL Dolby Vision et présenter du HEVC simple", - "demuxer": "Démultiplexeur de conteneur", - "demuxerDescription": "Quel démultiplexeur analyse les fichiers lus en direct. Passez à media3 si un fichier pose problème.", - "demuxerFfmpeg": "FFmpeg (recommandé)", - "demuxerMedia3": "media3", "requireProfileSelectionOnOpen": "Demander le profil à l'ouverture", "requireProfileSelectionOnOpenDescription": "Afficher la sélection de profil à chaque ouverture de l'application", "forceTvMode": "Forcer le mode TV", diff --git a/lib/i18n/hu.i18n.json b/lib/i18n/hu.i18n.json index 54d69edf7..f24846ba1 100644 --- a/lib/i18n/hu.i18n.json +++ b/lib/i18n/hu.i18n.json @@ -332,10 +332,6 @@ "dvConversionNativeDescription": "A natív DV7 kényszerítése és a DV-átalakítási újrapróbálkozás letiltása", "dvConversionDv81Description": "A közvetlen RPU-átalakítás kényszerítése Dolby Vision Profile 8.1-re", "dvConversionHevcStripDescription": "A Dolby Vision RPU/EL-rétegek eltávolítása és egyszerű HEVC-ként való megjelenítés", - "demuxer": "Konténer-demuxer", - "demuxerDescription": "Melyik demuxer dolgozza fel a közvetlenül lejátszott fájlokat. Válts media3-ra, ha egy fájl hibásan viselkedik.", - "demuxerFfmpeg": "FFmpeg (ajánlott)", - "demuxerMedia3": "media3", "requireProfileSelectionOnOpen": "Profil kérése az alkalmazás megnyitásakor", "requireProfileSelectionOnOpenDescription": "Profilválasztó megjelenítése minden alkalommal, amikor az alkalmazást megnyitod", "forceTvMode": "TV-mód kényszerítése", diff --git a/lib/i18n/it.i18n.json b/lib/i18n/it.i18n.json index 776ed1138..e8202ff68 100644 --- a/lib/i18n/it.i18n.json +++ b/lib/i18n/it.i18n.json @@ -332,10 +332,6 @@ "dvConversionNativeDescription": "Forza il DV7 nativo e impedisce nuovi tentativi di conversione DV", "dvConversionDv81Description": "Forza la conversione RPU diretta al profilo Dolby Vision 8.1", "dvConversionHevcStripDescription": "Rimuove i livelli RPU/EL di Dolby Vision e riproduce il video come semplice HEVC", - "demuxer": "Demuxer del contenitore", - "demuxerDescription": "Quale demuxer analizza i file riprodotti in diretta. Passa a media3 se un file dà problemi.", - "demuxerFfmpeg": "FFmpeg (consigliato)", - "demuxerMedia3": "media3", "requireProfileSelectionOnOpen": "Chiedi di scegliere il profilo all'apertura", "requireProfileSelectionOnOpenDescription": "Mostra la selezione del profilo ogni volta che l'app viene aperta", "forceTvMode": "Forza modalità TV", diff --git a/lib/i18n/ja.i18n.json b/lib/i18n/ja.i18n.json index 7459ca3d8..b69ef7141 100644 --- a/lib/i18n/ja.i18n.json +++ b/lib/i18n/ja.i18n.json @@ -332,10 +332,6 @@ "dvConversionNativeDescription": "ネイティブ DV7 を強制し、DV 変換の再試行を抑制します", "dvConversionDv81Description": "Dolby Vision プロファイル 8.1 へのインライン RPU 変換を強制します", "dvConversionHevcStripDescription": "Dolby Vision の RPU/EL レイヤーを削除し、通常の HEVC として扱います", - "demuxer": "コンテナデマルチプレクサ", - "demuxerDescription": "ダイレクト再生のファイルを解析するデマルチプレクサ。ファイルの再生に問題がある場合は media3 に切り替えてください。", - "demuxerFfmpeg": "FFmpeg(推奨)", - "demuxerMedia3": "media3", "requireProfileSelectionOnOpen": "アプリ起動時にプロフィールを確認", "requireProfileSelectionOnOpenDescription": "アプリを開くたびにプロフィール選択を表示", "forceTvMode": "TVモードを強制", diff --git a/lib/i18n/kk.i18n.json b/lib/i18n/kk.i18n.json index f6e2c9d4a..d8bad7326 100644 --- a/lib/i18n/kk.i18n.json +++ b/lib/i18n/kk.i18n.json @@ -332,10 +332,6 @@ "dvConversionNativeDescription": "Ішкі DV7 режимін мәжбүрлеу", "dvConversionDv81Description": "Dolby Vision profile 8.1 форматына түрлендіру", "dvConversionHevcStripDescription": "Dolby Vision қабаттарын алып тастап, HEVC ретінде көрсету", - "demuxer": "Контейнер демультиплексоры", - "demuxerDescription": "Тікелей ойнатылатын файлдарды қай демультиплексор талдайды. Файл дұрыс ойнатылмаса, media3-ке ауысыңыз.", - "demuxerFfmpeg": "FFmpeg (ұсынылады)", - "demuxerMedia3": "media3", "requireProfileSelectionOnOpen": "Ашқанда профильді сұрау", "requireProfileSelectionOnOpenDescription": "Қолданба ашылған сайын профильді таңдауды көрсету", "forceTvMode": "TV режимін мәжбүрлеу", diff --git a/lib/i18n/ko.i18n.json b/lib/i18n/ko.i18n.json index 115dc99e6..4ed6156c3 100644 --- a/lib/i18n/ko.i18n.json +++ b/lib/i18n/ko.i18n.json @@ -332,10 +332,6 @@ "dvConversionNativeDescription": "네이티브 DV7을 강제하고 DV 변환 재시도를 억제합니다", "dvConversionDv81Description": "Dolby Vision 프로필 8.1로 인라인 RPU 변환을 강제합니다", "dvConversionHevcStripDescription": "Dolby Vision RPU/EL 레이어를 제거하고 일반 HEVC로 제공합니다", - "demuxer": "컨테이너 디먹서", - "demuxerDescription": "다이렉트 재생 파일을 분석할 디먹서입니다. 파일이 제대로 재생되지 않으면 media3으로 전환하세요.", - "demuxerFfmpeg": "FFmpeg (권장)", - "demuxerMedia3": "media3", "requireProfileSelectionOnOpen": "앱 실행 시 프로필 선택", "requireProfileSelectionOnOpenDescription": "앱을 열 때마다 프로필 선택 화면을 표시합니다", "forceTvMode": "TV 모드 강제 사용", diff --git a/lib/i18n/nb.i18n.json b/lib/i18n/nb.i18n.json index 198e7a9dc..68448a822 100644 --- a/lib/i18n/nb.i18n.json +++ b/lib/i18n/nb.i18n.json @@ -332,10 +332,6 @@ "dvConversionNativeDescription": "Tving opprinnelig DV7-avspilling og ikke prøv DV-konvertering på nytt", "dvConversionDv81Description": "Tving direkte RPU-konvertering til Dolby Vision-profil 8.1", "dvConversionHevcStripDescription": "Fjern Dolby Vision RPU/EL-lag og lever som vanlig HEVC", - "demuxer": "Container-demukser", - "demuxerDescription": "Hvilken demukser som leser direkteavspilte filer. Bytt til media3 hvis en fil oppfører seg rart.", - "demuxerFfmpeg": "FFmpeg (anbefalt)", - "demuxerMedia3": "media3", "requireProfileSelectionOnOpen": "Spør om profil ved appåpning", "requireProfileSelectionOnOpenDescription": "Vis profilvalg hver gang appen åpnes", "forceTvMode": "Tving TV-modus", diff --git a/lib/i18n/nl.i18n.json b/lib/i18n/nl.i18n.json index e37dcf70d..aaca49138 100644 --- a/lib/i18n/nl.i18n.json +++ b/lib/i18n/nl.i18n.json @@ -332,10 +332,6 @@ "dvConversionNativeDescription": "Dwing native DV7 af en voorkom een nieuwe poging met DV-conversie", "dvConversionDv81Description": "Dwing directe RPU-conversie naar Dolby Vision-profiel 8.1 af", "dvConversionHevcStripDescription": "Verwijder Dolby Vision RPU/EL-lagen en bied gewone HEVC aan", - "demuxer": "Container-demuxer", - "demuxerDescription": "Welke demuxer direct afgespeelde bestanden inleest. Schakel over naar media3 als een bestand zich vreemd gedraagt.", - "demuxerFfmpeg": "FFmpeg (aanbevolen)", - "demuxerMedia3": "media3", "requireProfileSelectionOnOpen": "Vraag om profiel bij openen", "requireProfileSelectionOnOpenDescription": "Toon profielselectie telkens wanneer de app wordt geopend", "forceTvMode": "Tv-modus afdwingen", diff --git a/lib/i18n/pl.i18n.json b/lib/i18n/pl.i18n.json index 69d17921f..1bc22a322 100644 --- a/lib/i18n/pl.i18n.json +++ b/lib/i18n/pl.i18n.json @@ -332,10 +332,6 @@ "dvConversionNativeDescription": "Wymuś natywne DV7 i wyłącz ponowną próbę konwersji DV", "dvConversionDv81Description": "Wymuś wbudowaną konwersję RPU do profilu Dolby Vision 8.1", "dvConversionHevcStripDescription": "Usuń warstwy Dolby Vision RPU/EL i przedstaw zwykłe HEVC", - "demuxer": "Demukser kontenera", - "demuxerDescription": "Który demukser analizuje pliki odtwarzane bezpośrednio. Przełącz na media3, jeśli plik sprawia problemy.", - "demuxerFfmpeg": "FFmpeg (zalecany)", - "demuxerMedia3": "media3", "requireProfileSelectionOnOpen": "Pytaj o profil przy otwarciu aplikacji", "requireProfileSelectionOnOpenDescription": "Pokaż wybór profilu za każdym razem, gdy aplikacja jest otwierana", "forceTvMode": "Wymuś tryb TV", diff --git a/lib/i18n/pt.i18n.json b/lib/i18n/pt.i18n.json index cf920a7c1..01643a50a 100644 --- a/lib/i18n/pt.i18n.json +++ b/lib/i18n/pt.i18n.json @@ -332,10 +332,6 @@ "dvConversionNativeDescription": "Forçar DV7 nativo e impedir uma nova tentativa de conversão de DV", "dvConversionDv81Description": "Forçar a conversão RPU integrada para Dolby Vision perfil 8.1", "dvConversionHevcStripDescription": "Remover as camadas RPU/EL do Dolby Vision e apresentar HEVC sem Dolby Vision", - "demuxer": "Demuxer de contêiner", - "demuxerDescription": "Qual demuxer processa os arquivos reproduzidos diretamente. Mude para media3 se algum arquivo apresentar problemas.", - "demuxerFfmpeg": "FFmpeg (recomendado)", - "demuxerMedia3": "media3", "requireProfileSelectionOnOpen": "Pedir perfil ao abrir o app", "requireProfileSelectionOnOpenDescription": "Mostrar a seleção de perfil sempre que o app for aberto", "forceTvMode": "Forçar modo TV", diff --git a/lib/i18n/ru.i18n.json b/lib/i18n/ru.i18n.json index 62a0e1347..df6a4bcef 100644 --- a/lib/i18n/ru.i18n.json +++ b/lib/i18n/ru.i18n.json @@ -332,10 +332,6 @@ "dvConversionNativeDescription": "Принудительно использовать нативный DV7 и не повторять DV-конвертацию", "dvConversionDv81Description": "Принудительно выполнять inline-конвертацию RPU в Dolby Vision профиль 8.1", "dvConversionHevcStripDescription": "Удалять слои Dolby Vision RPU/EL и передавать обычный HEVC", - "demuxer": "Демультиплексор контейнера", - "demuxerDescription": "Какой демультиплексор разбирает файлы при прямом воспроизведении. Переключитесь на media3, если файл воспроизводится неправильно.", - "demuxerFfmpeg": "FFmpeg (рекомендуется)", - "demuxerMedia3": "media3", "requireProfileSelectionOnOpen": "Запрашивать профиль при запуске", "requireProfileSelectionOnOpenDescription": "Показывать выбор профиля при каждом открытии приложения", "forceTvMode": "Принудительный режим ТВ", diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart index 314ece5b7..3fd7de1fe 100644 --- a/lib/i18n/strings.g.dart +++ b/lib/i18n/strings.g.dart @@ -4,7 +4,7 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 22 -/// Strings: 41706 (1895 per locale) +/// Strings: 41618 (1891 per locale) // coverage:ignore-file // ignore_for_file: type=lint, unused_import diff --git a/lib/i18n/strings_az.g.dart b/lib/i18n/strings_az.g.dart index 4d7ca408a..28132b766 100644 --- a/lib/i18n/strings_az.g.dart +++ b/lib/i18n/strings_az.g.dart @@ -439,10 +439,6 @@ class _Translations$settings$az extends Translations$settings$en { @override String get dvConversionNativeDescription => 'Daxili DV7-ni məcburi et'; @override String get dvConversionDv81Description => 'Dolby Vision profile 8.1-ə çevrilməni məcburi et'; @override String get dvConversionHevcStripDescription => 'Dolby Vision təbəqələrini sil və sadə HEVC kimi təqdim et'; - @override String get demuxer => 'Konteyner demukseri'; - @override String get demuxerDescription => 'Birbaşa oxudulan faylları hansı demukserin təhlil edəcəyi. Fayl düzgün işləmirsə, media3-ə keçin.'; - @override String get demuxerFfmpeg => 'FFmpeg (Tövsiyə olunan)'; - @override String get demuxerMedia3 => 'media3'; @override String get requireProfileSelectionOnOpen => 'Açılışda profil soruş'; @override String get requireProfileSelectionOnOpenDescription => 'Tətbiq hər dəfə açıldıqda profil seçimini göstər'; @override String get forceTvMode => 'TV rejimini məcburi et'; @@ -3089,10 +3085,6 @@ extension on TranslationsAz { 'settings.dvConversionNativeDescription' => 'Daxili DV7-ni məcburi et', 'settings.dvConversionDv81Description' => 'Dolby Vision profile 8.1-ə çevrilməni məcburi et', 'settings.dvConversionHevcStripDescription' => 'Dolby Vision təbəqələrini sil və sadə HEVC kimi təqdim et', - 'settings.demuxer' => 'Konteyner demukseri', - 'settings.demuxerDescription' => 'Birbaşa oxudulan faylları hansı demukserin təhlil edəcəyi. Fayl düzgün işləmirsə, media3-ə keçin.', - 'settings.demuxerFfmpeg' => 'FFmpeg (Tövsiyə olunan)', - 'settings.demuxerMedia3' => 'media3', 'settings.requireProfileSelectionOnOpen' => 'Açılışda profil soruş', 'settings.requireProfileSelectionOnOpenDescription' => 'Tətbiq hər dəfə açıldıqda profil seçimini göstər', 'settings.forceTvMode' => 'TV rejimini məcburi et', @@ -3284,12 +3276,12 @@ extension on TranslationsAz { 'mediaMenu.markAsUnwatched' => 'Baxılmadı olaraq işarələ', 'mediaMenu.removeFromContinueWatching' => 'İzləməyə davam et-dən sil', 'mediaMenu.viewDetails' => 'Ətraflı bax', - _ => null, - } ?? switch (path) { 'mediaMenu.goToSeries' => 'Seriala keç', 'mediaMenu.shufflePlay' => 'Qarışıq oynat', 'mediaMenu.shuffleNotAvailableOffline' => 'Qarışıq oynatma oflayn rejimdə əlçatan deyil', 'mediaMenu.fileInfo' => 'Fayl məlumatı', + _ => null, + } ?? switch (path) { 'mediaMenu.deleteEpisodeFromServer' => 'Seriyanı serverdən sil', 'mediaMenu.deleteSeasonFromServer' => 'Mövsümü serverdən sil', 'mediaMenu.deleteShowFromServer' => 'Serialı serverdən sil', @@ -3798,12 +3790,12 @@ extension on TranslationsAz { 'explore.rows.trendingShows' => 'Trend seriallar', 'explore.rows.popularMovies' => 'Məşhur kinolar', 'explore.rows.popularShows' => 'Məşhur seriallar', - _ => null, - } ?? switch (path) { 'explore.rows.trendingAnime' => 'Trend animelər', 'explore.rows.suggestedAnime' => 'Tövsiyə olunan animelər', 'explore.rows.airingAnime' => 'Ən yaxşı yayımlanan animelər', 'explore.rows.popularAnime' => 'Ən məşhur animelər', + _ => null, + } ?? switch (path) { 'explore.rows.trending' => 'Trendlər', 'explore.rows.upcomingMovies' => 'Gələcək kinolar', 'explore.rows.upcomingShows' => 'Gələcək seriallar', @@ -4312,12 +4304,12 @@ extension on TranslationsAz { 'companionRemote.pairing.validationHostFormat' => 'Format IP:port şəklində olmalıdır', 'companionRemote.pairing.connectionTimedOut' => 'Qoşulma vaxtı bitdi.', 'companionRemote.pairing.sessionNotFound' => 'Cihaz tapılmadı.', - _ => null, - } ?? switch (path) { 'companionRemote.pairing.authFailed' => 'Kimlik doğrulanması uğursuz oldu.', 'companionRemote.pairing.failedToConnect' => ({required Object error}) => 'Qoşulma uğursuz oldu: ${error}', 'companionRemote.remote.disconnectConfirm' => 'Pult seansından ayrılmaq istəyirsiniz?', 'companionRemote.remote.reconnecting' => 'Yenidən qoşulunur...', + _ => null, + } ?? switch (path) { 'companionRemote.remote.attemptOf' => ({required Object current}) => 'Cəhd ${current} / 5', 'companionRemote.remote.retryNow' => 'İndi təzədən cəhd et', 'companionRemote.remote.tabRemote' => 'Pult', diff --git a/lib/i18n/strings_bg.g.dart b/lib/i18n/strings_bg.g.dart index a74d226d4..75b0f5ff2 100644 --- a/lib/i18n/strings_bg.g.dart +++ b/lib/i18n/strings_bg.g.dart @@ -439,10 +439,6 @@ class _Translations$settings$bg extends Translations$settings$en { @override String get dvConversionNativeDescription => 'Принуждава директно възпроизвеждане на DV7 и изключва повторния опит за преобразуване'; @override String get dvConversionDv81Description => 'Принуждава директно преобразуване на RPU към Dolby Vision Profile 8.1'; @override String get dvConversionHevcStripDescription => 'Премахва слоевете Dolby Vision RPU/EL и подава обикновен HEVC поток'; - @override String get demuxer => 'Демултиплексор на контейнера'; - @override String get demuxerDescription => 'Кой демултиплексор анализира файловете при директно възпроизвеждане. Превключете на media3, ако даден файл създава проблеми.'; - @override String get demuxerFfmpeg => 'FFmpeg (препоръчително)'; - @override String get demuxerMedia3 => 'media3'; @override String get requireProfileSelectionOnOpen => 'Питай за профил при отваряне на приложението'; @override String get requireProfileSelectionOnOpenDescription => 'Показвай избор на профил всеки път при отваряне на приложението'; @override String get forceTvMode => 'Принуди TV режим'; @@ -3089,10 +3085,6 @@ extension on TranslationsBg { 'settings.dvConversionNativeDescription' => 'Принуждава директно възпроизвеждане на DV7 и изключва повторния опит за преобразуване', 'settings.dvConversionDv81Description' => 'Принуждава директно преобразуване на RPU към Dolby Vision Profile 8.1', 'settings.dvConversionHevcStripDescription' => 'Премахва слоевете Dolby Vision RPU/EL и подава обикновен HEVC поток', - 'settings.demuxer' => 'Демултиплексор на контейнера', - 'settings.demuxerDescription' => 'Кой демултиплексор анализира файловете при директно възпроизвеждане. Превключете на media3, ако даден файл създава проблеми.', - 'settings.demuxerFfmpeg' => 'FFmpeg (препоръчително)', - 'settings.demuxerMedia3' => 'media3', 'settings.requireProfileSelectionOnOpen' => 'Питай за профил при отваряне на приложението', 'settings.requireProfileSelectionOnOpenDescription' => 'Показвай избор на профил всеки път при отваряне на приложението', 'settings.forceTvMode' => 'Принуди TV режим', @@ -3284,12 +3276,12 @@ extension on TranslationsBg { 'mediaMenu.markAsUnwatched' => 'Маркирай като негледано', 'mediaMenu.removeFromContinueWatching' => 'Премахни от продължаване на гледането', 'mediaMenu.viewDetails' => 'Виж подробности', - _ => null, - } ?? switch (path) { 'mediaMenu.goToSeries' => 'Към сериала', 'mediaMenu.shufflePlay' => 'Разбъркано възпроизвеждане', 'mediaMenu.shuffleNotAvailableOffline' => 'Разбърканото възпроизвеждане не е налично офлайн', 'mediaMenu.fileInfo' => 'Информация за файла', + _ => null, + } ?? switch (path) { 'mediaMenu.deleteEpisodeFromServer' => 'Изтрий епизода от сървъра', 'mediaMenu.deleteSeasonFromServer' => 'Изтрий сезона от сървъра', 'mediaMenu.deleteShowFromServer' => 'Изтрий сериала от сървъра', @@ -3798,12 +3790,12 @@ extension on TranslationsBg { 'explore.rows.trendingShows' => 'Набиращи популярност сериали', 'explore.rows.popularMovies' => 'Популярни филми', 'explore.rows.popularShows' => 'Популярни сериали', - _ => null, - } ?? switch (path) { 'explore.rows.trendingAnime' => 'Набиращи популярност аниме', 'explore.rows.suggestedAnime' => 'Препоръчани аниме', 'explore.rows.airingAnime' => 'Топ излъчвани аниме', 'explore.rows.popularAnime' => 'Най-популярни аниме', + _ => null, + } ?? switch (path) { 'explore.rows.trending' => 'Набиращи популярност', 'explore.rows.upcomingMovies' => 'Предстоящи филми', 'explore.rows.upcomingShows' => 'Предстоящи сериали', @@ -4312,12 +4304,12 @@ extension on TranslationsBg { 'companionRemote.pairing.validationHostFormat' => 'Форматът трябва да е IP:port (напр. 192.168.1.100:48632)', 'companionRemote.pairing.connectionTimedOut' => 'Връзката изтече. Използвайте една и съща мрежа на двете устройства.', 'companionRemote.pairing.sessionNotFound' => 'Устройството не е намерено. Уверете се, че Plezy работи на хоста.', - _ => null, - } ?? switch (path) { 'companionRemote.pairing.authFailed' => 'Удостоверяването е неуспешно. Двете устройства трябва да използват същия Plex акаунт.', 'companionRemote.pairing.failedToConnect' => ({required Object error}) => 'Неуспешно свързване: ${error}', 'companionRemote.remote.disconnectConfirm' => 'Искате ли да прекъснете връзката с дистанционната сесия?', 'companionRemote.remote.reconnecting' => 'Повторно свързване...', + _ => null, + } ?? switch (path) { 'companionRemote.remote.attemptOf' => ({required Object current}) => 'Опит ${current} от 5', 'companionRemote.remote.retryNow' => 'Опитай сега', 'companionRemote.remote.tabRemote' => 'Дистанционно', diff --git a/lib/i18n/strings_da.g.dart b/lib/i18n/strings_da.g.dart index 11c1b7eff..897ee41f9 100644 --- a/lib/i18n/strings_da.g.dart +++ b/lib/i18n/strings_da.g.dart @@ -439,10 +439,6 @@ class _Translations$settings$da extends Translations$settings$en { @override String get dvConversionNativeDescription => 'Gennemtving indbygget DV7-understøttelse, og undlad at forsøge DV-konvertering igen'; @override String get dvConversionDv81Description => 'Tving inline RPU-konvertering til Dolby Vision profil 8.1'; @override String get dvConversionHevcStripDescription => 'Fjern Dolby Vision RPU/EL-lag og brug almindelig HEVC'; - @override String get demuxer => 'Container-demuxer'; - @override String get demuxerDescription => 'Hvilken demuxer der læser direkte afspillede filer. Skift til media3, hvis en fil driller.'; - @override String get demuxerFfmpeg => 'FFmpeg (anbefalet)'; - @override String get demuxerMedia3 => 'media3'; @override String get requireProfileSelectionOnOpen => 'Spørg om profil ved åbning'; @override String get requireProfileSelectionOnOpenDescription => 'Vis profilvalg hver gang appen åbnes'; @override String get forceTvMode => 'Gennemtving TV-tilstand'; @@ -3089,10 +3085,6 @@ extension on TranslationsDa { 'settings.dvConversionNativeDescription' => 'Gennemtving indbygget DV7-understøttelse, og undlad at forsøge DV-konvertering igen', 'settings.dvConversionDv81Description' => 'Tving inline RPU-konvertering til Dolby Vision profil 8.1', 'settings.dvConversionHevcStripDescription' => 'Fjern Dolby Vision RPU/EL-lag og brug almindelig HEVC', - 'settings.demuxer' => 'Container-demuxer', - 'settings.demuxerDescription' => 'Hvilken demuxer der læser direkte afspillede filer. Skift til media3, hvis en fil driller.', - 'settings.demuxerFfmpeg' => 'FFmpeg (anbefalet)', - 'settings.demuxerMedia3' => 'media3', 'settings.requireProfileSelectionOnOpen' => 'Spørg om profil ved åbning', 'settings.requireProfileSelectionOnOpenDescription' => 'Vis profilvalg hver gang appen åbnes', 'settings.forceTvMode' => 'Gennemtving TV-tilstand', @@ -3284,12 +3276,12 @@ extension on TranslationsDa { 'mediaMenu.markAsUnwatched' => 'Markér som uset', 'mediaMenu.removeFromContinueWatching' => 'Fjern fra Fortsæt med at se', 'mediaMenu.viewDetails' => 'Vis detaljer', - _ => null, - } ?? switch (path) { 'mediaMenu.goToSeries' => 'Gå til serie', 'mediaMenu.shufflePlay' => 'Afspil tilfældigt', 'mediaMenu.shuffleNotAvailableOffline' => 'Tilfældig afspilning er ikke tilgængelig offline', 'mediaMenu.fileInfo' => 'Filinfo', + _ => null, + } ?? switch (path) { 'mediaMenu.deleteEpisodeFromServer' => 'Slet episode fra serveren', 'mediaMenu.deleteSeasonFromServer' => 'Slet sæson fra serveren', 'mediaMenu.deleteShowFromServer' => 'Slet TV-serie fra serveren', @@ -3798,12 +3790,12 @@ extension on TranslationsDa { 'explore.rows.trendingShows' => 'Populære serier lige nu', 'explore.rows.popularMovies' => 'Populære film', 'explore.rows.popularShows' => 'Populære serier', - _ => null, - } ?? switch (path) { 'explore.rows.trendingAnime' => 'Populær anime lige nu', 'explore.rows.suggestedAnime' => 'Anbefalet anime', 'explore.rows.airingAnime' => 'Bedste aktuelle anime', 'explore.rows.popularAnime' => 'Mest populære anime', + _ => null, + } ?? switch (path) { 'explore.rows.trending' => 'Populært lige nu', 'explore.rows.upcomingMovies' => 'Kommende film', 'explore.rows.upcomingShows' => 'Kommende serier', @@ -4312,12 +4304,12 @@ extension on TranslationsDa { 'companionRemote.pairing.validationHostFormat' => 'Format skal være IP:port (f.eks. 192.168.1.100:48632)', 'companionRemote.pairing.connectionTimedOut' => 'Forbindelsen fik timeout. Brug samme netværk på begge enheder.', 'companionRemote.pairing.sessionNotFound' => 'Enhed ikke fundet. Sørg for, at Plezy kører på værten.', - _ => null, - } ?? switch (path) { 'companionRemote.pairing.authFailed' => 'Godkendelse mislykkedes. Begge enheder skal bruge samme Plex-konto.', 'companionRemote.pairing.failedToConnect' => ({required Object error}) => 'Kunne ikke oprette forbindelse: ${error}', 'companionRemote.remote.disconnectConfirm' => 'Vil du afbryde forbindelsen til fjernsessionen?', 'companionRemote.remote.reconnecting' => 'Genopretter forbindelse...', + _ => null, + } ?? switch (path) { 'companionRemote.remote.attemptOf' => ({required Object current}) => 'Forsøg ${current} af 5', 'companionRemote.remote.retryNow' => 'Prøv igen nu', 'companionRemote.remote.tabRemote' => 'Fjernbetjening', diff --git a/lib/i18n/strings_de.g.dart b/lib/i18n/strings_de.g.dart index 745282d57..859aa91b5 100644 --- a/lib/i18n/strings_de.g.dart +++ b/lib/i18n/strings_de.g.dart @@ -439,10 +439,6 @@ class _Translations$settings$de extends Translations$settings$en { @override String get dvConversionNativeDescription => 'Natives DV7 erzwingen und einen erneuten DV-Konvertierungsversuch unterdrücken'; @override String get dvConversionDv81Description => 'Inline-RPU-Konvertierung in Dolby-Vision-Profil 8.1 erzwingen'; @override String get dvConversionHevcStripDescription => 'Dolby-Vision-RPU/EL-Schichten entfernen und reines HEVC ausgeben'; - @override String get demuxer => 'Container-Demuxer'; - @override String get demuxerDescription => 'Welcher Demuxer direkt abgespielte Dateien einliest. Wechsle zu media3, wenn eine Datei Probleme macht.'; - @override String get demuxerFfmpeg => 'FFmpeg (empfohlen)'; - @override String get demuxerMedia3 => 'media3'; @override String get requireProfileSelectionOnOpen => 'Profil beim Öffnen abfragen'; @override String get requireProfileSelectionOnOpenDescription => 'Profilauswahl bei jedem Öffnen der App anzeigen'; @override String get forceTvMode => 'TV-Modus erzwingen'; @@ -3089,10 +3085,6 @@ extension on TranslationsDe { 'settings.dvConversionNativeDescription' => 'Natives DV7 erzwingen und einen erneuten DV-Konvertierungsversuch unterdrücken', 'settings.dvConversionDv81Description' => 'Inline-RPU-Konvertierung in Dolby-Vision-Profil 8.1 erzwingen', 'settings.dvConversionHevcStripDescription' => 'Dolby-Vision-RPU/EL-Schichten entfernen und reines HEVC ausgeben', - 'settings.demuxer' => 'Container-Demuxer', - 'settings.demuxerDescription' => 'Welcher Demuxer direkt abgespielte Dateien einliest. Wechsle zu media3, wenn eine Datei Probleme macht.', - 'settings.demuxerFfmpeg' => 'FFmpeg (empfohlen)', - 'settings.demuxerMedia3' => 'media3', 'settings.requireProfileSelectionOnOpen' => 'Profil beim Öffnen abfragen', 'settings.requireProfileSelectionOnOpenDescription' => 'Profilauswahl bei jedem Öffnen der App anzeigen', 'settings.forceTvMode' => 'TV-Modus erzwingen', @@ -3284,12 +3276,12 @@ extension on TranslationsDe { 'mediaMenu.markAsUnwatched' => 'Als ungesehen markieren', 'mediaMenu.removeFromContinueWatching' => 'Aus ‚Weiterschauen‘ entfernen', 'mediaMenu.viewDetails' => 'Details anzeigen', - _ => null, - } ?? switch (path) { 'mediaMenu.goToSeries' => 'Zur Serie', 'mediaMenu.shufflePlay' => 'Zufallswiedergabe', 'mediaMenu.shuffleNotAvailableOffline' => 'Zufallswiedergabe ist offline nicht verfügbar', 'mediaMenu.fileInfo' => 'Dateiinfo', + _ => null, + } ?? switch (path) { 'mediaMenu.deleteEpisodeFromServer' => 'Folge vom Server löschen', 'mediaMenu.deleteSeasonFromServer' => 'Staffel vom Server löschen', 'mediaMenu.deleteShowFromServer' => 'Serie vom Server löschen', @@ -3798,12 +3790,12 @@ extension on TranslationsDe { 'explore.rows.trendingShows' => 'Angesagte Serien', 'explore.rows.popularMovies' => 'Beliebte Filme', 'explore.rows.popularShows' => 'Beliebte Serien', - _ => null, - } ?? switch (path) { 'explore.rows.trendingAnime' => 'Angesagte Anime', 'explore.rows.suggestedAnime' => 'Empfohlene Anime', 'explore.rows.airingAnime' => 'Beste derzeit laufende Anime', 'explore.rows.popularAnime' => 'Beliebteste Anime', + _ => null, + } ?? switch (path) { 'explore.rows.trending' => 'Angesagt', 'explore.rows.upcomingMovies' => 'Kommende Filme', 'explore.rows.upcomingShows' => 'Kommende Serien', @@ -4312,12 +4304,12 @@ extension on TranslationsDe { 'companionRemote.pairing.validationHostFormat' => 'Format muss IP:Port sein (z. B. 192.168.1.100:48632)', 'companionRemote.pairing.connectionTimedOut' => 'Zeitüberschreitung bei der Verbindung. Nutze auf beiden Geräten dasselbe Netzwerk.', 'companionRemote.pairing.sessionNotFound' => 'Gerät nicht gefunden. Stelle sicher, dass Plezy auf dem Host läuft.', - _ => null, - } ?? switch (path) { 'companionRemote.pairing.authFailed' => 'Authentifizierung fehlgeschlagen. Beide Geräte benötigen dasselbe Plex-Konto.', 'companionRemote.pairing.failedToConnect' => ({required Object error}) => 'Verbindung fehlgeschlagen: ${error}', 'companionRemote.remote.disconnectConfirm' => 'Möchtest du die Verbindung zur Fernsteuerungssitzung trennen?', 'companionRemote.remote.reconnecting' => 'Verbindung wird wiederhergestellt...', + _ => null, + } ?? switch (path) { 'companionRemote.remote.attemptOf' => ({required Object current}) => 'Versuch ${current} von 5', 'companionRemote.remote.retryNow' => 'Jetzt wiederholen', 'companionRemote.remote.tabRemote' => 'Fernbedienung', diff --git a/lib/i18n/strings_en.g.dart b/lib/i18n/strings_en.g.dart index e3887d984..67b7b0492 100644 --- a/lib/i18n/strings_en.g.dart +++ b/lib/i18n/strings_en.g.dart @@ -1035,18 +1035,6 @@ class Translations$settings$en { /// en: 'Strip Dolby Vision RPU/EL layers and present plain HEVC' String get dvConversionHevcStripDescription => 'Strip Dolby Vision RPU/EL layers and present plain HEVC'; - /// en: 'Container Demuxer' - String get demuxer => 'Container Demuxer'; - - /// en: 'Which demuxer parses direct-played files. Switch to media3 if a file misbehaves.' - String get demuxerDescription => 'Which demuxer parses direct-played files. Switch to media3 if a file misbehaves.'; - - /// en: 'FFmpeg (Recommended)' - String get demuxerFfmpeg => 'FFmpeg (Recommended)'; - - /// en: 'media3' - String get demuxerMedia3 => 'media3'; - /// en: 'Ask for profile on app open' String get requireProfileSelectionOnOpen => 'Ask for profile on app open'; @@ -7127,10 +7115,6 @@ extension on Translations { 'settings.dvConversionNativeDescription' => 'Force native DV7 and suppress DV conversion retry', 'settings.dvConversionDv81Description' => 'Force inline RPU conversion to Dolby Vision profile 8.1', 'settings.dvConversionHevcStripDescription' => 'Strip Dolby Vision RPU/EL layers and present plain HEVC', - 'settings.demuxer' => 'Container Demuxer', - 'settings.demuxerDescription' => 'Which demuxer parses direct-played files. Switch to media3 if a file misbehaves.', - 'settings.demuxerFfmpeg' => 'FFmpeg (Recommended)', - 'settings.demuxerMedia3' => 'media3', 'settings.requireProfileSelectionOnOpen' => 'Ask for profile on app open', 'settings.requireProfileSelectionOnOpenDescription' => 'Show profile selection every time the app is opened', 'settings.forceTvMode' => 'Force TV mode', @@ -7321,12 +7305,12 @@ extension on Translations { 'mediaMenu.markAsWatched' => 'Mark as Watched', 'mediaMenu.markAsUnwatched' => 'Mark as Unwatched', 'mediaMenu.removeFromContinueWatching' => 'Remove from Continue Watching', - _ => null, - } ?? switch (path) { 'mediaMenu.viewDetails' => 'View details', 'mediaMenu.goToSeries' => 'Go to series', 'mediaMenu.shufflePlay' => 'Shuffle Play', 'mediaMenu.shuffleNotAvailableOffline' => 'Shuffle not available offline', + _ => null, + } ?? switch (path) { 'mediaMenu.fileInfo' => 'File Info', 'mediaMenu.deleteEpisodeFromServer' => 'Delete episode from server', 'mediaMenu.deleteSeasonFromServer' => 'Delete season from server', @@ -7835,12 +7819,12 @@ extension on Translations { 'about.versionLabel' => ({required Object version}) => 'Version ${version}', 'about.appDescription' => 'A beautiful Plex, Jellyfin, and Emby client for Flutter', 'about.viewLicensesDescription' => 'View licenses of third-party libraries', - _ => null, - } ?? switch (path) { 'serverSelection.noServersFoundForAccount' => ({required Object username, required Object email}) => 'No servers found for ${username} (${email})', 'serverSelection.failedToLoadServers' => ({required Object error}) => 'Failed to load servers: ${error}', 'serverSelection.noValidServers' => 'No usable servers were found on this account', 'hubDetail.title' => 'Title', + _ => null, + } ?? switch (path) { 'hubDetail.releaseYear' => 'Release Year', 'hubDetail.dateAdded' => 'Date Added', 'hubDetail.rating' => 'Rating', @@ -8349,12 +8333,12 @@ extension on Translations { 'downloads.backgroundWarning.dialogFixFirst' => 'Fix this first', 'downloads.backgroundWarning.statusTile' => 'Background downloads', 'downloads.backgroundWarning.statusOk' => 'Allowed to run in the background', - _ => null, - } ?? switch (path) { 'downloads.backgroundWarning.statusBlocked' => 'Blocked by system settings', 'downloads.backgroundWarning.statusDegraded' => 'Limited by system settings', 'downloads.backgroundWarning.statusUnknown' => 'Not checked yet', 'downloads.backgroundWarning.settingsUnavailable' => 'Couldn\'t open system settings on this device', + _ => null, + } ?? switch (path) { 'downloads.backgroundWarning.linkUnavailable' => 'Couldn\'t open dontkillmyapp.com on this device', 'downloads.unknownShow' => 'Unknown Show', 'downloads.unknownSeason' => 'Unknown Season', diff --git a/lib/i18n/strings_es.g.dart b/lib/i18n/strings_es.g.dart index d9cecece2..6da8d9fbe 100644 --- a/lib/i18n/strings_es.g.dart +++ b/lib/i18n/strings_es.g.dart @@ -439,10 +439,6 @@ class _Translations$settings$es extends Translations$settings$en { @override String get dvConversionNativeDescription => 'Forzar DV7 nativo y suprimir el reintento de conversión DV'; @override String get dvConversionDv81Description => 'Forzar la conversión de RPU en línea al perfil 8.1 de Dolby Vision'; @override String get dvConversionHevcStripDescription => 'Eliminar las capas RPU/EL de Dolby Vision y presentar HEVC convencional'; - @override String get demuxer => 'Demultiplexor de contenedor'; - @override String get demuxerDescription => 'Qué demultiplexor analiza los archivos de reproducción directa. Cambia a media3 si un archivo da problemas.'; - @override String get demuxerFfmpeg => 'FFmpeg (recomendado)'; - @override String get demuxerMedia3 => 'media3'; @override String get requireProfileSelectionOnOpen => 'Pedir perfil al abrir la aplicación'; @override String get requireProfileSelectionOnOpenDescription => 'Mostrar selección de perfil cada vez que se abre la aplicación'; @override String get forceTvMode => 'Forzar modo TV'; @@ -3089,10 +3085,6 @@ extension on TranslationsEs { 'settings.dvConversionNativeDescription' => 'Forzar DV7 nativo y suprimir el reintento de conversión DV', 'settings.dvConversionDv81Description' => 'Forzar la conversión de RPU en línea al perfil 8.1 de Dolby Vision', 'settings.dvConversionHevcStripDescription' => 'Eliminar las capas RPU/EL de Dolby Vision y presentar HEVC convencional', - 'settings.demuxer' => 'Demultiplexor de contenedor', - 'settings.demuxerDescription' => 'Qué demultiplexor analiza los archivos de reproducción directa. Cambia a media3 si un archivo da problemas.', - 'settings.demuxerFfmpeg' => 'FFmpeg (recomendado)', - 'settings.demuxerMedia3' => 'media3', 'settings.requireProfileSelectionOnOpen' => 'Pedir perfil al abrir la aplicación', 'settings.requireProfileSelectionOnOpenDescription' => 'Mostrar selección de perfil cada vez que se abre la aplicación', 'settings.forceTvMode' => 'Forzar modo TV', @@ -3284,12 +3276,12 @@ extension on TranslationsEs { 'mediaMenu.markAsUnwatched' => 'Marcar como no visto', 'mediaMenu.removeFromContinueWatching' => 'Eliminar de Seguir viendo', 'mediaMenu.viewDetails' => 'Ver detalles', - _ => null, - } ?? switch (path) { 'mediaMenu.goToSeries' => 'Ir a la serie', 'mediaMenu.shufflePlay' => 'Reproducción aleatoria', 'mediaMenu.shuffleNotAvailableOffline' => 'La reproducción aleatoria no está disponible sin conexión', 'mediaMenu.fileInfo' => 'Información del archivo', + _ => null, + } ?? switch (path) { 'mediaMenu.deleteEpisodeFromServer' => 'Eliminar el episodio del servidor', 'mediaMenu.deleteSeasonFromServer' => 'Eliminar la temporada del servidor', 'mediaMenu.deleteShowFromServer' => 'Eliminar la serie del servidor', @@ -3798,12 +3790,12 @@ extension on TranslationsEs { 'explore.rows.trendingShows' => 'Series en tendencia', 'explore.rows.popularMovies' => 'Películas populares', 'explore.rows.popularShows' => 'Series populares', - _ => null, - } ?? switch (path) { 'explore.rows.trendingAnime' => 'Anime en tendencia', 'explore.rows.suggestedAnime' => 'Anime sugerido', 'explore.rows.airingAnime' => 'Mejor anime en emisión', 'explore.rows.popularAnime' => 'Anime más popular', + _ => null, + } ?? switch (path) { 'explore.rows.trending' => 'Tendencias', 'explore.rows.upcomingMovies' => 'Próximas películas', 'explore.rows.upcomingShows' => 'Próximas series', @@ -4312,12 +4304,12 @@ extension on TranslationsEs { 'companionRemote.pairing.validationHostFormat' => 'El formato debe ser IP:puerto (p. ej., 192.168.1.100:48632)', 'companionRemote.pairing.connectionTimedOut' => 'Tiempo de conexión agotado. Usa la misma red en ambos dispositivos.', 'companionRemote.pairing.sessionNotFound' => 'Dispositivo no encontrado. Asegúrate de que Plezy esté en ejecución en el host.', - _ => null, - } ?? switch (path) { 'companionRemote.pairing.authFailed' => 'Autenticación fallida. Ambos dispositivos necesitan la misma cuenta Plex.', 'companionRemote.pairing.failedToConnect' => ({required Object error}) => 'Error al conectar: ${error}', 'companionRemote.remote.disconnectConfirm' => '¿Quieres desconectarte de la sesión remota?', 'companionRemote.remote.reconnecting' => 'Reconectando...', + _ => null, + } ?? switch (path) { 'companionRemote.remote.attemptOf' => ({required Object current}) => 'Intento ${current} de 5', 'companionRemote.remote.retryNow' => 'Reintentar ahora', 'companionRemote.remote.tabRemote' => 'Remoto', diff --git a/lib/i18n/strings_fr.g.dart b/lib/i18n/strings_fr.g.dart index dca2041ab..e47b6f08e 100644 --- a/lib/i18n/strings_fr.g.dart +++ b/lib/i18n/strings_fr.g.dart @@ -439,10 +439,6 @@ class _Translations$settings$fr extends Translations$settings$en { @override String get dvConversionNativeDescription => 'Forcer le DV7 natif et bloquer la nouvelle tentative de conversion DV'; @override String get dvConversionDv81Description => 'Forcer la conversion RPU intégrée vers le profil 8.1 de Dolby Vision'; @override String get dvConversionHevcStripDescription => 'Supprimer les couches RPU/EL Dolby Vision et présenter du HEVC simple'; - @override String get demuxer => 'Démultiplexeur de conteneur'; - @override String get demuxerDescription => 'Quel démultiplexeur analyse les fichiers lus en direct. Passez à media3 si un fichier pose problème.'; - @override String get demuxerFfmpeg => 'FFmpeg (recommandé)'; - @override String get demuxerMedia3 => 'media3'; @override String get requireProfileSelectionOnOpen => 'Demander le profil à l\'ouverture'; @override String get requireProfileSelectionOnOpenDescription => 'Afficher la sélection de profil à chaque ouverture de l\'application'; @override String get forceTvMode => 'Forcer le mode TV'; @@ -3089,10 +3085,6 @@ extension on TranslationsFr { 'settings.dvConversionNativeDescription' => 'Forcer le DV7 natif et bloquer la nouvelle tentative de conversion DV', 'settings.dvConversionDv81Description' => 'Forcer la conversion RPU intégrée vers le profil 8.1 de Dolby Vision', 'settings.dvConversionHevcStripDescription' => 'Supprimer les couches RPU/EL Dolby Vision et présenter du HEVC simple', - 'settings.demuxer' => 'Démultiplexeur de conteneur', - 'settings.demuxerDescription' => 'Quel démultiplexeur analyse les fichiers lus en direct. Passez à media3 si un fichier pose problème.', - 'settings.demuxerFfmpeg' => 'FFmpeg (recommandé)', - 'settings.demuxerMedia3' => 'media3', 'settings.requireProfileSelectionOnOpen' => 'Demander le profil à l\'ouverture', 'settings.requireProfileSelectionOnOpenDescription' => 'Afficher la sélection de profil à chaque ouverture de l\'application', 'settings.forceTvMode' => 'Forcer le mode TV', @@ -3284,12 +3276,12 @@ extension on TranslationsFr { 'mediaMenu.markAsUnwatched' => 'Marquer comme non visionné', 'mediaMenu.removeFromContinueWatching' => 'Supprimer de la liste "Continuer à regarder"', 'mediaMenu.viewDetails' => 'Voir les détails', - _ => null, - } ?? switch (path) { 'mediaMenu.goToSeries' => 'Aller à la série', 'mediaMenu.shufflePlay' => 'Lecture aléatoire', 'mediaMenu.shuffleNotAvailableOffline' => 'La lecture aléatoire n’est pas disponible hors ligne', 'mediaMenu.fileInfo' => 'Informations sur le fichier', + _ => null, + } ?? switch (path) { 'mediaMenu.deleteEpisodeFromServer' => 'Supprimer l\'épisode du serveur', 'mediaMenu.deleteSeasonFromServer' => 'Supprimer la saison du serveur', 'mediaMenu.deleteShowFromServer' => 'Supprimer la série du serveur', @@ -3798,12 +3790,12 @@ extension on TranslationsFr { 'explore.rows.trendingShows' => 'Séries tendance', 'explore.rows.popularMovies' => 'Films populaires', 'explore.rows.popularShows' => 'Séries populaires', - _ => null, - } ?? switch (path) { 'explore.rows.trendingAnime' => 'Animes tendance', 'explore.rows.suggestedAnime' => 'Animes suggérés', 'explore.rows.airingAnime' => 'Meilleurs animes en diffusion', 'explore.rows.popularAnime' => 'Animes les plus populaires', + _ => null, + } ?? switch (path) { 'explore.rows.trending' => 'Tendances', 'explore.rows.upcomingMovies' => 'Films à venir', 'explore.rows.upcomingShows' => 'Séries à venir', @@ -4312,12 +4304,12 @@ extension on TranslationsFr { 'companionRemote.pairing.validationHostFormat' => 'Le format doit être IP:port (p. ex. 192.168.1.100:48632)', 'companionRemote.pairing.connectionTimedOut' => 'Délai de connexion dépassé. Utilisez le même réseau sur les deux appareils.', 'companionRemote.pairing.sessionNotFound' => 'Appareil introuvable. Assurez-vous que Plezy fonctionne sur l\'hôte.', - _ => null, - } ?? switch (path) { 'companionRemote.pairing.authFailed' => 'Échec de l\'authentification. Les deux appareils doivent utiliser le même compte Plex.', 'companionRemote.pairing.failedToConnect' => ({required Object error}) => 'Échec de la connexion : ${error}', 'companionRemote.remote.disconnectConfirm' => 'Voulez-vous vous déconnecter de la session distante ?', 'companionRemote.remote.reconnecting' => 'Reconnexion...', + _ => null, + } ?? switch (path) { 'companionRemote.remote.attemptOf' => ({required Object current}) => 'Tentative ${current} sur 5', 'companionRemote.remote.retryNow' => 'Réessayer maintenant', 'companionRemote.remote.tabRemote' => 'Télécommande', diff --git a/lib/i18n/strings_hu.g.dart b/lib/i18n/strings_hu.g.dart index bedc3fa2f..beaae4a4c 100644 --- a/lib/i18n/strings_hu.g.dart +++ b/lib/i18n/strings_hu.g.dart @@ -439,10 +439,6 @@ class _Translations$settings$hu extends Translations$settings$en { @override String get dvConversionNativeDescription => 'A natív DV7 kényszerítése és a DV-átalakítási újrapróbálkozás letiltása'; @override String get dvConversionDv81Description => 'A közvetlen RPU-átalakítás kényszerítése Dolby Vision Profile 8.1-re'; @override String get dvConversionHevcStripDescription => 'A Dolby Vision RPU/EL-rétegek eltávolítása és egyszerű HEVC-ként való megjelenítés'; - @override String get demuxer => 'Konténer-demuxer'; - @override String get demuxerDescription => 'Melyik demuxer dolgozza fel a közvetlenül lejátszott fájlokat. Válts media3-ra, ha egy fájl hibásan viselkedik.'; - @override String get demuxerFfmpeg => 'FFmpeg (ajánlott)'; - @override String get demuxerMedia3 => 'media3'; @override String get requireProfileSelectionOnOpen => 'Profil kérése az alkalmazás megnyitásakor'; @override String get requireProfileSelectionOnOpenDescription => 'Profilválasztó megjelenítése minden alkalommal, amikor az alkalmazást megnyitod'; @override String get forceTvMode => 'TV-mód kényszerítése'; @@ -3089,10 +3085,6 @@ extension on TranslationsHu { 'settings.dvConversionNativeDescription' => 'A natív DV7 kényszerítése és a DV-átalakítási újrapróbálkozás letiltása', 'settings.dvConversionDv81Description' => 'A közvetlen RPU-átalakítás kényszerítése Dolby Vision Profile 8.1-re', 'settings.dvConversionHevcStripDescription' => 'A Dolby Vision RPU/EL-rétegek eltávolítása és egyszerű HEVC-ként való megjelenítés', - 'settings.demuxer' => 'Konténer-demuxer', - 'settings.demuxerDescription' => 'Melyik demuxer dolgozza fel a közvetlenül lejátszott fájlokat. Válts media3-ra, ha egy fájl hibásan viselkedik.', - 'settings.demuxerFfmpeg' => 'FFmpeg (ajánlott)', - 'settings.demuxerMedia3' => 'media3', 'settings.requireProfileSelectionOnOpen' => 'Profil kérése az alkalmazás megnyitásakor', 'settings.requireProfileSelectionOnOpenDescription' => 'Profilválasztó megjelenítése minden alkalommal, amikor az alkalmazást megnyitod', 'settings.forceTvMode' => 'TV-mód kényszerítése', @@ -3284,12 +3276,12 @@ extension on TranslationsHu { 'mediaMenu.markAsUnwatched' => 'Megjelölés nem megtekintettként', 'mediaMenu.removeFromContinueWatching' => 'Eltávolítás a folytatásból', 'mediaMenu.viewDetails' => 'Részletek megtekintése', - _ => null, - } ?? switch (path) { 'mediaMenu.goToSeries' => 'Ugrás a sorozathoz', 'mediaMenu.shufflePlay' => 'Véletlenszerű lejátszás', 'mediaMenu.shuffleNotAvailableOffline' => 'A véletlenszerű lejátszás nem érhető el offline', 'mediaMenu.fileInfo' => 'Fájlinformáció', + _ => null, + } ?? switch (path) { 'mediaMenu.deleteEpisodeFromServer' => 'Epizód törlése a szerverről', 'mediaMenu.deleteSeasonFromServer' => 'Évad törlése a szerverről', 'mediaMenu.deleteShowFromServer' => 'Sorozat törlése a szerverről', @@ -3798,12 +3790,12 @@ extension on TranslationsHu { 'explore.rows.trendingShows' => 'Felkapott sorozatok', 'explore.rows.popularMovies' => 'Népszerű filmek', 'explore.rows.popularShows' => 'Népszerű sorozatok', - _ => null, - } ?? switch (path) { 'explore.rows.trendingAnime' => 'Felkapott animék', 'explore.rows.suggestedAnime' => 'Ajánlott animék', 'explore.rows.airingAnime' => 'Jelenleg futó top animék', 'explore.rows.popularAnime' => 'Legnépszerűbb animék', + _ => null, + } ?? switch (path) { 'explore.rows.trending' => 'Felkapott', 'explore.rows.upcomingMovies' => 'Közelgő filmek', 'explore.rows.upcomingShows' => 'Közelgő sorozatok', @@ -4312,12 +4304,12 @@ extension on TranslationsHu { 'companionRemote.pairing.validationHostFormat' => 'A formátumnak IP:port kell lennie (pl. 192.168.1.100:48632)', 'companionRemote.pairing.connectionTimedOut' => 'A csatlakozás túllépte az időkorlátot. Használd ugyanazt a hálózatot mindkét eszközön.', 'companionRemote.pairing.sessionNotFound' => 'Az eszköz nem található. Győződj meg arról, hogy a Plezy fut a gazdagépen.', - _ => null, - } ?? switch (path) { 'companionRemote.pairing.authFailed' => 'A hitelesítés nem sikerült. Mindkét eszközön ugyanazt a Plex-fiókot kell használni.', 'companionRemote.pairing.failedToConnect' => ({required Object error}) => 'Nem sikerült a csatlakozás: ${error}', 'companionRemote.remote.disconnectConfirm' => 'Bontod a kapcsolatot a távoli munkamenettel?', 'companionRemote.remote.reconnecting' => 'Újracsatlakozás...', + _ => null, + } ?? switch (path) { 'companionRemote.remote.attemptOf' => ({required Object current}) => '${current} / 5 próbálkozás', 'companionRemote.remote.retryNow' => 'Újrapróbálkozás most', 'companionRemote.remote.tabRemote' => 'Távirányító', diff --git a/lib/i18n/strings_it.g.dart b/lib/i18n/strings_it.g.dart index 5846dc60c..cd0e5ba89 100644 --- a/lib/i18n/strings_it.g.dart +++ b/lib/i18n/strings_it.g.dart @@ -439,10 +439,6 @@ class _Translations$settings$it extends Translations$settings$en { @override String get dvConversionNativeDescription => 'Forza il DV7 nativo e impedisce nuovi tentativi di conversione DV'; @override String get dvConversionDv81Description => 'Forza la conversione RPU diretta al profilo Dolby Vision 8.1'; @override String get dvConversionHevcStripDescription => 'Rimuove i livelli RPU/EL di Dolby Vision e riproduce il video come semplice HEVC'; - @override String get demuxer => 'Demuxer del contenitore'; - @override String get demuxerDescription => 'Quale demuxer analizza i file riprodotti in diretta. Passa a media3 se un file dà problemi.'; - @override String get demuxerFfmpeg => 'FFmpeg (consigliato)'; - @override String get demuxerMedia3 => 'media3'; @override String get requireProfileSelectionOnOpen => 'Chiedi di scegliere il profilo all\'apertura'; @override String get requireProfileSelectionOnOpenDescription => 'Mostra la selezione del profilo ogni volta che l\'app viene aperta'; @override String get forceTvMode => 'Forza modalità TV'; @@ -3089,10 +3085,6 @@ extension on TranslationsIt { 'settings.dvConversionNativeDescription' => 'Forza il DV7 nativo e impedisce nuovi tentativi di conversione DV', 'settings.dvConversionDv81Description' => 'Forza la conversione RPU diretta al profilo Dolby Vision 8.1', 'settings.dvConversionHevcStripDescription' => 'Rimuove i livelli RPU/EL di Dolby Vision e riproduce il video come semplice HEVC', - 'settings.demuxer' => 'Demuxer del contenitore', - 'settings.demuxerDescription' => 'Quale demuxer analizza i file riprodotti in diretta. Passa a media3 se un file dà problemi.', - 'settings.demuxerFfmpeg' => 'FFmpeg (consigliato)', - 'settings.demuxerMedia3' => 'media3', 'settings.requireProfileSelectionOnOpen' => 'Chiedi di scegliere il profilo all\'apertura', 'settings.requireProfileSelectionOnOpenDescription' => 'Mostra la selezione del profilo ogni volta che l\'app viene aperta', 'settings.forceTvMode' => 'Forza modalità TV', @@ -3284,12 +3276,12 @@ extension on TranslationsIt { 'mediaMenu.markAsUnwatched' => 'Segna come non visto', 'mediaMenu.removeFromContinueWatching' => 'Rimuovi da Continua a guardare', 'mediaMenu.viewDetails' => 'Visualizza dettagli', - _ => null, - } ?? switch (path) { 'mediaMenu.goToSeries' => 'Vai alla serie', 'mediaMenu.shufflePlay' => 'Riproduzione casuale', 'mediaMenu.shuffleNotAvailableOffline' => 'Riproduzione casuale non disponibile offline', 'mediaMenu.fileInfo' => 'Info sul file', + _ => null, + } ?? switch (path) { 'mediaMenu.deleteEpisodeFromServer' => 'Elimina episodio dal server', 'mediaMenu.deleteSeasonFromServer' => 'Elimina stagione dal server', 'mediaMenu.deleteShowFromServer' => 'Elimina serie dal server', @@ -3798,12 +3790,12 @@ extension on TranslationsIt { 'explore.rows.trendingShows' => 'Serie TV di tendenza', 'explore.rows.popularMovies' => 'Film popolari', 'explore.rows.popularShows' => 'Serie TV popolari', - _ => null, - } ?? switch (path) { 'explore.rows.trendingAnime' => 'Anime di tendenza', 'explore.rows.suggestedAnime' => 'Anime suggeriti', 'explore.rows.airingAnime' => 'Migliori anime in onda', 'explore.rows.popularAnime' => 'Anime più popolari', + _ => null, + } ?? switch (path) { 'explore.rows.trending' => 'Di tendenza', 'explore.rows.upcomingMovies' => 'Film in arrivo', 'explore.rows.upcomingShows' => 'Serie TV in arrivo', @@ -4312,12 +4304,12 @@ extension on TranslationsIt { 'companionRemote.pairing.validationHostFormat' => 'Il formato deve essere IP:porta (es. 192.168.1.100:48632)', 'companionRemote.pairing.connectionTimedOut' => 'Tempo scaduto per la connessione. Usa la stessa rete su entrambi i dispositivi.', 'companionRemote.pairing.sessionNotFound' => 'Dispositivo non trovato. Assicurati che Plezy sia in esecuzione sull\'host.', - _ => null, - } ?? switch (path) { 'companionRemote.pairing.authFailed' => 'Autenticazione non riuscita. Entrambi i dispositivi devono usare lo stesso account Plex.', 'companionRemote.pairing.failedToConnect' => ({required Object error}) => 'Connessione non riuscita: ${error}', 'companionRemote.remote.disconnectConfirm' => 'Vuoi disconnetterti dalla sessione remota?', 'companionRemote.remote.reconnecting' => 'Riconnessione...', + _ => null, + } ?? switch (path) { 'companionRemote.remote.attemptOf' => ({required Object current}) => 'Tentativo ${current} di 5', 'companionRemote.remote.retryNow' => 'Riprova ora', 'companionRemote.remote.tabRemote' => 'Telecomando', diff --git a/lib/i18n/strings_ja.g.dart b/lib/i18n/strings_ja.g.dart index c06b87a02..1e714001d 100644 --- a/lib/i18n/strings_ja.g.dart +++ b/lib/i18n/strings_ja.g.dart @@ -439,10 +439,6 @@ class _Translations$settings$ja extends Translations$settings$en { @override String get dvConversionNativeDescription => 'ネイティブ DV7 を強制し、DV 変換の再試行を抑制します'; @override String get dvConversionDv81Description => 'Dolby Vision プロファイル 8.1 へのインライン RPU 変換を強制します'; @override String get dvConversionHevcStripDescription => 'Dolby Vision の RPU/EL レイヤーを削除し、通常の HEVC として扱います'; - @override String get demuxer => 'コンテナデマルチプレクサ'; - @override String get demuxerDescription => 'ダイレクト再生のファイルを解析するデマルチプレクサ。ファイルの再生に問題がある場合は media3 に切り替えてください。'; - @override String get demuxerFfmpeg => 'FFmpeg(推奨)'; - @override String get demuxerMedia3 => 'media3'; @override String get requireProfileSelectionOnOpen => 'アプリ起動時にプロフィールを確認'; @override String get requireProfileSelectionOnOpenDescription => 'アプリを開くたびにプロフィール選択を表示'; @override String get forceTvMode => 'TVモードを強制'; @@ -3079,10 +3075,6 @@ extension on TranslationsJa { 'settings.dvConversionNativeDescription' => 'ネイティブ DV7 を強制し、DV 変換の再試行を抑制します', 'settings.dvConversionDv81Description' => 'Dolby Vision プロファイル 8.1 へのインライン RPU 変換を強制します', 'settings.dvConversionHevcStripDescription' => 'Dolby Vision の RPU/EL レイヤーを削除し、通常の HEVC として扱います', - 'settings.demuxer' => 'コンテナデマルチプレクサ', - 'settings.demuxerDescription' => 'ダイレクト再生のファイルを解析するデマルチプレクサ。ファイルの再生に問題がある場合は media3 に切り替えてください。', - 'settings.demuxerFfmpeg' => 'FFmpeg(推奨)', - 'settings.demuxerMedia3' => 'media3', 'settings.requireProfileSelectionOnOpen' => 'アプリ起動時にプロフィールを確認', 'settings.requireProfileSelectionOnOpenDescription' => 'アプリを開くたびにプロフィール選択を表示', 'settings.forceTvMode' => 'TVモードを強制', @@ -3274,12 +3266,12 @@ extension on TranslationsJa { 'mediaMenu.markAsUnwatched' => '未視聴にする', 'mediaMenu.removeFromContinueWatching' => '視聴中から削除', 'mediaMenu.viewDetails' => '詳細を表示', - _ => null, - } ?? switch (path) { 'mediaMenu.goToSeries' => 'シリーズへ移動', 'mediaMenu.shufflePlay' => 'シャッフル再生', 'mediaMenu.shuffleNotAvailableOffline' => 'オフラインではシャッフルを利用できません', 'mediaMenu.fileInfo' => 'ファイル情報', + _ => null, + } ?? switch (path) { 'mediaMenu.deleteEpisodeFromServer' => 'サーバーからエピソードを削除', 'mediaMenu.deleteSeasonFromServer' => 'サーバーからシーズンを削除', 'mediaMenu.deleteShowFromServer' => 'サーバーから番組を削除', @@ -3788,12 +3780,12 @@ extension on TranslationsJa { 'explore.rows.trendingShows' => 'トレンドのテレビ番組', 'explore.rows.popularMovies' => '人気の映画', 'explore.rows.popularShows' => '人気のテレビ番組', - _ => null, - } ?? switch (path) { 'explore.rows.trendingAnime' => 'トレンドのアニメ', 'explore.rows.suggestedAnime' => 'おすすめのアニメ', 'explore.rows.airingAnime' => '放送中の注目アニメ', 'explore.rows.popularAnime' => '人気のアニメ', + _ => null, + } ?? switch (path) { 'explore.rows.trending' => 'トレンド', 'explore.rows.upcomingMovies' => '近日公開の映画', 'explore.rows.upcomingShows' => '放送予定の番組', @@ -4302,12 +4294,12 @@ extension on TranslationsJa { 'companionRemote.pairing.validationHostFormat' => '形式はIP:ポートである必要があります(例: 192.168.1.100:48632)', 'companionRemote.pairing.connectionTimedOut' => '接続がタイムアウトしました。両方のデバイスで同じネットワークを使用してください。', 'companionRemote.pairing.sessionNotFound' => 'デバイスが見つかりません。ホストでPlezyが実行中か確認してください。', - _ => null, - } ?? switch (path) { 'companionRemote.pairing.authFailed' => '認証に失敗しました。両方のデバイスで同じPlexアカウントが必要です。', 'companionRemote.pairing.failedToConnect' => ({required Object error}) => '接続に失敗しました: ${error}', 'companionRemote.remote.disconnectConfirm' => 'リモートセッションを切断しますか?', 'companionRemote.remote.reconnecting' => '再接続中…', + _ => null, + } ?? switch (path) { 'companionRemote.remote.attemptOf' => ({required Object current}) => '試行 ${current}/5', 'companionRemote.remote.retryNow' => '今すぐ再試行', 'companionRemote.remote.tabRemote' => 'リモコン', diff --git a/lib/i18n/strings_kk.g.dart b/lib/i18n/strings_kk.g.dart index 679235af9..0f235af39 100644 --- a/lib/i18n/strings_kk.g.dart +++ b/lib/i18n/strings_kk.g.dart @@ -439,10 +439,6 @@ class _Translations$settings$kk extends Translations$settings$en { @override String get dvConversionNativeDescription => 'Ішкі DV7 режимін мәжбүрлеу'; @override String get dvConversionDv81Description => 'Dolby Vision profile 8.1 форматына түрлендіру'; @override String get dvConversionHevcStripDescription => 'Dolby Vision қабаттарын алып тастап, HEVC ретінде көрсету'; - @override String get demuxer => 'Контейнер демультиплексоры'; - @override String get demuxerDescription => 'Тікелей ойнатылатын файлдарды қай демультиплексор талдайды. Файл дұрыс ойнатылмаса, media3-ке ауысыңыз.'; - @override String get demuxerFfmpeg => 'FFmpeg (ұсынылады)'; - @override String get demuxerMedia3 => 'media3'; @override String get requireProfileSelectionOnOpen => 'Ашқанда профильді сұрау'; @override String get requireProfileSelectionOnOpenDescription => 'Қолданба ашылған сайын профильді таңдауды көрсету'; @override String get forceTvMode => 'TV режимін мәжбүрлеу'; @@ -3089,10 +3085,6 @@ extension on TranslationsKk { 'settings.dvConversionNativeDescription' => 'Ішкі DV7 режимін мәжбүрлеу', 'settings.dvConversionDv81Description' => 'Dolby Vision profile 8.1 форматына түрлендіру', 'settings.dvConversionHevcStripDescription' => 'Dolby Vision қабаттарын алып тастап, HEVC ретінде көрсету', - 'settings.demuxer' => 'Контейнер демультиплексоры', - 'settings.demuxerDescription' => 'Тікелей ойнатылатын файлдарды қай демультиплексор талдайды. Файл дұрыс ойнатылмаса, media3-ке ауысыңыз.', - 'settings.demuxerFfmpeg' => 'FFmpeg (ұсынылады)', - 'settings.demuxerMedia3' => 'media3', 'settings.requireProfileSelectionOnOpen' => 'Ашқанда профильді сұрау', 'settings.requireProfileSelectionOnOpenDescription' => 'Қолданба ашылған сайын профильді таңдауды көрсету', 'settings.forceTvMode' => 'TV режимін мәжбүрлеу', @@ -3284,12 +3276,12 @@ extension on TranslationsKk { 'mediaMenu.markAsUnwatched' => 'Көрілмеді деп белгілеу', 'mediaMenu.removeFromContinueWatching' => '"Көруді жалғастыру" тізімінен өшіру', 'mediaMenu.viewDetails' => 'Толығырақ көру', - _ => null, - } ?? switch (path) { 'mediaMenu.goToSeries' => 'Сериалға өту', 'mediaMenu.shufflePlay' => 'Араластырып ойнату', 'mediaMenu.shuffleNotAvailableOffline' => 'Араластырып ойнату офлайн режимде қолжетімсіз', 'mediaMenu.fileInfo' => 'Файл туралы ақпарат', + _ => null, + } ?? switch (path) { 'mediaMenu.deleteEpisodeFromServer' => 'Бөлімді серверден өшіру', 'mediaMenu.deleteSeasonFromServer' => 'Маусымды серверден өшіру', 'mediaMenu.deleteShowFromServer' => 'Сериалды серверден өшіру', @@ -3798,12 +3790,12 @@ extension on TranslationsKk { 'explore.rows.trendingShows' => 'Трендтегі сериалдар', 'explore.rows.popularMovies' => 'Танымал фильмдер', 'explore.rows.popularShows' => 'Танымал сериалдар', - _ => null, - } ?? switch (path) { 'explore.rows.trendingAnime' => 'Трендтегі аниме', 'explore.rows.suggestedAnime' => 'Ұсынылатын аниме', 'explore.rows.airingAnime' => 'Эфирдегі үздік аниме', 'explore.rows.popularAnime' => 'Ең танымал аниме', + _ => null, + } ?? switch (path) { 'explore.rows.trending' => 'Трендтер', 'explore.rows.upcomingMovies' => 'Күтілетін фильмдер', 'explore.rows.upcomingShows' => 'Күтілетін сериалдар', @@ -4312,12 +4304,12 @@ extension on TranslationsKk { 'companionRemote.pairing.validationHostFormat' => 'Формат IP:порт түрінде болуы керек', 'companionRemote.pairing.connectionTimedOut' => 'Қосылу уақыты өтті.', 'companionRemote.pairing.sessionNotFound' => 'Құрылғы табылмады.', - _ => null, - } ?? switch (path) { 'companionRemote.pairing.authFailed' => 'Аутентификация қатесі.', 'companionRemote.pairing.failedToConnect' => ({required Object error}) => 'Қосылу мүмкін болмады: ${error}', 'companionRemote.remote.disconnectConfirm' => 'Пульт сеансынан ажырайсыз ба?', 'companionRemote.remote.reconnecting' => 'Қайта қосылуда...', + _ => null, + } ?? switch (path) { 'companionRemote.remote.attemptOf' => ({required Object current}) => 'Әрекет ${current} / 5', 'companionRemote.remote.retryNow' => 'Қазір қайталау', 'companionRemote.remote.tabRemote' => 'Пульт', diff --git a/lib/i18n/strings_ko.g.dart b/lib/i18n/strings_ko.g.dart index bc67bf9a9..261a0e130 100644 --- a/lib/i18n/strings_ko.g.dart +++ b/lib/i18n/strings_ko.g.dart @@ -439,10 +439,6 @@ class _Translations$settings$ko extends Translations$settings$en { @override String get dvConversionNativeDescription => '네이티브 DV7을 강제하고 DV 변환 재시도를 억제합니다'; @override String get dvConversionDv81Description => 'Dolby Vision 프로필 8.1로 인라인 RPU 변환을 강제합니다'; @override String get dvConversionHevcStripDescription => 'Dolby Vision RPU/EL 레이어를 제거하고 일반 HEVC로 제공합니다'; - @override String get demuxer => '컨테이너 디먹서'; - @override String get demuxerDescription => '다이렉트 재생 파일을 분석할 디먹서입니다. 파일이 제대로 재생되지 않으면 media3으로 전환하세요.'; - @override String get demuxerFfmpeg => 'FFmpeg (권장)'; - @override String get demuxerMedia3 => 'media3'; @override String get requireProfileSelectionOnOpen => '앱 실행 시 프로필 선택'; @override String get requireProfileSelectionOnOpenDescription => '앱을 열 때마다 프로필 선택 화면을 표시합니다'; @override String get forceTvMode => 'TV 모드 강제 사용'; @@ -3079,10 +3075,6 @@ extension on TranslationsKo { 'settings.dvConversionNativeDescription' => '네이티브 DV7을 강제하고 DV 변환 재시도를 억제합니다', 'settings.dvConversionDv81Description' => 'Dolby Vision 프로필 8.1로 인라인 RPU 변환을 강제합니다', 'settings.dvConversionHevcStripDescription' => 'Dolby Vision RPU/EL 레이어를 제거하고 일반 HEVC로 제공합니다', - 'settings.demuxer' => '컨테이너 디먹서', - 'settings.demuxerDescription' => '다이렉트 재생 파일을 분석할 디먹서입니다. 파일이 제대로 재생되지 않으면 media3으로 전환하세요.', - 'settings.demuxerFfmpeg' => 'FFmpeg (권장)', - 'settings.demuxerMedia3' => 'media3', 'settings.requireProfileSelectionOnOpen' => '앱 실행 시 프로필 선택', 'settings.requireProfileSelectionOnOpenDescription' => '앱을 열 때마다 프로필 선택 화면을 표시합니다', 'settings.forceTvMode' => 'TV 모드 강제 사용', @@ -3274,12 +3266,12 @@ extension on TranslationsKo { 'mediaMenu.markAsUnwatched' => '미시청으로 표시', 'mediaMenu.removeFromContinueWatching' => '계속 보기에서 제거', 'mediaMenu.viewDetails' => '상세 정보 보기', - _ => null, - } ?? switch (path) { 'mediaMenu.goToSeries' => '시리즈로 이동', 'mediaMenu.shufflePlay' => '무작위 재생', 'mediaMenu.shuffleNotAvailableOffline' => '오프라인에서는 무작위 재생을 사용할 수 없습니다', 'mediaMenu.fileInfo' => '파일 정보', + _ => null, + } ?? switch (path) { 'mediaMenu.deleteEpisodeFromServer' => '서버에서 에피소드 삭제', 'mediaMenu.deleteSeasonFromServer' => '서버에서 시즌 삭제', 'mediaMenu.deleteShowFromServer' => '서버에서 프로그램 삭제', @@ -3788,12 +3780,12 @@ extension on TranslationsKo { 'explore.rows.trendingShows' => '지금 뜨는 TV 프로그램', 'explore.rows.popularMovies' => '인기 영화', 'explore.rows.popularShows' => '인기 TV 프로그램', - _ => null, - } ?? switch (path) { 'explore.rows.trendingAnime' => '지금 뜨는 애니메이션', 'explore.rows.suggestedAnime' => '추천 애니메이션', 'explore.rows.airingAnime' => '방영 중인 인기 애니메이션', 'explore.rows.popularAnime' => '가장 인기 있는 애니메이션', + _ => null, + } ?? switch (path) { 'explore.rows.trending' => '지금 뜨는 콘텐츠', 'explore.rows.upcomingMovies' => '개봉 예정 영화', 'explore.rows.upcomingShows' => '방영 예정 TV 프로그램', @@ -4302,12 +4294,12 @@ extension on TranslationsKo { 'companionRemote.pairing.validationHostFormat' => '형식은 IP:포트여야 합니다 (예: 192.168.1.100:48632)', 'companionRemote.pairing.connectionTimedOut' => '연결 시간이 초과되었습니다. 두 기기에서 같은 네트워크를 사용하세요.', 'companionRemote.pairing.sessionNotFound' => '기기를 찾을 수 없습니다. 호스트에서 Plezy가 실행 중인지 확인하세요.', - _ => null, - } ?? switch (path) { 'companionRemote.pairing.authFailed' => '인증에 실패했습니다. 두 기기 모두 같은 Plex 계정이 필요합니다.', 'companionRemote.pairing.failedToConnect' => ({required Object error}) => '연결 실패: ${error}', 'companionRemote.remote.disconnectConfirm' => '원격 세션 연결을 해제하시겠습니까?', 'companionRemote.remote.reconnecting' => '재연결 중...', + _ => null, + } ?? switch (path) { 'companionRemote.remote.attemptOf' => ({required Object current}) => '${current}/5 시도 중', 'companionRemote.remote.retryNow' => '지금 재시도', 'companionRemote.remote.tabRemote' => '리모컨', diff --git a/lib/i18n/strings_nb.g.dart b/lib/i18n/strings_nb.g.dart index d32dec8f6..f52f517a8 100644 --- a/lib/i18n/strings_nb.g.dart +++ b/lib/i18n/strings_nb.g.dart @@ -439,10 +439,6 @@ class _Translations$settings$nb extends Translations$settings$en { @override String get dvConversionNativeDescription => 'Tving opprinnelig DV7-avspilling og ikke prøv DV-konvertering på nytt'; @override String get dvConversionDv81Description => 'Tving direkte RPU-konvertering til Dolby Vision-profil 8.1'; @override String get dvConversionHevcStripDescription => 'Fjern Dolby Vision RPU/EL-lag og lever som vanlig HEVC'; - @override String get demuxer => 'Container-demukser'; - @override String get demuxerDescription => 'Hvilken demukser som leser direkteavspilte filer. Bytt til media3 hvis en fil oppfører seg rart.'; - @override String get demuxerFfmpeg => 'FFmpeg (anbefalt)'; - @override String get demuxerMedia3 => 'media3'; @override String get requireProfileSelectionOnOpen => 'Spør om profil ved appåpning'; @override String get requireProfileSelectionOnOpenDescription => 'Vis profilvalg hver gang appen åpnes'; @override String get forceTvMode => 'Tving TV-modus'; @@ -3089,10 +3085,6 @@ extension on TranslationsNb { 'settings.dvConversionNativeDescription' => 'Tving opprinnelig DV7-avspilling og ikke prøv DV-konvertering på nytt', 'settings.dvConversionDv81Description' => 'Tving direkte RPU-konvertering til Dolby Vision-profil 8.1', 'settings.dvConversionHevcStripDescription' => 'Fjern Dolby Vision RPU/EL-lag og lever som vanlig HEVC', - 'settings.demuxer' => 'Container-demukser', - 'settings.demuxerDescription' => 'Hvilken demukser som leser direkteavspilte filer. Bytt til media3 hvis en fil oppfører seg rart.', - 'settings.demuxerFfmpeg' => 'FFmpeg (anbefalt)', - 'settings.demuxerMedia3' => 'media3', 'settings.requireProfileSelectionOnOpen' => 'Spør om profil ved appåpning', 'settings.requireProfileSelectionOnOpenDescription' => 'Vis profilvalg hver gang appen åpnes', 'settings.forceTvMode' => 'Tving TV-modus', @@ -3284,12 +3276,12 @@ extension on TranslationsNb { 'mediaMenu.markAsUnwatched' => 'Merk som usett', 'mediaMenu.removeFromContinueWatching' => 'Fjern fra Fortsett å se', 'mediaMenu.viewDetails' => 'Vis detaljer', - _ => null, - } ?? switch (path) { 'mediaMenu.goToSeries' => 'Gå til serie', 'mediaMenu.shufflePlay' => 'Tilfeldig avspilling', 'mediaMenu.shuffleNotAvailableOffline' => 'Tilfeldig avspilling er ikke tilgjengelig uten nett', 'mediaMenu.fileInfo' => 'Filinformasjon', + _ => null, + } ?? switch (path) { 'mediaMenu.deleteEpisodeFromServer' => 'Slett episode fra serveren', 'mediaMenu.deleteSeasonFromServer' => 'Slett sesong fra serveren', 'mediaMenu.deleteShowFromServer' => 'Slett serie fra serveren', @@ -3798,12 +3790,12 @@ extension on TranslationsNb { 'explore.rows.trendingShows' => 'Populære serier nå', 'explore.rows.popularMovies' => 'Populære filmer', 'explore.rows.popularShows' => 'Populære serier', - _ => null, - } ?? switch (path) { 'explore.rows.trendingAnime' => 'Populær anime nå', 'explore.rows.suggestedAnime' => 'Foreslått anime', 'explore.rows.airingAnime' => 'Topp pågående anime', 'explore.rows.popularAnime' => 'Mest populær anime', + _ => null, + } ?? switch (path) { 'explore.rows.trending' => 'Populært nå', 'explore.rows.upcomingMovies' => 'Kommende filmer', 'explore.rows.upcomingShows' => 'Kommende serier', @@ -4312,12 +4304,12 @@ extension on TranslationsNb { 'companionRemote.pairing.validationHostFormat' => 'Format må være IP:port (f.eks. 192.168.1.100:48632)', 'companionRemote.pairing.connectionTimedOut' => 'Tilkoblingen fikk tidsavbrudd. Bruk samme nettverk på begge enheter.', 'companionRemote.pairing.sessionNotFound' => 'Enhet ikke funnet. Sørg for at Plezy kjører på verten.', - _ => null, - } ?? switch (path) { 'companionRemote.pairing.authFailed' => 'Autentisering mislyktes. Begge enheter må bruke samme Plex-konto.', 'companionRemote.pairing.failedToConnect' => ({required Object error}) => 'Kunne ikke koble til: ${error}', 'companionRemote.remote.disconnectConfirm' => 'Vil du koble fra fjernøkten?', 'companionRemote.remote.reconnecting' => 'Kobler til på nytt...', + _ => null, + } ?? switch (path) { 'companionRemote.remote.attemptOf' => ({required Object current}) => 'Forsøk ${current} av 5', 'companionRemote.remote.retryNow' => 'Prøv nå', 'companionRemote.remote.tabRemote' => 'Fjernkontroll', diff --git a/lib/i18n/strings_nl.g.dart b/lib/i18n/strings_nl.g.dart index 95ff604c5..d176d163b 100644 --- a/lib/i18n/strings_nl.g.dart +++ b/lib/i18n/strings_nl.g.dart @@ -439,10 +439,6 @@ class _Translations$settings$nl extends Translations$settings$en { @override String get dvConversionNativeDescription => 'Dwing native DV7 af en voorkom een nieuwe poging met DV-conversie'; @override String get dvConversionDv81Description => 'Dwing directe RPU-conversie naar Dolby Vision-profiel 8.1 af'; @override String get dvConversionHevcStripDescription => 'Verwijder Dolby Vision RPU/EL-lagen en bied gewone HEVC aan'; - @override String get demuxer => 'Container-demuxer'; - @override String get demuxerDescription => 'Welke demuxer direct afgespeelde bestanden inleest. Schakel over naar media3 als een bestand zich vreemd gedraagt.'; - @override String get demuxerFfmpeg => 'FFmpeg (aanbevolen)'; - @override String get demuxerMedia3 => 'media3'; @override String get requireProfileSelectionOnOpen => 'Vraag om profiel bij openen'; @override String get requireProfileSelectionOnOpenDescription => 'Toon profielselectie telkens wanneer de app wordt geopend'; @override String get forceTvMode => 'Tv-modus afdwingen'; @@ -3089,10 +3085,6 @@ extension on TranslationsNl { 'settings.dvConversionNativeDescription' => 'Dwing native DV7 af en voorkom een nieuwe poging met DV-conversie', 'settings.dvConversionDv81Description' => 'Dwing directe RPU-conversie naar Dolby Vision-profiel 8.1 af', 'settings.dvConversionHevcStripDescription' => 'Verwijder Dolby Vision RPU/EL-lagen en bied gewone HEVC aan', - 'settings.demuxer' => 'Container-demuxer', - 'settings.demuxerDescription' => 'Welke demuxer direct afgespeelde bestanden inleest. Schakel over naar media3 als een bestand zich vreemd gedraagt.', - 'settings.demuxerFfmpeg' => 'FFmpeg (aanbevolen)', - 'settings.demuxerMedia3' => 'media3', 'settings.requireProfileSelectionOnOpen' => 'Vraag om profiel bij openen', 'settings.requireProfileSelectionOnOpenDescription' => 'Toon profielselectie telkens wanneer de app wordt geopend', 'settings.forceTvMode' => 'Tv-modus afdwingen', @@ -3284,12 +3276,12 @@ extension on TranslationsNl { 'mediaMenu.markAsUnwatched' => 'Als ongekeken markeren', 'mediaMenu.removeFromContinueWatching' => 'Uit \'Doorgaan met kijken\' verwijderen', 'mediaMenu.viewDetails' => 'Details bekijken', - _ => null, - } ?? switch (path) { 'mediaMenu.goToSeries' => 'Ga naar serie', 'mediaMenu.shufflePlay' => 'Willekeurig afspelen', 'mediaMenu.shuffleNotAvailableOffline' => 'Willekeurig afspelen is offline niet beschikbaar', 'mediaMenu.fileInfo' => 'Bestandsinformatie', + _ => null, + } ?? switch (path) { 'mediaMenu.deleteEpisodeFromServer' => 'Aflevering verwijderen van server', 'mediaMenu.deleteSeasonFromServer' => 'Seizoen verwijderen van server', 'mediaMenu.deleteShowFromServer' => 'Serie verwijderen van server', @@ -3798,12 +3790,12 @@ extension on TranslationsNl { 'explore.rows.trendingShows' => 'Trending series', 'explore.rows.popularMovies' => 'Populaire films', 'explore.rows.popularShows' => 'Populaire series', - _ => null, - } ?? switch (path) { 'explore.rows.trendingAnime' => 'Trending anime', 'explore.rows.suggestedAnime' => 'Aanbevolen anime', 'explore.rows.airingAnime' => 'Beste lopende anime', 'explore.rows.popularAnime' => 'Populairste anime', + _ => null, + } ?? switch (path) { 'explore.rows.trending' => 'Trending', 'explore.rows.upcomingMovies' => 'Aankomende films', 'explore.rows.upcomingShows' => 'Aankomende series', @@ -4312,12 +4304,12 @@ extension on TranslationsNl { 'companionRemote.pairing.validationHostFormat' => 'Formaat moet IP:poort zijn (bijv. 192.168.1.100:48632)', 'companionRemote.pairing.connectionTimedOut' => 'Er is een time-out opgetreden. Gebruik op beide apparaten hetzelfde netwerk.', 'companionRemote.pairing.sessionNotFound' => 'Apparaat niet gevonden. Zorg dat Plezy op de host draait.', - _ => null, - } ?? switch (path) { 'companionRemote.pairing.authFailed' => 'Authenticatie mislukt. Beide apparaten hebben hetzelfde Plex-account nodig.', 'companionRemote.pairing.failedToConnect' => ({required Object error}) => 'Kan niet verbinden: ${error}', 'companionRemote.remote.disconnectConfirm' => 'Wil je de verbinding met de externe sessie verbreken?', 'companionRemote.remote.reconnecting' => 'Opnieuw verbinden...', + _ => null, + } ?? switch (path) { 'companionRemote.remote.attemptOf' => ({required Object current}) => 'Poging ${current} van 5', 'companionRemote.remote.retryNow' => 'Nu opnieuw proberen', 'companionRemote.remote.tabRemote' => 'Afstandsbediening', diff --git a/lib/i18n/strings_pl.g.dart b/lib/i18n/strings_pl.g.dart index ba52f6271..e90cfbf3d 100644 --- a/lib/i18n/strings_pl.g.dart +++ b/lib/i18n/strings_pl.g.dart @@ -439,10 +439,6 @@ class _Translations$settings$pl extends Translations$settings$en { @override String get dvConversionNativeDescription => 'Wymuś natywne DV7 i wyłącz ponowną próbę konwersji DV'; @override String get dvConversionDv81Description => 'Wymuś wbudowaną konwersję RPU do profilu Dolby Vision 8.1'; @override String get dvConversionHevcStripDescription => 'Usuń warstwy Dolby Vision RPU/EL i przedstaw zwykłe HEVC'; - @override String get demuxer => 'Demukser kontenera'; - @override String get demuxerDescription => 'Który demukser analizuje pliki odtwarzane bezpośrednio. Przełącz na media3, jeśli plik sprawia problemy.'; - @override String get demuxerFfmpeg => 'FFmpeg (zalecany)'; - @override String get demuxerMedia3 => 'media3'; @override String get requireProfileSelectionOnOpen => 'Pytaj o profil przy otwarciu aplikacji'; @override String get requireProfileSelectionOnOpenDescription => 'Pokaż wybór profilu za każdym razem, gdy aplikacja jest otwierana'; @override String get forceTvMode => 'Wymuś tryb TV'; @@ -3109,10 +3105,6 @@ extension on TranslationsPl { 'settings.dvConversionNativeDescription' => 'Wymuś natywne DV7 i wyłącz ponowną próbę konwersji DV', 'settings.dvConversionDv81Description' => 'Wymuś wbudowaną konwersję RPU do profilu Dolby Vision 8.1', 'settings.dvConversionHevcStripDescription' => 'Usuń warstwy Dolby Vision RPU/EL i przedstaw zwykłe HEVC', - 'settings.demuxer' => 'Demukser kontenera', - 'settings.demuxerDescription' => 'Który demukser analizuje pliki odtwarzane bezpośrednio. Przełącz na media3, jeśli plik sprawia problemy.', - 'settings.demuxerFfmpeg' => 'FFmpeg (zalecany)', - 'settings.demuxerMedia3' => 'media3', 'settings.requireProfileSelectionOnOpen' => 'Pytaj o profil przy otwarciu aplikacji', 'settings.requireProfileSelectionOnOpenDescription' => 'Pokaż wybór profilu za każdym razem, gdy aplikacja jest otwierana', 'settings.forceTvMode' => 'Wymuś tryb TV', @@ -3304,12 +3296,12 @@ extension on TranslationsPl { 'mediaMenu.markAsUnwatched' => 'Oznacz jako nieobejrzane', 'mediaMenu.removeFromContinueWatching' => 'Usuń z kontynuowania oglądania', 'mediaMenu.viewDetails' => 'Pokaż szczegóły', - _ => null, - } ?? switch (path) { 'mediaMenu.goToSeries' => 'Przejdź do serialu', 'mediaMenu.shufflePlay' => 'Odtwarzanie losowe', 'mediaMenu.shuffleNotAvailableOffline' => 'Odtwarzanie losowe nie jest dostępne offline', 'mediaMenu.fileInfo' => 'Informacje o pliku', + _ => null, + } ?? switch (path) { 'mediaMenu.deleteEpisodeFromServer' => 'Usuń odcinek z serwera', 'mediaMenu.deleteSeasonFromServer' => 'Usuń sezon z serwera', 'mediaMenu.deleteShowFromServer' => 'Usuń serial z serwera', @@ -3818,12 +3810,12 @@ extension on TranslationsPl { 'explore.rows.trendingShows' => 'Seriale na czasie', 'explore.rows.popularMovies' => 'Popularne filmy', 'explore.rows.popularShows' => 'Popularne seriale', - _ => null, - } ?? switch (path) { 'explore.rows.trendingAnime' => 'Anime na czasie', 'explore.rows.suggestedAnime' => 'Sugerowane anime', 'explore.rows.airingAnime' => 'Najpopularniejsze emitowane anime', 'explore.rows.popularAnime' => 'Najpopularniejsze anime', + _ => null, + } ?? switch (path) { 'explore.rows.trending' => 'Na czasie', 'explore.rows.upcomingMovies' => 'Nadchodzące filmy', 'explore.rows.upcomingShows' => 'Nadchodzące seriale', @@ -4332,12 +4324,12 @@ extension on TranslationsPl { 'companionRemote.pairing.validationHostFormat' => 'Format musi być IP:port (np. 192.168.1.100:48632)', 'companionRemote.pairing.connectionTimedOut' => 'Limit czasu połączenia. Użyj tej samej sieci na obu urządzeniach.', 'companionRemote.pairing.sessionNotFound' => 'Nie znaleziono urządzenia. Upewnij się, że Plezy działa na hoście.', - _ => null, - } ?? switch (path) { 'companionRemote.pairing.authFailed' => 'Uwierzytelnianie nie powiodło się. Oba urządzenia muszą używać tego samego konta Plex.', 'companionRemote.pairing.failedToConnect' => ({required Object error}) => 'Nie udało się połączyć: ${error}', 'companionRemote.remote.disconnectConfirm' => 'Czy chcesz się rozłączyć od sesji zdalnej?', 'companionRemote.remote.reconnecting' => 'Ponowne łączenie...', + _ => null, + } ?? switch (path) { 'companionRemote.remote.attemptOf' => ({required Object current}) => 'Próba ${current} z 5', 'companionRemote.remote.retryNow' => 'Ponów teraz', 'companionRemote.remote.tabRemote' => 'Pilot', diff --git a/lib/i18n/strings_pt.g.dart b/lib/i18n/strings_pt.g.dart index e9d70efde..f1379743d 100644 --- a/lib/i18n/strings_pt.g.dart +++ b/lib/i18n/strings_pt.g.dart @@ -439,10 +439,6 @@ class _Translations$settings$pt extends Translations$settings$en { @override String get dvConversionNativeDescription => 'Forçar DV7 nativo e impedir uma nova tentativa de conversão de DV'; @override String get dvConversionDv81Description => 'Forçar a conversão RPU integrada para Dolby Vision perfil 8.1'; @override String get dvConversionHevcStripDescription => 'Remover as camadas RPU/EL do Dolby Vision e apresentar HEVC sem Dolby Vision'; - @override String get demuxer => 'Demuxer de contêiner'; - @override String get demuxerDescription => 'Qual demuxer processa os arquivos reproduzidos diretamente. Mude para media3 se algum arquivo apresentar problemas.'; - @override String get demuxerFfmpeg => 'FFmpeg (recomendado)'; - @override String get demuxerMedia3 => 'media3'; @override String get requireProfileSelectionOnOpen => 'Pedir perfil ao abrir o app'; @override String get requireProfileSelectionOnOpenDescription => 'Mostrar a seleção de perfil sempre que o app for aberto'; @override String get forceTvMode => 'Forçar modo TV'; @@ -3089,10 +3085,6 @@ extension on TranslationsPt { 'settings.dvConversionNativeDescription' => 'Forçar DV7 nativo e impedir uma nova tentativa de conversão de DV', 'settings.dvConversionDv81Description' => 'Forçar a conversão RPU integrada para Dolby Vision perfil 8.1', 'settings.dvConversionHevcStripDescription' => 'Remover as camadas RPU/EL do Dolby Vision e apresentar HEVC sem Dolby Vision', - 'settings.demuxer' => 'Demuxer de contêiner', - 'settings.demuxerDescription' => 'Qual demuxer processa os arquivos reproduzidos diretamente. Mude para media3 se algum arquivo apresentar problemas.', - 'settings.demuxerFfmpeg' => 'FFmpeg (recomendado)', - 'settings.demuxerMedia3' => 'media3', 'settings.requireProfileSelectionOnOpen' => 'Pedir perfil ao abrir o app', 'settings.requireProfileSelectionOnOpenDescription' => 'Mostrar a seleção de perfil sempre que o app for aberto', 'settings.forceTvMode' => 'Forçar modo TV', @@ -3284,12 +3276,12 @@ extension on TranslationsPt { 'mediaMenu.markAsUnwatched' => 'Marcar como Não Assistido', 'mediaMenu.removeFromContinueWatching' => 'Remover de Continuar Assistindo', 'mediaMenu.viewDetails' => 'Ver detalhes', - _ => null, - } ?? switch (path) { 'mediaMenu.goToSeries' => 'Ir para a série', 'mediaMenu.shufflePlay' => 'Reprodução Aleatória', 'mediaMenu.shuffleNotAvailableOffline' => 'Reprodução aleatória indisponível offline', 'mediaMenu.fileInfo' => 'Informações do arquivo', + _ => null, + } ?? switch (path) { 'mediaMenu.deleteEpisodeFromServer' => 'Excluir episódio do servidor', 'mediaMenu.deleteSeasonFromServer' => 'Excluir temporada do servidor', 'mediaMenu.deleteShowFromServer' => 'Excluir série do servidor', @@ -3798,12 +3790,12 @@ extension on TranslationsPt { 'explore.rows.trendingShows' => 'Séries em alta', 'explore.rows.popularMovies' => 'Filmes populares', 'explore.rows.popularShows' => 'Séries populares', - _ => null, - } ?? switch (path) { 'explore.rows.trendingAnime' => 'Anime em alta', 'explore.rows.suggestedAnime' => 'Anime sugerido', 'explore.rows.airingAnime' => 'Melhores animes em exibição', 'explore.rows.popularAnime' => 'Anime mais popular', + _ => null, + } ?? switch (path) { 'explore.rows.trending' => 'Em alta', 'explore.rows.upcomingMovies' => 'Próximos filmes', 'explore.rows.upcomingShows' => 'Próximas séries', @@ -4312,12 +4304,12 @@ extension on TranslationsPt { 'companionRemote.pairing.validationHostFormat' => 'O formato deve ser IP:porta (ex.: 192.168.1.100:48632)', 'companionRemote.pairing.connectionTimedOut' => 'A conexão atingiu o tempo limite. Use a mesma rede nos dois dispositivos.', 'companionRemote.pairing.sessionNotFound' => 'Dispositivo não encontrado. Verifique se o Plezy está em execução no host.', - _ => null, - } ?? switch (path) { 'companionRemote.pairing.authFailed' => 'Falha na autenticação. Ambos os dispositivos precisam da mesma conta Plex.', 'companionRemote.pairing.failedToConnect' => ({required Object error}) => 'Falha ao conectar: ${error}', 'companionRemote.remote.disconnectConfirm' => 'Deseja desconectar da sessão remota?', 'companionRemote.remote.reconnecting' => 'Reconectando...', + _ => null, + } ?? switch (path) { 'companionRemote.remote.attemptOf' => ({required Object current}) => 'Tentativa ${current} de 5', 'companionRemote.remote.retryNow' => 'Tentar agora', 'companionRemote.remote.tabRemote' => 'Remoto', diff --git a/lib/i18n/strings_ru.g.dart b/lib/i18n/strings_ru.g.dart index 0c6d54b51..7f5523641 100644 --- a/lib/i18n/strings_ru.g.dart +++ b/lib/i18n/strings_ru.g.dart @@ -439,10 +439,6 @@ class _Translations$settings$ru extends Translations$settings$en { @override String get dvConversionNativeDescription => 'Принудительно использовать нативный DV7 и не повторять DV-конвертацию'; @override String get dvConversionDv81Description => 'Принудительно выполнять inline-конвертацию RPU в Dolby Vision профиль 8.1'; @override String get dvConversionHevcStripDescription => 'Удалять слои Dolby Vision RPU/EL и передавать обычный HEVC'; - @override String get demuxer => 'Демультиплексор контейнера'; - @override String get demuxerDescription => 'Какой демультиплексор разбирает файлы при прямом воспроизведении. Переключитесь на media3, если файл воспроизводится неправильно.'; - @override String get demuxerFfmpeg => 'FFmpeg (рекомендуется)'; - @override String get demuxerMedia3 => 'media3'; @override String get requireProfileSelectionOnOpen => 'Запрашивать профиль при запуске'; @override String get requireProfileSelectionOnOpenDescription => 'Показывать выбор профиля при каждом открытии приложения'; @override String get forceTvMode => 'Принудительный режим ТВ'; @@ -3109,10 +3105,6 @@ extension on TranslationsRu { 'settings.dvConversionNativeDescription' => 'Принудительно использовать нативный DV7 и не повторять DV-конвертацию', 'settings.dvConversionDv81Description' => 'Принудительно выполнять inline-конвертацию RPU в Dolby Vision профиль 8.1', 'settings.dvConversionHevcStripDescription' => 'Удалять слои Dolby Vision RPU/EL и передавать обычный HEVC', - 'settings.demuxer' => 'Демультиплексор контейнера', - 'settings.demuxerDescription' => 'Какой демультиплексор разбирает файлы при прямом воспроизведении. Переключитесь на media3, если файл воспроизводится неправильно.', - 'settings.demuxerFfmpeg' => 'FFmpeg (рекомендуется)', - 'settings.demuxerMedia3' => 'media3', 'settings.requireProfileSelectionOnOpen' => 'Запрашивать профиль при запуске', 'settings.requireProfileSelectionOnOpenDescription' => 'Показывать выбор профиля при каждом открытии приложения', 'settings.forceTvMode' => 'Принудительный режим ТВ', @@ -3304,12 +3296,12 @@ extension on TranslationsRu { 'mediaMenu.markAsUnwatched' => 'Отметить как непросмотренное', 'mediaMenu.removeFromContinueWatching' => 'Удалить из «Продолжить просмотр»', 'mediaMenu.viewDetails' => 'Показать сведения', - _ => null, - } ?? switch (path) { 'mediaMenu.goToSeries' => 'Перейти к сериалу', 'mediaMenu.shufflePlay' => 'Случайное воспроизведение', 'mediaMenu.shuffleNotAvailableOffline' => 'Перемешивание недоступно офлайн', 'mediaMenu.fileInfo' => 'Информация о файле', + _ => null, + } ?? switch (path) { 'mediaMenu.deleteEpisodeFromServer' => 'Удалить эпизод с сервера', 'mediaMenu.deleteSeasonFromServer' => 'Удалить сезон с сервера', 'mediaMenu.deleteShowFromServer' => 'Удалить сериал с сервера', @@ -3818,12 +3810,12 @@ extension on TranslationsRu { 'explore.rows.trendingShows' => 'Сериалы в тренде', 'explore.rows.popularMovies' => 'Популярные фильмы', 'explore.rows.popularShows' => 'Популярные сериалы', - _ => null, - } ?? switch (path) { 'explore.rows.trendingAnime' => 'Аниме в тренде', 'explore.rows.suggestedAnime' => 'Рекомендуемые аниме', 'explore.rows.airingAnime' => 'Топ выходящих аниме', 'explore.rows.popularAnime' => 'Самые популярные аниме', + _ => null, + } ?? switch (path) { 'explore.rows.trending' => 'В тренде', 'explore.rows.upcomingMovies' => 'Ожидаемые фильмы', 'explore.rows.upcomingShows' => 'Ожидаемые сериалы', @@ -4332,12 +4324,12 @@ extension on TranslationsRu { 'companionRemote.pairing.validationHostFormat' => 'Формат должен быть IP:порт (например, 192.168.1.100:48632)', 'companionRemote.pairing.connectionTimedOut' => 'Время подключения истекло. Используйте одну сеть на обоих устройствах.', 'companionRemote.pairing.sessionNotFound' => 'Устройство не найдено. Убедитесь, что Plezy запущен на хосте.', - _ => null, - } ?? switch (path) { 'companionRemote.pairing.authFailed' => 'Аутентификация не удалась. На обоих устройствах нужен один аккаунт Plex.', 'companionRemote.pairing.failedToConnect' => ({required Object error}) => 'Не удалось подключиться: ${error}', 'companionRemote.remote.disconnectConfirm' => 'Отключиться от удалённой сессии?', 'companionRemote.remote.reconnecting' => 'Переподключение...', + _ => null, + } ?? switch (path) { 'companionRemote.remote.attemptOf' => ({required Object current}) => 'Попытка ${current} из 5', 'companionRemote.remote.retryNow' => 'Повторить сейчас', 'companionRemote.remote.tabRemote' => 'Пульт', diff --git a/lib/i18n/strings_sv.g.dart b/lib/i18n/strings_sv.g.dart index 9b0afa166..433ec7168 100644 --- a/lib/i18n/strings_sv.g.dart +++ b/lib/i18n/strings_sv.g.dart @@ -439,10 +439,6 @@ class _Translations$settings$sv extends Translations$settings$en { @override String get dvConversionNativeDescription => 'Tvinga inbyggd DV7 och förhindra nya försök med DV-konvertering'; @override String get dvConversionDv81Description => 'Tvinga direkt RPU-konvertering till Dolby Vision-profil 8.1'; @override String get dvConversionHevcStripDescription => 'Ta bort Dolby Visions RPU-/EL-lager och använd vanlig HEVC'; - @override String get demuxer => 'Container-demuxer'; - @override String get demuxerDescription => 'Vilken demuxer som läser direktuppspelade filer. Byt till media3 om en fil krånglar.'; - @override String get demuxerFfmpeg => 'FFmpeg (rekommenderas)'; - @override String get demuxerMedia3 => 'media3'; @override String get requireProfileSelectionOnOpen => 'Fråga efter profil vid appstart'; @override String get requireProfileSelectionOnOpenDescription => 'Visa profilval varje gång appen öppnas'; @override String get forceTvMode => 'Tvinga TV-läge'; @@ -3089,10 +3085,6 @@ extension on TranslationsSv { 'settings.dvConversionNativeDescription' => 'Tvinga inbyggd DV7 och förhindra nya försök med DV-konvertering', 'settings.dvConversionDv81Description' => 'Tvinga direkt RPU-konvertering till Dolby Vision-profil 8.1', 'settings.dvConversionHevcStripDescription' => 'Ta bort Dolby Visions RPU-/EL-lager och använd vanlig HEVC', - 'settings.demuxer' => 'Container-demuxer', - 'settings.demuxerDescription' => 'Vilken demuxer som läser direktuppspelade filer. Byt till media3 om en fil krånglar.', - 'settings.demuxerFfmpeg' => 'FFmpeg (rekommenderas)', - 'settings.demuxerMedia3' => 'media3', 'settings.requireProfileSelectionOnOpen' => 'Fråga efter profil vid appstart', 'settings.requireProfileSelectionOnOpenDescription' => 'Visa profilval varje gång appen öppnas', 'settings.forceTvMode' => 'Tvinga TV-läge', @@ -3284,12 +3276,12 @@ extension on TranslationsSv { 'mediaMenu.markAsUnwatched' => 'Markera som osedd', 'mediaMenu.removeFromContinueWatching' => 'Ta bort från Fortsätt titta', 'mediaMenu.viewDetails' => 'Visa detaljer', - _ => null, - } ?? switch (path) { 'mediaMenu.goToSeries' => 'Gå till serie', 'mediaMenu.shufflePlay' => 'Blanda uppspelning', 'mediaMenu.shuffleNotAvailableOffline' => 'Blandad uppspelning är inte tillgänglig offline', 'mediaMenu.fileInfo' => 'Filinformation', + _ => null, + } ?? switch (path) { 'mediaMenu.deleteEpisodeFromServer' => 'Ta bort avsnitt från servern', 'mediaMenu.deleteSeasonFromServer' => 'Ta bort säsong från servern', 'mediaMenu.deleteShowFromServer' => 'Ta bort serie från servern', @@ -3798,12 +3790,12 @@ extension on TranslationsSv { 'explore.rows.trendingShows' => 'Populära serier just nu', 'explore.rows.popularMovies' => 'Populära filmer', 'explore.rows.popularShows' => 'Populära serier', - _ => null, - } ?? switch (path) { 'explore.rows.trendingAnime' => 'Populär anime just nu', 'explore.rows.suggestedAnime' => 'Föreslagen anime', 'explore.rows.airingAnime' => 'Bästa anime som sänds nu', 'explore.rows.popularAnime' => 'Mest populära anime', + _ => null, + } ?? switch (path) { 'explore.rows.trending' => 'Trendar nu', 'explore.rows.upcomingMovies' => 'Kommande filmer', 'explore.rows.upcomingShows' => 'Kommande serier', @@ -4312,12 +4304,12 @@ extension on TranslationsSv { 'companionRemote.pairing.validationHostFormat' => 'Formatet måste vara IP:port (t.ex. 192.168.1.100:48632)', 'companionRemote.pairing.connectionTimedOut' => 'Anslutningen tog för lång tid. Använd samma nätverk på båda enheterna.', 'companionRemote.pairing.sessionNotFound' => 'Enheten hittades inte. Kontrollera att Plezy körs på värden.', - _ => null, - } ?? switch (path) { 'companionRemote.pairing.authFailed' => 'Autentiseringen misslyckades. Båda enheterna måste använda samma Plex-konto.', 'companionRemote.pairing.failedToConnect' => ({required Object error}) => 'Kunde inte ansluta: ${error}', 'companionRemote.remote.disconnectConfirm' => 'Vill du koppla från fjärrsessionen?', 'companionRemote.remote.reconnecting' => 'Återansluter...', + _ => null, + } ?? switch (path) { 'companionRemote.remote.attemptOf' => ({required Object current}) => 'Försök ${current} av 5', 'companionRemote.remote.retryNow' => 'Försök nu', 'companionRemote.remote.tabRemote' => 'Fjärrkontroll', diff --git a/lib/i18n/strings_tr.g.dart b/lib/i18n/strings_tr.g.dart index 856fc5383..58c417b4f 100644 --- a/lib/i18n/strings_tr.g.dart +++ b/lib/i18n/strings_tr.g.dart @@ -439,10 +439,6 @@ class _Translations$settings$tr extends Translations$settings$en { @override String get dvConversionNativeDescription => 'Yerel DV7\'yi zorla ve DV dönüştürme yeniden denemesini engelle'; @override String get dvConversionDv81Description => 'Dolby Vision profile 8.1\'e satır içi RPU dönüştürmeyi zorla'; @override String get dvConversionHevcStripDescription => 'Dolby Vision RPU/EL katmanlarını soy ve düz HEVC olarak sun'; - @override String get demuxer => 'Kapsayıcı demux çözücü'; - @override String get demuxerDescription => 'Doğrudan oynatılan dosyaları hangi demux çözücünün ayrıştıracağı. Bir dosya sorun çıkarırsa media3\'e geçin.'; - @override String get demuxerFfmpeg => 'FFmpeg (Önerilen)'; - @override String get demuxerMedia3 => 'media3'; @override String get requireProfileSelectionOnOpen => 'Uygulama açılışında profil sor'; @override String get requireProfileSelectionOnOpenDescription => 'Uygulama her açıldığında profil seçimini göster'; @override String get forceTvMode => 'TV modunu zorla'; @@ -3089,10 +3085,6 @@ extension on TranslationsTr { 'settings.dvConversionNativeDescription' => 'Yerel DV7\'yi zorla ve DV dönüştürme yeniden denemesini engelle', 'settings.dvConversionDv81Description' => 'Dolby Vision profile 8.1\'e satır içi RPU dönüştürmeyi zorla', 'settings.dvConversionHevcStripDescription' => 'Dolby Vision RPU/EL katmanlarını soy ve düz HEVC olarak sun', - 'settings.demuxer' => 'Kapsayıcı demux çözücü', - 'settings.demuxerDescription' => 'Doğrudan oynatılan dosyaları hangi demux çözücünün ayrıştıracağı. Bir dosya sorun çıkarırsa media3\'e geçin.', - 'settings.demuxerFfmpeg' => 'FFmpeg (Önerilen)', - 'settings.demuxerMedia3' => 'media3', 'settings.requireProfileSelectionOnOpen' => 'Uygulama açılışında profil sor', 'settings.requireProfileSelectionOnOpenDescription' => 'Uygulama her açıldığında profil seçimini göster', 'settings.forceTvMode' => 'TV modunu zorla', @@ -3284,12 +3276,12 @@ extension on TranslationsTr { 'mediaMenu.markAsUnwatched' => 'İzlenmedi Olarak İşaretle', 'mediaMenu.removeFromContinueWatching' => 'İzlemeye Devam Et\'ten Kaldır', 'mediaMenu.viewDetails' => 'Detayları görüntüle', - _ => null, - } ?? switch (path) { 'mediaMenu.goToSeries' => 'Diziye git', 'mediaMenu.shufflePlay' => 'Karışık Oynat', 'mediaMenu.shuffleNotAvailableOffline' => 'Karışık oynatma çevrimdışıyken kullanılamaz', 'mediaMenu.fileInfo' => 'Dosya Bilgisi', + _ => null, + } ?? switch (path) { 'mediaMenu.deleteEpisodeFromServer' => 'Bölümü sunucudan sil', 'mediaMenu.deleteSeasonFromServer' => 'Sezonu sunucudan sil', 'mediaMenu.deleteShowFromServer' => 'Diziyi sunucudan sil', @@ -3798,12 +3790,12 @@ extension on TranslationsTr { 'explore.rows.trendingShows' => 'Trend Diziler', 'explore.rows.popularMovies' => 'Popüler Filmler', 'explore.rows.popularShows' => 'Popüler Diziler', - _ => null, - } ?? switch (path) { 'explore.rows.trendingAnime' => 'Trend Animeler', 'explore.rows.suggestedAnime' => 'Önerilen Animeler', 'explore.rows.airingAnime' => 'En İyi Yayınlanan Animeler', 'explore.rows.popularAnime' => 'En Popüler Animeler', + _ => null, + } ?? switch (path) { 'explore.rows.trending' => 'Trendler', 'explore.rows.upcomingMovies' => 'Gelecek Filmler', 'explore.rows.upcomingShows' => 'Gelecek Diziler', @@ -4312,12 +4304,12 @@ extension on TranslationsTr { 'companionRemote.pairing.validationHostFormat' => 'Biçim IP:port şeklinde olmalıdır (ör. 192.168.1.100:48632)', 'companionRemote.pairing.connectionTimedOut' => 'Bağlantı zaman aşımına uğradı. Her iki cihazda da aynı ağı kullanın.', 'companionRemote.pairing.sessionNotFound' => 'Cihaz bulunamadı. Plezy\'nin ana bilgisayarda çalıştığından emin olun.', - _ => null, - } ?? switch (path) { 'companionRemote.pairing.authFailed' => 'Kimlik doğrulama başarısız oldu. Her iki cihazın da aynı Plex hesabına ihtiyacı var.', 'companionRemote.pairing.failedToConnect' => ({required Object error}) => 'Bağlantı başarısız: ${error}', 'companionRemote.remote.disconnectConfirm' => 'Uzaktan kumanda oturumundan ayrılmak istiyor musunuz?', 'companionRemote.remote.reconnecting' => 'Yeniden bağlanılıyor...', + _ => null, + } ?? switch (path) { 'companionRemote.remote.attemptOf' => ({required Object current}) => 'Deneme ${current} / 5', 'companionRemote.remote.retryNow' => 'Şimdi Tekrar Dene', 'companionRemote.remote.tabRemote' => 'Kumanda', diff --git a/lib/i18n/strings_uz.g.dart b/lib/i18n/strings_uz.g.dart index 56f3fadbd..0189ca1ea 100644 --- a/lib/i18n/strings_uz.g.dart +++ b/lib/i18n/strings_uz.g.dart @@ -439,10 +439,6 @@ class _Translations$settings$uz extends Translations$settings$en { @override String get dvConversionNativeDescription => 'Ichki DV7 rejimini majburlash'; @override String get dvConversionDv81Description => 'Dolby Vision profile 8.1 formatiga oʻtkazish'; @override String get dvConversionHevcStripDescription => 'Dolby Vision qatlamlarini olib tashlash va HEVC sifatida koʻrsatish'; - @override String get demuxer => 'Konteyner demukseri'; - @override String get demuxerDescription => 'Toʻgʻridan-toʻgʻri ijro etilgan fayllarni qaysi demukser tahlil qiladi. Fayl notoʻgʻri ishlasa, media3 ga oʻting.'; - @override String get demuxerFfmpeg => 'FFmpeg (tavsiya etiladi)'; - @override String get demuxerMedia3 => 'media3'; @override String get requireProfileSelectionOnOpen => 'Ochilganda profilni soʻrash'; @override String get requireProfileSelectionOnOpenDescription => 'Ilova ochilgan har safar profilni tanlashni koʻrsatish'; @override String get forceTvMode => 'TV rejimini majburlash'; @@ -3089,10 +3085,6 @@ extension on TranslationsUz { 'settings.dvConversionNativeDescription' => 'Ichki DV7 rejimini majburlash', 'settings.dvConversionDv81Description' => 'Dolby Vision profile 8.1 formatiga oʻtkazish', 'settings.dvConversionHevcStripDescription' => 'Dolby Vision qatlamlarini olib tashlash va HEVC sifatida koʻrsatish', - 'settings.demuxer' => 'Konteyner demukseri', - 'settings.demuxerDescription' => 'Toʻgʻridan-toʻgʻri ijro etilgan fayllarni qaysi demukser tahlil qiladi. Fayl notoʻgʻri ishlasa, media3 ga oʻting.', - 'settings.demuxerFfmpeg' => 'FFmpeg (tavsiya etiladi)', - 'settings.demuxerMedia3' => 'media3', 'settings.requireProfileSelectionOnOpen' => 'Ochilganda profilni soʻrash', 'settings.requireProfileSelectionOnOpenDescription' => 'Ilova ochilgan har safar profilni tanlashni koʻrsatish', 'settings.forceTvMode' => 'TV rejimini majburlash', @@ -3284,12 +3276,12 @@ extension on TranslationsUz { 'mediaMenu.markAsUnwatched' => 'Koʻrilmagan deb belgilash', 'mediaMenu.removeFromContinueWatching' => '"Tomoshani davom ettirish"dan oʻchirish', 'mediaMenu.viewDetails' => 'Batafsil koʻrish', - _ => null, - } ?? switch (path) { 'mediaMenu.goToSeries' => 'Serialga oʻtish', 'mediaMenu.shufflePlay' => 'Aralashtirib ijro etish', 'mediaMenu.shuffleNotAvailableOffline' => 'Aralashtirib ijro etish oflayn rejimda mavjud emas', 'mediaMenu.fileInfo' => 'Fayl haqida maʼlumot', + _ => null, + } ?? switch (path) { 'mediaMenu.deleteEpisodeFromServer' => 'Qismni serverdan oʻchirish', 'mediaMenu.deleteSeasonFromServer' => 'Mavsumni serverdan oʻchirish', 'mediaMenu.deleteShowFromServer' => 'Shouni serverdan oʻchirish', @@ -3798,12 +3790,12 @@ extension on TranslationsUz { 'explore.rows.trendingShows' => 'Ommabop seriallar', 'explore.rows.popularMovies' => 'Mashhur filmlar', 'explore.rows.popularShows' => 'Mashhur seriallar', - _ => null, - } ?? switch (path) { 'explore.rows.trendingAnime' => 'Ommabop anime', 'explore.rows.suggestedAnime' => 'Tavsiya etilgan anime', 'explore.rows.airingAnime' => 'Efirga uzatilayotgan eng yaxshi anime', 'explore.rows.popularAnime' => 'Eng mashhur anime', + _ => null, + } ?? switch (path) { 'explore.rows.trending' => 'Ommaboplar', 'explore.rows.upcomingMovies' => 'Kutilayotgan filmlar', 'explore.rows.upcomingShows' => 'Kutilayotgan seriallar', @@ -4312,12 +4304,12 @@ extension on TranslationsUz { 'companionRemote.pairing.validationHostFormat' => 'Format IP:port koʻrinishida boʻlishi kerak', 'companionRemote.pairing.connectionTimedOut' => 'Ulanish vaqti tugadi.', 'companionRemote.pairing.sessionNotFound' => 'Qurilma topilmadi.', - _ => null, - } ?? switch (path) { 'companionRemote.pairing.authFailed' => 'Autentifikatsiya xatoligi.', 'companionRemote.pairing.failedToConnect' => ({required Object error}) => 'Ulanib boʻlmadi: ${error}', 'companionRemote.remote.disconnectConfirm' => 'Pult seansidan uzilasizmi?', 'companionRemote.remote.reconnecting' => 'Qayta ulanilmoqda...', + _ => null, + } ?? switch (path) { 'companionRemote.remote.attemptOf' => ({required Object current}) => 'Urinish ${current} / 5', 'companionRemote.remote.retryNow' => 'Hozir qaytadan urinib koʻrish', 'companionRemote.remote.tabRemote' => 'Pult', diff --git a/lib/i18n/strings_zh.g.dart b/lib/i18n/strings_zh.g.dart index 0036715bd..a90744d7c 100644 --- a/lib/i18n/strings_zh.g.dart +++ b/lib/i18n/strings_zh.g.dart @@ -439,10 +439,6 @@ class Translations$settings$zh extends Translations$settings$en { @override String get dvConversionNativeDescription => '强制原生 DV7 并禁止重试 DV 转换'; @override String get dvConversionDv81Description => '强制内联 RPU 转换为 Dolby Vision Profile 8.1'; @override String get dvConversionHevcStripDescription => '移除 Dolby Vision RPU/EL 层并呈现普通 HEVC'; - @override String get demuxer => '容器解复用器'; - @override String get demuxerDescription => '由哪个解复用器解析直接播放的文件。若某个文件播放异常,可切换为 media3。'; - @override String get demuxerFfmpeg => 'FFmpeg(推荐)'; - @override String get demuxerMedia3 => 'media3'; @override String get requireProfileSelectionOnOpen => '打开应用时选择用户资料'; @override String get requireProfileSelectionOnOpenDescription => '每次打开应用时都显示用户资料选择界面'; @override String get forceTvMode => '强制 TV 模式'; @@ -3079,10 +3075,6 @@ extension on TranslationsZh { 'settings.dvConversionNativeDescription' => '强制原生 DV7 并禁止重试 DV 转换', 'settings.dvConversionDv81Description' => '强制内联 RPU 转换为 Dolby Vision Profile 8.1', 'settings.dvConversionHevcStripDescription' => '移除 Dolby Vision RPU/EL 层并呈现普通 HEVC', - 'settings.demuxer' => '容器解复用器', - 'settings.demuxerDescription' => '由哪个解复用器解析直接播放的文件。若某个文件播放异常,可切换为 media3。', - 'settings.demuxerFfmpeg' => 'FFmpeg(推荐)', - 'settings.demuxerMedia3' => 'media3', 'settings.requireProfileSelectionOnOpen' => '打开应用时选择用户资料', 'settings.requireProfileSelectionOnOpenDescription' => '每次打开应用时都显示用户资料选择界面', 'settings.forceTvMode' => '强制 TV 模式', @@ -3274,12 +3266,12 @@ extension on TranslationsZh { 'mediaMenu.markAsUnwatched' => '标记为未观看', 'mediaMenu.removeFromContinueWatching' => '从继续观看中移除', 'mediaMenu.viewDetails' => '查看详情', - _ => null, - } ?? switch (path) { 'mediaMenu.goToSeries' => '前往剧集', 'mediaMenu.shufflePlay' => '随机播放', 'mediaMenu.shuffleNotAvailableOffline' => '离线时无法随机播放', 'mediaMenu.fileInfo' => '文件信息', + _ => null, + } ?? switch (path) { 'mediaMenu.deleteEpisodeFromServer' => '从服务器删除单集', 'mediaMenu.deleteSeasonFromServer' => '从服务器删除季度', 'mediaMenu.deleteShowFromServer' => '从服务器删除剧集', @@ -3788,12 +3780,12 @@ extension on TranslationsZh { 'explore.rows.trendingShows' => '近期热门剧集', 'explore.rows.popularMovies' => '人气电影', 'explore.rows.popularShows' => '人气剧集', - _ => null, - } ?? switch (path) { 'explore.rows.trendingAnime' => '热门动画', 'explore.rows.suggestedAnime' => '推荐动画', 'explore.rows.airingAnime' => '热门连载动画', 'explore.rows.popularAnime' => '最受欢迎动画', + _ => null, + } ?? switch (path) { 'explore.rows.trending' => '近期热门', 'explore.rows.upcomingMovies' => '即将上映的电影', 'explore.rows.upcomingShows' => '即将播出的剧集', @@ -4302,12 +4294,12 @@ extension on TranslationsZh { 'companionRemote.pairing.validationHostFormat' => '格式必须为 IP:端口(例如 192.168.1.100:48632)', 'companionRemote.pairing.connectionTimedOut' => '连接超时。请在两台设备上使用同一网络。', 'companionRemote.pairing.sessionNotFound' => '未找到设备。请确认 Plezy 正在主机上运行。', - _ => null, - } ?? switch (path) { 'companionRemote.pairing.authFailed' => '认证失败。两台设备需要使用同一 Plex 账号。', 'companionRemote.pairing.failedToConnect' => ({required Object error}) => '连接失败:${error}', 'companionRemote.remote.disconnectConfirm' => '确定要断开遥控会话吗?', 'companionRemote.remote.reconnecting' => '正在重新连接…', + _ => null, + } ?? switch (path) { 'companionRemote.remote.attemptOf' => ({required Object current}) => '第 ${current} 次尝试,共 5 次', 'companionRemote.remote.retryNow' => '立即重试', 'companionRemote.remote.tabRemote' => '遥控器', diff --git a/lib/i18n/strings_zh_Hant.g.dart b/lib/i18n/strings_zh_Hant.g.dart index 671abcd0e..b4952ef63 100644 --- a/lib/i18n/strings_zh_Hant.g.dart +++ b/lib/i18n/strings_zh_Hant.g.dart @@ -440,10 +440,6 @@ class _Translations$settings$zh_Hant extends Translations$settings$zh { @override String get dvConversionNativeDescription => '強制使用原生 DV7 並停用 DV 轉換重試'; @override String get dvConversionDv81Description => '強制將內嵌的 RPU 轉換為 Dolby Vision Profile 8.1'; @override String get dvConversionHevcStripDescription => '移除 Dolby Vision RPU/EL 層,並以一般 HEVC 呈現'; - @override String get demuxer => '容器解多工器'; - @override String get demuxerDescription => '由哪個解多工器解析直接播放的檔案。若某個檔案播放異常,可切換為 media3。'; - @override String get demuxerFfmpeg => 'FFmpeg(建議)'; - @override String get demuxerMedia3 => 'media3'; @override String get requireProfileSelectionOnOpen => '開啟應用程式時要求選擇使用者'; @override String get requireProfileSelectionOnOpenDescription => '每次開啟應用程式時顯示使用者設定檔選擇畫面'; @override String get forceTvMode => '強制 TV 模式'; @@ -3080,10 +3076,6 @@ extension on TranslationsZhHant { 'settings.dvConversionNativeDescription' => '強制使用原生 DV7 並停用 DV 轉換重試', 'settings.dvConversionDv81Description' => '強制將內嵌的 RPU 轉換為 Dolby Vision Profile 8.1', 'settings.dvConversionHevcStripDescription' => '移除 Dolby Vision RPU/EL 層,並以一般 HEVC 呈現', - 'settings.demuxer' => '容器解多工器', - 'settings.demuxerDescription' => '由哪個解多工器解析直接播放的檔案。若某個檔案播放異常,可切換為 media3。', - 'settings.demuxerFfmpeg' => 'FFmpeg(建議)', - 'settings.demuxerMedia3' => 'media3', 'settings.requireProfileSelectionOnOpen' => '開啟應用程式時要求選擇使用者', 'settings.requireProfileSelectionOnOpenDescription' => '每次開啟應用程式時顯示使用者設定檔選擇畫面', 'settings.forceTvMode' => '強制 TV 模式', @@ -3275,12 +3267,12 @@ extension on TranslationsZhHant { 'mediaMenu.markAsUnwatched' => '標記為未觀看', 'mediaMenu.removeFromContinueWatching' => '從「繼續觀看」中移除', 'mediaMenu.viewDetails' => '查看詳情', - _ => null, - } ?? switch (path) { 'mediaMenu.goToSeries' => '前往影集', 'mediaMenu.shufflePlay' => '隨機播放', 'mediaMenu.shuffleNotAvailableOffline' => '離線時無法隨機播放', 'mediaMenu.fileInfo' => '檔案資訊', + _ => null, + } ?? switch (path) { 'mediaMenu.deleteEpisodeFromServer' => '從伺服器刪除單集', 'mediaMenu.deleteSeasonFromServer' => '從伺服器刪除季', 'mediaMenu.deleteShowFromServer' => '從伺服器刪除影集', @@ -3789,12 +3781,12 @@ extension on TranslationsZhHant { 'explore.rows.trendingShows' => '近期熱門影集', 'explore.rows.popularMovies' => '熱門電影', 'explore.rows.popularShows' => '熱門影集', - _ => null, - } ?? switch (path) { 'explore.rows.trendingAnime' => '近期熱門動畫', 'explore.rows.suggestedAnime' => '推薦動畫', 'explore.rows.airingAnime' => '熱門連載動畫', 'explore.rows.popularAnime' => '最受歡迎的動畫', + _ => null, + } ?? switch (path) { 'explore.rows.trending' => '趨勢', 'explore.rows.upcomingMovies' => '即將上映的電影', 'explore.rows.upcomingShows' => '即將播出的影集', @@ -4303,12 +4295,12 @@ extension on TranslationsZhHant { 'companionRemote.pairing.validationHostFormat' => '格式必須為 IP 位址:連接埠(例如 192.168.1.100:48632)', 'companionRemote.pairing.connectionTimedOut' => '連線逾時。請確認兩台裝置都使用相同網路。', 'companionRemote.pairing.sessionNotFound' => '找不到裝置。請確認主機上已啟動 Plezy。', - _ => null, - } ?? switch (path) { 'companionRemote.pairing.authFailed' => '驗證失敗。兩台裝置需要登入相同的 Plex 帳戶。', 'companionRemote.pairing.failedToConnect' => ({required Object error}) => '連線失敗:${error}', 'companionRemote.remote.disconnectConfirm' => '是否要中斷遠端連線工作階段?', 'companionRemote.remote.reconnecting' => '重新連線中…', + _ => null, + } ?? switch (path) { 'companionRemote.remote.attemptOf' => ({required Object current}) => '第 ${current} 次嘗試,共 5 次', 'companionRemote.remote.retryNow' => '立即重試', 'companionRemote.remote.tabRemote' => '遙控', diff --git a/lib/i18n/sv.i18n.json b/lib/i18n/sv.i18n.json index a8c8beb0e..9037fc32f 100644 --- a/lib/i18n/sv.i18n.json +++ b/lib/i18n/sv.i18n.json @@ -332,10 +332,6 @@ "dvConversionNativeDescription": "Tvinga inbyggd DV7 och förhindra nya försök med DV-konvertering", "dvConversionDv81Description": "Tvinga direkt RPU-konvertering till Dolby Vision-profil 8.1", "dvConversionHevcStripDescription": "Ta bort Dolby Visions RPU-/EL-lager och använd vanlig HEVC", - "demuxer": "Container-demuxer", - "demuxerDescription": "Vilken demuxer som läser direktuppspelade filer. Byt till media3 om en fil krånglar.", - "demuxerFfmpeg": "FFmpeg (rekommenderas)", - "demuxerMedia3": "media3", "requireProfileSelectionOnOpen": "Fråga efter profil vid appstart", "requireProfileSelectionOnOpenDescription": "Visa profilval varje gång appen öppnas", "forceTvMode": "Tvinga TV-läge", diff --git a/lib/i18n/tr.i18n.json b/lib/i18n/tr.i18n.json index ca8b3d077..5c013ae7a 100644 --- a/lib/i18n/tr.i18n.json +++ b/lib/i18n/tr.i18n.json @@ -332,10 +332,6 @@ "dvConversionNativeDescription": "Yerel DV7'yi zorla ve DV dönüştürme yeniden denemesini engelle", "dvConversionDv81Description": "Dolby Vision profile 8.1'e satır içi RPU dönüştürmeyi zorla", "dvConversionHevcStripDescription": "Dolby Vision RPU/EL katmanlarını soy ve düz HEVC olarak sun", - "demuxer": "Kapsayıcı demux çözücü", - "demuxerDescription": "Doğrudan oynatılan dosyaları hangi demux çözücünün ayrıştıracağı. Bir dosya sorun çıkarırsa media3'e geçin.", - "demuxerFfmpeg": "FFmpeg (Önerilen)", - "demuxerMedia3": "media3", "requireProfileSelectionOnOpen": "Uygulama açılışında profil sor", "requireProfileSelectionOnOpenDescription": "Uygulama her açıldığında profil seçimini göster", "forceTvMode": "TV modunu zorla", diff --git a/lib/i18n/uz.i18n.json b/lib/i18n/uz.i18n.json index 28e598e16..cea32e660 100644 --- a/lib/i18n/uz.i18n.json +++ b/lib/i18n/uz.i18n.json @@ -332,10 +332,6 @@ "dvConversionNativeDescription": "Ichki DV7 rejimini majburlash", "dvConversionDv81Description": "Dolby Vision profile 8.1 formatiga oʻtkazish", "dvConversionHevcStripDescription": "Dolby Vision qatlamlarini olib tashlash va HEVC sifatida koʻrsatish", - "demuxer": "Konteyner demukseri", - "demuxerDescription": "Toʻgʻridan-toʻgʻri ijro etilgan fayllarni qaysi demukser tahlil qiladi. Fayl notoʻgʻri ishlasa, media3 ga oʻting.", - "demuxerFfmpeg": "FFmpeg (tavsiya etiladi)", - "demuxerMedia3": "media3", "requireProfileSelectionOnOpen": "Ochilganda profilni soʻrash", "requireProfileSelectionOnOpenDescription": "Ilova ochilgan har safar profilni tanlashni koʻrsatish", "forceTvMode": "TV rejimini majburlash", diff --git a/lib/i18n/zh-Hant.i18n.json b/lib/i18n/zh-Hant.i18n.json index cdbd41ae6..0729a740b 100644 --- a/lib/i18n/zh-Hant.i18n.json +++ b/lib/i18n/zh-Hant.i18n.json @@ -332,10 +332,6 @@ "dvConversionNativeDescription": "強制使用原生 DV7 並停用 DV 轉換重試", "dvConversionDv81Description": "強制將內嵌的 RPU 轉換為 Dolby Vision Profile 8.1", "dvConversionHevcStripDescription": "移除 Dolby Vision RPU/EL 層,並以一般 HEVC 呈現", - "demuxer": "容器解多工器", - "demuxerDescription": "由哪個解多工器解析直接播放的檔案。若某個檔案播放異常,可切換為 media3。", - "demuxerFfmpeg": "FFmpeg(建議)", - "demuxerMedia3": "media3", "requireProfileSelectionOnOpen": "開啟應用程式時要求選擇使用者", "requireProfileSelectionOnOpenDescription": "每次開啟應用程式時顯示使用者設定檔選擇畫面", "forceTvMode": "強制 TV 模式", diff --git a/lib/i18n/zh.i18n.json b/lib/i18n/zh.i18n.json index 976c4e784..9487d2d78 100644 --- a/lib/i18n/zh.i18n.json +++ b/lib/i18n/zh.i18n.json @@ -332,10 +332,6 @@ "dvConversionNativeDescription": "强制原生 DV7 并禁止重试 DV 转换", "dvConversionDv81Description": "强制内联 RPU 转换为 Dolby Vision Profile 8.1", "dvConversionHevcStripDescription": "移除 Dolby Vision RPU/EL 层并呈现普通 HEVC", - "demuxer": "容器解复用器", - "demuxerDescription": "由哪个解复用器解析直接播放的文件。若某个文件播放异常,可切换为 media3。", - "demuxerFfmpeg": "FFmpeg(推荐)", - "demuxerMedia3": "media3", "requireProfileSelectionOnOpen": "打开应用时选择用户资料", "requireProfileSelectionOnOpenDescription": "每次打开应用时都显示用户资料选择界面", "forceTvMode": "强制 TV 模式", diff --git a/lib/mpv/player/platform/player_android.dart b/lib/mpv/player/platform/player_android.dart index 2655c05fb..e5671b1b5 100644 --- a/lib/mpv/player/platform/player_android.dart +++ b/lib/mpv/player/platform/player_android.dart @@ -14,7 +14,6 @@ class PlayerAndroid extends PlayerBase { String _bufferTier = 'auto'; bool _tunnelingEnabled = false; String _dvConversionMode = 'auto'; - String _demuxerMode = 'ffmpeg'; bool _audioNormalizationEnabled = false; bool _audioPassthroughEnabled = false; bool _downmixEnabled = false; @@ -116,7 +115,6 @@ class PlayerAndroid extends PlayerBase { 'bufferTier': _bufferTier, 'tunnelingEnabled': _tunnelingEnabled, 'dvConversionMode': _dvConversionMode, - 'demuxerMode': _demuxerMode, 'audioPassthroughEnabled': _audioPassthroughEnabled, // Cheap (32-bit) TV boxes run the hardware video path a frame behind a GL // subtitle overlay; render the ASS one frame earlier there to realign. @@ -345,10 +343,6 @@ class PlayerAndroid extends PlayerBase { () => _dvConversionMode == value, ); break; - case 'demuxer-mode': - _demuxerMode = value; - await _applyWhenInitialized(() => invoke('setDemuxerMode', {'mode': value}), () => _demuxerMode == value); - break; case 'sub-visibility': if (value == 'no') { if (_subtitlesHidden) break; diff --git a/lib/screens/settings/playback_settings_screen.dart b/lib/screens/settings/playback_settings_screen.dart index 72368147c..24e957669 100644 --- a/lib/screens/settings/playback_settings_screen.dart +++ b/lib/screens/settings/playback_settings_screen.dart @@ -89,7 +89,6 @@ class _PlaybackSettingsScreenState extends State { _audioDownmixTile(), if (downmixOn) _downmixCenterBoostTile(), if (downmixOn) _downmixNormalizeTile(), - if (exoActive) _demuxerModeTile(), if (Platform.isAndroid) _dvConversionModeTile(), if (exoActive) _playbackBufferTile(), _defaultQualityTile(), @@ -475,19 +474,6 @@ class _PlaybackSettingsScreenState extends State { DvConversionModePreference.hevcStrip => t.settings.dvConversionHevcStrip, }; - Widget _demuxerModeTile() => SettingSelectionTile( - pref: SettingsService.demuxerMode, - icon: Symbols.schema_rounded, - title: t.settings.demuxer, - subtitleBuilder: (mode) => '${_demuxerModeLabel(mode)} · ${t.settings.demuxerDescription}', - options: DemuxerPreference.values.map((m) => DialogOption(value: m, title: _demuxerModeLabel(m))).toList(), - ); - - String _demuxerModeLabel(DemuxerPreference mode) => switch (mode) { - DemuxerPreference.ffmpeg => t.settings.demuxerFfmpeg, - DemuxerPreference.media3 => t.settings.demuxerMedia3, - }; - Widget _playbackBufferTile() => SettingSelectionTile( pref: SettingsService.playbackBufferTier, icon: Symbols.hourglass_top_rounded, diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 35fdd288e..94f7600d9 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -1319,7 +1319,6 @@ class VideoPlayerScreenState extends State with WidgetsBindin final tunneledPlayback = settingsService.read(SettingsService.tunneledPlayback); await currentPlayer.setProperty('tunneled-playback', tunneledPlayback ? 'yes' : 'no'); await currentPlayer.setProperty('exo-buffer-tier', playbackBufferTier.nativeValue); - await currentPlayer.setProperty('demuxer-mode', settingsService.read(SettingsService.demuxerMode).nativeValue); } if (Platform.isAndroid || Platform.isIOS || Platform.isMacOS) { final dvConversionMode = settingsService.read(SettingsService.dvConversionMode); diff --git a/lib/services/settings_service.dart b/lib/services/settings_service.dart index d66e9c8f3..0bd1d968a 100644 --- a/lib/services/settings_service.dart +++ b/lib/services/settings_service.dart @@ -113,17 +113,6 @@ extension DvConversionModePreferenceNativeValue on DvConversionModePreference { }; } -/// Which demuxer parses direct-played files on Android. Wire values are read -/// natively by `FfmpegDemuxerPolicy`; unknown values resolve to FFmpeg there. -enum DemuxerPreference { ffmpeg, media3 } - -extension DemuxerPreferenceNativeValue on DemuxerPreference { - String get nativeValue => switch (this) { - DemuxerPreference.ffmpeg => 'ffmpeg', - DemuxerPreference.media3 => 'media3', - }; -} - enum PlaybackBufferTier { auto, large, extraLarge } extension PlaybackBufferTierNativeValue on PlaybackBufferTier { @@ -136,6 +125,7 @@ extension PlaybackBufferTierNativeValue on PlaybackBufferTier { const String _bufferSizeMigratedKey = 'buffer_size_migrated_to_auto'; const String _legacyBufferSizeKey = 'buffer_size'; +const String _legacyDemuxerModeKey = 'demuxer_mode'; const String _legacyUseSeasonPosterKey = 'use_season_poster'; const String _legacyMpvConfigEntriesKey = 'mpv_config_entries'; @@ -493,11 +483,6 @@ class SettingsService extends BaseSharedPreferencesService { values: DvConversionModePreference.values, defaultValue: DvConversionModePreference.auto, ); - static const demuxerMode = EnumPref( - 'demuxer_mode', - values: DemuxerPreference.values, - defaultValue: DemuxerPreference.ffmpeg, - ); static const defaultQualityPreset = EnumPref( 'default_quality_preset', values: TranscodeQualityPreset.values, @@ -1060,7 +1045,6 @@ class SettingsService extends BaseSharedPreferencesService { specialsOrdering, useExoPlayer, startupSection, - demuxerMode, showExploreTab, alwaysKeepSidebarOpen, librariesSectionExpanded, @@ -1171,6 +1155,7 @@ class SettingsService extends BaseSharedPreferencesService { prefs.remove(_legacyUseSeasonPosterKey), prefs.remove(_legacyMpvConfigEntriesKey), prefs.remove(_legacyBufferSizeKey), + prefs.remove(_legacyDemuxerModeKey), prefs.remove(_bufferSizeMigratedKey), ]); refreshListenables(); diff --git a/scripts/maestro/run_maestro_ci.py b/scripts/maestro/run_maestro_ci.py index b63e5ba04..6c77a2bc3 100755 --- a/scripts/maestro/run_maestro_ci.py +++ b/scripts/maestro/run_maestro_ci.py @@ -17,12 +17,7 @@ ANDROID_15_INSTRUMENTATION_CLASSES = ( ANDROID_15_INSTRUMENTATION_TARGET = "android-15-instrumentation" # Separate R8 reachability from instrumentation: these suites exercise APIs the app # does not call directly, so shrinking them is expected. Covers name-based reachability (#1703). -ANDROID_R8_REACHABILITY_CLASSES = ",".join( - ( - "androidx.media3.decoder.ffmpeg.FfmpegDecoderReachabilityTest", - "com.edde746.plezy.exoplayer.FfmpegDemuxerReachabilityTest", - ) -) +ANDROID_R8_REACHABILITY_CLASSES = "androidx.media3.decoder.ffmpeg.FfmpegDecoderReachabilityTest" ANDROID_R8_REACHABILITY_TARGET = "android-r8-reachability" diff --git a/test/widgets/performance_stats_service_test.dart b/test/widgets/performance_stats_service_test.dart index 84784096e..b13033ac0 100644 --- a/test/widgets/performance_stats_service_test.dart +++ b/test/widgets/performance_stats_service_test.dart @@ -136,8 +136,8 @@ void main() { group('PerformanceStatsService ExoPlayer codec display (#2063)', () { test('falls back to the sample MIME type when the container has no codecs string', () async { - // Matroska via the FFmpeg demuxer: Format.codecs is null; only the - // MIME types identify the streams. + // Matroska: Format.codecs is null; only the MIME types identify the + // streams. final stats = await _firstStats( _NativeStatsPlayer({ 'playerType': 'exoplayer',