Files
plezy/lib/mpv/player/player.dart
T
edde746 6c14049e95 fix(tvos): make EAC3 playback conform to Dolby's guidance
Groundwork for #1300. Establishes the session, buffering and route
handling Dolby's application guide prescribes, and adds the diagnostic
arm needed to find out whether Apple's sample-buffer renderer can carry
Atmos objects at all.

Audio session, per the guide's sequence:

- Adopt the long-form playback profile in one atomic call at app launch
  and activate the session there. The SDK only accepts that policy with
  category Playback, a Default/MoviePlayback/SpokenAudio mode and no
  options, so it cannot be assembled from separate calls.
- Report the resolved rendering mode in the player, hidden unless the
  system resolves it. Apple only resolves it for CarPlay and AirPlay, so
  an unresolved value means unknown, never "not Dolby".

Diagnostics (Apple TV only, Settings > Video Playback > Atmos Output Test):

- Add a sample-buffer arm. It reads the asset with AVAssetReader at
  outputSettings nil and hands the untouched compressed buffers and the
  untouched format description straight to the renderer, with a variant
  that rebuilds the description the way playback builds it. Every
  existing mode went through AVPlayer, so nothing exercised the path
  playback actually uses; this is what tells us whether the renderer or
  our construction is at fault.
- Add an AirPlay route picker. AirPlay is the only route where the system
  resolves the rendering mode and the supported channel layouts, so it is
  what makes those observations reachable at all, and the AVPlayer arms
  now allow external playback so every arm can be compared on the same
  destination.
- Add a session-mode toggle for the one profile difference between the
  guide and previous playback behaviour.
- Report the session profile, supported layouts, both format
  descriptions, the magic cookie and the renderer status, and release the
  session on stop so a failed run cannot contaminate the next one.

Also bumps MPVKit to 1.0.14, which carries the matching audio output
work: the channel layout AVFoundation itself uses for Dolby content, a
renderer-failure observer so the fallback to PCM can actually run, the
prescribed feed ordering and preroll, flush recovery that re-supplies the
discarded audio instead of shifting later audio into its place, and
capability-driven fallback on route and capability changes.

This does not yet fix #1300. Whether the sample-buffer renderer can carry
JOC is still unknown; it removes every difference from the documented
setup that could explain the failure, and gives us the arm to answer it
on real hardware.
2026-07-26 20:42:58 +02:00

415 lines
16 KiB
Dart

import 'dart:io' show Platform;
import '../../media/media_display_criteria.dart';
import '../../media/playback_rate.dart';
import '../models.dart';
import 'audio_rendering_mode.dart';
import 'platform/player_android.dart';
import 'player_native.dart';
import 'player_state.dart';
import 'player_streams.dart';
import 'platform/player_linux.dart';
import 'platform/player_windows.dart';
export 'player_base.dart';
/// Abstract interface for the video player.
///
/// This interface defines all playback control methods, state access,
/// and reactive streams for the video player.
///
/// Example usage:
/// ```dart
/// final player = Player();
/// await player.open(Media('https://example.com/video.mp4'));
/// await player.play();
///
/// // Configure player properties
/// await player.setProperty('hwdec', 'auto');
/// await player.setProperty('demuxer-max-bytes', '150000000');
///
/// // Listen to position updates
/// player.streams.position.listen((position) {
/// print('Position: $position');
/// });
///
/// // Access current state
/// print('Playing: ${player.state.playing}');
/// ```
abstract class Player {
/// Current synchronous state snapshot.
///
/// Use this for immediate state access in UI.
PlayerState get state;
/// Reactive streams for state changes.
///
/// Use these for reactive UI updates.
PlayerStreams get streams;
/// Fresh playback position, stored on every native position tick.
///
/// [PlayerState.position] is only refreshed at ~4Hz alongside the position
/// stream; use this for time-sensitive reads (sync anchors, drift math).
/// ExoPlayer's native tick is itself 250ms, which bounds freshness there.
Duration get currentPosition;
/// Whether audio passthrough (bitstream output) is currently active.
///
/// [setRate] with a non-1.0 rate tears passthrough down, so callers that
/// adjust the rate transiently (e.g. sync micro-corrections) must check
/// this first.
bool get audioPassthroughActive;
/// Texture ID for Flutter's Texture widget (video rendering).
///
/// This is set by the platform implementation when video
/// rendering is initialized. Returns null if not ready.
int? get textureId;
/// The type of player backend being used (e.g., 'mpv', 'exoplayer').
String get playerType;
/// Open a media source for playback.
///
/// [media] - The media source to open.
/// [play] - Whether to start playback immediately (default: true).
Future<void> open(
Media media, {
bool play = true,
bool isLive = false,
List<SubtitleTrack>? externalSubtitles,
Duration? timelineDuration,
});
/// Start or resume playback.
Future<void> play();
/// Pause playback.
Future<void> pause();
/// Toggle between play and pause.
Future<void> playOrPause();
/// Stop playback and reset position.
Future<void> stop();
/// Seek to a specific position.
Future<void> seek(Duration position);
/// Arm (or replace/clear) the item the backend should auto-advance into
/// when the current one plays out — the gapless-audio primitive.
///
/// Audio players keep a native playlist of `[current, next?]`: ExoPlayer
/// via `addMediaItem`, mpv via `loadfile append` with `gapless-audio`.
/// When the advance happens the backend emits
/// [PlayerStreams.trackTransition] with the armed [Media.uri] instead of
/// `completed`. Pass `null` to clear. No-op on video backends.
Future<void> setNext(Media? media);
/// Select an audio track.
Future<void> selectAudioTrack(AudioTrack track);
/// Select a subtitle track.
///
/// Pass [SubtitleTrack.off] to disable subtitles.
Future<void> selectSubtitleTrack(SubtitleTrack track);
/// Select a secondary subtitle track (displayed simultaneously with primary).
///
/// Only supported on mpv backends (desktop + Android mpv fallback).
/// Pass [SubtitleTrack.off] to disable secondary subtitles.
Future<void> selectSecondarySubtitleTrack(SubtitleTrack track);
/// Whether this player backend supports secondary subtitle tracks.
bool get supportsSecondarySubtitles;
/// Whether this backend ingests external subtitles in [open] (single
/// prepare(), safe to auto-play immediately). Backends returning false
/// need external subtitles added after open via [addSubtitleTrack] while
/// paused, and the caller resumes once the tracks are selected.
bool get attachesExternalSubtitlesAtOpen;
/// Whether the backend detects container fps from rendered frame
/// timestamps, so `container-fps` only becomes available a few frames
/// after playback starts (retry the property read instead of giving up).
bool get detectsFpsAfterRender;
/// Whether the video decoder must be refreshed (seek-in-place or
/// drop-buffers) after a display mode switch. True for mpv on Android,
/// where MediaCodec can stall against the reconfigured surface.
bool get needsDecoderRefreshAfterDisplaySwitch;
/// Whether [getStats] aggregates performance stats natively for the
/// active backend (the Android plugin covers both ExoPlayer and its mpv
/// fallback). Backends returning false are sampled via mpv property
/// reads instead.
bool get providesNativeStats;
/// Add an external subtitle track.
///
/// [uri] - URL or path to the subtitle file.
/// [title] - Optional display title.
/// [language] - Optional language code.
/// [select] - Whether to select this track immediately.
Future<void> addSubtitleTrack({required String uri, String? title, String? language, bool select = false});
/// Set the playback volume.
///
/// [volume] - Volume level from 0.0 (muted) to 100.0 (max).
Future<void> setVolume(double volume);
/// Set the playback rate/speed.
///
/// [rate] - Playback rate from [minimumPlaybackRate] to [maximumPlaybackRate] (1.0 = normal speed).
Future<void> setRate(double rate);
/// Set the audio output device.
///
/// [device] - The audio device to use.
Future<void> setAudioDevice(AudioDevice device);
/// Set an MPV property by name.
///
/// Common properties:
/// - 'hwdec': Hardware decoding mode ('auto', 'no', 'videotoolbox', etc.)
/// - 'demuxer-max-bytes': Buffer size in bytes
/// - 'audio-delay': Audio sync offset in seconds (e.g., '0.5')
/// - 'sub-delay': Subtitle sync offset in seconds
/// - 'sub-font': Subtitle font name
/// - 'sub-font-size': Subtitle font size
/// - 'sub-color': Subtitle text color
/// - 'sub-back-color': Subtitle background color
/// - 'sub-border-size': Subtitle border size
/// - 'sub-margin-y': Vertical subtitle margin
/// - 'sub-ass': Enable/disable ASS subtitle rendering ('yes'/'no')
/// - 'audio-exclusive': Exclusive audio mode ('yes'/'no')
/// - 'audio-spdif': Audio passthrough formats (e.g., 'ac3,eac3,dts,truehd')
Future<void> setProperty(String name, String value);
/// Get an MPV property value by name.
Future<String?> getProperty(String name);
/// Set the native MPV log message level (e.g., "warn", "v", "debug").
///
/// This controls the volume of log messages sent from the native player
/// over the event channel. Use "warn" in production and "v" for debugging.
Future<void> setLogLevel(String level);
/// Execute a raw MPV command.
///
/// [args] - Command and arguments as a list of strings.
Future<void> command(List<String> args);
/// Prime native display matching from server metadata before the decoder
/// emits stream properties. Unsupported platforms ignore this.
///
/// [extraDelayMs] is added after a native display-switch completion event,
/// for TVs or AVRs that need extra HDMI settle time.
Future<void> setDisplayCriteria(MediaDisplayCriteria? criteria, {int extraDelayMs = 0});
/// Configure subtitle fonts for libass rendering.
///
/// Extracts a comprehensive Unicode font (Go Noto) to the cache directory
/// and sets `sub-fonts-dir` and `sub-font` properties.
Future<void> configureSubtitleFonts();
/// Enable or disable audio passthrough mode.
///
/// When enabled, supported audio codecs (AC3, DTS, etc.) will be
/// passed through to the audio device without decoding.
Future<void> setAudioPassthrough(bool enabled);
/// The system's resolved audio rendering mode (Apple only); null elsewhere.
Future<AudioRenderingMode?> getAudioRenderingMode();
/// Enable or disable loudness normalization.
///
/// mpv backends insert/remove the `loudnorm` audio filter. Android
/// ExoPlayer attaches platform audio effects (DynamicsProcessing on
/// API 28+, LoudnessEnhancer otherwise) and forces decoded non-tunneled
/// PCM output while enabled so the effects can process the stream.
Future<void> setAudioNormalization(bool enabled);
/// Force a stereo downmix with a Kodi-style center channel boost.
///
/// [centerBoostDb] (0-12) raises the center channel above its standard
/// -3 dB downmix coefficient to improve dialogue clarity. [normalize]
/// attenuates the mix so it cannot clip; off keeps the original level
/// (Kodi's "maintain original volume"). mpv backends rebuild the audio
/// chain via `audio-channels`; Android ExoPlayer routes a
/// ChannelMixingAudioProcessor in the audio sink and force-decodes
/// encoded audio while enabled.
Future<void> setAudioDownmix({required bool enabled, required int centerBoostDb, required bool normalize});
/// Show or hide the video rendering layer.
///
/// On macOS, this controls the Metal layer visibility.
/// On other platforms, this may have no effect.
///
/// When [restoreOnWindowVisible] is true, macOS may restore the layer as soon
/// as AppKit reports the window visible again instead of waiting for Dart's
/// lifecycle resume callback.
///
/// Returns true if the operation was successful.
Future<bool> setVisible(bool visible, {bool restoreOnWindowVisible = false});
/// Update the video frame/surface dimensions.
///
/// On iOS/macOS, this updates the Metal layer's frame to match the current
/// window size. Call this when the layout changes (e.g., device rotation).
/// On other platforms, this is a no-op.
Future<void> updateFrame();
/// Set the video frame rate for display refresh rate matching.
///
/// On Android, this hints the system to adjust the display refresh rate
/// to match the video content's frame rate, reducing judder and saving
/// battery on LTPO displays.
///
/// [fps] - The video frame rate (e.g., 23.976, 24, 30, 60).
/// [durationMs] - The video duration in milliseconds.
/// [extraDelayMs] - Extra settle time (ms) added to the native display-change
/// wait before playback is auto-resumed. Used to absorb the
/// user-configured "display switch delay" on Android TV.
///
/// Returns `true` if a display mode switch was initiated and the platform
/// will resume playback once the display settles; `false` if no switch was
/// needed (seamless fallback, invalid fps, no matching mode), in which case
/// the caller is responsible for starting playback itself.
///
/// On other platforms, this is a no-op that returns `false`.
Future<bool> setVideoFrameRate(
double fps,
int durationMs, {
int extraDelayMs = 0,
int videoWidth = 0,
int videoHeight = 0,
});
/// Clear the video frame rate hint and restore default display mode.
///
/// Call this when playback ends to restore the normal display refresh rate.
/// On other platforms, this is a no-op.
Future<void> clearVideoFrameRate();
/// Apply subtitle styling to the native rendering layer.
///
/// ExoPlayer renders subtitles natively (CaptionStyleCompat for text subs,
/// libass font scale for ASS), so styling must be pushed after [open].
/// No-op on mpv backends, which style subtitles via `sub-*` properties.
Future<void> setSubtitleStyle({
required double fontSize,
required String textColor,
required double borderSize,
required String borderColor,
required String bgColor,
required int bgOpacity,
int subtitlePosition = 100,
bool bold = false,
bool italic = false,
});
/// Apply the box-fit mode to the native video layer
/// (0=FIT, 1=ZOOM/cover, 2=FILL/stretch).
///
/// ExoPlayer scales via AspectRatioFrameLayout; mpv backends are a no-op
/// here and scale via `panscan`/`video-aspect-override` properties instead.
Future<void> setBoxFitMode(int mode);
/// Apply custom zoom to the native video layer. No-op on mpv backends,
/// which zoom via the `video-zoom` property.
Future<void> setVideoZoom(double scale);
/// Aggregated native playback stats (codecs, dimensions, dropped frames…).
///
/// Returns an empty map on backends without native stats aggregation;
/// query mpv properties directly there instead.
Future<Map<String, dynamic>> getStats();
/// The backend actually playing right now, resolved from the native side.
///
/// Unlike [playerType] (the configured backend), this reflects runtime
/// fallbacks — e.g. 'mpv' after ExoPlayer hit an unsupported format.
Future<String> runtimePlayerType();
/// Request audio focus before starting playback.
///
/// On Android, this notifies the system that the app wants to play audio,
/// causing other media apps (Spotify, podcasts, etc.) to pause.
///
/// Returns true if audio focus was granted.
/// On other platforms, this is a no-op and returns true.
Future<bool> requestAudioFocus();
/// Abandon audio focus when playback stops.
///
/// On Android, this notifies the system that the app is done playing audio,
/// allowing other apps to resume their playback.
///
/// On other platforms, this is a no-op.
Future<void> abandonAudioFocus();
/// Whether the player has been disposed.
bool get disposed;
/// Dispose of the player and release resources.
///
/// [preserveDisplayMode] keeps any native display-mode hint active while a
/// replacement video route is being opened. Use false when leaving playback.
///
/// After calling this, the player instance should not be used.
Future<void> dispose({bool preserveDisplayMode = false});
/// Creates a new player instance.
///
/// Returns a platform-specific implementation:
/// - macOS/iOS: [PlayerNative] using MPVKit/libmpv with Metal rendering
/// - Android: [PlayerAndroid] using ExoPlayer (default) or [PlayerNative] using MPV (fallback)
/// - Windows: [PlayerWindows] using libmpv with native window embedding
/// - Linux: [PlayerLinux] using libmpv with OpenGL rendering via GtkGLArea
///
/// On Android, pass [useExoPlayer] to override the default:
/// - true: Use ExoPlayer (default, better hardware support)
/// - false: Use MPV (more features, ASS subtitle rendering)
factory Player({bool? useExoPlayer}) {
if (Platform.isAndroid) {
// Default to ExoPlayer on Android, with MPV as fallback
// The caller should pass useExoPlayer based on SettingsService.getUseExoPlayer()
final useExo = useExoPlayer ?? true;
if (useExo) {
return PlayerAndroid(); // ExoPlayer (default)
}
return PlayerNative(); // MPV fallback
}
if (Platform.isMacOS || Platform.isIOS) {
return PlayerNative();
}
if (Platform.isWindows) {
return PlayerWindows();
}
if (Platform.isLinux) {
return PlayerLinux();
}
throw UnsupportedError('Player is not supported on this platform');
}
/// Creates the dedicated audio-only player used for music playback.
///
/// An mpv audio-only core on every platform — regardless of the Android
/// video backend setting — running on its own native core and channels
/// (`com.plezy/mpv_audio_player`), so it never contends with the video
/// pipeline. Desktop and Android need none of the video plumbing (display
/// modes, GL textures, surfaces) — the plain mpv wrapper suffices. Only
/// one native player is kept alive at a time: the music service disposes
/// this instance when video playback claims the session (see
/// `PlaybackCoordinator`), and the video core only exists while the video
/// player screen is open.
factory Player.audio() {
if (Platform.isAndroid || Platform.isMacOS || Platform.isIOS || Platform.isWindows || Platform.isLinux) {
return PlayerNative.audio();
}
throw UnsupportedError('Player is not supported on this platform');
}
}