fix(android): stop tunneling 24p video on the Fire TV Stick 4K

Tunneled playback on an AFTMM judders continuously through 23.976p direct play.
The #1802 reporter isolated it: turning off Tunneled Playback with every other
setting unchanged makes it smooth, and their log shows tunneling active for the
whole session with E-AC3 bitstreamed and the decoded-PCM guard never firing.

Audio Passthrough looked like the trigger only because it is the one user-facing
switch that decides it. Passthrough off, or Downmix to Stereo on, both force the
Dolby track to decode to PCM, which trips the #1458 guard and takes tunneling
down with it. Passthrough on with downmix off is the only combination that keeps
a bitstreamed track, so it is the only one that stays tunneled.

Withdraw tunneling on that model for content at or below 30fps. The cut-off
keeps 4K50/60 tunneled, which is the workload Amazon documents the feature for.
The mechanism stays unconfirmed: tunneling fires no VideoFrameMetadataListener
and stops media3 counting frames in the codec, so nothing app-side can measure
the cadence. Only the trigger is established, and the quirk is scoped to it.

That needs a frame rate the app did not have. Neither MatroskaExtractor nor
Mp4Extractor populates Format.frameRate, and a tunneled session renders no
frames back for the native detector, so the server's rate now rides on the open
call. It is sent only for direct play, matching _primeDisplayCriteria: a
transcode's metadata describes the source, not what the server is about to send.

Also move Audio Passthrough out of the in-player settings sheet. It configures
the audio output route rather than the current playback, and applying it
mid-stream bounces the audio renderer and re-decides tunneling. Settings > Video
Playback already owns it, next to Tunneled Playback, which is applied the same
way. That description now mentions stutter, not only black HDR video, so the
workaround is findable on hardware this quirk does not cover.

The mpv backend failing to start the same 4K file is a separate defect and is
not addressed here; its uploaded log is no longer retrievable.
This commit is contained in:
edde746
2026-08-06 03:45:09 +02:00
parent 1b6a811c07
commit f93952ba6f
56 changed files with 263 additions and 88 deletions
@@ -0,0 +1,93 @@
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/mpv/models.dart';
import 'package:plezy/mpv/player/platform/player_android.dart';
import 'package:plezy/services/settings_service.dart';
import '../test_helpers/mock_player_channels.dart';
import '../test_helpers/prefs.dart';
/// Drives the content frame-rate contract between Dart and `ExoPlayerPlugin` (#1802).
///
/// The native side gates video tunneling on this rate: a Fire TV Stick 4K judders through
/// tunneled 23.976p, so `DeviceQuirks.hasUnreliableTunneledPlayback` withdraws tunneling
/// below 30fps and leaves the 4K60 workload tunneling alone. Neither the Matroska nor the
/// MP4 extractor populates `Format.frameRate`, and a tunneled session renders no frames
/// back for the native FPS detector, so this channel argument is the only source.
///
/// Losing it is silent — the native default is "unknown", which keeps stock behaviour and
/// simply never applies the fix.
Future<MethodCall> _captureOpen({required Future<void> Function(PlayerAndroid player) configure}) async {
late MethodCall open;
await withMockPlayerChannels(
methodChannelName: 'com.plezy/exo_player',
eventChannelName: 'com.plezy/exo_player/events',
methodHandler: (call) async {
if (call.method == 'open') open = call;
return call.method == 'initialize' ? true : null;
},
testBody: () async {
final player = PlayerAndroid();
try {
await configure(player);
await player.open(const Media('https://example.test/a.mkv'), play: false);
} finally {
await player.dispose();
}
},
);
return open;
}
Map<Object?, Object?> _args(MethodCall call) => call.arguments as Map<Object?, Object?>;
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() async {
resetSharedPreferencesForTest();
SettingsService.resetForTesting();
await SettingsService.getInstance();
});
test('the metadata frame rate reaches the native open call', () async {
final open = await _captureOpen(configure: (player) => player.setProperty('content-frame-rate', '23.976'));
expect(_args(open)['contentFrameRate'], closeTo(23.976, 1e-6));
});
test('a high frame rate is forwarded unchanged so tunneling survives', () async {
// The quirk must be able to tell 4K60 apart from 24p; clamping or rounding here
// would withdraw tunneling from the workload it exists for.
final open = await _captureOpen(configure: (player) => player.setProperty('content-frame-rate', '59.94'));
expect(_args(open)['contentFrameRate'], closeTo(59.94, 1e-6));
});
test('unknown metadata sends no rate rather than a bogus one', () async {
// video_player_screen writes "0" when the server gave no frame rate. Forwarding 0 as a
// real value would be indistinguishable from a measured rate on the native side.
final open = await _captureOpen(configure: (player) => player.setProperty('content-frame-rate', '0'));
expect(_args(open).containsKey('contentFrameRate'), isFalse);
});
test('an item without a rate clears the previous item\'s rate', () async {
// Episode-to-episode reuse keeps the same PlayerAndroid, so a stale 24p rate would
// keep tunneling withdrawn for a following 60fps item.
final open = await _captureOpen(
configure: (player) async {
await player.setProperty('content-frame-rate', '23.976');
await player.setProperty('content-frame-rate', '0');
},
);
expect(_args(open).containsKey('contentFrameRate'), isFalse);
});
test('an unparseable rate is dropped instead of forwarded', () async {
final open = await _captureOpen(configure: (player) => player.setProperty('content-frame-rate', 'nonsense'));
expect(_args(open).containsKey('contentFrameRate'), isFalse);
});
}
+10 -3
View File
@@ -36,12 +36,19 @@ void main() {
LocaleSettings.setLocaleSync(AppLocale.en);
});
testWidgets('shows audio passthrough on supported TV-style surfaces', (tester) async {
testWidgets('keeps audio passthrough out of the in-player sheet', (tester) async {
// It configures the audio output route, not this playback, and applying it
// mid-stream bounces the audio renderer and re-decides video tunneling. Settings >
// Video Playback owns it, alongside Tunneled Playback.
await _pumpSheet(tester);
await tester.scrollUntilVisible(find.text('Audio Passthrough'), 500, scrollable: find.byType(Scrollable).first);
final scrollable = find.byType(Scrollable).first;
for (var i = 0; i < 10; i++) {
await tester.drag(scrollable, const Offset(0, -300));
await tester.pumpAndSettle();
}
expect(find.text('Audio Passthrough'), findsOneWidget);
expect(find.text('Audio Passthrough'), findsNothing);
});
testWidgets('localizes Off, Normal, and Active video setting values', (tester) async {