This commit is contained in:
edde746
2025-12-02 15:46:19 +01:00
parent 01bb1126ef
commit d9cdde66d8
53 changed files with 2645 additions and 685 deletions
-4
View File
@@ -1,6 +1,5 @@
import 'package:flutter/material.dart';
import 'dart:io' show Platform;
import 'package:media_kit/media_kit.dart';
import 'package:window_manager/window_manager.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart';
@@ -49,9 +48,6 @@ void main() async {
// Configure macOS window with custom titlebar
await MacOSTitlebarService.setupCustomTitlebar();
// Initialize MediaKit
MediaKit.ensureInitialized();
// Note: Orientation will be set dynamically based on device type in MainApp
await StorageService.getInstance();
+69
View File
@@ -0,0 +1,69 @@
/// MPV Player library for Flutter.
///
/// This library provides a platform-agnostic interface for video playback
/// using MPV as the underlying player engine.
///
/// ## Features
///
/// - Stream-based reactive state updates
/// - Audio passthrough support for lossless audio
/// - Subtitle rendering with libass
/// - Hardware-accelerated decoding
/// - Cross-platform support (macOS, iOS, Android, Windows, Linux)
///
/// ## Usage
///
/// ```dart
/// import 'package:flutter_application_1/mpv/mpv.dart';
///
/// // Create a player
/// final player = MpvPlayer();
///
/// // Configure player properties
/// await player.setProperty('hwdec', 'auto');
/// await player.setProperty('demuxer-max-bytes', '150000000');
/// await player.setAudioPassthrough(true);
///
/// // Open and play media
/// await player.open(MpvMedia('https://example.com/video.mp4'));
///
/// // Listen to state changes
/// player.streams.position.listen((position) {
/// print('Position: $position');
/// });
///
/// // Display video
/// MpvVideo(
/// player: player,
/// controls: (context) => MyCustomControls(),
/// )
///
/// // Clean up
/// await player.dispose();
/// ```
library;
// Import for type alias
import 'src/models/mpv_track_selection.dart' as track_selection;
// Player
export 'src/player/mpv_player.dart';
export 'src/player/mpv_player_state.dart';
export 'src/player/mpv_player_streams.dart';
export 'src/player/mpv_player_stub.dart';
// Models
export 'src/models/mpv_media.dart';
export 'src/models/mpv_audio_device.dart';
export 'src/models/mpv_audio_track.dart';
export 'src/models/mpv_subtitle_track.dart';
export 'src/models/mpv_tracks.dart';
export 'src/models/mpv_track_selection.dart';
export 'src/models/mpv_log.dart';
export 'src/models/mpv_log_level.dart';
// Video
export 'src/video/mpv_video.dart';
// Type alias for compatibility (must be after exports)
typedef MpvTrack = track_selection.MpvTrackSelection;
+29
View File
@@ -0,0 +1,29 @@
/// Represents an audio output device.
class MpvAudioDevice {
/// Unique identifier for the device.
final String name;
/// Human-readable description of the device.
final String description;
const MpvAudioDevice({
required this.name,
this.description = '',
});
/// Default/auto audio device.
static const auto = MpvAudioDevice(name: 'auto', description: 'Auto');
@override
String toString() => 'MpvAudioDevice($name, $description)';
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is MpvAudioDevice &&
runtimeType == other.runtimeType &&
name == other.name;
@override
int get hashCode => name.hashCode;
}
+70
View File
@@ -0,0 +1,70 @@
/// Represents an audio track in the media.
class MpvAudioTrack {
/// Unique identifier for the track.
final String id;
/// Human-readable title of the track.
final String? title;
/// Language code (e.g., 'eng', 'jpn').
final String? language;
/// Audio codec (e.g., 'aac', 'ac3', 'dts').
final String? codec;
/// Number of audio channels.
final int? channels;
/// Alias for channels (media_kit compatibility).
int? get channelsCount => channels;
/// Sample rate in Hz.
final int? sampleRate;
/// Bitrate in bits per second.
final int? bitrate;
/// Whether this is the default track.
final bool isDefault;
/// Whether this track is forced.
final bool isForced;
const MpvAudioTrack({
required this.id,
this.title,
this.language,
this.codec,
this.channels,
this.sampleRate,
this.bitrate,
this.isDefault = false,
this.isForced = false,
});
/// Auto-select track.
static const auto = MpvAudioTrack(id: 'auto', title: 'Auto');
/// Disable audio.
static const off = MpvAudioTrack(id: 'no', title: 'Off');
/// Returns a display name for the track.
String get displayName {
if (title != null && title!.isNotEmpty) return title!;
if (language != null && language!.isNotEmpty) return language!;
return 'Track $id';
}
@override
String toString() => 'MpvAudioTrack($id, $displayName)';
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is MpvAudioTrack &&
runtimeType == other.runtimeType &&
id == other.id;
@override
int get hashCode => id.hashCode;
}
+22
View File
@@ -0,0 +1,22 @@
import 'mpv_log_level.dart';
/// A log entry from the MPV player.
class MpvLog {
/// The log level of this message.
final MpvLogLevel level;
/// The prefix/category of the log message (e.g., 'cplayer', 'ffmpeg').
final String prefix;
/// The log message text.
final String text;
const MpvLog({
required this.level,
required this.prefix,
required this.text,
});
@override
String toString() => '[$prefix] ${level.name}: $text';
}
+26
View File
@@ -0,0 +1,26 @@
/// Log level for MPV player messages.
enum MpvLogLevel {
/// No logging.
none,
/// Fatal errors only.
fatal,
/// Errors.
error,
/// Warnings.
warn,
/// Informational messages.
info,
/// Verbose output.
verbose,
/// Debug messages.
debug,
/// Trace-level output (very verbose).
trace,
}
+31
View File
@@ -0,0 +1,31 @@
/// Represents a media source for the MPV player.
class MpvMedia {
/// The URI of the media (file path, HTTP URL, etc.).
final String uri;
/// Optional HTTP headers for network requests.
final Map<String, String>? headers;
/// Optional start position for playback.
final Duration? start;
const MpvMedia(
this.uri, {
this.headers,
this.start,
});
@override
String toString() => 'MpvMedia($uri)';
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is MpvMedia &&
runtimeType == other.runtimeType &&
uri == other.uri &&
start == other.start;
@override
int get hashCode => uri.hashCode ^ start.hashCode;
}
@@ -0,0 +1,79 @@
/// Represents a subtitle track in the media.
class MpvSubtitleTrack {
/// Unique identifier for the track.
final String id;
/// Human-readable title of the track.
final String? title;
/// Language code (e.g., 'eng', 'jpn').
final String? language;
/// Subtitle codec/format (e.g., 'subrip', 'ass', 'pgs').
final String? codec;
/// Whether this is the default track.
final bool isDefault;
/// Whether this track is forced (e.g., for foreign language segments).
final bool isForced;
/// Whether this is an external subtitle file.
final bool isExternal;
/// URI of external subtitle file (if isExternal is true).
final String? uri;
const MpvSubtitleTrack({
required this.id,
this.title,
this.language,
this.codec,
this.isDefault = false,
this.isForced = false,
this.isExternal = false,
this.uri,
});
/// Create a subtitle track from an external URI.
factory MpvSubtitleTrack.uri(
String uri, {
String? title,
String? language,
}) {
return MpvSubtitleTrack(
id: 'external:$uri',
title: title,
language: language,
isExternal: true,
uri: uri,
);
}
/// Auto-select track.
static const auto = MpvSubtitleTrack(id: 'auto', title: 'Auto');
/// Disable subtitles.
static const off = MpvSubtitleTrack(id: 'no', title: 'Off');
/// Returns a display name for the track.
String get displayName {
if (title != null && title!.isNotEmpty) return title!;
if (language != null && language!.isNotEmpty) return language!;
if (isExternal) return 'External';
return 'Track $id';
}
@override
String toString() => 'MpvSubtitleTrack($id, $displayName)';
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is MpvSubtitleTrack &&
runtimeType == other.runtimeType &&
id == other.id;
@override
int get hashCode => id.hashCode;
}
@@ -0,0 +1,30 @@
import 'mpv_audio_track.dart';
import 'mpv_subtitle_track.dart';
/// Represents the currently selected tracks.
class MpvTrackSelection {
/// Currently selected audio track.
final MpvAudioTrack? audio;
/// Currently selected subtitle track.
final MpvSubtitleTrack? subtitle;
const MpvTrackSelection({
this.audio,
this.subtitle,
});
/// Creates a copy with the given fields replaced.
MpvTrackSelection copyWith({
MpvAudioTrack? audio,
MpvSubtitleTrack? subtitle,
}) {
return MpvTrackSelection(
audio: audio ?? this.audio,
subtitle: subtitle ?? this.subtitle,
);
}
@override
String toString() => 'MpvTrackSelection(audio: $audio, subtitle: $subtitle)';
}
+31
View File
@@ -0,0 +1,31 @@
import 'mpv_audio_track.dart';
import 'mpv_subtitle_track.dart';
/// Container for all available tracks in the media.
class MpvTracks {
/// Available audio tracks.
final List<MpvAudioTrack> audio;
/// Available subtitle tracks.
final List<MpvSubtitleTrack> subtitle;
const MpvTracks({
this.audio = const [],
this.subtitle = const [],
});
/// Creates a copy with the given fields replaced.
MpvTracks copyWith({
List<MpvAudioTrack>? audio,
List<MpvSubtitleTrack>? subtitle,
}) {
return MpvTracks(
audio: audio ?? this.audio,
subtitle: subtitle ?? this.subtitle,
);
}
@override
String toString() =>
'MpvTracks(audio: ${audio.length}, subtitle: ${subtitle.length})';
}
+198
View File
@@ -0,0 +1,198 @@
import 'dart:io' show Platform;
import '../models/mpv_audio_device.dart';
import '../models/mpv_media.dart';
import '../models/mpv_audio_track.dart';
import '../models/mpv_subtitle_track.dart';
import 'mpv_player_macos.dart';
import 'mpv_player_state.dart';
import 'mpv_player_streams.dart';
import 'mpv_player_stub.dart';
/// Abstract interface for the MPV player.
///
/// This interface defines all playback control methods, state access,
/// and reactive streams for the video player.
///
/// Example usage:
/// ```dart
/// final player = MpvPlayer();
/// await player.open(MpvMedia('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 MpvPlayer {
/// Current synchronous state snapshot.
///
/// Use this for immediate state access in UI.
MpvPlayerState get state;
/// Reactive streams for state changes.
///
/// Use these for reactive UI updates.
MpvPlayerStreams get streams;
/// 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;
// ============================================
// Playback Control
// ============================================
/// Open a media source for playback.
///
/// [media] - The media source to open.
/// [play] - Whether to start playback immediately (default: true).
Future<void> open(MpvMedia media, {bool play = true});
/// 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);
// ============================================
// Track Selection
// ============================================
/// Select an audio track.
Future<void> selectAudioTrack(MpvAudioTrack track);
/// Select a subtitle track.
///
/// Pass [MpvSubtitleTrack.off] to disable subtitles.
Future<void> selectSubtitleTrack(MpvSubtitleTrack track);
/// 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,
});
// ============================================
// Volume and Rate
// ============================================
/// 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 0.25 to 4.0 (1.0 = normal speed).
Future<void> setRate(double rate);
/// Set the audio output device.
///
/// [device] - The audio device to use.
Future<void> setAudioDevice(MpvAudioDevice device);
// ============================================
// MPV Properties (Advanced)
// ============================================
/// 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);
/// Execute a raw MPV command.
///
/// [args] - Command and arguments as a list of strings.
Future<void> command(List<String> args);
// ============================================
// Passthrough Mode (Audio)
// ============================================
/// 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);
// ============================================
// Visibility (macOS Metal Layer)
// ============================================
/// Show or hide the video rendering layer.
///
/// On macOS, this controls the Metal layer visibility.
/// On other platforms, this may have no effect.
///
/// Returns true if the operation was successful.
Future<bool> setVisible(bool visible);
// ============================================
// Lifecycle
// ============================================
/// Dispose of the player and release resources.
///
/// After calling this, the player instance should not be used.
Future<void> dispose();
// ============================================
// Factory
// ============================================
/// Creates a new MPV player instance.
///
/// Returns a platform-specific implementation:
/// - macOS: [MpvPlayerMacOS] using MPVKit with Metal rendering
/// - Other platforms: [MpvPlayerStub] (placeholder)
factory MpvPlayer() {
if (Platform.isMacOS) {
return MpvPlayerMacOS();
}
// Future: Add iOS, Android, Windows, Linux implementations
return MpvPlayerStub();
}
}
+506
View File
@@ -0,0 +1,506 @@
import 'dart:async';
import 'package:flutter/services.dart';
import '../models/mpv_audio_device.dart';
import '../models/mpv_audio_track.dart';
import '../models/mpv_log.dart';
import '../models/mpv_log_level.dart';
import '../models/mpv_media.dart';
import '../models/mpv_subtitle_track.dart';
import '../models/mpv_track_selection.dart';
import '../models/mpv_tracks.dart';
import 'mpv_player.dart';
import 'mpv_player_state.dart';
import 'mpv_player_streams.dart';
/// macOS implementation of [MpvPlayer] using MPVKit via platform channels.
class MpvPlayerMacOS implements MpvPlayer {
static const _methodChannel = MethodChannel('com.plezy/mpv_player');
static const _eventChannel = EventChannel('com.plezy/mpv_player/events');
MpvPlayerState _state = const MpvPlayerState();
@override
MpvPlayerState get state => _state;
late final MpvPlayerStreams _streams;
@override
MpvPlayerStreams get streams => _streams;
@override
int? get textureId => null; // macOS uses direct Metal layer, not Flutter texture
// Stream controllers
final _playingController = StreamController<bool>.broadcast();
final _completedController = StreamController<bool>.broadcast();
final _bufferingController = StreamController<bool>.broadcast();
final _positionController = StreamController<Duration>.broadcast();
final _durationController = StreamController<Duration>.broadcast();
final _bufferController = StreamController<Duration>.broadcast();
final _volumeController = StreamController<double>.broadcast();
final _rateController = StreamController<double>.broadcast();
final _tracksController = StreamController<MpvTracks>.broadcast();
final _trackController = StreamController<MpvTrackSelection>.broadcast();
final _logController = StreamController<MpvLog>.broadcast();
final _errorController = StreamController<String>.broadcast();
final _audioDeviceController = StreamController<MpvAudioDevice>.broadcast();
final _audioDevicesController =
StreamController<List<MpvAudioDevice>>.broadcast();
StreamSubscription? _eventSubscription;
bool _disposed = false;
bool _initialized = false;
bool _isVisible = false;
MpvPlayerMacOS() {
_streams = MpvPlayerStreams(
playing: _playingController.stream,
completed: _completedController.stream,
buffering: _bufferingController.stream,
position: _positionController.stream,
duration: _durationController.stream,
buffer: _bufferController.stream,
volume: _volumeController.stream,
rate: _rateController.stream,
tracks: _tracksController.stream,
track: _trackController.stream,
log: _logController.stream,
error: _errorController.stream,
audioDevice: _audioDeviceController.stream,
audioDevices: _audioDevicesController.stream,
);
_setupEventListener();
}
void _setupEventListener() {
_eventSubscription = _eventChannel.receiveBroadcastStream().listen(
_handleEvent,
onError: (error) {
_errorController.add(error.toString());
},
);
}
void _handleEvent(dynamic event) {
if (event is! Map) return;
final type = event['type'] as String?;
final name = event['name'] as String?;
if (type == 'property' && name != null) {
_handlePropertyChange(name, event['value']);
} else if (type == 'event' && name != null) {
_handleMpvEvent(name, event['data'] as Map?);
}
}
void _handlePropertyChange(String name, dynamic value) {
switch (name) {
case 'pause':
final playing = value == false;
_state = _state.copyWith(playing: playing);
_playingController.add(playing);
break;
case 'eof-reached':
final completed = value == true;
_state = _state.copyWith(completed: completed);
_completedController.add(completed);
break;
case 'paused-for-cache':
final buffering = value == true;
_state = _state.copyWith(buffering: buffering);
_bufferingController.add(buffering);
break;
case 'time-pos':
if (value is num) {
final position = Duration(milliseconds: (value * 1000).toInt());
_state = _state.copyWith(position: position);
_positionController.add(position);
}
break;
case 'duration':
if (value is num) {
final duration = Duration(milliseconds: (value * 1000).toInt());
_state = _state.copyWith(duration: duration);
_durationController.add(duration);
}
break;
case 'demuxer-cache-time':
if (value is num) {
final buffer = Duration(milliseconds: (value * 1000).toInt());
_state = _state.copyWith(buffer: buffer);
_bufferController.add(buffer);
}
break;
case 'volume':
if (value is num) {
final volume = value.toDouble();
_state = _state.copyWith(volume: volume);
_volumeController.add(volume);
}
break;
case 'speed':
if (value is num) {
final rate = value.toDouble();
_state = _state.copyWith(rate: rate);
_rateController.add(rate);
}
break;
case 'track-list':
if (value is List) {
final tracks = _parseTrackList(value);
_state = _state.copyWith(tracks: tracks);
_tracksController.add(tracks);
}
break;
case 'aid':
_updateSelectedAudioTrack(value);
break;
case 'sid':
_updateSelectedSubtitleTrack(value);
break;
}
}
void _handleMpvEvent(String name, Map? data) {
switch (name) {
case 'end-file':
final reason = data?['reason'] as String?;
if (reason == 'eof') {
_state = _state.copyWith(completed: true);
_completedController.add(true);
} else if (reason == 'error') {
_errorController.add('Playback error');
}
break;
case 'file-loaded':
// Reset completed state when new file is loaded
_state = _state.copyWith(completed: false);
_completedController.add(false);
break;
}
}
MpvTracks _parseTrackList(List trackList) {
final audioTracks = <MpvAudioTrack>[];
final subtitleTracks = <MpvSubtitleTrack>[];
for (final track in trackList) {
if (track is! Map) continue;
final type = track['type'] as String?;
final id = track['id']?.toString() ?? '';
if (type == 'audio') {
audioTracks.add(MpvAudioTrack(
id: id,
title: track['title'] as String?,
language: track['lang'] as String?,
codec: track['codec'] as String?,
channels: (track['demux-channel-count'] as num?)?.toInt(),
sampleRate: (track['demux-samplerate'] as num?)?.toInt(),
isDefault: track['default'] as bool? ?? false,
));
} else if (type == 'sub') {
subtitleTracks.add(MpvSubtitleTrack(
id: id,
title: track['title'] as String?,
language: track['lang'] as String?,
codec: track['codec'] as String?,
isExternal: track['external'] as bool? ?? false,
uri: track['external-filename'] as String?,
));
}
}
return MpvTracks(audio: audioTracks, subtitle: subtitleTracks);
}
void _updateSelectedAudioTrack(dynamic trackId) {
final id = trackId?.toString();
MpvAudioTrack? selectedTrack;
if (id != null && id != 'no') {
selectedTrack = _state.tracks.audio.cast<MpvAudioTrack?>().firstWhere(
(t) => t?.id == id,
orElse: () => null,
);
}
_state = _state.copyWith(
track: _state.track.copyWith(audio: selectedTrack),
);
_trackController.add(_state.track);
}
void _updateSelectedSubtitleTrack(dynamic trackId) {
final id = trackId?.toString();
MpvSubtitleTrack? selectedTrack;
if (id != null && id != 'no') {
selectedTrack =
_state.tracks.subtitle.cast<MpvSubtitleTrack?>().firstWhere(
(t) => t?.id == id,
orElse: () => null,
);
}
_state = _state.copyWith(
track: _state.track.copyWith(subtitle: selectedTrack),
);
_trackController.add(_state.track);
}
Future<void> _ensureInitialized() async {
if (_initialized) return;
try {
final result = await _methodChannel.invokeMethod<bool>('initialize');
_initialized = result == true;
if (!_initialized) {
throw Exception('Failed to initialize MPV player');
}
// Subscribe to MPV properties
await _observeProperty('time-pos', 'double');
await _observeProperty('duration', 'double');
await _observeProperty('pause', 'flag');
await _observeProperty('paused-for-cache', 'flag');
await _observeProperty('track-list', 'node');
await _observeProperty('eof-reached', 'flag');
await _observeProperty('volume', 'double');
await _observeProperty('aid', 'string');
await _observeProperty('sid', 'string');
} catch (e) {
_errorController.add('Initialization failed: $e');
rethrow;
}
}
Future<void> _observeProperty(String name, String format) async {
await _methodChannel.invokeMethod('observeProperty', {
'name': name,
'format': format,
});
}
void _checkDisposed() {
if (_disposed) {
throw StateError('MpvPlayer has been disposed');
}
}
// ============================================
// Playback Control
// ============================================
@override
Future<void> open(MpvMedia media, {bool play = true}) async {
_checkDisposed();
await _ensureInitialized();
// Show the video layer
await _methodChannel.invokeMethod('setVisible', {'visible': true});
_isVisible = true;
await command(['loadfile', media.uri, 'replace']);
if (!play) {
await setProperty('pause', 'yes');
}
}
@override
Future<void> play() async {
_checkDisposed();
await setProperty('pause', 'no');
}
@override
Future<void> pause() async {
_checkDisposed();
await setProperty('pause', 'yes');
}
@override
Future<void> playOrPause() async {
_checkDisposed();
if (_state.playing) {
await pause();
} else {
await play();
}
}
@override
Future<void> stop() async {
_checkDisposed();
await command(['stop']);
await _methodChannel.invokeMethod('setVisible', {'visible': false});
_isVisible = false;
}
@override
Future<void> seek(Duration position) async {
_checkDisposed();
await command([
'seek',
(position.inMilliseconds / 1000.0).toString(),
'absolute',
]);
}
// ============================================
// Track Selection
// ============================================
@override
Future<void> selectAudioTrack(MpvAudioTrack track) async {
_checkDisposed();
await setProperty('aid', track.id);
}
@override
Future<void> selectSubtitleTrack(MpvSubtitleTrack track) async {
_checkDisposed();
await setProperty('sid', track.id);
}
@override
Future<void> addSubtitleTrack({
required String uri,
String? title,
String? language,
bool select = false,
}) async {
_checkDisposed();
final args = ['sub-add', uri, select ? 'select' : 'auto'];
if (title != null) args.add('title=$title');
if (language != null) args.add('lang=$language');
await command(args);
}
// ============================================
// Volume and Rate
// ============================================
@override
Future<void> setVolume(double volume) async {
_checkDisposed();
await setProperty('volume', volume.toString());
}
@override
Future<void> setRate(double rate) async {
_checkDisposed();
await setProperty('speed', rate.toString());
}
@override
Future<void> setAudioDevice(MpvAudioDevice device) async {
_checkDisposed();
await setProperty('audio-device', device.name);
}
// ============================================
// MPV Properties
// ============================================
@override
Future<void> setProperty(String name, String value) async {
_checkDisposed();
await _methodChannel.invokeMethod('setProperty', {
'name': name,
'value': value,
});
}
@override
Future<String?> getProperty(String name) async {
_checkDisposed();
return await _methodChannel.invokeMethod<String>('getProperty', {
'name': name,
});
}
@override
Future<void> command(List<String> args) async {
_checkDisposed();
await _methodChannel.invokeMethod('command', {'args': args});
}
// ============================================
// Passthrough
// ============================================
@override
Future<void> setAudioPassthrough(bool enabled) async {
_checkDisposed();
if (enabled) {
await setProperty('audio-spdif', 'ac3,eac3,dts,dts-hd,truehd');
await setProperty('audio-exclusive', 'yes');
} else {
await setProperty('audio-spdif', '');
await setProperty('audio-exclusive', 'no');
}
}
// ============================================
// Visibility
// ============================================
@override
Future<bool> setVisible(bool visible) async {
_checkDisposed();
try {
await _methodChannel.invokeMethod('setVisible', {'visible': visible});
_isVisible = visible;
return true;
} catch (e) {
_errorController.add('Failed to set visibility: $e');
return false;
}
}
// ============================================
// Lifecycle
// ============================================
@override
Future<void> dispose() async {
if (_disposed) return;
_disposed = true;
await _eventSubscription?.cancel();
await _methodChannel.invokeMethod('dispose');
await _playingController.close();
await _completedController.close();
await _bufferingController.close();
await _positionController.close();
await _durationController.close();
await _bufferController.close();
await _volumeController.close();
await _rateController.close();
await _tracksController.close();
await _trackController.close();
await _logController.close();
await _errorController.close();
await _audioDeviceController.close();
await _audioDevicesController.close();
}
}
+113
View File
@@ -0,0 +1,113 @@
import '../models/mpv_audio_device.dart';
import '../models/mpv_tracks.dart';
import '../models/mpv_track_selection.dart';
/// Immutable snapshot of the current player state.
///
/// This class provides synchronous access to the player's current state.
/// For reactive updates, use [MpvPlayerStreams].
class MpvPlayerState {
/// Whether playback is currently active.
final bool playing;
/// Whether the media has completed playback.
final bool completed;
/// Whether the player is currently buffering.
final bool buffering;
/// Current playback position.
final Duration position;
/// Total duration of the media.
final Duration duration;
/// Amount of media buffered ahead of current position.
final Duration buffer;
/// Current volume level (0.0 to 100.0).
final double volume;
/// Current playback rate (1.0 = normal speed).
final double rate;
/// Available tracks in the media.
final MpvTracks tracks;
/// Currently selected tracks.
final MpvTrackSelection track;
/// Audio delay/sync offset in seconds.
final double audioDelay;
/// Subtitle delay/sync offset in seconds.
final double subtitleDelay;
/// Whether audio passthrough is currently enabled.
final bool audioPassthrough;
/// Current audio output device.
final MpvAudioDevice audioDevice;
/// Available audio output devices.
final List<MpvAudioDevice> audioDevices;
const MpvPlayerState({
this.playing = false,
this.completed = false,
this.buffering = false,
this.position = Duration.zero,
this.duration = Duration.zero,
this.buffer = Duration.zero,
this.volume = 100.0,
this.rate = 1.0,
this.tracks = const MpvTracks(),
this.track = const MpvTrackSelection(),
this.audioDelay = 0.0,
this.subtitleDelay = 0.0,
this.audioPassthrough = false,
this.audioDevice = MpvAudioDevice.auto,
this.audioDevices = const [],
});
/// Creates a copy with the given fields replaced.
MpvPlayerState copyWith({
bool? playing,
bool? completed,
bool? buffering,
Duration? position,
Duration? duration,
Duration? buffer,
double? volume,
double? rate,
MpvTracks? tracks,
MpvTrackSelection? track,
double? audioDelay,
double? subtitleDelay,
bool? audioPassthrough,
MpvAudioDevice? audioDevice,
List<MpvAudioDevice>? audioDevices,
}) {
return MpvPlayerState(
playing: playing ?? this.playing,
completed: completed ?? this.completed,
buffering: buffering ?? this.buffering,
position: position ?? this.position,
duration: duration ?? this.duration,
buffer: buffer ?? this.buffer,
volume: volume ?? this.volume,
rate: rate ?? this.rate,
tracks: tracks ?? this.tracks,
track: track ?? this.track,
audioDelay: audioDelay ?? this.audioDelay,
subtitleDelay: subtitleDelay ?? this.subtitleDelay,
audioPassthrough: audioPassthrough ?? this.audioPassthrough,
audioDevice: audioDevice ?? this.audioDevice,
audioDevices: audioDevices ?? this.audioDevices,
);
}
@override
String toString() =>
'MpvPlayerState(playing: $playing, position: $position, duration: $duration)';
}
@@ -0,0 +1,69 @@
import '../models/mpv_audio_device.dart';
import '../models/mpv_log.dart';
import '../models/mpv_tracks.dart';
import '../models/mpv_track_selection.dart';
/// Reactive streams for player state changes.
///
/// Subscribe to these streams to receive updates when the player state changes.
/// For synchronous state access, use [MpvPlayerState].
class MpvPlayerStreams {
/// Stream of playing state changes.
final Stream<bool> playing;
/// Stream of completion state changes.
final Stream<bool> completed;
/// Stream of buffering state changes.
final Stream<bool> buffering;
/// Stream of position updates.
final Stream<Duration> position;
/// Stream of duration changes (when media is loaded).
final Stream<Duration> duration;
/// Stream of buffer position updates.
final Stream<Duration> buffer;
/// Stream of volume changes.
final Stream<double> volume;
/// Stream of playback rate changes.
final Stream<double> rate;
/// Stream of available tracks updates.
final Stream<MpvTracks> tracks;
/// Stream of track selection changes.
final Stream<MpvTrackSelection> track;
/// Stream of log messages from MPV.
final Stream<MpvLog> log;
/// Stream of error messages.
final Stream<String> error;
/// Stream of audio device changes.
final Stream<MpvAudioDevice> audioDevice;
/// Stream of available audio devices.
final Stream<List<MpvAudioDevice>> audioDevices;
const MpvPlayerStreams({
required this.playing,
required this.completed,
required this.buffering,
required this.position,
required this.duration,
required this.buffer,
required this.volume,
required this.rate,
required this.tracks,
required this.track,
required this.log,
required this.error,
required this.audioDevice,
required this.audioDevices,
});
}
+242
View File
@@ -0,0 +1,242 @@
import 'dart:async';
import '../models/mpv_audio_device.dart';
import '../models/mpv_media.dart';
import '../models/mpv_audio_track.dart';
import '../models/mpv_subtitle_track.dart';
import '../models/mpv_tracks.dart';
import '../models/mpv_track_selection.dart';
import '../models/mpv_log.dart';
import 'mpv_player.dart';
import 'mpv_player_state.dart';
import 'mpv_player_streams.dart';
/// Stub implementation of [MpvPlayer].
///
/// This implementation creates all the necessary stream controllers and
/// state management, but throws [UnimplementedError] for all playback
/// methods. It serves as a foundation for platform-specific implementations.
class MpvPlayerStub implements MpvPlayer {
MpvPlayerState _state = const MpvPlayerState();
@override
MpvPlayerState get state => _state;
late final MpvPlayerStreams _streams;
@override
MpvPlayerStreams get streams => _streams;
@override
int? get textureId => null; // Set by platform implementation
// Stream controllers
final _playingController = StreamController<bool>.broadcast();
final _completedController = StreamController<bool>.broadcast();
final _bufferingController = StreamController<bool>.broadcast();
final _positionController = StreamController<Duration>.broadcast();
final _durationController = StreamController<Duration>.broadcast();
final _bufferController = StreamController<Duration>.broadcast();
final _volumeController = StreamController<double>.broadcast();
final _rateController = StreamController<double>.broadcast();
final _tracksController = StreamController<MpvTracks>.broadcast();
final _trackController = StreamController<MpvTrackSelection>.broadcast();
final _logController = StreamController<MpvLog>.broadcast();
final _errorController = StreamController<String>.broadcast();
final _audioDeviceController = StreamController<MpvAudioDevice>.broadcast();
final _audioDevicesController =
StreamController<List<MpvAudioDevice>>.broadcast();
bool _disposed = false;
MpvPlayerStub() {
_streams = MpvPlayerStreams(
playing: _playingController.stream,
completed: _completedController.stream,
buffering: _bufferingController.stream,
position: _positionController.stream,
duration: _durationController.stream,
buffer: _bufferController.stream,
volume: _volumeController.stream,
rate: _rateController.stream,
tracks: _tracksController.stream,
track: _trackController.stream,
log: _logController.stream,
error: _errorController.stream,
audioDevice: _audioDeviceController.stream,
audioDevices: _audioDevicesController.stream,
);
}
void _checkDisposed() {
if (_disposed) {
throw StateError('MpvPlayer has been disposed');
}
}
// ============================================
// Playback Control
// ============================================
@override
Future<void> open(MpvMedia media, {bool play = true}) async {
_checkDisposed();
throw UnimplementedError('MpvPlayer.open() is not yet implemented');
}
@override
Future<void> play() async {
_checkDisposed();
throw UnimplementedError('MpvPlayer.play() is not yet implemented');
}
@override
Future<void> pause() async {
_checkDisposed();
throw UnimplementedError('MpvPlayer.pause() is not yet implemented');
}
@override
Future<void> playOrPause() async {
_checkDisposed();
if (_state.playing) {
await pause();
} else {
await play();
}
}
@override
Future<void> stop() async {
_checkDisposed();
throw UnimplementedError('MpvPlayer.stop() is not yet implemented');
}
@override
Future<void> seek(Duration position) async {
_checkDisposed();
throw UnimplementedError('MpvPlayer.seek() is not yet implemented');
}
// ============================================
// Track Selection
// ============================================
@override
Future<void> selectAudioTrack(MpvAudioTrack track) async {
_checkDisposed();
throw UnimplementedError(
'MpvPlayer.selectAudioTrack() is not yet implemented');
}
@override
Future<void> selectSubtitleTrack(MpvSubtitleTrack track) async {
_checkDisposed();
throw UnimplementedError(
'MpvPlayer.selectSubtitleTrack() is not yet implemented');
}
@override
Future<void> addSubtitleTrack({
required String uri,
String? title,
String? language,
bool select = false,
}) async {
_checkDisposed();
throw UnimplementedError(
'MpvPlayer.addSubtitleTrack() is not yet implemented');
}
// ============================================
// Volume and Rate
// ============================================
@override
Future<void> setVolume(double volume) async {
_checkDisposed();
throw UnimplementedError('MpvPlayer.setVolume() is not yet implemented');
}
@override
Future<void> setRate(double rate) async {
_checkDisposed();
throw UnimplementedError('MpvPlayer.setRate() is not yet implemented');
}
@override
Future<void> setAudioDevice(MpvAudioDevice device) async {
_checkDisposed();
throw UnimplementedError(
'MpvPlayer.setAudioDevice() is not yet implemented');
}
// ============================================
// MPV Properties
// ============================================
@override
Future<void> setProperty(String name, String value) async {
_checkDisposed();
throw UnimplementedError('MpvPlayer.setProperty() is not yet implemented');
}
@override
Future<String?> getProperty(String name) async {
_checkDisposed();
throw UnimplementedError('MpvPlayer.getProperty() is not yet implemented');
}
@override
Future<void> command(List<String> args) async {
_checkDisposed();
throw UnimplementedError('MpvPlayer.command() is not yet implemented');
}
// ============================================
// Passthrough
// ============================================
@override
Future<void> setAudioPassthrough(bool enabled) async {
_checkDisposed();
throw UnimplementedError(
'MpvPlayer.setAudioPassthrough() is not yet implemented');
}
// ============================================
// Visibility
// ============================================
@override
Future<bool> setVisible(bool visible) async {
_checkDisposed();
// No-op on unsupported platforms
return false;
}
// ============================================
// Lifecycle
// ============================================
@override
Future<void> dispose() async {
if (_disposed) return;
_disposed = true;
await _playingController.close();
await _completedController.close();
await _bufferingController.close();
await _positionController.close();
await _durationController.close();
await _bufferController.close();
await _volumeController.close();
await _rateController.close();
await _tracksController.close();
await _trackController.close();
await _logController.close();
await _errorController.close();
await _audioDeviceController.close();
await _audioDevicesController.close();
}
}
+106
View File
@@ -0,0 +1,106 @@
import 'dart:io' show Platform;
import 'package:flutter/material.dart';
import '../player/mpv_player.dart';
/// Video widget for displaying MPV player output.
///
/// This widget displays the video output from an [MpvPlayer] instance
/// and optionally overlays custom controls.
///
/// Example usage:
/// ```dart
/// final player = MpvPlayer();
///
/// MpvVideo(
/// player: player,
/// fit: BoxFit.contain,
/// controls: (context) => MyCustomControls(),
/// )
/// ```
class MpvVideo extends StatefulWidget {
/// The player instance.
final MpvPlayer player;
/// How the video should be inscribed into the widget's box.
final BoxFit fit;
/// Builder for custom video controls overlay.
final Widget Function(BuildContext context)? controls;
/// Background color shown behind the video.
final Color backgroundColor;
const MpvVideo({
super.key,
required this.player,
this.fit = BoxFit.contain,
this.controls,
this.backgroundColor = Colors.black,
});
@override
State<MpvVideo> createState() => _MpvVideoState();
}
class _MpvVideoState extends State<MpvVideo> {
@override
Widget build(BuildContext context) {
// macOS uses transparent background so native Metal layer shows through
final bgColor = Platform.isMacOS ? Colors.transparent : widget.backgroundColor;
return Container(
color: bgColor,
child: Stack(
fit: StackFit.expand,
children: [
// Video rendering area
_buildVideoSurface(),
// Controls overlay
if (widget.controls != null) widget.controls!(context),
],
),
);
}
Widget _buildVideoSurface() {
// macOS: Video renders behind Flutter via native Metal layer
// Just show transparent container so native layer shows through
if (Platform.isMacOS) {
return const SizedBox.expand();
}
// When textureId is available, use Flutter's Texture widget
final textureId = widget.player.textureId;
if (textureId != null) {
return FittedBox(
fit: widget.fit,
child: Texture(textureId: textureId),
);
}
// Placeholder when video is not ready
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.play_circle_outline,
size: 64,
color: Colors.white.withValues(alpha: 0.3),
),
const SizedBox(height: 16),
Text(
'MPV Video',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.3),
fontSize: 14,
),
),
],
),
);
}
}
+99 -117
View File
@@ -3,11 +3,11 @@ import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:media_kit/media_kit.dart';
import 'package:media_kit_video/media_kit_video.dart';
import 'package:os_media_controls/os_media_controls.dart';
import 'package:provider/provider.dart';
import '../mpv/mpv.dart';
import '../client/plex_client.dart';
import '../models/plex_media_version.dart';
import '../models/plex_metadata.dart';
@@ -28,8 +28,8 @@ import '../i18n/strings.g.dart';
class VideoPlayerScreen extends StatefulWidget {
final PlexMetadata metadata;
final AudioTrack? preferredAudioTrack;
final SubtitleTrack? preferredSubtitleTrack;
final MpvAudioTrack? preferredAudioTrack;
final MpvSubtitleTrack? preferredSubtitleTrack;
final double? preferredPlaybackRate;
final int selectedMediaIndex;
@@ -48,8 +48,7 @@ class VideoPlayerScreen extends StatefulWidget {
class VideoPlayerScreenState extends State<VideoPlayerScreen>
with WidgetsBindingObserver {
Player? player;
VideoController? controller;
MpvPlayer? player;
bool _isPlayerInitialized = false;
PlexMetadata? _nextEpisode;
PlexMetadata? _previousEpisode;
@@ -57,13 +56,13 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
bool _showPlayNextDialog = false;
bool _isPhone = false;
List<PlexMediaVersion> _availableVersions = [];
StreamSubscription<PlayerLog>? _logSubscription;
StreamSubscription<MpvLog>? _logSubscription;
StreamSubscription<String>? _errorSubscription;
StreamSubscription<bool>? _playingSubscription;
StreamSubscription<bool>? _completedSubscription;
StreamSubscription<dynamic>? _mediaControlSubscription;
StreamSubscription<bool>? _bufferingSubscription;
StreamSubscription<Tracks>? _trackLoadingSubscription;
StreamSubscription<MpvTracks>? _trackLoadingSubscription;
bool _isReplacingWithVideo =
false; // Flag to skip orientation restoration during video-to-video navigation
@@ -222,57 +221,44 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
.getEnableHardwareDecoding();
final debugLoggingEnabled = settingsService.getEnableDebugLogging();
// Build MPV configuration
final config = <String, String>{
'sub-font-size': settingsService.getSubtitleFontSize().toString(),
'sub-color': settingsService.getSubtitleTextColor(),
'sub-border-size': settingsService.getSubtitleBorderSize().toString(),
'sub-border-color': settingsService.getSubtitleBorderColor(),
'sub-back-color':
'#${(settingsService.getSubtitleBackgroundOpacity() * 255 / 100).toInt().toRadixString(16).padLeft(2, '0').toUpperCase()}${settingsService.getSubtitleBackgroundColor().replaceFirst('#', '')}',
'sub-ass-override': 'no',
};
// Create player
player = MpvPlayer();
// Configure player properties
await player!.setProperty('sub-ass', 'yes'); // Enable libass
await player!.setProperty('sub-fonts-dir', 'assets');
await player!.setProperty('sub-font', 'Go Noto Current-Regular');
await player!.setProperty('demuxer-max-bytes', bufferSizeBytes.toString());
await player!.setProperty('msg-level', debugLoggingEnabled ? 'all=debug' : 'all=error');
// await player!.setProperty('hwdec', enableHardwareDecoding ? 'auto' : 'no');
// Subtitle styling
await player!.setProperty('sub-font-size', settingsService.getSubtitleFontSize().toString());
await player!.setProperty('sub-color', settingsService.getSubtitleTextColor());
await player!.setProperty('sub-border-size', settingsService.getSubtitleBorderSize().toString());
await player!.setProperty('sub-border-color', settingsService.getSubtitleBorderColor());
final bgOpacity = (settingsService.getSubtitleBackgroundOpacity() * 255 / 100).toInt();
final bgColor = settingsService.getSubtitleBackgroundColor().replaceFirst('#', '');
await player!.setProperty('sub-back-color', '#${bgOpacity.toRadixString(16).padLeft(2, '0').toUpperCase()}$bgColor');
await player!.setProperty('sub-ass-override', 'no');
// Platform-specific settings
if (Platform.isIOS) {
config['audio-exclusive'] = 'yes';
await player!.setProperty('audio-exclusive', 'yes');
}
// Create player with configuration
player = Player(
configuration: PlayerConfiguration(
libass: true,
libassAndroidFont: 'assets/go-noto-current-regular.ttf',
libassAndroidFontName: 'Go Noto Current-Regular',
bufferSize: bufferSizeBytes,
logLevel: debugLoggingEnabled ? MPVLogLevel.debug : MPVLogLevel.error,
mpvConfiguration: config,
),
);
controller = VideoController(
player!,
configuration: VideoControllerConfiguration(
enableHardwareAcceleration: enableHardwareDecoding,
),
);
// Apply audio sync offset
final audioSyncOffset = settingsService.getAudioSyncOffset();
if (audioSyncOffset != 0) {
final offsetSeconds = audioSyncOffset / 1000.0;
await (player!.platform as dynamic).setProperty(
'audio-delay',
offsetSeconds.toString(),
);
await player!.setProperty('audio-delay', offsetSeconds.toString());
}
// Apply subtitle sync offset
final subtitleSyncOffset = settingsService.getSubtitleSyncOffset();
if (subtitleSyncOffset != 0) {
final offsetSeconds = subtitleSyncOffset / 1000.0;
await (player!.platform as dynamic).setProperty(
'sub-delay',
offsetSeconds.toString(),
);
await player!.setProperty('sub-delay', offsetSeconds.toString());
}
// Apply saved volume
@@ -310,23 +296,23 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
}
// Listen to playback state changes
_playingSubscription = player!.stream.playing.listen(
_playingSubscription = player!.streams.playing.listen(
_onPlayingStateChanged,
);
// Listen to completion
_completedSubscription = player!.stream.completed.listen(
_completedSubscription = player!.streams.completed.listen(
_onVideoCompleted,
);
// Listen to MPV logs
_logSubscription = player!.stream.log.listen(_onPlayerLog);
_logSubscription = player!.streams.log.listen(_onPlayerLog);
// Listen to MPV errors
_errorSubscription = player!.stream.error.listen(_onPlayerError);
_errorSubscription = player!.streams.error.listen(_onPlayerError);
// Listen to buffering state
_bufferingSubscription = player!.stream.buffering.listen((isBuffering) {
_bufferingSubscription = player!.streams.buffering.listen((isBuffering) {
_isBuffering.value = isBuffering;
});
@@ -424,12 +410,12 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
);
// Listen to playing state and update media controls
player!.stream.playing.listen((isPlaying) {
player!.streams.playing.listen((isPlaying) {
_updateMediaControlsPlaybackState();
});
// Listen to position updates for media controls
player!.stream.position.listen((position) {
player!.streams.position.listen((position) {
_mediaControlsManager?.updatePlaybackState(
isPlaying: player!.state.playing,
position: position,
@@ -542,26 +528,22 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
// Use server-specific client for this metadata
final client = _getClientForMetadata(context);
// Capture profile settings before async gap
final profileSettings = context.profileSettings;
// Initialize playback service
final playbackService = PlaybackInitializationService(
player: player!,
client: client,
context: context,
);
// Start playback and get available versions
final result = await playbackService.startPlayback(
// Get playback data (video URL and available versions)
final result = await playbackService.getPlaybackData(
metadata: widget.metadata,
selectedMediaIndex: widget.selectedMediaIndex,
profileSettings: profileSettings,
preferredAudioTrack: widget.preferredAudioTrack,
preferredSubtitleTrack: widget.preferredSubtitleTrack,
preferredPlaybackRate: widget.preferredPlaybackRate,
);
// Open video through MpvPlayer
if (result.videoUrl != null) {
await player!.open(MpvMedia(result.videoUrl!));
}
// Update available versions from the playback data
if (mounted) {
setState(() {
@@ -643,11 +625,10 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
// Clear video filter and reset subtitle margins before disposing player
try {
if (player != null) {
final nativePlayer = player!.platform as dynamic;
nativePlayer.setProperty('vf', '');
nativePlayer.setProperty('sub-margin-x', '0');
nativePlayer.setProperty('sub-margin-y', '0');
nativePlayer.setProperty('sub-scale', '1.0');
player!.setProperty('vf', '');
player!.setProperty('sub-margin-x', '0');
player!.setProperty('sub-margin-y', '0');
player!.setProperty('sub-scale', '1.0');
}
} catch (e) {
// Non-critical: Cleanup operations during disposal
@@ -700,26 +681,26 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
}
}
void _onPlayerLog(PlayerLog log) {
void _onPlayerLog(MpvLog log) {
// Map MPV log levels to app logger levels
switch (log.level.toLowerCase()) {
case 'fatal':
case 'error':
switch (log.level) {
case MpvLogLevel.fatal:
case MpvLogLevel.error:
appLogger.e('[MPV:${log.prefix}] ${log.text}');
break;
case 'warn':
case MpvLogLevel.warn:
appLogger.w('[MPV:${log.prefix}] ${log.text}');
break;
case 'info':
case MpvLogLevel.info:
appLogger.i('[MPV:${log.prefix}] ${log.text}');
break;
case 'debug':
case 'trace':
case 'v':
case MpvLogLevel.debug:
case MpvLogLevel.trace:
case MpvLogLevel.verbose:
appLogger.d('[MPV:${log.prefix}] ${log.text}');
break;
default:
appLogger.d('[MPV:${log.prefix}:${log.level}] ${log.text}');
appLogger.d('[MPV:${log.prefix}:${log.level.name}] ${log.text}');
}
}
@@ -758,7 +739,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
}
/// Handle audio track changes from the user - save as per-media preference if enabled
Future<void> _onAudioTrackChanged(AudioTrack track) async {
Future<void> _onAudioTrackChanged(MpvAudioTrack track) async {
final settings = await SettingsService.getInstance();
// Only save if remember track selections is enabled
@@ -800,7 +781,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
}
/// Handle subtitle track changes from the user - save as per-media preference if enabled
Future<void> _onSubtitleTrackChanged(SubtitleTrack track) async {
Future<void> _onSubtitleTrackChanged(MpvSubtitleTrack track) async {
final settings = await SettingsService.getInstance();
// Only save if remember track selections is enabled
@@ -908,7 +889,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
@override
Widget build(BuildContext context) {
// Show loading indicator while player initializes
if (!_isPlayerInitialized || controller == null || player == null) {
if (!_isPlayerInitialized || player == null) {
return const Scaffold(
backgroundColor: Colors.black,
body: Center(child: CircularProgressIndicator(color: Colors.white)),
@@ -928,7 +909,8 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
}
},
child: Scaffold(
backgroundColor: Colors.black,
// Use transparent background on macOS when native video layer is active
backgroundColor: Platform.isMacOS ? Colors.transparent : Colors.black,
body: GestureDetector(
behavior: HitTestBehavior
.translucent, // Allow taps to pass through to controls
@@ -959,44 +941,44 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
children: [
// Video player
Center(
child: LayoutBuilder(
builder: (context, constraints) {
// Update player size when layout changes
final newSize = Size(
constraints.maxWidth,
constraints.maxHeight,
);
child: LayoutBuilder(
builder: (context, constraints) {
// Update player size when layout changes
final newSize = Size(
constraints.maxWidth,
constraints.maxHeight,
);
// Update player size in video filter manager
if (_videoFilterManager != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
_videoFilterManager!.updatePlayerSize(newSize);
}
});
}
// Update player size in video filter manager
if (_videoFilterManager != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
_videoFilterManager!.updatePlayerSize(newSize);
}
});
}
return Video(
controller: controller!,
fit: _videoFilterManager?.currentBoxFit ?? BoxFit.contain,
controls: (state) => plexVideoControlsBuilder(
player!,
widget.metadata,
onNext: _nextEpisode != null ? _playNext : null,
onPrevious: _previousEpisode != null
? _playPrevious
: null,
availableVersions: _availableVersions,
selectedMediaIndex: widget.selectedMediaIndex,
boxFitMode: _videoFilterManager?.boxFitMode ?? 0,
onCycleBoxFitMode: _cycleBoxFitMode,
onAudioTrackChanged: _onAudioTrackChanged,
onSubtitleTrackChanged: _onSubtitleTrackChanged,
),
);
},
return MpvVideo(
player: player!,
fit: _videoFilterManager?.currentBoxFit ?? BoxFit.contain,
controls: (context) => plexVideoControlsBuilder(
player!,
widget.metadata,
onNext: _nextEpisode != null ? _playNext : null,
onPrevious: _previousEpisode != null
? _playPrevious
: null,
availableVersions: _availableVersions,
selectedMediaIndex: widget.selectedMediaIndex,
boxFitMode: _videoFilterManager?.boxFitMode ?? 0,
onCycleBoxFitMode: _cycleBoxFitMode,
onAudioTrackChanged: _onAudioTrackChanged,
onSubtitleTrackChanged: _onSubtitleTrackChanged,
),
);
},
),
),
),
// Play Next Dialog
if (_showPlayNextDialog && _nextEpisode != null)
Positioned.fill(
+5 -5
View File
@@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:media_kit/media_kit.dart';
import 'package:provider/provider.dart';
import '../mpv/mpv.dart';
import '../client/plex_client.dart';
import '../models/plex_metadata.dart';
import '../providers/playback_state_provider.dart';
@@ -77,14 +77,14 @@ class EpisodeNavigationService {
Future<void> navigateToEpisode({
required BuildContext context,
required PlexMetadata episode,
required Player? player,
required MpvPlayer? player,
bool usePushReplacement = true,
}) async {
if (!context.mounted) return;
// Capture current player state before navigation
AudioTrack? currentAudioTrack;
SubtitleTrack? currentSubtitleTrack;
MpvAudioTrack? currentAudioTrack;
MpvSubtitleTrack? currentSubtitleTrack;
double? currentPlaybackRate;
if (player != null) {
@@ -93,7 +93,7 @@ class EpisodeNavigationService {
currentPlaybackRate = player.state.rate;
appLogger.d(
'Navigating to episode with preserved settings - Audio: ${currentAudioTrack.id}, Subtitle: ${currentSubtitleTrack.id}, Rate: ${currentPlaybackRate}x',
'Navigating to episode with preserved settings - Audio: ${currentAudioTrack?.id}, Subtitle: ${currentSubtitleTrack?.id}, Rate: ${currentPlaybackRate}x',
);
}
+3 -3
View File
@@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:media_kit/media_kit.dart';
import 'package:hotkey_manager/hotkey_manager.dart';
import '../mpv/mpv.dart';
import 'settings_service.dart';
import '../utils/keyboard_utils.dart';
import '../utils/player_utils.dart';
@@ -148,7 +148,7 @@ class KeyboardShortcutsService {
// Handle keyboard input for video player
KeyEventResult handleVideoPlayerKeyEvent(
KeyEvent event,
Player player,
MpvPlayer player,
VoidCallback? onToggleFullscreen,
VoidCallback? onToggleSubtitles,
VoidCallback? onNextAudioTrack,
@@ -241,7 +241,7 @@ class KeyboardShortcutsService {
void _executeAction(
String action,
Player player,
MpvPlayer player,
VoidCallback? onToggleFullscreen,
VoidCallback? onToggleSubtitles,
VoidCallback? onNextAudioTrack,
+11 -161
View File
@@ -1,45 +1,21 @@
import 'package:flutter/material.dart';
import 'package:media_kit/media_kit.dart';
import '../client/plex_client.dart';
import '../models/plex_media_info.dart';
import '../models/plex_metadata.dart';
import '../models/plex_user_profile.dart';
import '../utils/app_logger.dart';
import '../i18n/strings.g.dart';
import 'track_selection_service.dart';
/// Service responsible for initializing video playback
///
/// Handles the complex process of:
/// 1. Fetching video playback data from the Plex server
/// 2. Building external subtitle tracks
/// 3. Opening media in the player
/// 4. Adding external subtitles to the player
/// 5. Selecting and applying audio/subtitle tracks
/// 6. Seeking to resume position
/// 7. Starting playback
/// Service responsible for fetching video playback data from the Plex server
class PlaybackInitializationService {
final Player player;
final PlexClient client;
final BuildContext context;
PlaybackInitializationService({
required this.player,
required this.client,
required this.context,
});
/// Start playback for the given metadata
/// Fetch playback data for the given metadata
///
/// Returns a PlaybackInitializationResult with available versions and other data
Future<PlaybackInitializationResult> startPlayback({
/// Returns a PlaybackInitializationResult with video URL and available versions
Future<PlaybackInitializationResult> getPlaybackData({
required PlexMetadata metadata,
required int selectedMediaIndex,
required PlexUserProfile? profileSettings,
AudioTrack? preferredAudioTrack,
SubtitleTrack? preferredSubtitleTrack,
double? preferredPlaybackRate,
}) async {
try {
// Get consolidated playback data (URL, media info, and versions) in a single API call
@@ -52,50 +28,10 @@ class PlaybackInitializationService {
throw PlaybackException(t.messages.fileInfoNotAvailable);
}
final videoUrl = playbackData.videoUrl!;
final mediaInfo = playbackData.mediaInfo;
// Build list of external subtitle tracks for media_kit
final externalSubtitles = _buildExternalSubtitles(mediaInfo);
// Open video (without external subtitles in Media constructor)
await player.open(Media(videoUrl), play: false);
// Wait for media to be ready (duration > 0)
await _waitForMediaReady();
// Add external subtitle tracks without auto-selecting them
if (externalSubtitles.isNotEmpty) {
await _addExternalSubtitles(externalSubtitles);
}
// Select and apply tracks BEFORE seeking to resume position
// This prevents track changes from resetting the playback position on Android
final trackSelectionService = TrackSelectionService(
player: player,
profileSettings: profileSettings,
metadata: metadata,
);
await trackSelectionService.selectAndApplyTracks(
preferredAudioTrack: preferredAudioTrack,
preferredSubtitleTrack: preferredSubtitleTrack,
preferredPlaybackRate: preferredPlaybackRate,
);
// Set up playback position if resuming (AFTER track selection)
if (metadata.viewOffset != null && metadata.viewOffset! > 0) {
appLogger.d('Resuming playback at ${metadata.viewOffset} ms');
final resumePosition = Duration(milliseconds: metadata.viewOffset!);
await player.seek(resumePosition);
}
// Start playback after seeking
await player.play();
// Return result with available versions for UI updates
// Return result with available versions and video URL
return PlaybackInitializationResult(
availableVersions: playbackData.availableVersions,
videoUrl: playbackData.videoUrl!,
);
} catch (e) {
if (e is PlaybackException) {
@@ -104,103 +40,17 @@ class PlaybackInitializationService {
throw PlaybackException(t.messages.errorLoading(error: e.toString()));
}
}
/// Build list of external subtitle tracks from media info
List<SubtitleTrack> _buildExternalSubtitles(PlexMediaInfo? mediaInfo) {
final externalSubtitles = <SubtitleTrack>[];
if (mediaInfo == null) {
return externalSubtitles;
}
final externalTracks = mediaInfo.subtitleTracks
.where((PlexSubtitleTrack track) => track.isExternal)
.toList();
if (externalTracks.isNotEmpty) {
appLogger.d('Found ${externalTracks.length} external subtitle track(s)');
}
for (final plexTrack in externalTracks) {
try {
// Skip if no auth token is available
final token = client.config.token;
if (token == null) {
appLogger.w('No auth token available for external subtitles');
continue;
}
final url = plexTrack.getSubtitleUrl(client.config.baseUrl, token);
// Skip if URL couldn't be constructed
if (url == null) continue;
externalSubtitles.add(
SubtitleTrack.uri(
url,
title:
plexTrack.displayTitle ??
plexTrack.language ??
'Track ${plexTrack.id}',
language: plexTrack.languageCode,
),
);
} catch (e) {
// Silent fallback - log error but continue with other subtitles
appLogger.w(
'Failed to add external subtitle track ${plexTrack.id}',
error: e,
);
}
}
return externalSubtitles;
}
/// Wait for media to be ready (duration > 0)
Future<void> _waitForMediaReady() async {
int attempts = 0;
while (player.state.duration.inMilliseconds == 0 && attempts < 100) {
await Future.delayed(const Duration(milliseconds: 100));
attempts++;
}
}
/// Add external subtitle tracks to the player without auto-selecting them
Future<void> _addExternalSubtitles(
List<SubtitleTrack> externalSubtitles,
) async {
appLogger.d(
'Adding ${externalSubtitles.length} external subtitle(s) to player',
);
final nativePlayer = player.platform as dynamic;
for (final subtitleTrack in externalSubtitles) {
try {
// Use mpv's sub-add with 'auto' flag to avoid auto-selection
await nativePlayer.command([
'sub-add',
subtitleTrack.id,
'auto',
subtitleTrack.title ?? 'external',
subtitleTrack.language ?? 'auto',
]);
} catch (e) {
appLogger.w(
'Failed to add external subtitle: ${subtitleTrack.title}',
error: e,
);
}
}
}
}
/// Result of playback initialization
class PlaybackInitializationResult {
final List<dynamic> availableVersions;
final String? videoUrl;
PlaybackInitializationResult({required this.availableVersions});
PlaybackInitializationResult({
required this.availableVersions,
this.videoUrl,
});
}
/// Exception thrown when playback initialization fails
+2 -2
View File
@@ -1,6 +1,6 @@
import 'dart:async';
import 'package:media_kit/media_kit.dart';
import '../mpv/mpv.dart';
import '../client/plex_client.dart';
import '../models/plex_metadata.dart';
@@ -15,7 +15,7 @@ import '../utils/app_logger.dart';
class PlaybackProgressTracker {
final PlexClient client;
final PlexMetadata metadata;
final Player player;
final MpvPlayer player;
/// Timer for periodic progress updates
Timer? _progressTimer;
+47 -47
View File
@@ -1,4 +1,4 @@
import 'package:media_kit/media_kit.dart';
import '../mpv/mpv.dart';
import '../models/plex_metadata.dart';
import '../models/plex_user_profile.dart';
@@ -8,7 +8,7 @@ import '../utils/language_codes.dart';
/// Service for selecting and applying audio and subtitle tracks based on
/// preferences, user profiles, and per-media settings.
class TrackSelectionService {
final Player player;
final MpvPlayer player;
final PlexUserProfile? profileSettings;
final PlexMetadata metadata;
@@ -69,11 +69,11 @@ class TrackSelectionService {
return null;
}
AudioTrack? findBestAudioMatch(
List<AudioTrack> availableTracks,
AudioTrack preferred,
MpvAudioTrack? findBestAudioMatch(
List<MpvAudioTrack> availableTracks,
MpvAudioTrack preferred,
) {
return findBestTrackMatch<AudioTrack>(
return findBestTrackMatch<MpvAudioTrack>(
availableTracks,
preferred,
(t) => t.id,
@@ -82,8 +82,8 @@ class TrackSelectionService {
);
}
AudioTrack? findAudioTrackByProfile(
List<AudioTrack> availableTracks,
MpvAudioTrack? findAudioTrackByProfile(
List<MpvAudioTrack> availableTracks,
PlexUserProfile profile,
) {
appLogger.d('Audio track selection using user profile');
@@ -123,7 +123,7 @@ class TrackSelectionService {
'Checking language variations for "$preferredLanguage": ${languageVariations.join(", ")}',
);
final match = _findTrackByLanguageVariations<AudioTrack>(
final match = _findTrackByLanguageVariations<MpvAudioTrack>(
availableTracks,
preferredLanguage,
languageVariations,
@@ -140,16 +140,16 @@ class TrackSelectionService {
return null;
}
SubtitleTrack? findBestSubtitleMatch(
List<SubtitleTrack> availableTracks,
SubtitleTrack preferred,
MpvSubtitleTrack? findBestSubtitleMatch(
List<MpvSubtitleTrack> availableTracks,
MpvSubtitleTrack preferred,
) {
// Handle special "no subtitles" case
if (preferred.id == 'no') {
return SubtitleTrack.no();
return MpvSubtitleTrack.off;
}
return findBestTrackMatch<SubtitleTrack>(
return findBestTrackMatch<MpvSubtitleTrack>(
availableTracks,
preferred,
(t) => t.id,
@@ -158,10 +158,10 @@ class TrackSelectionService {
);
}
SubtitleTrack? findSubtitleTrackByProfile(
List<SubtitleTrack> availableTracks,
MpvSubtitleTrack? findSubtitleTrackByProfile(
List<MpvSubtitleTrack> availableTracks,
PlexUserProfile profile, {
AudioTrack? selectedAudioTrack,
MpvAudioTrack? selectedAudioTrack,
}) {
appLogger.d('Subtitle track selection using user profile');
appLogger.d(
@@ -178,7 +178,7 @@ class TrackSelectionService {
appLogger.d(
'Profile specifies manual mode (autoSelectSubtitle=0) - Subtitles OFF',
);
return SubtitleTrack.no();
return MpvSubtitleTrack.off;
}
// Mode 1: Shown with foreign audio
@@ -201,7 +201,7 @@ class TrackSelectionService {
// If audio matches preferred language, no subtitles needed
if (audioLang != null && languageVariations.contains(audioLang)) {
appLogger.d('Audio matches preferred language - Subtitles OFF');
return SubtitleTrack.no();
return MpvSubtitleTrack.off;
}
appLogger.d('Foreign audio detected - enabling subtitles');
}
@@ -258,7 +258,7 @@ class TrackSelectionService {
'Checking language variations for "$preferredLanguage": ${languageVariations.join(", ")}',
);
final match = _findTrackByLanguageVariations<SubtitleTrack>(
final match = _findTrackByLanguageVariations<MpvSubtitleTrack>(
candidateTracks,
preferredLanguage,
languageVariations,
@@ -282,8 +282,8 @@ class TrackSelectionService {
/// - 1: Prefer SDH subtitles
/// - 2: Only show SDH subtitles
/// - 3: Only show non-SDH subtitles
List<SubtitleTrack> filterSubtitlesBySDH(
List<SubtitleTrack> tracks,
List<MpvSubtitleTrack> filterSubtitlesBySDH(
List<MpvSubtitleTrack> tracks,
int preference,
) {
if (preference == 0 || preference == 1) {
@@ -319,8 +319,8 @@ class TrackSelectionService {
/// - 1: Prefer forced subtitles
/// - 2: Only show forced subtitles
/// - 3: Only show non-forced subtitles
List<SubtitleTrack> filterSubtitlesByForced(
List<SubtitleTrack> tracks,
List<MpvSubtitleTrack> filterSubtitlesByForced(
List<MpvSubtitleTrack> tracks,
int preference,
) {
if (preference == 0 || preference == 1) {
@@ -353,8 +353,8 @@ class TrackSelectionService {
/// Checks if a subtitle track is SDH (Subtitles for Deaf or Hard-of-Hearing)
///
/// Since media_kit may not expose this directly, we infer from the title
bool isSDH(SubtitleTrack track) {
/// Since mpv may not expose this directly, we infer from the title
bool isSDH(MpvSubtitleTrack track) {
final title = track.title?.toLowerCase() ?? '';
// Look for common SDH indicators
@@ -365,7 +365,7 @@ class TrackSelectionService {
}
/// Checks if a subtitle track is forced
bool isForced(SubtitleTrack track) {
bool isForced(MpvSubtitleTrack track) {
final title = track.title?.toLowerCase() ?? '';
return title.contains('forced');
}
@@ -423,8 +423,8 @@ class TrackSelectionService {
/// Log available tracks for debugging
void logAvailableTracks(
List<AudioTrack> audioTracks,
List<SubtitleTrack> subtitleTracks,
List<MpvAudioTrack> audioTracks,
List<MpvSubtitleTrack> subtitleTracks,
) {
appLogger.d('Available audio tracks: ${audioTracks.length}');
for (var track in audioTracks) {
@@ -445,13 +445,13 @@ class TrackSelectionService {
/// Priority 2: Per-media language preference
/// Priority 3: User profile preferences
/// Priority 4: Default or first track
AudioTrack? selectAudioTrack(
List<AudioTrack> availableTracks,
AudioTrack? preferredAudioTrack,
MpvAudioTrack? selectAudioTrack(
List<MpvAudioTrack> availableTracks,
MpvAudioTrack? preferredAudioTrack,
) {
if (availableTracks.isEmpty) return null;
AudioTrack? trackToSelect;
MpvAudioTrack? trackToSelect;
// Priority 1: Try to match preferred track from navigation
if (preferredAudioTrack != null) {
@@ -522,19 +522,19 @@ class TrackSelectionService {
/// Priority 3: User profile preferences
/// Priority 4: Default track
/// Priority 5: Off
SubtitleTrack selectSubtitleTrack(
List<SubtitleTrack> availableTracks,
SubtitleTrack? preferredSubtitleTrack,
AudioTrack? selectedAudioTrack,
MpvSubtitleTrack selectSubtitleTrack(
List<MpvSubtitleTrack> availableTracks,
MpvSubtitleTrack? preferredSubtitleTrack,
MpvAudioTrack? selectedAudioTrack,
) {
SubtitleTrack? subtitleToSelect;
MpvSubtitleTrack? subtitleToSelect;
// Priority 1: Try preferred track from navigation (always wins)
if (preferredSubtitleTrack != null) {
appLogger.d('Priority 1: Checking preferred track from navigation');
if (preferredSubtitleTrack.id == 'no') {
appLogger.d(' Preferred: OFF');
return SubtitleTrack.no();
return MpvSubtitleTrack.off;
} else if (availableTracks.isNotEmpty) {
appLogger.d(
' Preferred: ${preferredSubtitleTrack.title ?? "Track ${preferredSubtitleTrack.id}"} (${preferredSubtitleTrack.language ?? "unknown"})',
@@ -566,7 +566,7 @@ class TrackSelectionService {
if (metadata.subtitleLanguage == 'none' ||
metadata.subtitleLanguage!.isEmpty) {
appLogger.d(' Per-media preference: Subtitles OFF');
return SubtitleTrack.no();
return MpvSubtitleTrack.off;
} else if (availableTracks.isNotEmpty) {
final matchedTrack = availableTracks.firstWhere(
(track) => languageMatches(track.language, metadata.subtitleLanguage),
@@ -615,16 +615,16 @@ class TrackSelectionService {
// Priority 5: If still no subtitle selected, turn off
appLogger.d('Priority 5: No subtitle selected - Subtitles OFF');
return SubtitleTrack.no();
return MpvSubtitleTrack.off;
}
/// Select and apply audio and subtitle tracks based on preferences
Future<void> selectAndApplyTracks({
AudioTrack? preferredAudioTrack,
SubtitleTrack? preferredSubtitleTrack,
MpvAudioTrack? preferredAudioTrack,
MpvSubtitleTrack? preferredSubtitleTrack,
double? preferredPlaybackRate,
Function(AudioTrack)? onAudioTrackChanged,
Function(SubtitleTrack)? onSubtitleTrackChanged,
Function(MpvAudioTrack)? onAudioTrackChanged,
Function(MpvSubtitleTrack)? onSubtitleTrackChanged,
}) async {
// Wait for tracks to be loaded
int attempts = 0;
@@ -658,7 +658,7 @@ class TrackSelectionService {
appLogger.i(
'Final audio selection: ${selectedAudioTrack.title ?? "Track ${selectedAudioTrack.id}"} (${selectedAudioTrack.language ?? "unknown"})',
);
player.setAudioTrack(selectedAudioTrack);
player.selectAudioTrack(selectedAudioTrack);
} else {
appLogger.d('No audio tracks available');
}
@@ -674,7 +674,7 @@ class TrackSelectionService {
? 'OFF'
: '${selectedSubtitleTrack.title ?? "Track ${selectedSubtitleTrack.id}"} (${selectedSubtitleTrack.language ?? "unknown"})';
appLogger.i('Final subtitle selection: $finalSubtitle');
player.setSubtitleTrack(selectedSubtitleTrack);
player.selectSubtitleTrack(selectedSubtitleTrack);
// Set playback rate if preferred rate was provided
if (preferredPlaybackRate != null) {
+15 -16
View File
@@ -1,7 +1,8 @@
import 'package:flutter/material.dart';
import 'package:media_kit/media_kit.dart';
import 'package:rate_limiter/rate_limiter.dart';
import '../mpv/mpv.dart';
import '../models/plex_media_version.dart';
import '../utils/app_logger.dart';
@@ -13,7 +14,7 @@ import '../utils/app_logger.dart';
/// - Subtitle positioning adjustments based on crop parameters
/// - Debounced video filter updates on resize events
class VideoFilterManager {
final Player player;
final MpvPlayer player;
final List<PlexMediaVersion> availableVersions;
final int selectedMediaIndex;
@@ -203,8 +204,6 @@ class VideoFilterManager {
/// Update the video filter based on current crop mode
void updateVideoFilter() async {
try {
final nativePlayer = player.platform as dynamic;
if (_boxFitMode == 1) {
// Fill screen mode - apply crop filter
_videoSize = _getCurrentVideoSize();
@@ -218,7 +217,7 @@ class VideoFilterManager {
);
// Apply crop filter
await nativePlayer.setProperty('vf', cropFilter);
await player.setProperty('vf', cropFilter);
// Apply subtitle margins and scaling to compensate for crop zoom
final subMarginX = cropParams['subMarginX']!;
@@ -229,28 +228,28 @@ class VideoFilterManager {
'Applying subtitle properties - margins: x=$subMarginX, y=$subMarginY, scale=$subScale',
);
await nativePlayer.setProperty('sub-margin-x', subMarginX.toString());
await nativePlayer.setProperty('sub-margin-y', subMarginY.toString());
await nativePlayer.setProperty('sub-scale', subScale.toString());
await player.setProperty('sub-margin-x', subMarginX.toString());
await player.setProperty('sub-margin-y', subMarginY.toString());
await player.setProperty('sub-scale', subScale.toString());
} else {
// Clear filter but apply base margins if no cropping needed
appLogger.d(
'Clearing video filter - aspect ratios similar, applying base margins (player: $_playerSize, video: $_videoSize)',
);
await nativePlayer.setProperty('vf', '');
await nativePlayer.setProperty('sub-margin-x', '20'); // Base margin
await nativePlayer.setProperty('sub-margin-y', '40'); // Base margin
await nativePlayer.setProperty('sub-scale', '1.0'); // Reset scale
await player.setProperty('vf', '');
await player.setProperty('sub-margin-x', '20'); // Base margin
await player.setProperty('sub-margin-y', '40'); // Base margin
await player.setProperty('sub-scale', '1.0'); // Reset scale
}
} else {
// Other modes - clear video filter but apply base margins
appLogger.d(
'Clearing video filter, applying base margins - BoxFit mode $_boxFitMode',
);
await nativePlayer.setProperty('vf', '');
await nativePlayer.setProperty('sub-margin-x', '20'); // Base margin
await nativePlayer.setProperty('sub-margin-y', '40'); // Base margin
await nativePlayer.setProperty('sub-scale', '1.0'); // Reset scale
await player.setProperty('vf', '');
await player.setProperty('sub-margin-x', '20'); // Base margin
await player.setProperty('sub-margin-y', '40'); // Base margin
await player.setProperty('sub-scale', '1.0'); // Reset scale
}
} catch (e) {
appLogger.w('Failed to update video filter', error: e);
+2 -2
View File
@@ -1,8 +1,8 @@
import 'package:media_kit/media_kit.dart';
import '../mpv/mpv.dart';
/// Seeks by the given offset (can be positive or negative) while clamping
/// the result between 0 and the video duration
void seekWithClamping(Player player, Duration offset) {
void seekWithClamping(MpvPlayer player, Duration offset) {
final currentPosition = player.state.position;
final duration = player.state.duration;
final newPosition = currentPosition + offset;
+4 -3
View File
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:media_kit/media_kit.dart';
import '../mpv/mpv.dart';
import '../models/plex_metadata.dart';
import '../screens/video_player_screen.dart';
import '../services/settings_service.dart';
@@ -26,8 +27,8 @@ import '../services/settings_service.dart';
Future<bool?> navigateToVideoPlayer(
BuildContext context, {
required PlexMetadata metadata,
AudioTrack? preferredAudioTrack,
SubtitleTrack? preferredSubtitleTrack,
MpvAudioTrack? preferredAudioTrack,
MpvSubtitleTrack? preferredSubtitleTrack,
double? preferredPlaybackRate,
int? selectedMediaIndex,
bool usePushReplacement = false,
@@ -1,8 +1,8 @@
import 'dart:io' show Platform;
import 'package:flutter/material.dart';
import 'package:media_kit/media_kit.dart';
import '../../mpv/mpv.dart';
import '../../models/plex_media_info.dart';
import '../../models/plex_metadata.dart';
import '../../services/fullscreen_state_manager.dart';
@@ -14,7 +14,7 @@ import 'widgets/timeline_slider.dart';
/// Desktop-specific video controls layout with top bar and bottom controls
class DesktopVideoControls extends StatelessWidget {
final Player player;
final MpvPlayer player;
final PlexMetadata metadata;
final VoidCallback? onNext;
final VoidCallback? onPrevious;
@@ -158,11 +158,11 @@ class DesktopVideoControls extends StatelessWidget {
children: [
// Row 1: Timeline with time indicators
StreamBuilder<Duration>(
stream: player.stream.position,
stream: player.streams.position,
initialData: player.state.position,
builder: (context, positionSnapshot) {
return StreamBuilder<Duration>(
stream: player.stream.duration,
stream: player.streams.duration,
initialData: player.state.duration,
builder: (context, durationSnapshot) {
final position = positionSnapshot.data ?? Duration.zero;
@@ -238,7 +238,7 @@ class DesktopVideoControls extends StatelessWidget {
),
// Play/Pause
StreamBuilder<bool>(
stream: player.stream.playing,
stream: player.streams.playing,
initialData: player.state.playing,
builder: (context, snapshot) {
final isPlaying = snapshot.data ?? false;
@@ -1,6 +1,6 @@
import 'package:flutter/material.dart';
import 'package:media_kit/media_kit.dart';
import '../../mpv/mpv.dart';
import '../../models/plex_media_info.dart';
import '../../models/plex_metadata.dart';
import '../../utils/desktop_window_padding.dart';
@@ -18,7 +18,7 @@ import 'widgets/timeline_slider.dart';
/// - Center: Large playback controls (seek backward, play/pause, seek forward)
/// - Bottom bar: Timeline slider with chapter markers and timestamps
class MobileVideoControls extends StatelessWidget {
final Player player;
final MpvPlayer player;
final PlexMetadata metadata;
final List<PlexChapter> chapters;
final bool chaptersLoaded;
@@ -114,7 +114,7 @@ class MobileVideoControls extends StatelessWidget {
Widget _buildPlaybackControls(BuildContext context) {
return StreamBuilder<bool>(
stream: player.stream.playing,
stream: player.streams.playing,
initialData: player.state.playing,
builder: (context, snapshot) {
final isPlaying = snapshot.data ?? false;
@@ -214,11 +214,11 @@ class MobileVideoControls extends StatelessWidget {
child: Padding(
padding: const EdgeInsets.all(16),
child: StreamBuilder<Duration>(
stream: player.stream.position,
stream: player.streams.position,
initialData: player.state.position,
builder: (context, positionSnapshot) {
return StreamBuilder<Duration>(
stream: player.stream.duration,
stream: player.streams.duration,
initialData: player.state.duration,
builder: (context, durationSnapshot) {
final position = positionSnapshot.data ?? Duration.zero;
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:media_kit/media_kit.dart';
import '../../../mpv/mpv.dart';
import '../../../i18n/strings.g.dart';
import 'track_selection_sheet.dart';
@@ -7,10 +8,10 @@ import 'track_selection_sheet.dart';
class AudioTrackSheet {
static void show(
BuildContext context,
Player player, {
Function(AudioTrack)? onTrackChanged,
MpvPlayer player, {
Function(MpvAudioTrack)? onTrackChanged,
}) {
TrackSelectionSheet.show<AudioTrack>(
TrackSelectionSheet.show<MpvAudioTrack>(
context: context,
player: player,
title: t.videoControls.audioLabel,
@@ -28,12 +29,12 @@ class AudioTrackSheet {
if (audioTrack.codec != null && audioTrack.codec!.isNotEmpty) {
parts.add(audioTrack.codec!.toUpperCase());
}
if (audioTrack.channelscount != null) {
parts.add('${audioTrack.channelscount}ch');
if (audioTrack.channelsCount != null) {
parts.add('${audioTrack.channelsCount}ch');
}
return parts.isEmpty ? 'Audio Track ${index + 1}' : parts.join(' · ');
},
setTrack: (track) => player.setAudioTrack(track),
setTrack: (track) => player.selectAudioTrack(track),
onTrackChanged: onTrackChanged,
);
}
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:media_kit/media_kit.dart';
import '../../../mpv/mpv.dart';
import '../../../client/plex_client.dart';
import '../../../models/plex_media_info.dart';
import '../../../utils/duration_formatter.dart';
@@ -8,7 +9,7 @@ import 'base_video_control_sheet.dart';
/// Bottom sheet for selecting chapters
class ChapterSheet extends StatelessWidget {
final Player player;
final MpvPlayer player;
final List<PlexChapter> chapters;
final bool chaptersLoaded;
final String? serverId; // Server ID for the metadata these chapters belong to
@@ -23,7 +24,7 @@ class ChapterSheet extends StatelessWidget {
static void show(
BuildContext context,
Player player,
MpvPlayer player,
List<PlexChapter> chapters,
bool chaptersLoaded, {
String? serverId,
@@ -47,7 +48,7 @@ class ChapterSheet extends StatelessWidget {
@override
Widget build(BuildContext context) {
return StreamBuilder<Duration>(
stream: player.stream.position,
stream: player.streams.position,
initialData: player.state.position,
builder: (context, positionSnapshot) {
final currentPosition = positionSnapshot.data ?? Duration.zero;
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:media_kit/media_kit.dart';
import '../../../mpv/mpv.dart';
import '../../../i18n/strings.g.dart';
import 'track_selection_sheet.dart';
@@ -7,10 +8,10 @@ import 'track_selection_sheet.dart';
class SubtitleTrackSheet {
static void show(
BuildContext context,
Player player, {
Function(SubtitleTrack)? onTrackChanged,
MpvPlayer player, {
Function(MpvSubtitleTrack)? onTrackChanged,
}) {
TrackSelectionSheet.show<SubtitleTrack>(
TrackSelectionSheet.show<MpvSubtitleTrack>(
context: context,
player: player,
title: t.videoControls.subtitlesLabel,
@@ -41,10 +42,10 @@ class SubtitleTrackSheet {
}
return parts.isEmpty ? 'Track ${index + 1}' : parts.join(' · ');
},
setTrack: (track) => player.setSubtitleTrack(track),
setTrack: (track) => player.selectSubtitleTrack(track),
onTrackChanged: onTrackChanged,
showOffOption: true,
createOffTrack: () => SubtitleTrack.no(),
createOffTrack: () => MpvSubtitleTrack.off,
isOffTrack: (track) => track.id == 'no',
);
}
@@ -1,16 +1,17 @@
import 'package:flutter/material.dart';
import 'package:media_kit/media_kit.dart';
import '../../../mpv/mpv.dart';
import 'base_video_control_sheet.dart';
/// Generic track selection sheet for audio and subtitle tracks
///
/// Type parameter [T] should be either [AudioTrack] or [SubtitleTrack]
/// Type parameter [T] should be either [MpvAudioTrack] or [MpvSubtitleTrack]
class TrackSelectionSheet<T> extends StatelessWidget {
final Player player;
final MpvPlayer player;
final String title;
final IconData icon;
final List<T> Function(Tracks?) extractTracks;
final T Function(Track) getCurrentTrack;
final List<T> Function(MpvTracks?) extractTracks;
final T? Function(MpvTrack) getCurrentTrack;
final String Function(T track, int index) buildLabel;
final void Function(T track) setTrack;
final Function(T)? onTrackChanged;
@@ -35,11 +36,11 @@ class TrackSelectionSheet<T> extends StatelessWidget {
static void show<T>({
required BuildContext context,
required Player player,
required MpvPlayer player,
required String title,
required IconData icon,
required List<T> Function(Tracks?) extractTracks,
required T Function(Track) getCurrentTrack,
required List<T> Function(MpvTracks?) extractTracks,
required T? Function(MpvTrack) getCurrentTrack,
required String Function(T track, int index) buildLabel,
required void Function(T track) setTrack,
Function(T)? onTrackChanged,
@@ -66,9 +67,9 @@ class TrackSelectionSheet<T> extends StatelessWidget {
}
String _getEmptyMessage() {
if (T == SubtitleTrack) {
if (T == MpvSubtitleTrack) {
return 'No subtitles available';
} else if (T == AudioTrack) {
} else if (T == MpvAudioTrack) {
return 'No audio tracks available';
}
return 'No tracks available';
@@ -76,16 +77,16 @@ class TrackSelectionSheet<T> extends StatelessWidget {
@override
Widget build(BuildContext context) {
return StreamBuilder<Tracks>(
stream: player.stream.tracks,
return StreamBuilder<MpvTracks>(
stream: player.streams.tracks,
initialData: player.state.tracks,
builder: (context, snapshot) {
final tracks = snapshot.data;
final availableTracks = extractTracks(tracks).where((track) {
// Filter out 'auto' and 'no' tracks from the list
if (track is AudioTrack) {
if (track is MpvAudioTrack) {
return track.id != 'auto' && track.id != 'no';
} else if (track is SubtitleTrack) {
} else if (track is MpvSubtitleTrack) {
return track.id != 'auto' && track.id != 'no';
}
return true;
@@ -101,17 +102,17 @@ class TrackSelectionSheet<T> extends StatelessWidget {
style: const TextStyle(color: Colors.white70),
),
)
: StreamBuilder<Track>(
stream: player.stream.track,
: StreamBuilder<MpvTrack>(
stream: player.streams.track,
initialData: player.state.track,
builder: (context, selectedSnapshot) {
final currentTrack =
selectedSnapshot.data ?? player.state.track;
final selectedTrack = getCurrentTrack(currentTrack);
// Determine if "Off" is selected
final isOffSelected =
isOffTrack?.call(selectedTrack) ?? false;
// Determine if "Off" is selected (null or explicit off)
final isOffSelected = selectedTrack == null ||
(isOffTrack?.call(selectedTrack) ?? false);
final itemCount =
availableTracks.length + (showOffOption ? 1 : 0);
@@ -150,18 +151,20 @@ class TrackSelectionSheet<T> extends StatelessWidget {
// Check if this track is selected
String trackId;
if (track is AudioTrack) {
if (track is MpvAudioTrack) {
trackId = track.id;
} else if (track is SubtitleTrack) {
} else if (track is MpvSubtitleTrack) {
trackId = track.id;
} else {
trackId = '';
}
String selectedId;
if (selectedTrack is AudioTrack) {
if (selectedTrack == null) {
selectedId = '';
} else if (selectedTrack is MpvAudioTrack) {
selectedId = selectedTrack.id;
} else if (selectedTrack is SubtitleTrack) {
} else if (selectedTrack is MpvSubtitleTrack) {
selectedId = selectedTrack.id;
} else {
selectedId = '';
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:media_kit/media_kit.dart';
import '../../../mpv/mpv.dart';
import '../../../services/settings_service.dart';
import '../../../services/sleep_timer_service.dart';
import '../../../utils/platform_detector.dart';
@@ -58,7 +59,7 @@ class _SettingsMenuItem extends StatelessWidget {
/// Unified settings sheet for playback adjustments with in-sheet navigation
class VideoSettingsSheet extends StatefulWidget {
final Player player;
final MpvPlayer player;
final int audioSyncOffset;
final int subtitleSyncOffset;
@@ -71,7 +72,7 @@ class VideoSettingsSheet extends StatefulWidget {
static Future<void> show(
BuildContext context,
Player player,
MpvPlayer player,
int audioSyncOffset,
int subtitleSyncOffset,
) {
@@ -200,7 +201,7 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
children: [
// Playback Speed
StreamBuilder<double>(
stream: widget.player.stream.rate,
stream: widget.player.streams.rate,
initialData: widget.player.state.rate,
builder: (context, snapshot) {
final currentRate = snapshot.data ?? 1.0;
@@ -248,8 +249,8 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
// Audio Output Device (Desktop only)
if (isDesktop)
StreamBuilder<AudioDevice>(
stream: widget.player.stream.audioDevice,
StreamBuilder<MpvAudioDevice>(
stream: widget.player.streams.audioDevice,
initialData: widget.player.state.audioDevice,
builder: (context, snapshot) {
final currentDevice =
@@ -273,7 +274,7 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
Widget _buildSpeedView() {
return StreamBuilder<double>(
stream: widget.player.stream.rate,
stream: widget.player.streams.rate,
initialData: widget.player.state.rate,
builder: (context, snapshot) {
final currentRate = snapshot.data ?? 1.0;
@@ -352,14 +353,14 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
}
Widget _buildAudioDeviceView() {
return StreamBuilder<List<AudioDevice>>(
stream: widget.player.stream.audioDevices,
return StreamBuilder<List<MpvAudioDevice>>(
stream: widget.player.streams.audioDevices,
initialData: widget.player.state.audioDevices,
builder: (context, snapshot) {
final devices = snapshot.data ?? [];
return StreamBuilder<AudioDevice>(
stream: widget.player.stream.audioDevice,
return StreamBuilder<MpvAudioDevice>(
stream: widget.player.streams.audioDevice,
initialData: widget.player.state.audioDevice,
builder: (context, selectedSnapshot) {
final currentDevice =
+33 -9
View File
@@ -5,9 +5,10 @@ import 'package:flutter/material.dart';
import 'package:rate_limiter/rate_limiter.dart';
import 'package:flutter/services.dart' show SystemChrome, DeviceOrientation;
import 'package:macos_window_utils/macos_window_utils.dart';
import 'package:media_kit/media_kit.dart';
import 'package:window_manager/window_manager.dart';
import '../../mpv/mpv.dart';
import '../../client/plex_client.dart';
import '../../models/plex_media_info.dart';
import '../../models/plex_media_version.dart';
@@ -27,7 +28,7 @@ import 'desktop_video_controls.dart';
/// Custom video controls builder for Plex with chapter, audio, and subtitle support
Widget plexVideoControlsBuilder(
Player player,
MpvPlayer player,
PlexMetadata metadata, {
VoidCallback? onNext,
VoidCallback? onPrevious,
@@ -35,8 +36,8 @@ Widget plexVideoControlsBuilder(
int? selectedMediaIndex,
int boxFitMode = 0,
VoidCallback? onCycleBoxFitMode,
Function(AudioTrack)? onAudioTrackChanged,
Function(SubtitleTrack)? onSubtitleTrackChanged,
Function(MpvAudioTrack)? onAudioTrackChanged,
Function(MpvSubtitleTrack)? onSubtitleTrackChanged,
}) {
return PlexVideoControls(
player: player,
@@ -53,7 +54,7 @@ Widget plexVideoControlsBuilder(
}
class PlexVideoControls extends StatefulWidget {
final Player player;
final MpvPlayer player;
final PlexMetadata metadata;
final VoidCallback? onNext;
final VoidCallback? onPrevious;
@@ -61,8 +62,8 @@ class PlexVideoControls extends StatefulWidget {
final int selectedMediaIndex;
final int boxFitMode;
final VoidCallback? onCycleBoxFitMode;
final Function(AudioTrack)? onAudioTrackChanged;
final Function(SubtitleTrack)? onSubtitleTrackChanged;
final Function(MpvAudioTrack)? onAudioTrackChanged;
final Function(MpvSubtitleTrack)? onSubtitleTrackChanged;
const PlexVideoControls({
super.key,
@@ -114,6 +115,9 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
bool _markersLoaded = false;
// Playback state subscription for auto-hide timer
StreamSubscription<bool>? _playingSubscription;
// Window resize pause state
Timer? _resizeDebounceTimer;
bool _wasPlayingBeforeResize = false;
@override
void initState() {
@@ -145,7 +149,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
}
void _listenToPosition() {
widget.player.stream.position.listen((position) {
widget.player.streams.position.listen((position) {
if (_markers.isEmpty || !_markersLoaded) {
return;
}
@@ -170,7 +174,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
/// Listen to playback state changes to manage auto-hide timer on iOS/mobile
void _listenToPlayingState() {
_playingSubscription = widget.player.stream.playing.listen((isPlaying) {
_playingSubscription = widget.player.streams.playing.listen((isPlaying) {
if (isPlaying && _showControls) {
_startHideTimer();
} else if (!isPlaying) {
@@ -240,6 +244,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
void dispose() {
_hideTimer?.cancel();
_feedbackTimer?.cancel();
_resizeDebounceTimer?.cancel();
_seekThrottle.cancel();
_playingSubscription?.cancel();
_focusNode.dispose();
@@ -298,6 +303,25 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
}
}
@override
void onWindowResize() {
// Pause video while resizing to prevent lag
if (_resizeDebounceTimer == null && widget.player.state.playing) {
_wasPlayingBeforeResize = true;
widget.player.pause();
}
// Reset debounce timer - resume when resizing stops
_resizeDebounceTimer?.cancel();
_resizeDebounceTimer = Timer(const Duration(milliseconds: 300), () {
if (_wasPlayingBeforeResize && mounted) {
widget.player.play();
}
_wasPlayingBeforeResize = false;
_resizeDebounceTimer = null;
});
}
void _startHideTimer() {
_hideTimer?.cancel();
// Only auto-hide if playing
@@ -1,12 +1,13 @@
import 'package:flutter/material.dart';
import 'package:media_kit/media_kit.dart';
import '../../../mpv/mpv.dart';
import '../../../services/sleep_timer_service.dart';
import 'sleep_timer_active_status.dart';
import 'sleep_timer_duration_list.dart';
/// Shared UI for sleep timer selection and active status.
class SleepTimerContent extends StatelessWidget {
final Player player;
final MpvPlayer player;
final SleepTimerService sleepTimer;
final int? defaultDuration;
final VoidCallback? onCancel;
@@ -1,12 +1,13 @@
import 'package:flutter/material.dart';
import 'package:media_kit/media_kit.dart';
import '../../../mpv/mpv.dart';
import '../../../services/sleep_timer_service.dart';
import '../../../utils/duration_formatter.dart';
import '../../../i18n/strings.g.dart';
/// Widget displaying list of sleep timer durations for selection
class SleepTimerDurationList extends StatelessWidget {
final Player player;
final MpvPlayer player;
final SleepTimerService sleepTimer;
final int? defaultDuration;
@@ -1,11 +1,12 @@
import 'package:flutter/material.dart';
import 'package:media_kit/media_kit.dart';
import '../../../mpv/mpv.dart';
import '../../../i18n/strings.g.dart';
import '../../../utils/duration_formatter.dart';
/// Reusable widget for adjusting sync offsets (audio or subtitle)
class SyncOffsetControl extends StatefulWidget {
final Player player;
final MpvPlayer player;
final String propertyName; // 'audio-delay' or 'sub-delay'
final int initialOffset;
final String labelText; // 'Audio' or 'Subtitles'
@@ -44,11 +45,11 @@ class _SyncOffsetControlState extends State<SyncOffsetControl> {
}
Future<void> _applyOffset(double offsetMs) async {
// Convert milliseconds to seconds for media_kit
// Convert milliseconds to seconds for mpv
final offsetSeconds = offsetMs / 1000.0;
// Apply to player using setProperty
await (widget.player.platform as dynamic).setProperty(
await widget.player.setProperty(
widget.propertyName,
offsetSeconds.toString(),
);
@@ -1,8 +1,8 @@
import 'dart:io' show Platform;
import 'package:flutter/material.dart';
import 'package:media_kit/media_kit.dart';
import '../../../mpv/mpv.dart';
import '../../../models/plex_media_info.dart';
import '../../../models/plex_media_version.dart';
import '../../../services/sleep_timer_service.dart';
@@ -17,7 +17,7 @@ import '../video_control_button.dart';
/// Row of track and chapter control buttons for the video player
class TrackChapterControls extends StatelessWidget {
final Player player;
final MpvPlayer player;
final List<PlexChapter> chapters;
final bool chaptersLoaded;
final List<PlexMediaVersion> availableVersions;
@@ -31,8 +31,8 @@ class TrackChapterControls extends StatelessWidget {
final VoidCallback? onToggleRotationLock;
final VoidCallback? onToggleFullscreen;
final Function(int)? onSwitchVersion;
final Function(AudioTrack)? onAudioTrackChanged;
final Function(SubtitleTrack)? onSubtitleTrackChanged;
final Function(MpvAudioTrack)? onAudioTrackChanged;
final Function(MpvSubtitleTrack)? onSubtitleTrackChanged;
final VoidCallback? onLoadSeekTimes;
final String serverId;
@@ -60,8 +60,8 @@ class TrackChapterControls extends StatelessWidget {
@override
Widget build(BuildContext context) {
return StreamBuilder<Tracks>(
stream: player.stream.tracks,
return StreamBuilder<MpvTracks>(
stream: player.streams.tracks,
initialData: player.state.tracks,
builder: (context, snapshot) {
final tracks = snapshot.data;
@@ -175,7 +175,7 @@ class TrackChapterControls extends StatelessWidget {
);
}
bool _hasMultipleAudioTracks(Tracks? tracks) {
bool _hasMultipleAudioTracks(MpvTracks? tracks) {
if (tracks == null) return false;
final audioTracks = tracks.audio
.where((track) => track.id != 'auto' && track.id != 'no')
@@ -183,7 +183,7 @@ class TrackChapterControls extends StatelessWidget {
return audioTracks.length > 1;
}
bool _hasSubtitles(Tracks? tracks) {
bool _hasSubtitles(MpvTracks? tracks) {
if (tracks == null) return false;
final subtitles = tracks.subtitle
.where((track) => track.id != 'auto' && track.id != 'no')
@@ -1,22 +1,22 @@
import 'package:flutter/material.dart';
import 'package:media_kit/media_kit.dart';
import '../../../mpv/mpv.dart';
import '../../../services/settings_service.dart';
import '../../../i18n/strings.g.dart';
/// A volume control widget that displays a mute/unmute button and volume slider.
///
/// This widget integrates with [Player] to control volume and persists
/// This widget integrates with [MpvPlayer] to control volume and persists
/// the volume setting using [SettingsService].
class VolumeControl extends StatelessWidget {
final Player player;
final MpvPlayer player;
const VolumeControl({super.key, required this.player});
@override
Widget build(BuildContext context) {
return StreamBuilder<double>(
stream: player.stream.volume,
stream: player.streams.volume,
initialData: player.state.volume,
builder: (context, snapshot) {
final volume = snapshot.data ?? 100.0;
@@ -7,24 +7,15 @@
#include "generated_plugin_registrant.h"
#include <hotkey_manager_linux/hotkey_manager_linux_plugin.h>
#include <media_kit_libs_linux/media_kit_libs_linux_plugin.h>
#include <media_kit_video/media_kit_video_plugin.h>
#include <os_media_controls/os_media_controls_plugin.h>
#include <screen_retriever_linux/screen_retriever_linux_plugin.h>
#include <url_launcher_linux/url_launcher_plugin.h>
#include <volume_controller/volume_controller_plugin.h>
#include <window_manager/window_manager_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) hotkey_manager_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "HotkeyManagerLinuxPlugin");
hotkey_manager_linux_plugin_register_with_registrar(hotkey_manager_linux_registrar);
g_autoptr(FlPluginRegistrar) media_kit_libs_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "MediaKitLibsLinuxPlugin");
media_kit_libs_linux_plugin_register_with_registrar(media_kit_libs_linux_registrar);
g_autoptr(FlPluginRegistrar) media_kit_video_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "MediaKitVideoPlugin");
media_kit_video_plugin_register_with_registrar(media_kit_video_registrar);
g_autoptr(FlPluginRegistrar) os_media_controls_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "OsMediaControlsPlugin");
os_media_controls_plugin_register_with_registrar(os_media_controls_registrar);
@@ -34,9 +25,6 @@ void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
g_autoptr(FlPluginRegistrar) volume_controller_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "VolumeControllerPlugin");
volume_controller_plugin_register_with_registrar(volume_controller_registrar);
g_autoptr(FlPluginRegistrar) window_manager_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "WindowManagerPlugin");
window_manager_plugin_register_with_registrar(window_manager_registrar);
-3
View File
@@ -4,12 +4,9 @@
list(APPEND FLUTTER_PLUGIN_LIST
hotkey_manager_linux
media_kit_libs_linux
media_kit_video
os_media_controls
screen_retriever_linux
url_launcher_linux
volume_controller
window_manager
)
@@ -8,8 +8,6 @@ import Foundation
import connectivity_plus
import hotkey_manager_macos
import macos_window_utils
import media_kit_libs_macos_video
import media_kit_video
import os_media_controls
import package_info_plus
import path_provider_foundation
@@ -17,16 +15,12 @@ import screen_retriever_macos
import shared_preferences_foundation
import sqflite_darwin
import url_launcher_macos
import volume_controller
import wakelock_plus
import window_manager
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin"))
HotkeyManagerMacosPlugin.register(with: registry.registrar(forPlugin: "HotkeyManagerMacosPlugin"))
MacOSWindowUtilsPlugin.register(with: registry.registrar(forPlugin: "MacOSWindowUtilsPlugin"))
MediaKitLibsMacosVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitLibsMacosVideoPlugin"))
MediaKitVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitVideoPlugin"))
OsMediaControlsPlugin.register(with: registry.registrar(forPlugin: "OsMediaControlsPlugin"))
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
@@ -34,7 +28,5 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
VolumeControllerPlugin.register(with: registry.registrar(forPlugin: "VolumeControllerPlugin"))
WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin"))
WindowManagerPlugin.register(with: registry.registrar(forPlugin: "WindowManagerPlugin"))
}
+1 -1
View File
@@ -1,4 +1,4 @@
platform :osx, '10.15'
platform :osx, '14.0'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
+1 -25
View File
@@ -8,10 +8,6 @@ PODS:
- HotKey
- macos_window_utils (1.0.0):
- FlutterMacOS
- media_kit_libs_macos_video (1.0.4):
- FlutterMacOS
- media_kit_video (0.0.1):
- FlutterMacOS
- os_media_controls (0.0.1):
- FlutterMacOS
- package_info_plus (0.0.1):
@@ -29,10 +25,6 @@ PODS:
- FlutterMacOS
- url_launcher_macos (0.0.1):
- FlutterMacOS
- volume_controller (0.0.1):
- FlutterMacOS
- wakelock_plus (0.0.1):
- FlutterMacOS
- window_manager (0.2.0):
- FlutterMacOS
@@ -41,8 +33,6 @@ DEPENDENCIES:
- FlutterMacOS (from `Flutter/ephemeral`)
- hotkey_manager_macos (from `Flutter/ephemeral/.symlinks/plugins/hotkey_manager_macos/macos`)
- macos_window_utils (from `Flutter/ephemeral/.symlinks/plugins/macos_window_utils/macos`)
- media_kit_libs_macos_video (from `Flutter/ephemeral/.symlinks/plugins/media_kit_libs_macos_video/macos`)
- media_kit_video (from `Flutter/ephemeral/.symlinks/plugins/media_kit_video/macos`)
- os_media_controls (from `Flutter/ephemeral/.symlinks/plugins/os_media_controls/macos`)
- package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`)
- path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`)
@@ -50,8 +40,6 @@ DEPENDENCIES:
- shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`)
- sqflite_darwin (from `Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin`)
- url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`)
- volume_controller (from `Flutter/ephemeral/.symlinks/plugins/volume_controller/macos`)
- wakelock_plus (from `Flutter/ephemeral/.symlinks/plugins/wakelock_plus/macos`)
- window_manager (from `Flutter/ephemeral/.symlinks/plugins/window_manager/macos`)
SPEC REPOS:
@@ -67,10 +55,6 @@ EXTERNAL SOURCES:
:path: Flutter/ephemeral/.symlinks/plugins/hotkey_manager_macos/macos
macos_window_utils:
:path: Flutter/ephemeral/.symlinks/plugins/macos_window_utils/macos
media_kit_libs_macos_video:
:path: Flutter/ephemeral/.symlinks/plugins/media_kit_libs_macos_video/macos
media_kit_video:
:path: Flutter/ephemeral/.symlinks/plugins/media_kit_video/macos
os_media_controls:
:path: Flutter/ephemeral/.symlinks/plugins/os_media_controls/macos
package_info_plus:
@@ -85,10 +69,6 @@ EXTERNAL SOURCES:
:path: Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin
url_launcher_macos:
:path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos
volume_controller:
:path: Flutter/ephemeral/.symlinks/plugins/volume_controller/macos
wakelock_plus:
:path: Flutter/ephemeral/.symlinks/plugins/wakelock_plus/macos
window_manager:
:path: Flutter/ephemeral/.symlinks/plugins/window_manager/macos
@@ -98,8 +78,6 @@ SPEC CHECKSUMS:
HotKey: 400beb7caa29054ea8d864c96f5ba7e5b4852277
hotkey_manager_macos: a4317849af96d2430fa89944d3c58977ca089fbe
macos_window_utils: 23f54331a0fd51eea9e0ed347253bf48fd379d1d
media_kit_libs_macos_video: 85a23e549b5f480e72cae3e5634b5514bc692f65
media_kit_video: fa6564e3799a0a28bff39442334817088b7ca758
os_media_controls: c07c04c4afdf59dda0a3f398457a46823c4ce0ed
package_info_plus: f0052d280d17aa382b932f399edf32507174e870
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
@@ -107,10 +85,8 @@ SPEC CHECKSUMS:
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
url_launcher_macos: f87a979182d112f911de6820aefddaf56ee9fbfd
volume_controller: 5c068e6d085c80dadd33fc2c918d2114b775b3dd
wakelock_plus: 917609be14d812ddd9e9528876538b2263aaa03b
window_manager: 1d01fa7ac65a6e6f83b965471b1a7fdd3f06166c
PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009
PODFILE CHECKSUM: d16f5e5d196d1ca9863d7a71d5885cad7ffa7d2d
COCOAPODS: 1.16.2
+43 -3
View File
@@ -26,7 +26,10 @@
33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; };
33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; };
33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; };
6AB6B0952EDB27C100EAC8DB /* MPVKit-GPL in Frameworks */ = {isa = PBXBuildFile; productRef = 6AB6B0942EDB27C100EAC8DB /* MPVKit-GPL */; };
6AC86ED72EA70B4C0067BC66 /* plezy.icon in Resources */ = {isa = PBXBuildFile; fileRef = 6AC86ED62EA70B4C0067BC66 /* plezy.icon */; };
6AD8B1612ED7B50000E9E1B4 /* MpvPlayerCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AD8B1632ED7B50000E9E1B4 /* MpvPlayerCore.swift */; };
6AD8B1622ED7B50000E9E1B4 /* MpvPlayerPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AD8B1642ED7B50000E9E1B4 /* MpvPlayerPlugin.swift */; };
9AECA605E2E1BECE3CA5BD01 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 78F9F23331B540A25C4A70C7 /* Pods_Runner.framework */; };
FE52EE1D2D489FA88507D14E /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 84260356652A3270E664FED4 /* Pods_RunnerTests.framework */; };
/* End PBXBuildFile section */
@@ -81,6 +84,8 @@
4E4BFEE5F31FF1CC308F1693 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
5EBEC2B546108DF45BEB0B00 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = "<group>"; };
6AC86ED62EA70B4C0067BC66 /* plezy.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = plezy.icon; sourceTree = "<group>"; };
6AD8B1632ED7B50000E9E1B4 /* MpvPlayerCore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MpvPlayerCore.swift; sourceTree = "<group>"; };
6AD8B1642ED7B50000E9E1B4 /* MpvPlayerPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MpvPlayerPlugin.swift; sourceTree = "<group>"; };
78F9F23331B540A25C4A70C7 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = "<group>"; };
84260356652A3270E664FED4 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -104,6 +109,7 @@
buildActionMask = 2147483647;
files = (
9AECA605E2E1BECE3CA5BD01 /* Pods_Runner.framework in Frameworks */,
6AB6B0952EDB27C100EAC8DB /* MPVKit-GPL in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -190,6 +196,7 @@
children = (
33CC10F02044A3C60003C045 /* AppDelegate.swift */,
33CC11122044BFA00003C045 /* MainFlutterWindow.swift */,
6AD8B1652ED7B50000E9E1B4 /* MpvPlayer */,
33E51913231747F40026EE4D /* DebugProfile.entitlements */,
33E51914231749380026EE4D /* Release.entitlements */,
33CC11242044D66E0003C045 /* Resources */,
@@ -207,6 +214,15 @@
name = Frameworks;
sourceTree = "<group>";
};
6AD8B1652ED7B50000E9E1B4 /* MpvPlayer */ = {
isa = PBXGroup;
children = (
6AD8B1632ED7B50000E9E1B4 /* MpvPlayerCore.swift */,
6AD8B1642ED7B50000E9E1B4 /* MpvPlayerPlugin.swift */,
);
path = MpvPlayer;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
@@ -291,6 +307,9 @@
Base,
);
mainGroup = 33CC10E42044A3C60003C045;
packageReferences = (
6AB6B0932EDB27C100EAC8DB /* XCRemoteSwiftPackageReference "MPVKit" */,
);
productRefGroup = 33CC10EE2044A3C60003C045 /* Products */;
projectDirPath = "";
projectRoot = "";
@@ -439,6 +458,8 @@
33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */,
33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */,
335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */,
6AD8B1612ED7B50000E9E1B4 /* MpvPlayerCore.swift in Sources */,
6AD8B1622ED7B50000E9E1B4 /* MpvPlayerPlugin.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -556,7 +577,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
MACOSX_DEPLOYMENT_TARGET = 10.15;
MACOSX_DEPLOYMENT_TARGET = 14.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = macosx;
SWIFT_COMPILATION_MODE = wholemodule;
@@ -638,7 +659,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
MACOSX_DEPLOYMENT_TARGET = 10.15;
MACOSX_DEPLOYMENT_TARGET = 14.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = macosx;
@@ -688,7 +709,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
MACOSX_DEPLOYMENT_TARGET = 10.15;
MACOSX_DEPLOYMENT_TARGET = 14.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = macosx;
SWIFT_COMPILATION_MODE = wholemodule;
@@ -795,6 +816,25 @@
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
/* Begin XCRemoteSwiftPackageReference section */
6AB6B0932EDB27C100EAC8DB /* XCRemoteSwiftPackageReference "MPVKit" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/edde746/MPVKit";
requirement = {
kind = revision;
revision = a8ae0d2f08df6188c662b7670bddb97131db8c5a;
};
};
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
6AB6B0942EDB27C100EAC8DB /* MPVKit-GPL */ = {
isa = XCSwiftPackageProductDependency;
package = 6AB6B0932EDB27C100EAC8DB /* XCRemoteSwiftPackageReference "MPVKit" */;
productName = "MPVKit-GPL";
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = 33CC10E52044A3C60003C045 /* Project object */;
}
@@ -0,0 +1,14 @@
{
"originHash" : "c535d3cdb62bd4e11e67f3c9e68290fa7ee9306634c5f56d2b0396eb75ed41ee",
"pins" : [
{
"identity" : "mpvkit",
"kind" : "remoteSourceControl",
"location" : "https://github.com/edde746/MPVKit",
"state" : {
"revision" : "a8ae0d2f08df6188c662b7670bddb97131db8c5a"
}
}
],
"version" : 3
}
@@ -0,0 +1,14 @@
{
"originHash" : "c535d3cdb62bd4e11e67f3c9e68290fa7ee9306634c5f56d2b0396eb75ed41ee",
"pins" : [
{
"identity" : "mpvkit",
"kind" : "remoteSourceControl",
"location" : "https://github.com/edde746/MPVKit",
"state" : {
"revision" : "a8ae0d2f08df6188c662b7670bddb97131db8c5a"
}
}
],
"version" : 3
}
+8
View File
@@ -4,10 +4,18 @@ import FlutterMacOS
class MainFlutterWindow: NSWindow {
override func awakeFromNib() {
let flutterViewController = FlutterViewController()
// Enable transparency for Metal layer behind Flutter
self.backgroundColor = NSColor.clear
flutterViewController.backgroundColor = NSColor.clear
let windowFrame = self.frame
self.contentViewController = flutterViewController
self.setFrame(windowFrame, display: true)
// Register MPV player plugin for video playback
MpvPlayerPlugin.register(with: flutterViewController.registrar(forPlugin: "MpvPlayerPlugin"))
RegisterGeneratedPlugins(registry: flutterViewController)
super.awakeFromNib()
+382
View File
@@ -0,0 +1,382 @@
import Cocoa
import Libmpv
/// Protocol for receiving player events
protocol MpvPlayerDelegate: AnyObject {
func onPropertyChange(name: String, value: Any?)
func onEvent(name: String, data: [String: Any]?)
}
// Workaround for MoltenVK problems that cause flicker
// https://github.com/mpv-player/mpv/pull/13651
private class MetalLayer: CAMetalLayer {
override var drawableSize: CGSize {
get { return super.drawableSize }
set {
if Int(newValue.width) > 1 && Int(newValue.height) > 1 {
super.drawableSize = newValue
}
}
}
// Fix for target-colorspace-hint - needs main thread for EDR
override var wantsExtendedDynamicRangeContent: Bool {
get { return super.wantsExtendedDynamicRangeContent }
set {
if Thread.isMainThread {
super.wantsExtendedDynamicRangeContent = newValue
} else {
DispatchQueue.main.sync {
super.wantsExtendedDynamicRangeContent = newValue
}
}
}
}
}
/// Core MPV player using Metal rendering
class MpvPlayerCore: NSObject {
// MARK: - Properties
private var metalLayer: MetalLayer?
private var mpv: OpaquePointer?
private weak var window: NSWindow?
private lazy var queue = DispatchQueue(label: "mpv", qos: .userInitiated)
weak var delegate: MpvPlayerDelegate?
private(set) var isInitialized = false
// MARK: - Initialization
func initialize(in window: NSWindow) -> Bool {
guard !isInitialized else {
print("[MpvPlayerCore] Already initialized")
return true
}
guard let contentView = window.contentView else {
print("[MpvPlayerCore] No content view")
return false
}
self.window = window
// Create Metal layer for video rendering
let layer = MetalLayer()
layer.frame = contentView.bounds
if let screen = window.screen ?? NSScreen.main {
layer.contentsScale = screen.backingScaleFactor
}
layer.framebufferOnly = true
layer.autoresizingMask = [.layerWidthSizable, .layerHeightSizable]
metalLayer = layer
// Ensure contentView has a layer and add our Metal layer
contentView.wantsLayer = true
contentView.layer?.addSublayer(layer)
print("[MpvPlayerCore] Metal layer added, frame: \(layer.frame)")
// Initialize MPV with this Metal layer
guard setupMpv() else {
print("[MpvPlayerCore] Failed to setup MPV")
layer.removeFromSuperlayer()
metalLayer = nil
return false
}
isInitialized = true
print("[MpvPlayerCore] Initialized successfully with MPV")
return true
}
private func setupMpv() -> Bool {
guard let metalLayer = metalLayer else { return false }
mpv = mpv_create()
guard mpv != nil else {
print("[MpvPlayerCore] Failed to create MPV context")
return false
}
// Logging
#if DEBUG
checkError(mpv_request_log_messages(mpv, "info"))
#else
checkError(mpv_request_log_messages(mpv, "warn"))
#endif
// Set the Metal layer as the render target (must use local var for &)
var layer = metalLayer
checkError(mpv_set_option(mpv, "wid", MPV_FORMAT_INT64, &layer))
// Video output settings for Metal/Vulkan
checkError(mpv_set_option_string(mpv, "vo", "gpu-next"))
checkError(mpv_set_option_string(mpv, "gpu-api", "vulkan"))
checkError(mpv_set_option_string(mpv, "gpu-context", "moltenvk"))
checkError(mpv_set_option_string(mpv, "hwdec", "videotoolbox"))
// Initialize MPV
let initResult = mpv_initialize(mpv)
if initResult < 0 {
print("[MpvPlayerCore] mpv_initialize failed: \(String(cString: mpv_error_string(initResult)))")
mpv_terminate_destroy(mpv)
mpv = nil
return false
}
// Set up wakeup callback for event handling
mpv_set_wakeup_callback(mpv, { ctx in
let core = Unmanaged<MpvPlayerCore>.fromOpaque(ctx!).takeUnretainedValue()
core.readEvents()
}, UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque()))
print("[MpvPlayerCore] MPV initialized successfully")
return true
}
// MARK: - MPV Properties and Commands
func setProperty(_ name: String, value: String) {
guard mpv != nil else { return }
mpv_set_property_string(mpv, name, value)
}
func getProperty(_ name: String) -> String? {
guard mpv != nil else { return nil }
let cstr = mpv_get_property_string(mpv, name)
defer { mpv_free(cstr) }
return cstr.map { String(cString: $0) }
}
func observeProperty(_ name: String, format: String) {
guard mpv != nil else { return }
let mpvFormat: mpv_format
switch format {
case "double": mpvFormat = MPV_FORMAT_DOUBLE
case "flag": mpvFormat = MPV_FORMAT_FLAG
case "node": mpvFormat = MPV_FORMAT_NODE
case "string": mpvFormat = MPV_FORMAT_STRING
default: return
}
mpv_observe_property(mpv, 0, name, mpvFormat)
}
func command(_ args: [String]) {
guard mpv != nil, !args.isEmpty else { return }
command(args[0], args: Array(args.dropFirst()))
}
// MARK: - Visibility
func setVisible(_ visible: Bool) {
guard let layer = metalLayer else { return }
if visible {
// Re-insert after background layer but before Flutter control views
layer.removeFromSuperlayer()
if let superlayer = window?.contentView?.layer {
superlayer.insertSublayer(layer, at: 1)
}
}
layer.isHidden = !visible
print("[MpvPlayerCore] setVisible(\(visible))")
}
func updateFrame(_ frame: CGRect? = nil) {
guard let metalLayer = metalLayer else { return }
if let frame = frame {
metalLayer.frame = frame
} else if let contentView = window?.contentView {
metalLayer.frame = contentView.bounds
}
// Update drawable size for proper scaling
if let screen = window?.screen ?? NSScreen.main {
let scale = screen.backingScaleFactor
metalLayer.drawableSize = CGSize(
width: metalLayer.frame.width * scale,
height: metalLayer.frame.height * scale
)
}
print("[MpvPlayerCore] updateFrame: \(metalLayer.frame)")
}
// MARK: - Private Helpers
private func command(_ cmd: String, args: [String] = []) {
guard mpv != nil else { return }
// Build array of C strings for mpv_command
var cargs: [UnsafeMutablePointer<CChar>?] = ([cmd] + args).map { strdup($0) }
cargs.append(nil) // null-terminate
defer {
for ptr in cargs {
free(ptr)
}
}
// mpv_command expects UnsafePointer, use withUnsafeBufferPointer for the conversion
cargs.withUnsafeBufferPointer { buffer in
var constPtrs = buffer.map { UnsafePointer($0) }
_ = mpv_command(mpv, &constPtrs)
}
}
private func readEvents() {
queue.async { [weak self] in
guard let self = self, let mpv = self.mpv else { return }
while true {
let event = mpv_wait_event(mpv, 0)
guard let eventPtr = event else { break }
if eventPtr.pointee.event_id == MPV_EVENT_NONE {
break
}
self.handleEvent(eventPtr.pointee)
}
}
}
private func handleEvent(_ event: mpv_event) {
switch event.event_id {
case MPV_EVENT_PROPERTY_CHANGE:
guard let data = event.data else { break }
let property = data.assumingMemoryBound(to: mpv_event_property.self).pointee
let name = String(cString: property.name)
handlePropertyChange(name: name, property: property)
case MPV_EVENT_FILE_LOADED:
DispatchQueue.main.async {
self.delegate?.onEvent(name: "file-loaded", data: nil)
}
case MPV_EVENT_END_FILE:
DispatchQueue.main.async {
self.delegate?.onEvent(name: "end-file", data: nil)
}
case MPV_EVENT_SHUTDOWN:
print("[MpvPlayerCore] MPV shutdown event")
case MPV_EVENT_LOG_MESSAGE:
if let msgPtr = event.data?.assumingMemoryBound(to: mpv_event_log_message.self) {
let msg = msgPtr.pointee
if let prefix = msg.prefix, let level = msg.level, let text = msg.text {
print("[MPV:\(String(cString: prefix))] \(String(cString: level)): \(String(cString: text))", terminator: "")
}
}
default:
break
}
}
private func handlePropertyChange(name: String, property: mpv_event_property) {
var value: Any?
switch property.format {
case MPV_FORMAT_DOUBLE:
if let ptr = property.data {
value = ptr.assumingMemoryBound(to: Double.self).pointee
}
case MPV_FORMAT_FLAG:
if let ptr = property.data {
value = ptr.assumingMemoryBound(to: Int32.self).pointee != 0
}
case MPV_FORMAT_NODE:
if let ptr = property.data {
let node = ptr.assumingMemoryBound(to: mpv_node.self).pointee
value = convertNode(node)
}
case MPV_FORMAT_STRING:
if let ptr = property.data {
let cstr = ptr.assumingMemoryBound(to: UnsafePointer<CChar>?.self).pointee
value = cstr.map { String(cString: $0) }
}
default:
break
}
DispatchQueue.main.async {
self.delegate?.onPropertyChange(name: name, value: value)
}
}
private func convertNode(_ node: mpv_node) -> Any? {
switch node.format {
case MPV_FORMAT_STRING:
return node.u.string.map { String(cString: $0) }
case MPV_FORMAT_FLAG:
return node.u.flag != 0
case MPV_FORMAT_INT64:
return node.u.int64
case MPV_FORMAT_DOUBLE:
return node.u.double_
case MPV_FORMAT_NODE_ARRAY:
guard let list = node.u.list?.pointee else { return nil }
var array = [Any]()
for i in 0..<Int(list.num) {
if let item = convertNode(list.values[i]) {
array.append(item)
}
}
return array
case MPV_FORMAT_NODE_MAP:
guard let list = node.u.list?.pointee else { return nil }
var dict = [String: Any]()
for i in 0..<Int(list.num) {
if let key = list.keys?[i].map({ String(cString: $0) }),
let val = convertNode(list.values[i]) {
dict[key] = val
}
}
return dict
default:
return nil
}
}
private func checkError(_ status: CInt) {
if status < 0 {
print("[MpvPlayerCore] MPV error: \(String(cString: mpv_error_string(status)))")
}
}
// MARK: - Cleanup
func dispose() {
if let mpv = mpv {
mpv_terminate_destroy(mpv)
self.mpv = nil
}
metalLayer?.removeFromSuperlayer()
metalLayer = nil
isInitialized = false
print("[MpvPlayerCore] Disposed")
}
deinit {
dispose()
}
}
@@ -0,0 +1,249 @@
import Cocoa
import FlutterMacOS
/// Flutter plugin that bridges MPV player to Dart via method and event channels
class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPlayerDelegate {
// MARK: - Properties
private var playerCore: MpvPlayerCore?
private var eventSink: FlutterEventSink?
private weak var registrar: FlutterPluginRegistrar?
// MARK: - FlutterPlugin Registration
static func register(with registrar: FlutterPluginRegistrar) {
// Method channel for commands
let methodChannel = FlutterMethodChannel(
name: "com.plezy/mpv_player",
binaryMessenger: registrar.messenger
)
// Event channel for state updates
let eventChannel = FlutterEventChannel(
name: "com.plezy/mpv_player/events",
binaryMessenger: registrar.messenger
)
let instance = MpvPlayerPlugin()
instance.registrar = registrar
registrar.addMethodCallDelegate(instance, channel: methodChannel)
eventChannel.setStreamHandler(instance)
print("[MpvPlayerPlugin] Registered with Flutter")
}
// MARK: - FlutterStreamHandler
func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? {
self.eventSink = events
print("[MpvPlayerPlugin] Event stream connected")
return nil
}
func onCancel(withArguments arguments: Any?) -> FlutterError? {
self.eventSink = nil
print("[MpvPlayerPlugin] Event stream disconnected")
return nil
}
// MARK: - FlutterPlugin Method Handler
func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method {
case "initialize":
handleInitialize(result: result)
case "dispose":
handleDispose(result: result)
case "setProperty":
handleSetProperty(call: call, result: result)
case "getProperty":
handleGetProperty(call: call, result: result)
case "observeProperty":
handleObserveProperty(call: call, result: result)
case "command":
handleCommand(call: call, result: result)
case "setVisible":
handleSetVisible(call: call, result: result)
case "isInitialized":
result(playerCore?.isInitialized ?? false)
default:
result(FlutterMethodNotImplemented)
}
}
// MARK: - Method Handlers
private func handleInitialize(result: @escaping FlutterResult) {
DispatchQueue.main.async { [weak self] in
guard let self = self else {
result(FlutterError(code: "ERROR", message: "Plugin deallocated", details: nil))
return
}
// Check if already initialized
if self.playerCore?.isInitialized == true {
print("[MpvPlayerPlugin] Already initialized")
result(true)
return
}
// Find the Flutter window
guard let (window, _, _) = self.findFlutterWindow() else {
print("[MpvPlayerPlugin] Failed to find Flutter window")
result(FlutterError(code: "NO_WINDOW", message: "Could not find Flutter window", details: nil))
return
}
// Create and initialize player core
let core = MpvPlayerCore()
core.delegate = self
guard core.initialize(in: window) else {
print("[MpvPlayerPlugin] Failed to initialize MPV")
result(FlutterError(code: "MPV_INIT_FAILED", message: "Failed to initialize MPV", details: nil))
return
}
self.playerCore = core
// Start hidden
core.setVisible(false)
print("[MpvPlayerPlugin] Initialized successfully")
result(true)
}
}
private func handleDispose(result: @escaping FlutterResult) {
DispatchQueue.main.async { [weak self] in
self?.playerCore?.dispose()
self?.playerCore = nil
print("[MpvPlayerPlugin] Disposed")
result(nil)
}
}
private func handleSetProperty(call: FlutterMethodCall, result: @escaping FlutterResult) {
guard let args = call.arguments as? [String: Any],
let name = args["name"] as? String,
let value = args["value"] as? String else {
result(FlutterError(code: "INVALID_ARGS", message: "Missing 'name' or 'value' argument", details: nil))
return
}
playerCore?.setProperty(name, value: value)
result(nil)
}
private func handleGetProperty(call: FlutterMethodCall, result: @escaping FlutterResult) {
guard let args = call.arguments as? [String: Any],
let name = args["name"] as? String else {
result(FlutterError(code: "INVALID_ARGS", message: "Missing 'name' argument", details: nil))
return
}
let value = playerCore?.getProperty(name)
result(value)
}
private func handleObserveProperty(call: FlutterMethodCall, result: @escaping FlutterResult) {
guard let args = call.arguments as? [String: Any],
let name = args["name"] as? String,
let format = args["format"] as? String else {
result(FlutterError(code: "INVALID_ARGS", message: "Missing 'name' or 'format' argument", details: nil))
return
}
playerCore?.observeProperty(name, format: format)
result(nil)
}
private func handleCommand(call: FlutterMethodCall, result: @escaping FlutterResult) {
guard let args = call.arguments as? [String: Any],
let commandArgs = args["args"] as? [String] else {
result(FlutterError(code: "INVALID_ARGS", message: "Missing 'args' argument", details: nil))
return
}
playerCore?.command(commandArgs)
result(nil)
}
private func handleSetVisible(call: FlutterMethodCall, result: @escaping FlutterResult) {
guard let args = call.arguments as? [String: Any],
let visible = args["visible"] as? Bool else {
result(FlutterError(code: "INVALID_ARGS", message: "Missing 'visible' argument", details: nil))
return
}
DispatchQueue.main.async { [weak self] in
self?.playerCore?.setVisible(visible)
// Update frame when becoming visible
if visible {
self?.playerCore?.updateFrame()
}
result(nil)
}
}
// MARK: - MpvPlayerDelegate
func onPropertyChange(name: String, value: Any?) {
guard let eventSink = eventSink else { return }
var event: [String: Any] = ["type": "property", "name": name]
if let value = value {
event["value"] = value
}
eventSink(event)
}
func onEvent(name: String, data: [String: Any]?) {
guard let eventSink = eventSink else { return }
var event: [String: Any] = ["type": "event", "name": name]
if let data = data {
event["data"] = data
}
eventSink(event)
}
// MARK: - Helpers
private func findFlutterWindow() -> (NSWindow, NSView, NSView)? {
for window in NSApplication.shared.windows {
if window is MainFlutterWindow,
let contentView = window.contentView,
let contentVC = window.contentViewController {
let flutterView = contentVC.view
return (window, contentView, flutterView)
}
}
// Fallback
for window in NSApplication.shared.windows {
if let contentView = window.contentView,
let contentVC = window.contentViewController {
let flutterView = contentVC.view
return (window, contentView, flutterView)
}
}
return nil
}
}
-136
View File
@@ -600,78 +600,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.11.1"
media_kit:
dependency: "direct main"
description:
path: media_kit
ref: dce2963936424ffc80309ef4bca7e95041dfbfef
resolved-ref: dce2963936424ffc80309ef4bca7e95041dfbfef
url: "https://github.com/edde746/media-kit"
source: git
version: "1.2.1"
media_kit_libs_android_video:
dependency: "direct overridden"
description:
path: "libs/android/media_kit_libs_android_video"
ref: dce2963936424ffc80309ef4bca7e95041dfbfef
resolved-ref: dce2963936424ffc80309ef4bca7e95041dfbfef
url: "https://github.com/edde746/media-kit"
source: git
version: "1.3.9"
media_kit_libs_ios_video:
dependency: "direct overridden"
description:
path: "libs/ios/media_kit_libs_ios_video"
ref: dce2963936424ffc80309ef4bca7e95041dfbfef
resolved-ref: dce2963936424ffc80309ef4bca7e95041dfbfef
url: "https://github.com/edde746/media-kit"
source: git
version: "1.1.4"
media_kit_libs_linux:
dependency: transitive
description:
path: "libs/linux/media_kit_libs_linux"
ref: dce2963936424ffc80309ef4bca7e95041dfbfef
resolved-ref: dce2963936424ffc80309ef4bca7e95041dfbfef
url: "https://github.com/edde746/media-kit"
source: git
version: "1.2.1"
media_kit_libs_macos_video:
dependency: "direct overridden"
description:
path: "libs/macos/media_kit_libs_macos_video"
ref: dce2963936424ffc80309ef4bca7e95041dfbfef
resolved-ref: dce2963936424ffc80309ef4bca7e95041dfbfef
url: "https://github.com/edde746/media-kit"
source: git
version: "1.1.5"
media_kit_libs_video:
dependency: "direct main"
description:
path: "libs/universal/media_kit_libs_video"
ref: dce2963936424ffc80309ef4bca7e95041dfbfef
resolved-ref: dce2963936424ffc80309ef4bca7e95041dfbfef
url: "https://github.com/edde746/media-kit"
source: git
version: "1.0.7"
media_kit_libs_windows_video:
dependency: transitive
description:
path: "libs/windows/media_kit_libs_windows_video"
ref: dce2963936424ffc80309ef4bca7e95041dfbfef
resolved-ref: dce2963936424ffc80309ef4bca7e95041dfbfef
url: "https://github.com/edde746/media-kit"
source: git
version: "1.0.12"
media_kit_video:
dependency: "direct main"
description:
path: media_kit_video
ref: dce2963936424ffc80309ef4bca7e95041dfbfef
resolved-ref: dce2963936424ffc80309ef4bca7e95041dfbfef
url: "https://github.com/edde746/media-kit"
source: git
version: "1.3.1"
meta:
dependency: transitive
description:
@@ -913,30 +841,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.28.0"
safe_local_storage:
dependency: transitive
description:
name: safe_local_storage
sha256: e9a21b6fec7a8aa62cc2585ff4c1b127df42f3185adbd2aca66b47abe2e80236
url: "https://pub.dev"
source: hosted
version: "2.0.1"
screen_brightness_android:
dependency: transitive
description:
name: screen_brightness_android
sha256: d34f5321abd03bc3474f4c381f53d189117eba0b039eac1916aa92cca5fd0a96
url: "https://pub.dev"
source: hosted
version: "2.1.3"
screen_brightness_platform_interface:
dependency: transitive
description:
name: screen_brightness_platform_interface
sha256: "737bd47b57746bc4291cab1b8a5843ee881af499514881b0247ec77447ee769c"
url: "https://pub.dev"
source: hosted
version: "2.1.0"
screen_retriever:
dependency: transitive
description:
@@ -1222,22 +1126,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.1.3"
universal_platform:
dependency: transitive
description:
name: universal_platform
sha256: "64e16458a0ea9b99260ceb5467a214c1f298d647c659af1bff6d3bf82536b1ec"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
uri_parser:
dependency: transitive
description:
name: uri_parser
sha256: ff4d2c720aca3f4f7d5445e23b11b2d15ef8af5ddce5164643f38ff962dcb270
url: "https://pub.dev"
source: hosted
version: "3.0.0"
url_launcher:
dependency: "direct main"
description:
@@ -1326,30 +1214,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "15.0.2"
volume_controller:
dependency: transitive
description:
name: volume_controller
sha256: d75039e69c0d90e7810bfd47e3eedf29ff8543ea7a10392792e81f9bded7edf5
url: "https://pub.dev"
source: hosted
version: "3.4.0"
wakelock_plus:
dependency: transitive
description:
name: wakelock_plus
sha256: "9296d40c9adbedaba95d1e704f4e0b434be446e2792948d0e4aa977048104228"
url: "https://pub.dev"
source: hosted
version: "1.4.0"
wakelock_plus_platform_interface:
dependency: transitive
description:
name: wakelock_plus_platform_interface
sha256: "036deb14cd62f558ca3b73006d52ce049fabcdcb2eddfe0bf0fe4e8a943b5cf2"
url: "https://pub.dev"
source: hosted
version: "1.3.0"
watcher:
dependency: transitive
description:
-35
View File
@@ -13,9 +13,6 @@ dependencies:
json_annotation: ^4.8.1
shared_preferences: ^2.2.2
cached_network_image: ^3.4.1
media_kit: ^1.1.10+1
media_kit_video: ^1.2.4
media_kit_libs_video: ^1.0.4
macos_window_utils: ^1.9.0
url_launcher: ^6.3.0
uuid: ^4.4.0
@@ -36,38 +33,6 @@ dependencies:
ref: 75dc5642cd148c54c03fe96976702b8d31c48599
rate_limiter: ^1.0.0
dependency_overrides:
media_kit:
git:
url: https://github.com/edde746/media-kit
ref: dce2963936424ffc80309ef4bca7e95041dfbfef
path: media_kit
media_kit_video:
git:
url: https://github.com/edde746/media-kit
ref: dce2963936424ffc80309ef4bca7e95041dfbfef
path: media_kit_video
media_kit_libs_video:
git:
url: https://github.com/edde746/media-kit
ref: dce2963936424ffc80309ef4bca7e95041dfbfef
path: libs/universal/media_kit_libs_video
media_kit_libs_macos_video:
git:
url: https://github.com/edde746/media-kit
ref: dce2963936424ffc80309ef4bca7e95041dfbfef
path: libs/macos/media_kit_libs_macos_video
media_kit_libs_ios_video:
git:
url: https://github.com/edde746/media-kit
ref: dce2963936424ffc80309ef4bca7e95041dfbfef
path: libs/ios/media_kit_libs_ios_video
media_kit_libs_android_video:
git:
url: https://github.com/edde746/media-kit
ref: dce2963936424ffc80309ef4bca7e95041dfbfef
path: libs/android/media_kit_libs_android_video
dev_dependencies:
flutter_test:
sdk: flutter
@@ -8,12 +8,9 @@
#include <connectivity_plus/connectivity_plus_windows_plugin.h>
#include <hotkey_manager_windows/hotkey_manager_windows_plugin_c_api.h>
#include <media_kit_libs_windows_video/media_kit_libs_windows_video_plugin_c_api.h>
#include <media_kit_video/media_kit_video_plugin_c_api.h>
#include <os_media_controls/os_media_controls_plugin_c_api.h>
#include <screen_retriever_windows/screen_retriever_windows_plugin_c_api.h>
#include <url_launcher_windows/url_launcher_windows.h>
#include <volume_controller/volume_controller_plugin_c_api.h>
#include <window_manager/window_manager_plugin.h>
void RegisterPlugins(flutter::PluginRegistry* registry) {
@@ -21,18 +18,12 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin"));
HotkeyManagerWindowsPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("HotkeyManagerWindowsPluginCApi"));
MediaKitLibsWindowsVideoPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("MediaKitLibsWindowsVideoPluginCApi"));
MediaKitVideoPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("MediaKitVideoPluginCApi"));
OsMediaControlsPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("OsMediaControlsPluginCApi"));
ScreenRetrieverWindowsPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("ScreenRetrieverWindowsPluginCApi"));
UrlLauncherWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
VolumeControllerPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("VolumeControllerPluginCApi"));
WindowManagerPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("WindowManagerPlugin"));
}
-3
View File
@@ -5,12 +5,9 @@
list(APPEND FLUTTER_PLUGIN_LIST
connectivity_plus
hotkey_manager_windows
media_kit_libs_windows_video
media_kit_video
os_media_controls
screen_retriever_windows
url_launcher_windows
volume_controller
window_manager
)