forked from Foxyz/sync_replay_project
Restructure step 5: one media pipeline on a player host
- PlayerHost port (MediaKitHost): players by role, pins, hwdec policy, render probe, log routing, disposal order; the only media_kit user. - MediaPipeline: plan (one pure switch over state, origin, caps, presence) -> resolve -> open (prepared player promoted, else fresh) -> ready; commands queued during a switch apply once (P15); viewers play the buffer and wait for a dropped source without seek loops (D6). - Every web page resolved by our yt-dlp into an EDL with headers and cookies (danmaku excluded); mpv's ytdl_hook no longer used (ytdl=no). - D11: sign-in errors say which cookies were used and whether they hold any for the site. - Relay: 256 KiB slices read as they fill, superseded pulls released (seek-to-play ~4.2 s -> ~2.8 s, P29); own Jellyfin relay channel only when someone needs it (P28). - STARSYNC_SMOKE run and a Windows CI smoke step after the build. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8970f2694b
commit
25b19f2d6a
47 files changed
+4083
-1728
No files matched your search
@@ -47,6 +47,8 @@ Future<void> shutDownApp({Future<void> Function()? beforeQuit, bool restart = fa
|
||||
_step('thumbnails', cancelAllThumbnails(), const Duration(milliseconds: 1500)),
|
||||
_step('sounds', graph.sfx.dispose(), const Duration(milliseconds: 500)),
|
||||
]);
|
||||
// The host owns the disposal order: anything still alive goes now.
|
||||
await _step('all players', graph.ports.players.disposeAll(), const Duration(milliseconds: 1500));
|
||||
// Windows: media_kit_video frees a player's video output on a thread of
|
||||
// its own after dispose returns; it must be done before the engine
|
||||
// stops, or the plugin's teardown waits on it for good.
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'desktop_folders.dart';
|
||||
import 'desktop_notifications.dart';
|
||||
import 'desktop_shell.dart';
|
||||
import 'desktop_urls.dart';
|
||||
import 'media_kit_host.dart';
|
||||
|
||||
/// The desktop's [Ports].
|
||||
Ports desktopPorts() {
|
||||
@@ -14,5 +15,6 @@ Ports desktopPorts() {
|
||||
folders: const DesktopFolders(urls),
|
||||
links: AppYtDlp.instance,
|
||||
notifications: DesktopNotifications(),
|
||||
players: MediaKitHost(),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,502 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ffi' show Int8;
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:ffi/ffi.dart' show StringUtf8Pointer, malloc;
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import 'package:media_kit_video/media_kit_video.dart';
|
||||
import 'package:signals/signals.dart';
|
||||
import 'package:sync_relay_core/core.dart' show LogThrottle, createLogger;
|
||||
|
||||
import '../../state/settings.dart';
|
||||
import '../ports/player_host.dart';
|
||||
|
||||
// The desktop's players: media_kit over libmpv. Everything mpv-specific
|
||||
// that is not about one media lives here: the cache and HTTP pins, hwdec,
|
||||
// mpv's log level (media_kit has no API for it: `mpv_request_log_messages`
|
||||
// on its handle), the render-mode probe and its diagnostics, mpv's log into
|
||||
// the app log, and the order players are torn down in.
|
||||
|
||||
/// Read-ahead target: about two minutes rides out short outages and makes
|
||||
/// skipping an intro instant.
|
||||
const readAheadSecs = 150;
|
||||
|
||||
/// Forward demuxer cache of a session player. Holds [readAheadSecs] up to
|
||||
/// ~14 Mbit/s (a 1.4 GB / 24 min file is ~8); higher bitrates get less
|
||||
/// time. In RAM: `cache-on-disk=no` (media_kit sets it on without a
|
||||
/// cache-dir, and mpv warned "Failed to create file cache." per open).
|
||||
const sessionAheadBytes = 256 * 1024 * 1024;
|
||||
|
||||
/// Back buffer (media_kit sets it to bufferSize too): enough for a short
|
||||
/// seek back without holding another 256 MiB.
|
||||
const sessionBackBytes = 64 * 1024 * 1024;
|
||||
|
||||
/// The preload's forward cache: its first seconds and the container's
|
||||
/// index, at little memory. Raised to [sessionAheadBytes] when promoted.
|
||||
const preloadAheadBytes = 48 * 1024 * 1024;
|
||||
const preloadBackBytes = 8 * 1024 * 1024;
|
||||
|
||||
/// mpv's `volume-max` (its default is 130): the volume controls go this
|
||||
/// far, past 100 % amplified.
|
||||
const mpvMaxVolume = 150;
|
||||
|
||||
/// mpv's log level for session players. 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 during the render-mode probe, which needs mpv's
|
||||
/// verbose GL init lines.
|
||||
const sessionLogLevel = 'warn';
|
||||
|
||||
/// 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.
|
||||
const streamLavfOptions = 'reconnect_streamed=1,reconnect_on_http_error=5xx,'
|
||||
'reconnect_delay_max=4,reconnect_max_retries=3';
|
||||
|
||||
/// mpv echoes numbers in its own format (`150` reads back `150.000000`).
|
||||
bool pinMatches(String wanted, String got) {
|
||||
if (wanted == got) return true;
|
||||
final a = double.tryParse(wanted), b = double.tryParse(got);
|
||||
return a != null && b != null && a == b;
|
||||
}
|
||||
|
||||
/// The `VideoController` behind [video], for the `Video` widget (the only
|
||||
/// media_kit use outside this file).
|
||||
VideoController videoControllerOf(HostedVideo video) => (video as _MkVideo).controller;
|
||||
|
||||
final _mpvLog = createLogger('mpv');
|
||||
|
||||
class MediaKitHost implements PlayerHost {
|
||||
/// The hwdec setting ([AppSettings.hwdec] by default).
|
||||
final String? Function() _hwdecSetting;
|
||||
MediaKitHost({String? Function()? hwdecSetting})
|
||||
: _hwdecSetting = hwdecSetting ?? (() => AppSettings.instance.hwdec.peek());
|
||||
|
||||
final _live = <_MkPlayer>{};
|
||||
final _renderMode = signal(RenderMode.unknown);
|
||||
final _http = StreamController<String>.broadcast();
|
||||
final _throttle = LogThrottle(burst: 5);
|
||||
Future<void> _retiring = Future.value();
|
||||
var _closing = false;
|
||||
|
||||
@override
|
||||
final List<String> pinMismatches = [];
|
||||
|
||||
@override
|
||||
void init() => MediaKit.ensureInitialized();
|
||||
|
||||
@override
|
||||
ReadonlySignal<RenderMode> get renderMode => _renderMode;
|
||||
|
||||
@override
|
||||
Stream<String> get httpErrors => _http.stream;
|
||||
|
||||
@override
|
||||
int get live => _live.length;
|
||||
|
||||
@override
|
||||
Future<void> get retiring => _retiring;
|
||||
|
||||
@override
|
||||
String? get hwdec {
|
||||
final env = Platform.environment['SYNC_RELAY_HWDEC'];
|
||||
if (env != null && env.isNotEmpty) return env;
|
||||
return _hwdecSetting();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<HostedPlayer> create(PlayerRole role, {bool muted = false}) async {
|
||||
if (_closing) throw StateError('closing: no new players');
|
||||
final mk = _SyncPlayer(
|
||||
configuration: switch (role) {
|
||||
PlayerRole.session => PlayerConfiguration(
|
||||
bufferSize: sessionAheadBytes,
|
||||
libass: true,
|
||||
vo: Platform.environment['SYNC_RELAY_VO'],
|
||||
logLevel: MPVLogLevel.warn),
|
||||
PlayerRole.preload => PlayerConfiguration(
|
||||
bufferSize: preloadAheadBytes,
|
||||
libass: true,
|
||||
vo: Platform.environment['SYNC_RELAY_VO'],
|
||||
logLevel: MPVLogLevel.error),
|
||||
PlayerRole.thumbnail => const PlayerConfiguration(
|
||||
vo: 'null', muted: true, osc: false, libass: false, title: 'thumbnail', bufferSize: 4 << 20),
|
||||
});
|
||||
final p = _MkPlayer(this, mk, role);
|
||||
_live.add(p);
|
||||
if (role != PlayerRole.thumbnail) {
|
||||
p._logSub = p.logs.listen((l) => _route(p, l));
|
||||
final mismatches = <String>[];
|
||||
await _pinCache(p, role, mismatches);
|
||||
if (await p.pin('volume-max', '$mpvMaxVolume') case final m?) mismatches.add(m);
|
||||
if (muted || role == PlayerRole.preload) {
|
||||
if (await p.pin('mute', 'yes') case final m?) mismatches.add(m);
|
||||
}
|
||||
if (role == PlayerRole.session) {
|
||||
pinMismatches
|
||||
..clear()
|
||||
..addAll(mismatches);
|
||||
}
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> promote(HostedPlayer p) async {
|
||||
final mk = p as _MkPlayer;
|
||||
mk._role = PlayerRole.session;
|
||||
mk.setLogLevel(sessionLogLevel);
|
||||
final mismatches = <String>[];
|
||||
if (await p.pin('mute', 'no') case final m?) mismatches.add(m);
|
||||
await _pinCache(mk, PlayerRole.session, mismatches);
|
||||
pinMismatches
|
||||
..clear()
|
||||
..addAll(mismatches);
|
||||
}
|
||||
|
||||
Future<void> _pinCache(_MkPlayer p, PlayerRole role, List<String> out) async {
|
||||
final session = role == PlayerRole.session;
|
||||
for (final (k, v) in [
|
||||
('demuxer-readahead-secs', '$readAheadSecs'),
|
||||
('cache-secs', '$readAheadSecs'),
|
||||
('demuxer-max-bytes', '${session ? sessionAheadBytes : preloadAheadBytes}'),
|
||||
('demuxer-max-back-bytes', '${session ? sessionBackBytes : preloadBackBytes}'),
|
||||
('cache-on-disk', 'no'),
|
||||
if (hwdec case final h?) ('hwdec', h),
|
||||
('stream-lavf-o', streamLavfOptions),
|
||||
// Pages are resolved by the app's own yt-dlp (the media pipeline):
|
||||
// mpv's Lua hook never runs one.
|
||||
('ytdl', 'no'),
|
||||
]) {
|
||||
if (await p.pin(k, v) case final m?) out.add(m);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
HostedVideo video(HostedPlayer player, {bool probe = false}) {
|
||||
final p = player as _MkPlayer;
|
||||
if (probe) {
|
||||
_logRenderEnv();
|
||||
p.setLogLevel('v'); // back to the session level at the probe's end
|
||||
}
|
||||
// media_kit's video output sets hwdec itself on attach ('auto' unless
|
||||
// configured), which used to override SYNC_RELAY_HWDEC.
|
||||
final controller = VideoController(p.mk, configuration: VideoControllerConfiguration(hwdec: hwdec));
|
||||
final v = _MkVideo(p, controller);
|
||||
if (probe) unawaited(_probeRender(p, v));
|
||||
return v;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ the log
|
||||
|
||||
// mpv's warnings and errors to the app log, a few per kind at a time (a
|
||||
// refused request makes ffmpeg retry and warn on every try); ffmpeg's
|
||||
// HTTP status to [httpErrors]; the GL init lines while probing.
|
||||
void _route(_MkPlayer p, PlayerLogLine log) {
|
||||
if (log.prefix == 'ffmpeg') {
|
||||
final http = RegExp(r'HTTP error \d{3}.*').firstMatch(log.text);
|
||||
if (http != null) _http.add(http[0]!.trim());
|
||||
}
|
||||
if (log.prefix == 'libmpv_render') {
|
||||
if (_glInfo.hasMatch(log.text)) _mpvLog.info('render: [libmpv_render] ${log.text.trim()}');
|
||||
if (log.text.contains('suspected software renderer')) {
|
||||
_probeMode = RenderMode.softwareGl;
|
||||
_renderMode.value = RenderMode.softwareGl;
|
||||
} else if (log.text.startsWith('GL_VERSION') && p._probing && _probeMode == RenderMode.unknown) {
|
||||
_probeMode = RenderMode.gpu;
|
||||
_renderMode.value = RenderMode.gpu;
|
||||
}
|
||||
}
|
||||
final level = log.level;
|
||||
if (level != 'warn' && !log.isError) return;
|
||||
final text = log.text.trim();
|
||||
if (text.isEmpty) return;
|
||||
final who = p.role == PlayerRole.preload ? 'preload ' : '';
|
||||
final pass = _throttle.allow('$who${log.prefix} $level ${text.length > 40 ? text.substring(0, 40) : text}');
|
||||
if (pass == null) return;
|
||||
final line = '$pass$who[${log.prefix}] $text';
|
||||
level == 'warn' ? _mpvLog.info(line) : _mpvLog.warning(line);
|
||||
}
|
||||
|
||||
// mpv's GL init lines (`GL_VENDOR='...'`, `Detected desktop OpenGL 4.6.`).
|
||||
static final _glInfo =
|
||||
RegExp(r'^\s*(GL_(VENDOR|RENDERER|VERSION|SHADING_LANGUAGE_VERSION)=|Detected (GLES|desktop OpenGL))');
|
||||
|
||||
// Once per app start: what decides how media_kit and mpv render (the
|
||||
// display server, Flutter's and Mesa's overrides). Not secrets.
|
||||
static bool _renderEnvLogged = false;
|
||||
static void _logRenderEnv() {
|
||||
if (_renderEnvLogged || !Platform.isLinux) return;
|
||||
_renderEnvLogged = true;
|
||||
const names = [
|
||||
'XDG_SESSION_TYPE',
|
||||
'WAYLAND_DISPLAY',
|
||||
'DISPLAY',
|
||||
'GDK_BACKEND',
|
||||
'FLUTTER_LINUX_RENDERER',
|
||||
'LIBGL_ALWAYS_SOFTWARE',
|
||||
'__EGL_VENDOR_LIBRARY_FILENAMES',
|
||||
];
|
||||
final env = Platform.environment;
|
||||
_mpvLog.info('render env: ${[for (final n in names) '$n=${env[n] ?? '-'}'].join(' ')}');
|
||||
}
|
||||
|
||||
RenderMode _probeMode = RenderMode.unknown;
|
||||
|
||||
// media_kit's Linux/Windows video output creates mpv's render context
|
||||
// while the controller is created: OpenGL when it can, else the software
|
||||
// renderer, which never logs GL init (and ignores shaders). No GL line
|
||||
// within 1.5 s of the output being up means software.
|
||||
Future<void> _probeRender(_MkPlayer p, _MkVideo v) async {
|
||||
p._probing = true;
|
||||
_probeMode = RenderMode.unknown;
|
||||
try {
|
||||
await v.controller.platform.future;
|
||||
await Future.delayed(const Duration(milliseconds: 1500));
|
||||
} catch (_) {
|
||||
return;
|
||||
} finally {
|
||||
p._probing = false;
|
||||
if (!p.disposed) p.setLogLevel(sessionLogLevel);
|
||||
}
|
||||
if (p.disposed) return;
|
||||
final mode = _probeMode;
|
||||
if (mode != RenderMode.unknown) {
|
||||
_renderMode.value = mode;
|
||||
_mpvLog.info('render: ${mode.name} (${mode == RenderMode.gpu ? 'OpenGL on the GPU' : 'OpenGL on the CPU'})');
|
||||
return;
|
||||
}
|
||||
_renderMode.value = RenderMode.software;
|
||||
_mpvLog.info("render: software (no GL init logged: media_kit's own fallback; "
|
||||
'its reason is on stderr, "media_kit: VideoOutput: ..." when run from a terminal)');
|
||||
}
|
||||
|
||||
// Once per player, after its first frame: the decoder mpv picked; the
|
||||
// first time per app start also mpv's and ffmpeg's versions.
|
||||
static bool _versionsLogged = false;
|
||||
Future<void> _logFirstFrame(_MkPlayer p) async {
|
||||
try {
|
||||
final current = await p.getProperty('hwdec-current');
|
||||
final wanted = await p.getProperty('hwdec');
|
||||
_mpvLog.info('render: first frame ${p.state.width}x${p.state.height}, '
|
||||
'hwdec-current=${current.isEmpty ? 'no' : current} (hwdec=$wanted), mode ${_renderMode.peek().name}');
|
||||
if (!_versionsLogged) {
|
||||
_versionsLogged = true;
|
||||
_mpvLog.info('render: ${await versions(p)}');
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('first frame info: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String?> versions(HostedPlayer player) async {
|
||||
try {
|
||||
return '${await player.getProperty('mpv-version')}, ffmpeg ${await player.getProperty('ffmpeg-version')}';
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------- tear-down
|
||||
|
||||
@override
|
||||
Future<void> retire(HostedPlayer player) {
|
||||
final p = player as _MkPlayer;
|
||||
p._retired = true;
|
||||
unawaited(p.mk.pause().catchError((_) {}));
|
||||
return _retiring = () async {
|
||||
await _settleUi();
|
||||
await dispose(p);
|
||||
}();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> dispose(HostedPlayer player) async {
|
||||
final p = player as _MkPlayer;
|
||||
if (!_live.remove(p)) return;
|
||||
p._retired = true;
|
||||
await p._logSub?.cancel();
|
||||
try {
|
||||
await p.mk.dispose();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> disposeAll({Duration each = const Duration(milliseconds: 1500)}) async {
|
||||
_closing = true;
|
||||
await Future.wait([
|
||||
for (final p in _live.toList()) dispose(p).timeout(each).catchError((Object _) {}),
|
||||
]);
|
||||
_closing = false;
|
||||
}
|
||||
|
||||
// Two frames: one may already be mid-build when the view drops the
|
||||
// output. Then the length of a route pop (the fullscreen route holds its
|
||||
// own `Video` and leaves animated). Bounded, since a hidden window
|
||||
// renders no frames; headless tests have no binding and skip it all.
|
||||
static Future<void> _settleUi() async {
|
||||
try {
|
||||
for (var i = 0; i < 2; i++) {
|
||||
await WidgetsBinding.instance.endOfFrame.timeout(const Duration(milliseconds: 300));
|
||||
}
|
||||
await Future.delayed(const Duration(milliseconds: 350));
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
/// A [Player] whose public [seek] belongs to the user. media_kit's controls
|
||||
/// call `player.seek`; with [onUserSeek] set those calls become explicit
|
||||
/// events for the sync layer instead of moving mpv behind its back.
|
||||
class _SyncPlayer extends Player {
|
||||
_SyncPlayer({super.configuration});
|
||||
|
||||
void Function(Duration position)? onUserSeek;
|
||||
|
||||
@override
|
||||
Future<void> seek(Duration duration) async {
|
||||
final hook = onUserSeek;
|
||||
if (hook == null) return super.seek(duration);
|
||||
hook(duration);
|
||||
}
|
||||
|
||||
Future<void> syncSeek(Duration duration) => super.seek(duration);
|
||||
}
|
||||
|
||||
class _MkPlayer implements HostedPlayer {
|
||||
final MediaKitHost host;
|
||||
final _SyncPlayer mk;
|
||||
PlayerRole _role;
|
||||
_MkPlayer(this.host, this.mk, this._role) {
|
||||
_firstFrame = mk.stream.width.listen((w) {
|
||||
if (w == null || w <= 0 || _role == PlayerRole.thumbnail) return;
|
||||
unawaited(_firstFrame?.cancel());
|
||||
unawaited(host._logFirstFrame(this));
|
||||
});
|
||||
}
|
||||
|
||||
StreamSubscription<PlayerLogLine>? _logSub;
|
||||
StreamSubscription<int?>? _firstFrame;
|
||||
bool _probing = false;
|
||||
bool _retired = false;
|
||||
|
||||
dynamic get _mpv => mk.platform;
|
||||
|
||||
@override
|
||||
PlayerRole get role => _role;
|
||||
|
||||
@override
|
||||
bool get disposed => _retired;
|
||||
|
||||
@override
|
||||
HostedPlayerState get state {
|
||||
final s = mk.state;
|
||||
return HostedPlayerState(
|
||||
position: s.position,
|
||||
duration: s.duration,
|
||||
buffer: s.buffer,
|
||||
volume: s.volume,
|
||||
playing: s.playing,
|
||||
completed: s.completed,
|
||||
width: s.width,
|
||||
height: s.height,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<Duration> get positionStream => mk.stream.position;
|
||||
@override
|
||||
Stream<Duration> get durationStream => mk.stream.duration;
|
||||
@override
|
||||
Stream<Duration> get bufferStream => mk.stream.buffer;
|
||||
@override
|
||||
Stream<double> get volumeStream => mk.stream.volume;
|
||||
@override
|
||||
Stream<bool> get completedStream => mk.stream.completed;
|
||||
@override
|
||||
Stream<int?> get widthStream => mk.stream.width;
|
||||
@override
|
||||
Stream<PlayerLogLine> get logs => mk.stream.log.map((l) => PlayerLogLine(l.prefix, l.level, l.text));
|
||||
|
||||
@override
|
||||
void Function(Duration position)? get onUserSeek => mk.onUserSeek;
|
||||
@override
|
||||
set onUserSeek(void Function(Duration position)? f) => mk.onUserSeek = f;
|
||||
|
||||
@override
|
||||
Future<void> open(String url, {Map<String, String> headers = const {}, bool play = false}) =>
|
||||
// An empty map, not null: media_kit caches headers per URI.
|
||||
mk.open(Media(url, httpHeaders: headers), play: play);
|
||||
|
||||
@override
|
||||
Future<void> play() => mk.play();
|
||||
@override
|
||||
Future<void> pause() => mk.pause();
|
||||
@override
|
||||
Future<void> stop() => mk.stop();
|
||||
@override
|
||||
Future<void> seek(Duration position) => mk.seek(position);
|
||||
@override
|
||||
Future<void> syncSeek(Duration position) => mk.syncSeek(position);
|
||||
@override
|
||||
Future<void> setRate(double rate) => mk.setRate(rate);
|
||||
@override
|
||||
Future<void> setVolume(double volume) => mk.setVolume(volume);
|
||||
|
||||
// media_kit has no public property API: a guarded dynamic call on
|
||||
// `player.platform` (its NativePlayer).
|
||||
@override
|
||||
Future<void> setProperty(String name, String value) async => await _mpv?.setProperty(name, value);
|
||||
|
||||
@override
|
||||
Future<String> getProperty(String name) async => '${await _mpv?.getProperty(name)}';
|
||||
|
||||
@override
|
||||
Future<void> observeProperty(String name, Future<void> Function(String value) onChange) async =>
|
||||
await _mpv?.observeProperty(name, onChange);
|
||||
|
||||
@override
|
||||
Future<void> command(List<String> args) async => await _mpv?.command(args);
|
||||
|
||||
@override
|
||||
Future<String?> pin(String name, String value) async {
|
||||
try {
|
||||
await setProperty(name, value);
|
||||
final got = await getProperty(name);
|
||||
if (pinMatches(value, got)) return null;
|
||||
debugPrint('pin $name=$value not applied, mpv has "$got"');
|
||||
return '$name: $value -> $got';
|
||||
} catch (e) {
|
||||
debugPrint('pin $name=$value failed: $e');
|
||||
return '$name: $value -> error $e';
|
||||
}
|
||||
}
|
||||
|
||||
// mpv_request_log_messages on media_kit's handle.
|
||||
@override
|
||||
void setLogLevel(String level) {
|
||||
try {
|
||||
final dynamic platform = mk.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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _MkVideo implements HostedVideo {
|
||||
@override
|
||||
final _MkPlayer player;
|
||||
final VideoController controller;
|
||||
_MkVideo(this.player, this.controller);
|
||||
|
||||
@override
|
||||
Future<void> get ready => controller.platform.future;
|
||||
}
|
||||
@@ -10,8 +10,10 @@ abstract interface class LinkResolver {
|
||||
/// Gets it ready in the background (download on first run, updates).
|
||||
void warmUp();
|
||||
|
||||
/// mpv's `ytdl-raw-options` (sub languages, cookies).
|
||||
Future<String> mpvRawOptions();
|
||||
/// This device opens web pages itself (the media plan's `resolve`
|
||||
/// capability). Desktop: always (yt-dlp is downloaded when first
|
||||
/// needed); a device without it gets pages passed on by someone else.
|
||||
bool get resolvesLinks;
|
||||
|
||||
/// What [url] is, remembered for a while ([keep]); [fresh] asks again.
|
||||
Future<LinkProbe> probe(String url, {Duration keep, bool fresh});
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:signals/signals.dart';
|
||||
|
||||
// The media players as the app sees them: mpv instances, their video
|
||||
// outputs, how the video is rendered, mpv's log and the order players are
|
||||
// torn down in. Desktop: media_kit over libmpv
|
||||
// (`platform/desktop/media_kit_host.dart`); nothing else in the app
|
||||
// touches media_kit except the `Video` widget glue.
|
||||
|
||||
/// How the video output renders (shaders need [gpu]).
|
||||
enum RenderMode {
|
||||
unknown,
|
||||
|
||||
/// OpenGL on a real GPU: shaders work.
|
||||
gpu,
|
||||
|
||||
/// OpenGL on a software rasterizer (llvmpipe): shaders crawl.
|
||||
softwareGl,
|
||||
|
||||
/// The video output's own software fallback: shaders are ignored.
|
||||
software,
|
||||
}
|
||||
|
||||
/// What a player is for: its cache, log level and log routing.
|
||||
enum PlayerRole {
|
||||
/// The session's player (and a fresh one per media): full cache, mpv log
|
||||
/// at `warn` into the app log.
|
||||
session,
|
||||
|
||||
/// The hidden player holding what plays next: small cache, muted, log
|
||||
/// at `error`.
|
||||
preload,
|
||||
|
||||
/// A poster frame for Files: no audio or video output, no cache, no log.
|
||||
thumbnail,
|
||||
}
|
||||
|
||||
/// One line of mpv's log.
|
||||
class PlayerLogLine {
|
||||
final String prefix;
|
||||
final String level;
|
||||
final String text;
|
||||
const PlayerLogLine(this.prefix, this.level, this.text);
|
||||
|
||||
bool get isError => level == 'error' || level == 'fatal';
|
||||
}
|
||||
|
||||
/// A snapshot of a player's state.
|
||||
class HostedPlayerState {
|
||||
final Duration position;
|
||||
final Duration duration;
|
||||
|
||||
/// End of the cached range (mpv `demuxer-cache-time`).
|
||||
final Duration buffer;
|
||||
final double volume;
|
||||
final bool playing;
|
||||
|
||||
/// At the end of the file.
|
||||
final bool completed;
|
||||
final int? width;
|
||||
final int? height;
|
||||
const HostedPlayerState({
|
||||
this.position = Duration.zero,
|
||||
this.duration = Duration.zero,
|
||||
this.buffer = Duration.zero,
|
||||
this.volume = 100,
|
||||
this.playing = false,
|
||||
this.completed = false,
|
||||
this.width,
|
||||
this.height,
|
||||
});
|
||||
}
|
||||
|
||||
/// One mpv player. Each media gets its own (see ARCHITECTURE "Player
|
||||
/// lifecycle"); [PlayerHost.retire] / [PlayerHost.disposeAll] end it.
|
||||
abstract interface class HostedPlayer {
|
||||
PlayerRole get role;
|
||||
HostedPlayerState get state;
|
||||
Stream<Duration> get positionStream;
|
||||
Stream<Duration> get durationStream;
|
||||
Stream<Duration> get bufferStream;
|
||||
Stream<double> get volumeStream;
|
||||
Stream<bool> get completedStream;
|
||||
Stream<int?> get widthStream;
|
||||
|
||||
/// mpv's log lines at the player's level.
|
||||
Stream<PlayerLogLine> get logs;
|
||||
|
||||
/// The player is gone (disposed or being disposed): commands do nothing.
|
||||
bool get disposed;
|
||||
|
||||
/// Seeks from the video widget's own controls land here instead of in
|
||||
/// mpv when set ([seek] goes through it too); [syncSeek] always moves
|
||||
/// mpv.
|
||||
void Function(Duration position)? onUserSeek;
|
||||
|
||||
/// Opens [url] (with [headers] on every request), paused unless [play].
|
||||
Future<void> open(String url, {Map<String, String> headers = const {}, bool play = false});
|
||||
Future<void> play();
|
||||
Future<void> pause();
|
||||
Future<void> stop();
|
||||
|
||||
/// A user seek: through [onUserSeek] when set, else [syncSeek].
|
||||
Future<void> seek(Duration position);
|
||||
|
||||
/// Moves mpv (sync corrections, scrub preview).
|
||||
Future<void> syncSeek(Duration position);
|
||||
Future<void> setRate(double rate);
|
||||
Future<void> setVolume(double volume);
|
||||
Future<void> setProperty(String name, String value);
|
||||
Future<String> getProperty(String name);
|
||||
Future<void> observeProperty(String name, Future<void> Function(String value) onChange);
|
||||
Future<void> command(List<String> args);
|
||||
|
||||
/// Sets [name] and reads it back: null when mpv took [value], else
|
||||
/// `name: value -> got` (mpv ignores invalid values silently).
|
||||
Future<String?> pin(String name, String value);
|
||||
|
||||
/// mpv's log level for this player (`warn`, `v`, ...).
|
||||
void setLogLevel(String level);
|
||||
}
|
||||
|
||||
/// A player's video output (a texture the `Video` widget shows).
|
||||
abstract interface class HostedVideo {
|
||||
HostedPlayer get player;
|
||||
|
||||
/// Completes once the output is up (its texture registered).
|
||||
Future<void> get ready;
|
||||
}
|
||||
|
||||
/// Creates, owns and disposes the app's players. One per app (the
|
||||
/// composition root's `Ports`); tests make their own.
|
||||
abstract interface class PlayerHost {
|
||||
/// Once at start, before any player.
|
||||
void init();
|
||||
|
||||
/// A new player for [role], set up before it is returned to the caller's
|
||||
/// first await: cache, pins ([pinMismatches]), hwdec, log level.
|
||||
Future<HostedPlayer> create(PlayerRole role, {bool muted = false});
|
||||
|
||||
/// [player] (a preload) becomes a session player: unmuted, the full
|
||||
/// cache pins, the session's log level.
|
||||
Future<void> promote(HostedPlayer player);
|
||||
|
||||
/// [player]'s video output, with the hwdec policy. [probe]: listen to
|
||||
/// mpv's render init to learn [renderMode] (the first output of an app
|
||||
/// start).
|
||||
HostedVideo video(HostedPlayer player, {bool probe = false});
|
||||
|
||||
/// How video renders, as last learned (kept after leaving).
|
||||
ReadonlySignal<RenderMode> get renderMode;
|
||||
|
||||
/// mpv `hwdec` for new players: env `SYNC_RELAY_HWDEC` wins over the
|
||||
/// setting (Windows' auto is `auto-copy`).
|
||||
String? get hwdec;
|
||||
|
||||
/// Pins that did not take on the last session player created.
|
||||
List<String> get pinMismatches;
|
||||
|
||||
/// ffmpeg's HTTP errors (`HTTP error 403 Forbidden`), from whichever
|
||||
/// player ffmpeg logs through (one per process).
|
||||
Stream<String> get httpErrors;
|
||||
|
||||
/// `mpv-version` and `ffmpeg-version` of [player], for diagnostics.
|
||||
Future<String?> versions(HostedPlayer player);
|
||||
|
||||
/// Ends [player] once the view let go of its texture (two frames and a
|
||||
/// route pop); at most one retiring player lingers, a new [create] of a
|
||||
/// session player waits for it ([retiring]).
|
||||
Future<void> retire(HostedPlayer player);
|
||||
|
||||
/// The last retire, until done.
|
||||
Future<void> get retiring;
|
||||
|
||||
/// Disposes [player] at once (no view showed it).
|
||||
Future<void> dispose(HostedPlayer player);
|
||||
|
||||
/// App close: every live player goes, each bounded by [each], before
|
||||
/// the engine does (a live mpv calls back into a dead isolate).
|
||||
Future<void> disposeAll({Duration each});
|
||||
|
||||
/// Live players, for tests and the close.
|
||||
int get live;
|
||||
}
|
||||
|
||||
/// [log] says the media could not be opened: before a [duration] is known,
|
||||
/// mpv's fatal "nothing to play" or a demuxer error.
|
||||
bool mpvLoadFailed(PlayerLogLine log, Duration duration) {
|
||||
return duration == Duration.zero &&
|
||||
((log.prefix == 'cplayer' && log.level == 'fatal') || (log.prefix == 'demux' && log.level == 'error'));
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'folder_access.dart';
|
||||
import 'link_resolver.dart';
|
||||
import 'player_host.dart';
|
||||
import 'shell_port.dart';
|
||||
import 'system_notifications.dart';
|
||||
import 'url_opener.dart';
|
||||
@@ -7,6 +8,7 @@ import 'url_opener.dart';
|
||||
export 'app_dirs.dart';
|
||||
export 'folder_access.dart';
|
||||
export 'link_resolver.dart';
|
||||
export 'player_host.dart';
|
||||
export 'shell_port.dart';
|
||||
export 'system_notifications.dart';
|
||||
export 'url_opener.dart';
|
||||
@@ -19,11 +21,14 @@ class Ports {
|
||||
final FolderAccess folders;
|
||||
final LinkResolver links;
|
||||
final SystemNotifications notifications;
|
||||
/// The media players (mpv): created, rendered and disposed here.
|
||||
final PlayerHost players;
|
||||
const Ports({
|
||||
required this.shell,
|
||||
required this.urls,
|
||||
required this.folders,
|
||||
required this.links,
|
||||
required this.notifications,
|
||||
required this.players,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart' show debugPrint;
|
||||
|
||||
import '../app/app_graph.dart';
|
||||
import 'app_shutdown.dart';
|
||||
import 'ports/player_host.dart';
|
||||
|
||||
// The close smoke run (CI, `tool/smoke_windows.ps1` / `tool/smoke_linux.sh`):
|
||||
// env `STARSYNC_SMOKE=<media file>` makes the app, once up, open that file
|
||||
// in a session player with a video output through the media pipeline,
|
||||
// play a second and close the normal way ([shutDownApp]). Progress goes to
|
||||
// `STARSYNC_SMOKE_OUT` (one line per step: `start`, `opened <ms> render=<mode>`,
|
||||
// `closing`, or `skip <why>` / `fail <why>`), so the script can tell a
|
||||
// runner that cannot show a window (skip) from a close that hangs or
|
||||
// leaves a process behind (fail).
|
||||
|
||||
/// The smoke run's media, or null for a normal start.
|
||||
String? get smokeMedia {
|
||||
final m = Platform.environment['STARSYNC_SMOKE'];
|
||||
return m == null || m.isEmpty ? null : m;
|
||||
}
|
||||
|
||||
Future<void> runSmoke(AppGraph graph, String media) async {
|
||||
final outPath = Platform.environment['STARSYNC_SMOKE_OUT'];
|
||||
void step(String line) {
|
||||
debugPrint('smoke: $line'); // into the app log too
|
||||
if (outPath == null || outPath.isEmpty) return;
|
||||
try {
|
||||
File(outPath).writeAsStringSync('$line\n', mode: FileMode.append, flush: true);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
step('start');
|
||||
final sw = Stopwatch()..start();
|
||||
final pipe = graph.coordinator.media;
|
||||
try {
|
||||
if (!await File(media).exists()) {
|
||||
step('fail no media file at $media');
|
||||
} else {
|
||||
await pipe.start();
|
||||
await pipe.playback.setVolume(0);
|
||||
// The video output the session view would make: the render path
|
||||
// (EGL / ANGLE) is what breaks on some machines.
|
||||
final video = pipe.playback.video;
|
||||
await video.ready.timeout(const Duration(seconds: 15));
|
||||
final ok = await pipe.open(media);
|
||||
final end = DateTime.now().add(const Duration(seconds: 20));
|
||||
while (ok && pipe.playback.durationSecs <= 0 && DateTime.now().isBefore(end)) {
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
}
|
||||
if (pipe.playback.durationSecs <= 0) {
|
||||
step('fail the media did not open in 20 s');
|
||||
} else {
|
||||
await pipe.playback.player.play();
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
final mode = graph.ports.players.renderMode.peek();
|
||||
step('opened ${sw.elapsedMilliseconds} render=${mode.name}');
|
||||
if (mode == RenderMode.unknown) await Future.delayed(const Duration(seconds: 2));
|
||||
}
|
||||
}
|
||||
} on TimeoutException {
|
||||
step('skip the video output did not come up (no display for this runner?)');
|
||||
} catch (e) {
|
||||
step('fail $e');
|
||||
}
|
||||
step('closing');
|
||||
await shutDownApp();
|
||||
}
|
||||
Reference in new issue
Block a user