Player: mpv logs at warn (v only while probing the render mode), 4xx fails at once, load errors show

The session player asked libmpv for every v-level message all session and
media_kit drains them on the UI thread: a retry storm could freeze the
window. ffmpeg reconnects only on 5xx, bounded. A load that fails before
a duration is known reports once ('can't play this: HTTP error 403 ...').

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-10 21:12:00 +03:00
1 parent f23cc7b311
commit 1b04e002c2
6 files changed
+247 -13

No files matched your search

@@ -1,6 +1,8 @@
import 'dart:async'; import 'dart:async';
import 'dart:ffi' show Int8;
import 'dart:io'; import 'dart:io';
import 'package:ffi/ffi.dart' show StringUtf8Pointer, malloc;
import 'package:flutter/foundation.dart' show listEquals; import 'package:flutter/foundation.dart' show listEquals;
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:media_kit/media_kit.dart'; import 'package:media_kit/media_kit.dart';
@@ -64,6 +66,22 @@ class PlaybackController {
/// mpv's yt-dlp hook could not open the current link (its error text). /// mpv's yt-dlp hook could not open the current link (its error text).
void Function(String error)? onYtdlError; void Function(String error)? onYtdlError;
/// The current media failed to load (the HTTP error when there was one,
/// e.g. a CDN's 403), at most once per [openUrl].
void Function(String error)? onLoadError;
// Per opened media: the last HTTP error ffmpeg logged, and whether a
// load error was reported.
String? _httpError;
bool _loadErrorSent = false;
/// mpv's log level for this app. media_kit drains mpv's log on the UI
/// thread one line at a time, so a chatty level (ffmpeg retrying a
/// refused request logs every attempt) can starve the UI: warnings and
/// errors only, except while the video output starts ([video]), when
/// the render-mode probe needs mpv's verbose GL init lines.
static const logLevel = 'warn';
/// Points mpv's yt-dlp hook at [path] (the app's managed copy) for web /// Points mpv's yt-dlp hook at [path] (the app's managed copy) for web
/// page links, at most 1080p, once per player; its cookies /// page links, at most 1080p, once per player; its cookies
/// (`ytdl-raw-options`) whenever they changed. /// (`ytdl-raw-options`) whenever they changed.
@@ -162,6 +180,7 @@ class PlaybackController {
final existing = _video; final existing = _video;
if (existing != null) return existing; if (existing != null) return existing;
final hwdec = _hwdecOverride; final hwdec = _hwdecOverride;
_requestLog(player, 'v'); // back to [logLevel] by _detectRenderMode
// media_kit's video output sets hwdec itself on attach ('auto' unless // media_kit's video output sets hwdec itself on attach ('auto' unless
// configured), which used to override SYNC_RELAY_HWDEC. // configured), which used to override SYNC_RELAY_HWDEC.
final video = _video = VideoController( final video = _video = VideoController(
@@ -187,8 +206,7 @@ class PlaybackController {
bufferSize: maxBytes, bufferSize: maxBytes,
libass: true, libass: true,
vo: Platform.environment['SYNC_RELAY_VO'], vo: Platform.environment['SYNC_RELAY_VO'],
// Verbose: the render-mode probe needs mpv's GL init lines. logLevel: MPVLogLevel.warn, // see [logLevel]
logLevel: MPVLogLevel.v,
)) ))
..onUserSeek = (d) => onUserSeek?.call(d); ..onUserSeek = (d) => onUserSeek?.call(d);
_shaders = ''; _shaders = '';
@@ -243,8 +261,29 @@ class PlaybackController {
if (hwdec != null) { if (hwdec != null) {
await _pin('hwdec', hwdec); await _pin('hwdec', hwdec);
} }
await _pin('stream-lavf-o', await _pin('stream-lavf-o', streamLavfOptions);
'reconnect_streamed=1,reconnect_on_http_error=1,reconnect_delay_max=4'); }
/// ffmpeg's HTTP options (mpv `stream-lavf-o`, comma form): reconnect
/// after a dropped connection, also on a streamed (growing) relay
/// response, and a server error (5xx) while connecting; a refused request
/// (4xx) fails at once. At most 3 tries per read, 4 s apart at most.
static const streamLavfOptions = 'reconnect_streamed=1,reconnect_on_http_error=5xx,'
'reconnect_delay_max=4,reconnect_max_retries=3';
// mpv_request_log_messages on [p]'s handle (media_kit has no API for it).
static void _requestLog(Player p, String level) {
try {
final dynamic platform = p.platform;
final name = level.toNativeUtf8();
try {
platform.mpv.mpv_request_log_messages(platform.ctx, name.cast<Int8>());
} finally {
malloc.free(name);
}
} catch (e) {
debugPrint('mpv log level $level not set: $e');
}
} }
/// Puts the saved shader choice (Settings, Ctrl+0..6, shader button) on /// Puts the saved shader choice (Settings, Ctrl+0..6, shader button) on
@@ -331,7 +370,27 @@ class PlaybackController {
void _onLog(PlayerLog log) { void _onLog(PlayerLog log) {
if (log.prefix == 'ytdl_hook' && (log.level == 'error' || log.level == 'fatal')) { if (log.prefix == 'ytdl_hook' && (log.level == 'error' || log.level == 'fatal')) {
final text = log.text.trim().replaceFirst(RegExp(r'^ERROR:\s*'), ''); final text = log.text.trim().replaceFirst(RegExp(r'^ERROR:\s*'), '');
if (text.isNotEmpty) onYtdlError?.call(text); if (text.isNotEmpty) {
_loadErrorSent = true;
onYtdlError?.call(text);
}
return;
}
// ffmpeg logs through one mpv instance per process (the first one
// alive), so the HTTP status is a bonus, not a given.
if (log.prefix == 'ffmpeg') {
final http = RegExp(r'HTTP error \d{3}.*').firstMatch(log.text);
if (http != null) _httpError = http[0]!.trim();
return;
}
// Nothing playable opened (a track of mpv's yt-dlp EDL refused, ...).
if (!_loadErrorSent &&
duration.peek() == Duration.zero &&
((log.prefix == 'cplayer' && log.level == 'fatal') ||
(log.prefix == 'demux' && log.level == 'error'))) {
_loadErrorSent = true;
final http = _httpError;
onLoadError?.call(http == null ? 'the video stream could not be opened' : 'the video server answered $http');
return; return;
} }
if (log.prefix != 'libmpv_render') return; if (log.prefix != 'libmpv_render') return;
@@ -364,10 +423,12 @@ class PlaybackController {
Future<void> _detectRenderMode(Player p, VideoController video) async { Future<void> _detectRenderMode(Player p, VideoController video) async {
try { try {
await video.platform.future; await video.platform.future;
await Future.delayed(const Duration(milliseconds: 1500));
} catch (_) { } catch (_) {
return; return;
} finally {
if (identical(p, _player)) _requestLog(p, logLevel);
} }
await Future.delayed(const Duration(milliseconds: 1500));
if (!identical(p, _player) || renderMode.peek() != RenderMode.unknown) { if (!identical(p, _player) || renderMode.peek() != RenderMode.unknown) {
return; return;
} }
@@ -451,6 +512,8 @@ class PlaybackController {
if (!_ready) return; if (!_ready) return;
try { try {
_chapterJson = ''; _chapterJson = '';
_httpError = null;
_loadErrorSent = false;
chapters.value = const []; chapters.value = const [];
tracks.value = const []; tracks.value = const [];
buffered.value = Duration.zero; buffered.value = Duration.zero;
@@ -333,6 +333,7 @@ class SessionCoordinator {
try { try {
playback.onFrozen = () => unawaited(reloadVideo(auto: true)); playback.onFrozen = () => unawaited(reloadVideo(auto: true));
playback.onYtdlError = (e) => health.value = HealthStatus(HealthSeverity.error, "can't open this link: $e"); playback.onYtdlError = (e) => health.value = HealthStatus(HealthSeverity.error, "can't open this link: $e");
playback.onLoadError = (e) => health.value = HealthStatus(HealthSeverity.error, "can't play this: $e");
playback.onUserSeek = (d) => _userSeek(d.inMicroseconds / 1e6); playback.onUserSeek = (d) => _userSeek(d.inMicroseconds / 1e6);
await playback.init(); await playback.init();
_playerReady = true; _playerReady = true;
+1 -1
View File
@@ -138,7 +138,7 @@ packages:
source: hosted source: hosted
version: "1.3.3" version: "1.3.3"
ffi: ffi:
dependency: transitive dependency: "direct main"
description: description:
name: ffi name: ffi
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
+1
View File
@@ -24,6 +24,7 @@ dependencies:
image: ^4.9.2 image: ^4.9.2
window_manager: ^0.5.2 window_manager: ^0.5.2
crypto: ^3.0.6 crypto: ^3.0.6
ffi: ^2.1.0
# Drag & drop and clipboard files/images (vendored desktop_drop + pasteboard). # Drag & drop and clipboard files/images (vendored desktop_drop + pasteboard).
fox_utils: fox_utils:
git: git:
@@ -0,0 +1,148 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:media_kit/media_kit.dart';
import 'package:watch_together/src/playback/playback_controller.dart';
final _tmp = Platform.environment['WT_TEST_TMP'] ?? Directory.systemTemp.path;
const _referer = 'https://www.bilibili.com/';
/// Separate video-only and audio-only files, like Bilibili's DASH .m4s.
Future<(String, String)> _media() async {
final v = '$_tmp/ytdl_headers_v.mp4', a = '$_tmp/ytdl_headers_a.m4a';
for (final (path, args) in [
(v, ['-f', 'lavfi', '-i', 'testsrc2=size=320x180:rate=24', '-c:v', 'libx264', '-preset', 'ultrafast']),
(a, ['-f', 'lavfi', '-i', 'sine=frequency=440:sample_rate=48000', '-c:a', 'aac']),
]) {
if (await File(path).exists()) continue;
final r = await Process.run('ffmpeg', ['-y', '-v', 'error', ...args, '-t', '30', path]);
if (r.exitCode != 0) fail('ffmpeg failed: ${r.stderr}');
}
return (v, a);
}
/// A CDN like Bilibili's: 403 without the page's Referer.
class _Cdn {
_Cdn(this._server, this._files) {
_server.listen(_serve);
}
final HttpServer _server;
final Map<String, String> _files;
int ok = 0, forbidden = 0;
String url(String name) => 'http://127.0.0.1:${_server.port}/$name';
Future<void> _serve(HttpRequest req) async {
final res = req.response;
final file = _files[req.uri.pathSegments.lastOrNull];
if (file == null || req.headers.value('referer') != _referer) {
forbidden++;
res.statusCode = HttpStatus.forbidden;
await res.close();
return;
}
ok++;
final bytes = await File(file).readAsBytes();
var start = 0, end = bytes.length - 1;
final range = RegExp(r'bytes=(\d*)-(\d*)').firstMatch(req.headers.value('range') ?? '');
res.headers.set('accept-ranges', 'bytes');
if (range != null) {
start = int.tryParse(range[1]!) ?? 0;
end = int.tryParse(range[2]!) ?? end;
res.statusCode = HttpStatus.partialContent;
res.headers.set('content-range', 'bytes $start-$end/${bytes.length}');
}
res.contentLength = end - start + 1;
res.add(bytes.sublist(start, end + 1));
await res.close();
}
}
/// A stand-in yt-dlp printing what yt-dlp gives for a split-track page:
/// `requested_formats` with per-format `http_headers`, none at the top.
Future<String> _fakeYtDlp(_Cdn cdn, {required bool headers}) async {
final dir = await Directory('$_tmp/ytdl_headers_fake').create(recursive: true);
final h = headers ? {'Referer': _referer, 'User-Agent': 'fake-agent'} : <String, String>{};
final json = jsonEncode({
'id': 'BV1', 'title': 'Fake page', 'duration': 30, 'extractor': 'fake',
'format_id': 'v+a', 'ext': 'mp4',
'requested_formats': [
{'format_id': 'v', 'url': cdn.url('v.m4s'), 'protocol': 'https', 'ext': 'mp4',
'vcodec': 'avc1.64001F', 'acodec': 'none', 'width': 320, 'height': 180, 'http_headers': h},
{'format_id': 'a', 'url': cdn.url('a.m4s'), 'protocol': 'https', 'ext': 'm4a',
'vcodec': 'none', 'acodec': 'mp4a.40.2', 'http_headers': h},
],
});
final f = File('${dir.path}/yt-dlp-${headers ? 'h' : 'n'}');
await f.writeAsString("#!/bin/sh\ncat <<'EOF'\n$json\nEOF\n");
await Process.run('chmod', ['+x', f.path]);
return f.path;
}
void main() {
late _Cdn cdn;
late HttpServer server;
setUpAll(() async {
MediaKit.ensureInitialized();
final (v, a) = await _media();
server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
cdn = _Cdn(server, {'v.m4s': v, 'a.m4s': a});
});
tearDownAll(() => server.close(force: true));
test(
"mpv's yt-dlp hook sends each format's Referer to the CDN",
timeout: const Timeout(Duration(minutes: 1)),
() async {
final p = PlaybackController();
try {
await p.init();
expect(p.pinMismatches, isEmpty, reason: 'stream-lavf-o and the rest took effect');
await p.useYtDlp(await _fakeYtDlp(cdn, headers: true));
await p.openUrl('https://www.bilibili.com/video/BV1fake');
final end = DateTime.now().add(const Duration(seconds: 20));
while (DateTime.now().isBefore(end) && p.duration.peek() == Duration.zero) {
await Future.delayed(const Duration(milliseconds: 100));
}
expect(p.duration.peek(), greaterThan(Duration.zero), reason: 'both tracks opened');
expect(cdn.forbidden, 0, reason: 'every request had the Referer');
expect(cdn.ok, greaterThanOrEqualTo(2));
} finally {
await p.dispose();
}
},
skip: Platform.isLinux ? false : 'fake yt-dlp is a shell script',
);
test(
'a 403 is a load error, not a reconnect loop',
timeout: const Timeout(Duration(minutes: 1)),
() async {
final p = PlaybackController();
final errors = <String>[];
p.onLoadError = errors.add;
try {
await p.init();
var logs = 0;
final sub = p.player.stream.log.listen((_) => logs++);
await p.useYtDlp(await _fakeYtDlp(cdn, headers: false));
final before = cdn.forbidden;
await p.openUrl('https://www.bilibili.com/video/BV2fake');
await Future.delayed(const Duration(seconds: 8));
await sub.cancel();
// ignore: avoid_print
print('403s: ${cdn.forbidden - before}, log lines: $logs, errors: $errors');
expect(cdn.forbidden - before, lessThan(10), reason: 'no retry loop on 403');
expect(errors, hasLength(1));
// The status only when ffmpeg logs through this mpv (see _onLog).
expect(errors.single, anyOf(contains('403'), contains('could not be opened')));
expect(logs, lessThan(30), reason: 'warnings and errors only');
} finally {
await p.dispose();
}
},
skip: Platform.isLinux ? false : 'fake yt-dlp is a shell script',
);
}
+27 -6
View File
@@ -256,8 +256,16 @@ mpv settings (`PlaybackController`): `Player` with `bufferSize` 256 MiB
read-ahead: rides out short drops, intro skips are instant), read-ahead: rides out short drops, intro skips are instant),
`demuxer-max-bytes=268435456` (150 s up to ~14 Mbit/s; a 1.4 GB / 24 min `demuxer-max-bytes=268435456` (150 s up to ~14 Mbit/s; a 1.4 GB / 24 min
file is ~8), `demuxer-max-back-bytes=67108864`, file is ~8), `demuxer-max-back-bytes=67108864`,
`stream-lavf-o=reconnect_streamed=1,reconnect_on_http_error=1,reconnect_delay_max=4` `stream-lavf-o=reconnect_streamed=1,reconnect_on_http_error=5xx,reconnect_delay_max=4,reconnect_max_retries=3`
(comma form; this mpv build rejects colon-joined). The cache is in RAM: (comma form; this mpv build rejects colon-joined): a dropped connection
or a 5xx is retried, at most 3 times per read and 4 s apart at most; a
4xx (a CDN refusing a request) fails at once instead of being retried
(older ffmpeg ignores `reconnect_max_retries`). A load that fails (mpv's
fatal "No video or audio streams selected", or an EDL track that cannot
be opened) before a duration is known is reported once per open
(`onLoadError`) and shows as "can't play this: ..." with ffmpeg's HTTP
status when this player gets ffmpeg's log (ffmpeg logs through one mpv
instance per process, the first one alive). The cache is in RAM:
media_kit sets `cache-on-disk` without a `cache-dir`, and mpv falls back. mpv media_kit sets `cache-on-disk` without a `cache-dir`, and mpv falls back. mpv
ignores invalid values silently, so every pin is read back and a mismatch is ignores invalid values silently, so every pin is read back and a mismatch is
logged and kept in `pinMismatches`. Measured against the `/m` endpoint logged and kept in `pinMismatches`. Measured against the `/m` endpoint
@@ -288,12 +296,18 @@ turns shaders off (saved) with a toast.
Render mode: media_kit's Linux/Windows video output creates mpv's render Render mode: media_kit's Linux/Windows video output creates mpv's render
context (OpenGL, else its software renderer) when the `VideoController` is context (OpenGL, else its software renderer) when the `VideoController` is
created, and nothing in its API says which. The player therefore requests created, and nothing in its API says which. The player therefore requests
mpv logs at `v`: OpenGL init logs `GL_VERSION` under `libmpv_render` mpv logs at `v` while the controller is created: OpenGL init logs
`GL_VERSION` under `libmpv_render`
(`RenderMode.gpu`; "suspected software renderer" means llvmpipe, (`RenderMode.gpu`; "suspected software renderer" means llvmpipe,
`softwareGl`); none within 1.5 s of the controller being ready means the `softwareGl`); none within 1.5 s of the controller being ready means the
software path (`software`, where shaders do nothing; the shader toast, the software path (`software`, where shaders do nothing; the shader toast, the
menu and Settings say so). After startup the `v` level adds no steady menu and Settings say so). Otherwise the session player logs at `warn`
traffic (measured: 0 messages over 5 s of playback). The hardware path is (`PlaybackController.logLevel`, switched with `mpv_request_log_messages`
on media_kit's handle): media_kit drains mpv's log on the UI thread, one
message at a time, so a chatty level, or ffmpeg logging every retry of a
refused request, could starve the UI. Shader and yt-dlp hook errors are
`error` lines and still arrive. The thumbnail and sound players keep
media_kit's default (`error`) and nothing listens to their log. The hardware path is
fragile: media_kit borrows whatever EGL context GDK left current on the main fragile: media_kit borrows whatever EGL context GDK left current on the main
thread (upstream media-kit#1404), so an X11 session or a Flutter/GDK change thread (upstream media-kit#1404), so an X11 session or a Flutter/GDK change
can silently fall back to software. can silently fall back to software.
@@ -641,7 +655,14 @@ all three link modes. Nothing is bundled or installed by hand.
external link every app points mpv's hook at its managed copy external link every app points mpv's hook at its managed copy
(`script-opts=ytdl_hook-ytdl_path=...`, `ytdl-format=bv*[height<=1080]+ba/b`, (`script-opts=ytdl_hook-ytdl_path=...`, `ytdl-format=bv*[height<=1080]+ba/b`,
once per player), waiting for the download unless the URL names a media once per player), waiting for the download unless the URL names a media
file. A hook error shows as "can't open this link: ...". file. A hook error shows as "can't open this link: ...". The hook (mpv
0.41) gives mpv the first requested format's `http_headers` (User-Agent
as `user-agent`; Referer, Cookie, X-Forwarded-For as file-local
`http-header-fields`, which win over the empty list media_kit sets on
load) and its `cookies` (`stream-lavf-o`), for every track of its EDL:
Bilibili's CDN answers 403 without the Referer. The app never opens a
resolved format URL in mpv itself (the putter of "Through me" plays the
page through the hook too); relays read them with yt-dlp's headers.
- **Through me**: the putter's probe must give tracks, and a pieces - **Through me**: the putter's probe must give tracks, and a pieces
track's playlist must be readable, else the swap is refused ("Live track's playlist must be readable, else the swap is refused ("Live
streams can't be passed on", DRM, or nothing a relay can read) with a streams can't be passed on", DRM, or nothing a relay can read) with a