macos
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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)';
|
||||
}
|
||||
@@ -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})';
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user