diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/AssLatencyCalibrator.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/AssLatencyCalibrator.kt index c524cb1c..48e151e0 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/AssLatencyCalibrator.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/AssLatencyCalibrator.kt @@ -39,14 +39,16 @@ internal class AssLatencyCalibrator( private val overlaySurface: SurfaceView, private val onCalibrated: (Int) -> Unit, private val onDone: () -> Unit, - private val log: (String) -> Unit, + private val log: (String) -> Unit ) { private val lock = Any() private val finished = AtomicBoolean(false) private val applyFail = AtomicInteger(0) @Volatile private var frameIntervalNs: Long = 0L + @Volatile private var converged = false + @Volatile private var stopped = false private var videoFrames = 0 @@ -77,7 +79,11 @@ internal class AssLatencyCalibrator( doAttach = true } } - if (giveUp) finishIncomplete() else if (doAttach) attach(videoSurface, releaseTimeNs, SurfaceTxProbe.SOURCE_VIDEO) + if (giveUp) { + finishIncomplete() + } else if (doAttach) { + attach(videoSurface, releaseTimeNs, SurfaceTxProbe.SOURCE_VIDEO) + } } fun probeOverlay(releaseTimeNs: Long) { @@ -114,7 +120,7 @@ internal class AssLatencyCalibrator( surfaceCount: Int, fenceState: Int, source: Int, - callbackNs: Long, + callbackNs: Long ) { if (surfaceCount <= 0 || fenceState != FENCE_OK || releaseNs <= 0) return val relMs = (releaseNs - tag) / 1_000_000.0 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 60a293f8..5ce40485 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 @@ -168,6 +168,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { private var videoZoomScale: Float = 1.0f private var assHandler: AssHandler? = null private var assSubtitleView: AssSubtitleSurfaceView? = null + // Touched from the codec metadata listener, the GL-thread overlay hook, and the main-thread // media-item-transition/dispose paths — keep visibility across threads. @Volatile private var latencyCalibrator: AssLatencyCalibrator? = null @@ -739,7 +740,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { overlaySurface = assView, onCalibrated = { frames -> onAssLatencyCalibrated(frames) }, onDone = { assSubtitleView?.setPreSwapProbe(null) }, - log = { msg -> emitLog("info", "ass-latency-cal", msg) }, + log = { msg -> emitLog("info", "ass-latency-cal", msg) } ).also { it.start() // Bind the hook to THIS instance, not the volatile field, so a concurrent transition diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/SurfaceTxProbe.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/SurfaceTxProbe.kt index 0dee396a..ef0d1b59 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/SurfaceTxProbe.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/SurfaceTxProbe.kt @@ -36,7 +36,7 @@ object SurfaceTxProbe { surfaceCount: Int, fenceState: Int, source: Int, - callbackNs: Long, + callbackNs: Long ) { val s = sink ?: return s.invoke(tag, latchNs, releaseNs, surfaceCount, fenceState, source, callbackNs) diff --git a/android/libass/build.gradle.kts b/android/libass/build.gradle.kts index e6b5debf..25d5db5a 100644 --- a/android/libass/build.gradle.kts +++ b/android/libass/build.gradle.kts @@ -25,7 +25,7 @@ android { // A shared cache keeps per-ABI CMake runs from redownloading libass. arguments += listOf( "-DANDROID_STL=c++_shared", - "-DLIBASS_CACHE_DIR=${layout.buildDirectory.get().asFile}/libass-prebuilt", + "-DLIBASS_CACHE_DIR=${layout.buildDirectory.get().asFile}/libass-prebuilt" ) } } diff --git a/android/libass/src/main/cpp/AssKt.c b/android/libass/src/main/cpp/AssKt.c index b3a9a9c6..c6dedcb5 100644 --- a/android/libass/src/main/cpp/AssKt.c +++ b/android/libass/src/main/cpp/AssKt.c @@ -298,8 +298,8 @@ JNIEXPORT jobject JNICALL Java_com_edde746_plezy_libass_AssRender_nativeAssRende const jboolean hasOutput = imageListHasOutput(image) ? JNI_TRUE : JNI_FALSE; if (tAss - t0 > 40) { __android_log_print( - ANDROID_LOG_WARN, LOG_TAG, "slow render t=%lldms: ass=%lldms (changed=%d, hasOutput=%d)", - (long long)time, tAss - t0, changed, hasOutput == JNI_TRUE); + ANDROID_LOG_WARN, LOG_TAG, "slow render t=%lldms: ass=%lldms (changed=%d, hasOutput=%d)", (long long)time, + tAss - t0, changed, hasOutput == JNI_TRUE); } return (*env)->NewObject(env, atlasFrameClass, ctor, 0, 0, 0, changed, 0, hasOutput); } @@ -569,8 +569,7 @@ static EGLint gFtPresentName = EGL_DISPLAY_PRESENT_TIME_ANDROID; // Probes the extension on the currently-current draw surface and enables capture. // Re-resolves the display/surface each call so surface recreation is handled. // Returns one of the FT_* codes above. -JNIEXPORT jint JNICALL -Java_com_edde746_plezy_libass_AssFrameTimestamps_nativeInit(JNIEnv* env, jclass clazz) { +JNIEXPORT jint JNICALL Java_com_edde746_plezy_libass_AssFrameTimestamps_nativeInit(JNIEnv* env, jclass clazz) { gFtSurface = EGL_NO_SURFACE; EGLDisplay dpy = eglGetCurrentDisplay(); EGLSurface surf = eglGetCurrentSurface(EGL_DRAW); @@ -632,8 +631,7 @@ Java_com_edde746_plezy_libass_AssFrameTimestamps_nativeGetNextFrameId(JNIEnv* en // Present (or composition) time for frameId (System.nanoTime() domain), or the // PENDING(-2)/INVALID(-1) sentinels. Reported a few frames after the swap. JNIEXPORT jlong JNICALL -Java_com_edde746_plezy_libass_AssFrameTimestamps_nativeGetDisplayPresentTime( - JNIEnv* env, jclass clazz, jlong frameId) { +Java_com_edde746_plezy_libass_AssFrameTimestamps_nativeGetDisplayPresentTime(JNIEnv* env, jclass clazz, jlong frameId) { if (pEglGetFrameTimestamps == NULL || gFtSurface == EGL_NO_SURFACE) return EGL_TIMESTAMP_INVALID_ANDROID; const EGLint names[1] = {gFtPresentName}; EGLnsecsANDROID values[1] = {0}; diff --git a/android/libass/src/main/cpp/SurfaceTxProbe.c b/android/libass/src/main/cpp/SurfaceTxProbe.c index b0cc882d..0390f073 100644 --- a/android/libass/src/main/cpp/SurfaceTxProbe.c +++ b/android/libass/src/main/cpp/SurfaceTxProbe.c @@ -67,8 +67,7 @@ static int64_t readFenceTimeNs(int fd, int* outStatus) { if (ioctl(fd, SYNC_IOC_FILE_INFO, &probe) < 0) return -1; *outStatus = probe.status; if (probe.status != 1 || probe.num_fences == 0) return -1; - struct stp_fence_info* arr = - (struct stp_fence_info*)calloc(probe.num_fences, sizeof(struct stp_fence_info)); + struct stp_fence_info* arr = (struct stp_fence_info*)calloc(probe.num_fences, sizeof(struct stp_fence_info)); if (!arr) return -1; struct stp_file_info req; memset(&req, 0, sizeof(req)); @@ -118,10 +117,7 @@ static int resolveSc(void) { p_getControls = (pf_getControls)dlsym(h, "ASurfaceTransactionStats_getASurfaceControls"); p_prevRelease = (pf_prevRelease)dlsym(h, "ASurfaceTransactionStats_getPreviousReleaseFenceFd"); p_releaseControls = (pf_releaseControls)dlsym(h, "ASurfaceTransactionStats_releaseASurfaceControls"); - gScOk = (p_fromJava && p_setOnComplete && p_latchTime && p_getControls && p_prevRelease && - p_releaseControls) - ? 1 - : 0; + gScOk = (p_fromJava && p_setOnComplete && p_latchTime && p_getControls && p_prevRelease && p_releaseControls) ? 1 : 0; return gScOk; } @@ -143,12 +139,12 @@ static int64_t nowMonoNs(void) { return (int64_t)ts.tv_sec * 1000000000LL + ts.tv_nsec; } -static void reportResult(JNIEnv* env, int64_t tag, int64_t latchNs, int64_t releaseNs, int count, - int fenceState, int source, int64_t cbNs) { +static void reportResult( + JNIEnv* env, int64_t tag, int64_t latchNs, int64_t releaseNs, int count, int fenceState, int source, int64_t cbNs) { if (!env || !gProbeClass || !gOnResult) return; - (*env)->CallStaticVoidMethod(env, gProbeClass, gOnResult, (jlong)tag, (jlong)latchNs, - (jlong)releaseNs, (jint)count, (jint)fenceState, (jint)source, - (jlong)cbNs); + (*env)->CallStaticVoidMethod( + env, gProbeClass, gOnResult, (jlong)tag, (jlong)latchNs, (jlong)releaseNs, (jint)count, (jint)fenceState, + (jint)source, (jlong)cbNs); if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env); } diff --git a/android/libass/src/main/java/com/edde746/plezy/libass/media/widget/AssSubtitleAtlasPipeline.kt b/android/libass/src/main/java/com/edde746/plezy/libass/media/widget/AssSubtitleAtlasPipeline.kt index 3d771a4e..898d1d6d 100644 --- a/android/libass/src/main/java/com/edde746/plezy/libass/media/widget/AssSubtitleAtlasPipeline.kt +++ b/android/libass/src/main/java/com/edde746/plezy/libass/media/widget/AssSubtitleAtlasPipeline.kt @@ -213,10 +213,9 @@ internal class AssAtlasPipeline( // first actual render. Confined to the libass thread after creation. private var slots: AtlasSlots? = null - private fun rendererStateGeneration(): Long = - assHandler.render?.let { - (System.identityHashCode(it).toLong() shl 32) or (it.stateGeneration.toLong() and 0xffffffffL) - } ?: -1L + private fun rendererStateGeneration(): Long = assHandler.render?.let { + (System.identityHashCode(it).toLong() shl 32) or (it.stateGeneration.toLong() and 0xffffffffL) + } ?: -1L private fun acquireSlots(): AtlasSlots { slots?.let { return it } @@ -484,8 +483,10 @@ private class SubtitleFramePhaseEstimator { val ptsDeltaUs = ptsUs - prevPtsUs val releaseDeltaNs = releaseNs - prevReleaseNs - if (ptsDeltaUs <= 0 || ptsDeltaUs > MAX_DELTA_US || - releaseDeltaNs <= 0 || releaseDeltaNs > MAX_DELTA_NS + if (ptsDeltaUs <= 0 || + ptsDeltaUs > MAX_DELTA_US || + releaseDeltaNs <= 0 || + releaseDeltaNs > MAX_DELTA_NS ) { deltaCount = 0 deltaIndex = 0 @@ -504,8 +505,7 @@ private class SubtitleFramePhaseEstimator { return copy[count / 2] } - private fun saturatedAdd(value: Long, delta: Long): Long = - if (delta > 0 && value > Long.MAX_VALUE - delta) Long.MAX_VALUE else value + delta + private fun saturatedAdd(value: Long, delta: Long): Long = if (delta > 0 && value > Long.MAX_VALUE - delta) Long.MAX_VALUE else value + delta private companion object { const val UNSET = Long.MIN_VALUE @@ -947,12 +947,12 @@ private class AtlasGlThread( copy.sort() measuredIntervalNs = copy[releaseDeltaCount / 2] swapLeadNs = (measuredIntervalNs / 2).coerceIn( - SCHEDULED_SWAP_LEAD_MIN_NS, SCHEDULED_SWAP_LEAD_MAX_NS + SCHEDULED_SWAP_LEAD_MIN_NS, + SCHEDULED_SWAP_LEAD_MAX_NS ) } } - private lateinit var handler: Handler private var eglDisplay: EGLDisplay = EGL14.EGL_NO_DISPLAY private var eglContext: EGLContext = EGL14.EGL_NO_CONTEXT @@ -1322,6 +1322,7 @@ private class AtlasGlThread( private const val MSG_SIZE_CHANGED = 3 private const val MSG_RELEASE = 4 private const val SYNC_LOG_INTERVAL_SWAPS = 120L + // Missing SurfaceFlinger's latch deadline costs a full refresh, so keep the // margin proportional to the active cadence. private const val SCHEDULED_SWAP_LEAD_MIN_NS = 6_000_000L diff --git a/android/libass/src/main/java/com/edde746/plezy/libass/media/widget/SpecRenderEngine.kt b/android/libass/src/main/java/com/edde746/plezy/libass/media/widget/SpecRenderEngine.kt index f9a84ab5..b67d2b7a 100644 --- a/android/libass/src/main/java/com/edde746/plezy/libass/media/widget/SpecRenderEngine.kt +++ b/android/libass/src/main/java/com/edde746/plezy/libass/media/widget/SpecRenderEngine.kt @@ -210,8 +210,7 @@ internal class SpecRenderEngine( return SpecWrite(target, frame) } - private fun isImplicitBlank(frame: AssAtlasFrame): Boolean = - !frame.hasOutput && libassLastFrame?.hasOutput == true + private fun isImplicitBlank(frame: AssAtlasFrame): Boolean = !frame.hasOutput && libassLastFrame?.hasOutput == true /** * Pre-renders [ptsUs] (an upcoming event's start) purely to warm the diff --git a/test/mpv/player_open_test.dart b/test/mpv/player_open_test.dart index 082816b3..8206d752 100644 --- a/test/mpv/player_open_test.dart +++ b/test/mpv/player_open_test.dart @@ -7,10 +7,19 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:plezy/mpv/mpv.dart'; import 'package:plezy/mpv/player/platform/player_android.dart'; import 'package:plezy/mpv/player/player_native.dart'; +import 'package:plezy/services/settings_service.dart'; + +import '../test_helpers/prefs.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); + setUp(() async { + resetSharedPreferencesForTest(); + SettingsService.resetForTesting(); + await SettingsService.getInstance(); + }); + group('player open', () { test('ExoPlayer clears stale Dart track state before opening new media', () async { await _withMockChannels( diff --git a/test/mpv/property_observation_test.dart b/test/mpv/property_observation_test.dart index 0ca73084..5275b3cf 100644 --- a/test/mpv/property_observation_test.dart +++ b/test/mpv/property_observation_test.dart @@ -3,6 +3,9 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:plezy/mpv/player/platform/player_android.dart'; import 'package:plezy/mpv/player/player_base.dart'; import 'package:plezy/mpv/player/player_native.dart'; +import 'package:plezy/services/settings_service.dart'; + +import '../test_helpers/prefs.dart'; /// Guards the channel contract: every property [PlayerBase.handlePropertyChange] /// depends on for core state must be registered by each backend at init. @@ -12,6 +15,12 @@ import 'package:plezy/mpv/player/player_native.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); + setUp(() async { + resetSharedPreferencesForTest(); + SettingsService.resetForTesting(); + await SettingsService.getInstance(); + }); + const coreNames = { 'time-pos', 'duration',