diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index d89c37d2..c39de772 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -1021,10 +1021,11 @@ class _PlexVideoControlsState extends State onHover: (_) => _showControlsFromPointerActivity(), child: Stack( children: [ - // Keep-alive: 1px widget that continuously repaints to prevent - // Flutter animations from freezing when the frame clock goes idle - if (Platform.isLinux || Platform.isWindows) - const Positioned(top: 0, left: 0, child: LinuxKeepAlive()), + // Keep-alive for Linux's idle GTK frame clock; inert on every + // other platform (the widget owns the platform decision). + // Windows must NOT tick here: forced repaints during playback + // perturb VRR scanout in fullscreen (#1707). + const Positioned(top: 0, left: 0, child: LinuxKeepAlive()), // Also handles long-press for 2x speed. Positioned.fill( child: Semantics( diff --git a/lib/widgets/video_controls/widgets/linux_keep_alive.dart b/lib/widgets/video_controls/widgets/linux_keep_alive.dart index be3e5d55..5726e374 100644 --- a/lib/widgets/video_controls/widgets/linux_keep_alive.dart +++ b/lib/widgets/video_controls/widgets/linux_keep_alive.dart @@ -1,12 +1,30 @@ import 'dart:async' show Timer; +import 'dart:io' show Platform; import 'package:flutter/material.dart'; -/// A 1x1 pixel widget that continuously repaints to keep Flutter's frame clock active on Linux. -/// This prevents animations from freezing when GTK's frame clock goes idle. +/// A 1x1 pixel widget that continuously repaints to keep Flutter's frame clock +/// active on Linux, where GTK's frame clock goes idle and freezes animations. +/// +/// Linux-only by design. Mounting this on other platforms is harmful: on +/// Windows the 10Hz repaints become DirectComposition commits during playback, +/// and once fullscreen focus engages VRR (FreeSync/G-Sync) each commit forces a +/// scanout off the video's cadence — the micro-stutter of issue #1707. Every +/// non-Linux platform must instead uphold the invariant that the player UI +/// schedules no frames while its chrome is hidden. class LinuxKeepAlive extends StatefulWidget { const LinuxKeepAlive({super.key}); + /// Forces the platform decision so tests can exercise both the ticking and + /// the inert path regardless of host OS. + @visibleForTesting + static bool? debugIsLinuxOverride; + + /// Whether this widget repaints on the current platform. Exposed so the + /// quiescence test can pin the policy to exactly [Platform.isLinux]. + @visibleForTesting + static bool get ticksOnThisPlatform => debugIsLinuxOverride ?? Platform.isLinux; + @override State createState() => _LinuxKeepAliveState(); } @@ -18,6 +36,7 @@ class _LinuxKeepAliveState extends State { @override void initState() { super.initState(); + if (!LinuxKeepAlive.ticksOnThisPlatform) return; // Repaint every 100ms to keep Flutter's frame scheduler active. _timer = Timer.periodic(const Duration(milliseconds: 100), (_) { if (mounted) { @@ -36,6 +55,7 @@ class _LinuxKeepAliveState extends State { @override Widget build(BuildContext context) { + if (_timer == null) return const SizedBox.shrink(); return SizedBox(width: 1, height: 1, child: ColoredBox(color: Color.fromRGBO(0, 0, 0, _tick % 2 == 0 ? 0.1 : 0.2))); } } diff --git a/test/widgets/video_controls_quiescence_test.dart b/test/widgets/video_controls_quiescence_test.dart new file mode 100644 index 00000000..c841f729 --- /dev/null +++ b/test/widgets/video_controls_quiescence_test.dart @@ -0,0 +1,197 @@ +import 'dart:io' show Platform; + +import 'package:drift/native.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:intl/date_symbol_data_local.dart'; +import 'package:provider/provider.dart'; + +import 'package:plezy/database/app_database.dart'; +import 'package:plezy/i18n/strings.g.dart'; +import 'package:plezy/mpv/mpv.dart'; +import 'package:plezy/providers/playback_state_provider.dart'; +import 'package:plezy/services/settings_service.dart'; +import 'package:plezy/services/video_volume_controller.dart'; +import 'package:plezy/utils/platform_detector.dart'; +import 'package:plezy/watch_together/providers/watch_together_provider.dart'; +import 'package:plezy/widgets/video_controls/player_chrome_controller.dart'; +import 'package:plezy/widgets/video_controls/video_controls.dart'; +import 'package:plezy/widgets/video_controls/widgets/linux_keep_alive.dart'; +import 'package:plezy/widgets/video_controls/widgets/player_toast_indicator.dart'; + +import '../test_helpers/media_items.dart'; +import '../test_helpers/prefs.dart'; +import '../test_helpers/theme.dart'; + +/// Regression coverage for #1707: with the chrome hidden and nothing transient +/// on screen, the player UI must schedule no frames at all. On Windows every +/// scheduled frame becomes a DirectComposition commit, and under fullscreen +/// VRR (FreeSync/G-Sync) each commit forces a scanout off the video's cadence, +/// which the viewer sees as micro-stutter. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('the keep-alive ticks on Linux and nowhere else', () { + LinuxKeepAlive.debugIsLinuxOverride = null; + expect( + LinuxKeepAlive.ticksOnThisPlatform, + Platform.isLinux, + reason: 'forced repaints on any other platform reintroduce the #1707 VRR micro-stutter', + ); + }); + + group('hidden-chrome frame quiescence', () { + late _IdlePlayer player; + late PlayerChromeController chrome; + late PlayerToastController toast; + late VideoVolumeController volume; + late PlaybackStateProvider playbackState; + late WatchTogetherProvider watchTogether; + late AppDatabase database; + + setUp(() async { + LocaleSettings.setLocaleSync(AppLocale.en); + await initializeDateFormatting('en'); + resetSharedPreferencesForTest(); + SettingsService.resetForTesting(); + final settings = await SettingsService.getInstance(); + + TvDetectionService.debugSetAppleTVOverride(true); + PlatformDetector.debugSetIsDesktopOSOverride(false); + + database = AppDatabase.forTesting(NativeDatabase.memory()); + player = _IdlePlayer(); + chrome = PlayerChromeController(); + toast = PlayerToastController(); + volume = VideoVolumeController(player: player, settings: settings, initialVolume: 100); + playbackState = PlaybackStateProvider(); + watchTogether = WatchTogetherProvider(); + }); + + tearDown(() async { + LinuxKeepAlive.debugIsLinuxOverride = null; + TvDetectionService.debugSetAppleTVOverride(null); + PlatformDetector.debugSetIsDesktopOSOverride(null); + volume.dispose(); + playbackState.dispose(); + watchTogether.dispose(); + chrome.dispose(); + toast.dispose(); + await database.close(); + }); + + Future pumpHiddenChrome(WidgetTester tester) async { + await tester.pumpWidget( + MultiProvider( + providers: [ + Provider.value(value: database), + ChangeNotifierProvider.value(value: playbackState), + ChangeNotifierProvider.value(value: watchTogether), + ], + child: MaterialApp( + theme: ThemeData(platform: TargetPlatform.android, extensions: const [testMonoTokens]), + home: Scaffold( + body: SizedBox( + width: 1280, + height: 720, + child: PlexVideoControls( + player: player, + volumeController: volume, + metadata: testMediaItem(id: 'quiescence'), + toastController: toast, + chromeController: chrome, + canNavigateMediaItems: false, + ), + ), + ), + ), + ), + ); + await tester.pump(); + chrome.hide(); + chrome.markControlsHidden(); + chrome.cancelAutoHide(); + await tester.pump(); + expect(chrome.controlsVisible, isFalse); + } + + testWidgets('the player UI schedules no frames while chrome is hidden', (tester) async { + LinuxKeepAlive.debugIsLinuxOverride = false; + await pumpHiddenChrome(tester); + + // Drain the chrome fade-out and any post-frame follow-ups. + await tester.pumpAndSettle(); + expect(tester.binding.hasScheduledFrame, isFalse); + + // Let fake time elapse without pumping: any periodic repaint (like the + // Linux keep-alive formerly mounted on Windows) would schedule a frame. + await tester.binding.delayed(const Duration(seconds: 5)); + expect( + tester.binding.hasScheduledFrame, + isFalse, + reason: 'the player UI must stay frame-quiescent while chrome is hidden (#1707)', + ); + + await tester.pumpWidget(const SizedBox.shrink()); + }); + + testWidgets('Linux still repaints to keep its frame clock alive', (tester) async { + LinuxKeepAlive.debugIsLinuxOverride = true; + await pumpHiddenChrome(tester); + + // Drive the fade to completion with bounded pumps; pumpAndSettle would + // never settle against the keep-alive's own repaints. + await tester.pump(const Duration(seconds: 1)); + await tester.pump(); + + await tester.binding.delayed(const Duration(milliseconds: 250)); + expect( + tester.binding.hasScheduledFrame, + isTrue, + reason: 'the GTK frame clock workaround must keep scheduling frames on Linux', + ); + + await tester.pumpWidget(const SizedBox.shrink()); + }); + }); +} + +/// Minimal [Player] that stays paused-forever idle so the controls have no +/// stream activity to react to — the state a real hidden-chrome session is in. +class _IdlePlayer implements Player { + @override + String get playerType => 'mpv'; + + @override + PlayerState get state => PlayerState( + playing: true, + position: const Duration(minutes: 10), + duration: const Duration(minutes: 45), + seekable: true, + ); + + @override + PlayerStreams get streams => PlayerStreams( + playing: const Stream.empty(), + completed: const Stream.empty(), + buffering: const Stream.empty(), + position: const Stream.empty(), + duration: const Stream.empty(), + seekable: const Stream.empty(), + buffer: const Stream.empty(), + volume: const Stream.empty(), + rate: const Stream.empty(), + tracks: const Stream.empty(), + track: const Stream.empty(), + log: const Stream.empty(), + error: const Stream.empty(), + audioDevice: const Stream.empty(), + audioDevices: const Stream>.empty(), + bufferRanges: const Stream>.empty(), + playbackRestart: const Stream.empty(), + backendSwitched: const Stream.empty(), + ); + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +}