macos
This commit is contained in:
@@ -1,6 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'dart:io' show Platform;
|
import 'dart:io' show Platform;
|
||||||
import 'package:media_kit/media_kit.dart';
|
|
||||||
import 'package:window_manager/window_manager.dart';
|
import 'package:window_manager/window_manager.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
@@ -49,9 +48,6 @@ void main() async {
|
|||||||
// Configure macOS window with custom titlebar
|
// Configure macOS window with custom titlebar
|
||||||
await MacOSTitlebarService.setupCustomTitlebar();
|
await MacOSTitlebarService.setupCustomTitlebar();
|
||||||
|
|
||||||
// Initialize MediaKit
|
|
||||||
MediaKit.ensureInitialized();
|
|
||||||
|
|
||||||
// Note: Orientation will be set dynamically based on device type in MainApp
|
// Note: Orientation will be set dynamically based on device type in MainApp
|
||||||
|
|
||||||
await StorageService.getInstance();
|
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/material.dart';
|
||||||
import 'package:flutter/services.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:os_media_controls/os_media_controls.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import '../mpv/mpv.dart';
|
||||||
|
|
||||||
import '../client/plex_client.dart';
|
import '../client/plex_client.dart';
|
||||||
import '../models/plex_media_version.dart';
|
import '../models/plex_media_version.dart';
|
||||||
import '../models/plex_metadata.dart';
|
import '../models/plex_metadata.dart';
|
||||||
@@ -28,8 +28,8 @@ import '../i18n/strings.g.dart';
|
|||||||
|
|
||||||
class VideoPlayerScreen extends StatefulWidget {
|
class VideoPlayerScreen extends StatefulWidget {
|
||||||
final PlexMetadata metadata;
|
final PlexMetadata metadata;
|
||||||
final AudioTrack? preferredAudioTrack;
|
final MpvAudioTrack? preferredAudioTrack;
|
||||||
final SubtitleTrack? preferredSubtitleTrack;
|
final MpvSubtitleTrack? preferredSubtitleTrack;
|
||||||
final double? preferredPlaybackRate;
|
final double? preferredPlaybackRate;
|
||||||
final int selectedMediaIndex;
|
final int selectedMediaIndex;
|
||||||
|
|
||||||
@@ -48,8 +48,7 @@ class VideoPlayerScreen extends StatefulWidget {
|
|||||||
|
|
||||||
class VideoPlayerScreenState extends State<VideoPlayerScreen>
|
class VideoPlayerScreenState extends State<VideoPlayerScreen>
|
||||||
with WidgetsBindingObserver {
|
with WidgetsBindingObserver {
|
||||||
Player? player;
|
MpvPlayer? player;
|
||||||
VideoController? controller;
|
|
||||||
bool _isPlayerInitialized = false;
|
bool _isPlayerInitialized = false;
|
||||||
PlexMetadata? _nextEpisode;
|
PlexMetadata? _nextEpisode;
|
||||||
PlexMetadata? _previousEpisode;
|
PlexMetadata? _previousEpisode;
|
||||||
@@ -57,13 +56,13 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
|
|||||||
bool _showPlayNextDialog = false;
|
bool _showPlayNextDialog = false;
|
||||||
bool _isPhone = false;
|
bool _isPhone = false;
|
||||||
List<PlexMediaVersion> _availableVersions = [];
|
List<PlexMediaVersion> _availableVersions = [];
|
||||||
StreamSubscription<PlayerLog>? _logSubscription;
|
StreamSubscription<MpvLog>? _logSubscription;
|
||||||
StreamSubscription<String>? _errorSubscription;
|
StreamSubscription<String>? _errorSubscription;
|
||||||
StreamSubscription<bool>? _playingSubscription;
|
StreamSubscription<bool>? _playingSubscription;
|
||||||
StreamSubscription<bool>? _completedSubscription;
|
StreamSubscription<bool>? _completedSubscription;
|
||||||
StreamSubscription<dynamic>? _mediaControlSubscription;
|
StreamSubscription<dynamic>? _mediaControlSubscription;
|
||||||
StreamSubscription<bool>? _bufferingSubscription;
|
StreamSubscription<bool>? _bufferingSubscription;
|
||||||
StreamSubscription<Tracks>? _trackLoadingSubscription;
|
StreamSubscription<MpvTracks>? _trackLoadingSubscription;
|
||||||
bool _isReplacingWithVideo =
|
bool _isReplacingWithVideo =
|
||||||
false; // Flag to skip orientation restoration during video-to-video navigation
|
false; // Flag to skip orientation restoration during video-to-video navigation
|
||||||
|
|
||||||
@@ -222,57 +221,44 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
|
|||||||
.getEnableHardwareDecoding();
|
.getEnableHardwareDecoding();
|
||||||
final debugLoggingEnabled = settingsService.getEnableDebugLogging();
|
final debugLoggingEnabled = settingsService.getEnableDebugLogging();
|
||||||
|
|
||||||
// Build MPV configuration
|
// Create player
|
||||||
final config = <String, String>{
|
player = MpvPlayer();
|
||||||
'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',
|
|
||||||
};
|
|
||||||
|
|
||||||
|
// 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) {
|
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
|
// Apply audio sync offset
|
||||||
final audioSyncOffset = settingsService.getAudioSyncOffset();
|
final audioSyncOffset = settingsService.getAudioSyncOffset();
|
||||||
if (audioSyncOffset != 0) {
|
if (audioSyncOffset != 0) {
|
||||||
final offsetSeconds = audioSyncOffset / 1000.0;
|
final offsetSeconds = audioSyncOffset / 1000.0;
|
||||||
await (player!.platform as dynamic).setProperty(
|
await player!.setProperty('audio-delay', offsetSeconds.toString());
|
||||||
'audio-delay',
|
|
||||||
offsetSeconds.toString(),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply subtitle sync offset
|
// Apply subtitle sync offset
|
||||||
final subtitleSyncOffset = settingsService.getSubtitleSyncOffset();
|
final subtitleSyncOffset = settingsService.getSubtitleSyncOffset();
|
||||||
if (subtitleSyncOffset != 0) {
|
if (subtitleSyncOffset != 0) {
|
||||||
final offsetSeconds = subtitleSyncOffset / 1000.0;
|
final offsetSeconds = subtitleSyncOffset / 1000.0;
|
||||||
await (player!.platform as dynamic).setProperty(
|
await player!.setProperty('sub-delay', offsetSeconds.toString());
|
||||||
'sub-delay',
|
|
||||||
offsetSeconds.toString(),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply saved volume
|
// Apply saved volume
|
||||||
@@ -310,23 +296,23 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Listen to playback state changes
|
// Listen to playback state changes
|
||||||
_playingSubscription = player!.stream.playing.listen(
|
_playingSubscription = player!.streams.playing.listen(
|
||||||
_onPlayingStateChanged,
|
_onPlayingStateChanged,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Listen to completion
|
// Listen to completion
|
||||||
_completedSubscription = player!.stream.completed.listen(
|
_completedSubscription = player!.streams.completed.listen(
|
||||||
_onVideoCompleted,
|
_onVideoCompleted,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Listen to MPV logs
|
// Listen to MPV logs
|
||||||
_logSubscription = player!.stream.log.listen(_onPlayerLog);
|
_logSubscription = player!.streams.log.listen(_onPlayerLog);
|
||||||
|
|
||||||
// Listen to MPV errors
|
// Listen to MPV errors
|
||||||
_errorSubscription = player!.stream.error.listen(_onPlayerError);
|
_errorSubscription = player!.streams.error.listen(_onPlayerError);
|
||||||
|
|
||||||
// Listen to buffering state
|
// Listen to buffering state
|
||||||
_bufferingSubscription = player!.stream.buffering.listen((isBuffering) {
|
_bufferingSubscription = player!.streams.buffering.listen((isBuffering) {
|
||||||
_isBuffering.value = isBuffering;
|
_isBuffering.value = isBuffering;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -424,12 +410,12 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Listen to playing state and update media controls
|
// Listen to playing state and update media controls
|
||||||
player!.stream.playing.listen((isPlaying) {
|
player!.streams.playing.listen((isPlaying) {
|
||||||
_updateMediaControlsPlaybackState();
|
_updateMediaControlsPlaybackState();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Listen to position updates for media controls
|
// Listen to position updates for media controls
|
||||||
player!.stream.position.listen((position) {
|
player!.streams.position.listen((position) {
|
||||||
_mediaControlsManager?.updatePlaybackState(
|
_mediaControlsManager?.updatePlaybackState(
|
||||||
isPlaying: player!.state.playing,
|
isPlaying: player!.state.playing,
|
||||||
position: position,
|
position: position,
|
||||||
@@ -542,26 +528,22 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
|
|||||||
// Use server-specific client for this metadata
|
// Use server-specific client for this metadata
|
||||||
final client = _getClientForMetadata(context);
|
final client = _getClientForMetadata(context);
|
||||||
|
|
||||||
// Capture profile settings before async gap
|
|
||||||
final profileSettings = context.profileSettings;
|
|
||||||
|
|
||||||
// Initialize playback service
|
// Initialize playback service
|
||||||
final playbackService = PlaybackInitializationService(
|
final playbackService = PlaybackInitializationService(
|
||||||
player: player!,
|
|
||||||
client: client,
|
client: client,
|
||||||
context: context,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Start playback and get available versions
|
// Get playback data (video URL and available versions)
|
||||||
final result = await playbackService.startPlayback(
|
final result = await playbackService.getPlaybackData(
|
||||||
metadata: widget.metadata,
|
metadata: widget.metadata,
|
||||||
selectedMediaIndex: widget.selectedMediaIndex,
|
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
|
// Update available versions from the playback data
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -643,11 +625,10 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
|
|||||||
// Clear video filter and reset subtitle margins before disposing player
|
// Clear video filter and reset subtitle margins before disposing player
|
||||||
try {
|
try {
|
||||||
if (player != null) {
|
if (player != null) {
|
||||||
final nativePlayer = player!.platform as dynamic;
|
player!.setProperty('vf', '');
|
||||||
nativePlayer.setProperty('vf', '');
|
player!.setProperty('sub-margin-x', '0');
|
||||||
nativePlayer.setProperty('sub-margin-x', '0');
|
player!.setProperty('sub-margin-y', '0');
|
||||||
nativePlayer.setProperty('sub-margin-y', '0');
|
player!.setProperty('sub-scale', '1.0');
|
||||||
nativePlayer.setProperty('sub-scale', '1.0');
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Non-critical: Cleanup operations during disposal
|
// 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
|
// Map MPV log levels to app logger levels
|
||||||
switch (log.level.toLowerCase()) {
|
switch (log.level) {
|
||||||
case 'fatal':
|
case MpvLogLevel.fatal:
|
||||||
case 'error':
|
case MpvLogLevel.error:
|
||||||
appLogger.e('[MPV:${log.prefix}] ${log.text}');
|
appLogger.e('[MPV:${log.prefix}] ${log.text}');
|
||||||
break;
|
break;
|
||||||
case 'warn':
|
case MpvLogLevel.warn:
|
||||||
appLogger.w('[MPV:${log.prefix}] ${log.text}');
|
appLogger.w('[MPV:${log.prefix}] ${log.text}');
|
||||||
break;
|
break;
|
||||||
case 'info':
|
case MpvLogLevel.info:
|
||||||
appLogger.i('[MPV:${log.prefix}] ${log.text}');
|
appLogger.i('[MPV:${log.prefix}] ${log.text}');
|
||||||
break;
|
break;
|
||||||
case 'debug':
|
case MpvLogLevel.debug:
|
||||||
case 'trace':
|
case MpvLogLevel.trace:
|
||||||
case 'v':
|
case MpvLogLevel.verbose:
|
||||||
appLogger.d('[MPV:${log.prefix}] ${log.text}');
|
appLogger.d('[MPV:${log.prefix}] ${log.text}');
|
||||||
break;
|
break;
|
||||||
default:
|
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
|
/// 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();
|
final settings = await SettingsService.getInstance();
|
||||||
|
|
||||||
// Only save if remember track selections is enabled
|
// 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
|
/// 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();
|
final settings = await SettingsService.getInstance();
|
||||||
|
|
||||||
// Only save if remember track selections is enabled
|
// Only save if remember track selections is enabled
|
||||||
@@ -908,7 +889,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
// Show loading indicator while player initializes
|
// Show loading indicator while player initializes
|
||||||
if (!_isPlayerInitialized || controller == null || player == null) {
|
if (!_isPlayerInitialized || player == null) {
|
||||||
return const Scaffold(
|
return const Scaffold(
|
||||||
backgroundColor: Colors.black,
|
backgroundColor: Colors.black,
|
||||||
body: Center(child: CircularProgressIndicator(color: Colors.white)),
|
body: Center(child: CircularProgressIndicator(color: Colors.white)),
|
||||||
@@ -928,7 +909,8 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: Scaffold(
|
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(
|
body: GestureDetector(
|
||||||
behavior: HitTestBehavior
|
behavior: HitTestBehavior
|
||||||
.translucent, // Allow taps to pass through to controls
|
.translucent, // Allow taps to pass through to controls
|
||||||
@@ -959,44 +941,44 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
|
|||||||
children: [
|
children: [
|
||||||
// Video player
|
// Video player
|
||||||
Center(
|
Center(
|
||||||
child: LayoutBuilder(
|
child: LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
// Update player size when layout changes
|
// Update player size when layout changes
|
||||||
final newSize = Size(
|
final newSize = Size(
|
||||||
constraints.maxWidth,
|
constraints.maxWidth,
|
||||||
constraints.maxHeight,
|
constraints.maxHeight,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update player size in video filter manager
|
// Update player size in video filter manager
|
||||||
if (_videoFilterManager != null) {
|
if (_videoFilterManager != null) {
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
_videoFilterManager!.updatePlayerSize(newSize);
|
_videoFilterManager!.updatePlayerSize(newSize);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return Video(
|
return MpvVideo(
|
||||||
controller: controller!,
|
player: player!,
|
||||||
fit: _videoFilterManager?.currentBoxFit ?? BoxFit.contain,
|
fit: _videoFilterManager?.currentBoxFit ?? BoxFit.contain,
|
||||||
controls: (state) => plexVideoControlsBuilder(
|
controls: (context) => plexVideoControlsBuilder(
|
||||||
player!,
|
player!,
|
||||||
widget.metadata,
|
widget.metadata,
|
||||||
onNext: _nextEpisode != null ? _playNext : null,
|
onNext: _nextEpisode != null ? _playNext : null,
|
||||||
onPrevious: _previousEpisode != null
|
onPrevious: _previousEpisode != null
|
||||||
? _playPrevious
|
? _playPrevious
|
||||||
: null,
|
: null,
|
||||||
availableVersions: _availableVersions,
|
availableVersions: _availableVersions,
|
||||||
selectedMediaIndex: widget.selectedMediaIndex,
|
selectedMediaIndex: widget.selectedMediaIndex,
|
||||||
boxFitMode: _videoFilterManager?.boxFitMode ?? 0,
|
boxFitMode: _videoFilterManager?.boxFitMode ?? 0,
|
||||||
onCycleBoxFitMode: _cycleBoxFitMode,
|
onCycleBoxFitMode: _cycleBoxFitMode,
|
||||||
onAudioTrackChanged: _onAudioTrackChanged,
|
onAudioTrackChanged: _onAudioTrackChanged,
|
||||||
onSubtitleTrackChanged: _onSubtitleTrackChanged,
|
onSubtitleTrackChanged: _onSubtitleTrackChanged,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
// Play Next Dialog
|
// Play Next Dialog
|
||||||
if (_showPlayNextDialog && _nextEpisode != null)
|
if (_showPlayNextDialog && _nextEpisode != null)
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:media_kit/media_kit.dart';
|
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import '../mpv/mpv.dart';
|
||||||
import '../client/plex_client.dart';
|
import '../client/plex_client.dart';
|
||||||
import '../models/plex_metadata.dart';
|
import '../models/plex_metadata.dart';
|
||||||
import '../providers/playback_state_provider.dart';
|
import '../providers/playback_state_provider.dart';
|
||||||
@@ -77,14 +77,14 @@ class EpisodeNavigationService {
|
|||||||
Future<void> navigateToEpisode({
|
Future<void> navigateToEpisode({
|
||||||
required BuildContext context,
|
required BuildContext context,
|
||||||
required PlexMetadata episode,
|
required PlexMetadata episode,
|
||||||
required Player? player,
|
required MpvPlayer? player,
|
||||||
bool usePushReplacement = true,
|
bool usePushReplacement = true,
|
||||||
}) async {
|
}) async {
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
|
|
||||||
// Capture current player state before navigation
|
// Capture current player state before navigation
|
||||||
AudioTrack? currentAudioTrack;
|
MpvAudioTrack? currentAudioTrack;
|
||||||
SubtitleTrack? currentSubtitleTrack;
|
MpvSubtitleTrack? currentSubtitleTrack;
|
||||||
double? currentPlaybackRate;
|
double? currentPlaybackRate;
|
||||||
|
|
||||||
if (player != null) {
|
if (player != null) {
|
||||||
@@ -93,7 +93,7 @@ class EpisodeNavigationService {
|
|||||||
currentPlaybackRate = player.state.rate;
|
currentPlaybackRate = player.state.rate;
|
||||||
|
|
||||||
appLogger.d(
|
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/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:media_kit/media_kit.dart';
|
|
||||||
import 'package:hotkey_manager/hotkey_manager.dart';
|
import 'package:hotkey_manager/hotkey_manager.dart';
|
||||||
|
import '../mpv/mpv.dart';
|
||||||
import 'settings_service.dart';
|
import 'settings_service.dart';
|
||||||
import '../utils/keyboard_utils.dart';
|
import '../utils/keyboard_utils.dart';
|
||||||
import '../utils/player_utils.dart';
|
import '../utils/player_utils.dart';
|
||||||
@@ -148,7 +148,7 @@ class KeyboardShortcutsService {
|
|||||||
// Handle keyboard input for video player
|
// Handle keyboard input for video player
|
||||||
KeyEventResult handleVideoPlayerKeyEvent(
|
KeyEventResult handleVideoPlayerKeyEvent(
|
||||||
KeyEvent event,
|
KeyEvent event,
|
||||||
Player player,
|
MpvPlayer player,
|
||||||
VoidCallback? onToggleFullscreen,
|
VoidCallback? onToggleFullscreen,
|
||||||
VoidCallback? onToggleSubtitles,
|
VoidCallback? onToggleSubtitles,
|
||||||
VoidCallback? onNextAudioTrack,
|
VoidCallback? onNextAudioTrack,
|
||||||
@@ -241,7 +241,7 @@ class KeyboardShortcutsService {
|
|||||||
|
|
||||||
void _executeAction(
|
void _executeAction(
|
||||||
String action,
|
String action,
|
||||||
Player player,
|
MpvPlayer player,
|
||||||
VoidCallback? onToggleFullscreen,
|
VoidCallback? onToggleFullscreen,
|
||||||
VoidCallback? onToggleSubtitles,
|
VoidCallback? onToggleSubtitles,
|
||||||
VoidCallback? onNextAudioTrack,
|
VoidCallback? onNextAudioTrack,
|
||||||
|
|||||||
@@ -1,45 +1,21 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:media_kit/media_kit.dart';
|
|
||||||
|
|
||||||
import '../client/plex_client.dart';
|
import '../client/plex_client.dart';
|
||||||
import '../models/plex_media_info.dart';
|
|
||||||
import '../models/plex_metadata.dart';
|
import '../models/plex_metadata.dart';
|
||||||
import '../models/plex_user_profile.dart';
|
|
||||||
import '../utils/app_logger.dart';
|
|
||||||
import '../i18n/strings.g.dart';
|
import '../i18n/strings.g.dart';
|
||||||
import 'track_selection_service.dart';
|
|
||||||
|
|
||||||
/// Service responsible for initializing video playback
|
/// Service responsible for fetching video playback data from the Plex server
|
||||||
///
|
|
||||||
/// 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
|
|
||||||
class PlaybackInitializationService {
|
class PlaybackInitializationService {
|
||||||
final Player player;
|
|
||||||
final PlexClient client;
|
final PlexClient client;
|
||||||
final BuildContext context;
|
|
||||||
|
|
||||||
PlaybackInitializationService({
|
PlaybackInitializationService({
|
||||||
required this.player,
|
|
||||||
required this.client,
|
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
|
/// Returns a PlaybackInitializationResult with video URL and available versions
|
||||||
Future<PlaybackInitializationResult> startPlayback({
|
Future<PlaybackInitializationResult> getPlaybackData({
|
||||||
required PlexMetadata metadata,
|
required PlexMetadata metadata,
|
||||||
required int selectedMediaIndex,
|
required int selectedMediaIndex,
|
||||||
required PlexUserProfile? profileSettings,
|
|
||||||
AudioTrack? preferredAudioTrack,
|
|
||||||
SubtitleTrack? preferredSubtitleTrack,
|
|
||||||
double? preferredPlaybackRate,
|
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
// Get consolidated playback data (URL, media info, and versions) in a single API call
|
// 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);
|
throw PlaybackException(t.messages.fileInfoNotAvailable);
|
||||||
}
|
}
|
||||||
|
|
||||||
final videoUrl = playbackData.videoUrl!;
|
// Return result with available versions and video URL
|
||||||
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 PlaybackInitializationResult(
|
return PlaybackInitializationResult(
|
||||||
availableVersions: playbackData.availableVersions,
|
availableVersions: playbackData.availableVersions,
|
||||||
|
videoUrl: playbackData.videoUrl!,
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e is PlaybackException) {
|
if (e is PlaybackException) {
|
||||||
@@ -104,103 +40,17 @@ class PlaybackInitializationService {
|
|||||||
throw PlaybackException(t.messages.errorLoading(error: e.toString()));
|
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
|
/// Result of playback initialization
|
||||||
class PlaybackInitializationResult {
|
class PlaybackInitializationResult {
|
||||||
final List<dynamic> availableVersions;
|
final List<dynamic> availableVersions;
|
||||||
|
final String? videoUrl;
|
||||||
|
|
||||||
PlaybackInitializationResult({required this.availableVersions});
|
PlaybackInitializationResult({
|
||||||
|
required this.availableVersions,
|
||||||
|
this.videoUrl,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Exception thrown when playback initialization fails
|
/// Exception thrown when playback initialization fails
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:media_kit/media_kit.dart';
|
import '../mpv/mpv.dart';
|
||||||
|
|
||||||
import '../client/plex_client.dart';
|
import '../client/plex_client.dart';
|
||||||
import '../models/plex_metadata.dart';
|
import '../models/plex_metadata.dart';
|
||||||
@@ -15,7 +15,7 @@ import '../utils/app_logger.dart';
|
|||||||
class PlaybackProgressTracker {
|
class PlaybackProgressTracker {
|
||||||
final PlexClient client;
|
final PlexClient client;
|
||||||
final PlexMetadata metadata;
|
final PlexMetadata metadata;
|
||||||
final Player player;
|
final MpvPlayer player;
|
||||||
|
|
||||||
/// Timer for periodic progress updates
|
/// Timer for periodic progress updates
|
||||||
Timer? _progressTimer;
|
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_metadata.dart';
|
||||||
import '../models/plex_user_profile.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
|
/// Service for selecting and applying audio and subtitle tracks based on
|
||||||
/// preferences, user profiles, and per-media settings.
|
/// preferences, user profiles, and per-media settings.
|
||||||
class TrackSelectionService {
|
class TrackSelectionService {
|
||||||
final Player player;
|
final MpvPlayer player;
|
||||||
final PlexUserProfile? profileSettings;
|
final PlexUserProfile? profileSettings;
|
||||||
final PlexMetadata metadata;
|
final PlexMetadata metadata;
|
||||||
|
|
||||||
@@ -69,11 +69,11 @@ class TrackSelectionService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
AudioTrack? findBestAudioMatch(
|
MpvAudioTrack? findBestAudioMatch(
|
||||||
List<AudioTrack> availableTracks,
|
List<MpvAudioTrack> availableTracks,
|
||||||
AudioTrack preferred,
|
MpvAudioTrack preferred,
|
||||||
) {
|
) {
|
||||||
return findBestTrackMatch<AudioTrack>(
|
return findBestTrackMatch<MpvAudioTrack>(
|
||||||
availableTracks,
|
availableTracks,
|
||||||
preferred,
|
preferred,
|
||||||
(t) => t.id,
|
(t) => t.id,
|
||||||
@@ -82,8 +82,8 @@ class TrackSelectionService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
AudioTrack? findAudioTrackByProfile(
|
MpvAudioTrack? findAudioTrackByProfile(
|
||||||
List<AudioTrack> availableTracks,
|
List<MpvAudioTrack> availableTracks,
|
||||||
PlexUserProfile profile,
|
PlexUserProfile profile,
|
||||||
) {
|
) {
|
||||||
appLogger.d('Audio track selection using user profile');
|
appLogger.d('Audio track selection using user profile');
|
||||||
@@ -123,7 +123,7 @@ class TrackSelectionService {
|
|||||||
'Checking language variations for "$preferredLanguage": ${languageVariations.join(", ")}',
|
'Checking language variations for "$preferredLanguage": ${languageVariations.join(", ")}',
|
||||||
);
|
);
|
||||||
|
|
||||||
final match = _findTrackByLanguageVariations<AudioTrack>(
|
final match = _findTrackByLanguageVariations<MpvAudioTrack>(
|
||||||
availableTracks,
|
availableTracks,
|
||||||
preferredLanguage,
|
preferredLanguage,
|
||||||
languageVariations,
|
languageVariations,
|
||||||
@@ -140,16 +140,16 @@ class TrackSelectionService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
SubtitleTrack? findBestSubtitleMatch(
|
MpvSubtitleTrack? findBestSubtitleMatch(
|
||||||
List<SubtitleTrack> availableTracks,
|
List<MpvSubtitleTrack> availableTracks,
|
||||||
SubtitleTrack preferred,
|
MpvSubtitleTrack preferred,
|
||||||
) {
|
) {
|
||||||
// Handle special "no subtitles" case
|
// Handle special "no subtitles" case
|
||||||
if (preferred.id == 'no') {
|
if (preferred.id == 'no') {
|
||||||
return SubtitleTrack.no();
|
return MpvSubtitleTrack.off;
|
||||||
}
|
}
|
||||||
|
|
||||||
return findBestTrackMatch<SubtitleTrack>(
|
return findBestTrackMatch<MpvSubtitleTrack>(
|
||||||
availableTracks,
|
availableTracks,
|
||||||
preferred,
|
preferred,
|
||||||
(t) => t.id,
|
(t) => t.id,
|
||||||
@@ -158,10 +158,10 @@ class TrackSelectionService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
SubtitleTrack? findSubtitleTrackByProfile(
|
MpvSubtitleTrack? findSubtitleTrackByProfile(
|
||||||
List<SubtitleTrack> availableTracks,
|
List<MpvSubtitleTrack> availableTracks,
|
||||||
PlexUserProfile profile, {
|
PlexUserProfile profile, {
|
||||||
AudioTrack? selectedAudioTrack,
|
MpvAudioTrack? selectedAudioTrack,
|
||||||
}) {
|
}) {
|
||||||
appLogger.d('Subtitle track selection using user profile');
|
appLogger.d('Subtitle track selection using user profile');
|
||||||
appLogger.d(
|
appLogger.d(
|
||||||
@@ -178,7 +178,7 @@ class TrackSelectionService {
|
|||||||
appLogger.d(
|
appLogger.d(
|
||||||
'Profile specifies manual mode (autoSelectSubtitle=0) - Subtitles OFF',
|
'Profile specifies manual mode (autoSelectSubtitle=0) - Subtitles OFF',
|
||||||
);
|
);
|
||||||
return SubtitleTrack.no();
|
return MpvSubtitleTrack.off;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mode 1: Shown with foreign audio
|
// Mode 1: Shown with foreign audio
|
||||||
@@ -201,7 +201,7 @@ class TrackSelectionService {
|
|||||||
// If audio matches preferred language, no subtitles needed
|
// If audio matches preferred language, no subtitles needed
|
||||||
if (audioLang != null && languageVariations.contains(audioLang)) {
|
if (audioLang != null && languageVariations.contains(audioLang)) {
|
||||||
appLogger.d('Audio matches preferred language - Subtitles OFF');
|
appLogger.d('Audio matches preferred language - Subtitles OFF');
|
||||||
return SubtitleTrack.no();
|
return MpvSubtitleTrack.off;
|
||||||
}
|
}
|
||||||
appLogger.d('Foreign audio detected - enabling subtitles');
|
appLogger.d('Foreign audio detected - enabling subtitles');
|
||||||
}
|
}
|
||||||
@@ -258,7 +258,7 @@ class TrackSelectionService {
|
|||||||
'Checking language variations for "$preferredLanguage": ${languageVariations.join(", ")}',
|
'Checking language variations for "$preferredLanguage": ${languageVariations.join(", ")}',
|
||||||
);
|
);
|
||||||
|
|
||||||
final match = _findTrackByLanguageVariations<SubtitleTrack>(
|
final match = _findTrackByLanguageVariations<MpvSubtitleTrack>(
|
||||||
candidateTracks,
|
candidateTracks,
|
||||||
preferredLanguage,
|
preferredLanguage,
|
||||||
languageVariations,
|
languageVariations,
|
||||||
@@ -282,8 +282,8 @@ class TrackSelectionService {
|
|||||||
/// - 1: Prefer SDH subtitles
|
/// - 1: Prefer SDH subtitles
|
||||||
/// - 2: Only show SDH subtitles
|
/// - 2: Only show SDH subtitles
|
||||||
/// - 3: Only show non-SDH subtitles
|
/// - 3: Only show non-SDH subtitles
|
||||||
List<SubtitleTrack> filterSubtitlesBySDH(
|
List<MpvSubtitleTrack> filterSubtitlesBySDH(
|
||||||
List<SubtitleTrack> tracks,
|
List<MpvSubtitleTrack> tracks,
|
||||||
int preference,
|
int preference,
|
||||||
) {
|
) {
|
||||||
if (preference == 0 || preference == 1) {
|
if (preference == 0 || preference == 1) {
|
||||||
@@ -319,8 +319,8 @@ class TrackSelectionService {
|
|||||||
/// - 1: Prefer forced subtitles
|
/// - 1: Prefer forced subtitles
|
||||||
/// - 2: Only show forced subtitles
|
/// - 2: Only show forced subtitles
|
||||||
/// - 3: Only show non-forced subtitles
|
/// - 3: Only show non-forced subtitles
|
||||||
List<SubtitleTrack> filterSubtitlesByForced(
|
List<MpvSubtitleTrack> filterSubtitlesByForced(
|
||||||
List<SubtitleTrack> tracks,
|
List<MpvSubtitleTrack> tracks,
|
||||||
int preference,
|
int preference,
|
||||||
) {
|
) {
|
||||||
if (preference == 0 || preference == 1) {
|
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)
|
/// 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
|
/// Since mpv may not expose this directly, we infer from the title
|
||||||
bool isSDH(SubtitleTrack track) {
|
bool isSDH(MpvSubtitleTrack track) {
|
||||||
final title = track.title?.toLowerCase() ?? '';
|
final title = track.title?.toLowerCase() ?? '';
|
||||||
|
|
||||||
// Look for common SDH indicators
|
// Look for common SDH indicators
|
||||||
@@ -365,7 +365,7 @@ class TrackSelectionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Checks if a subtitle track is forced
|
/// Checks if a subtitle track is forced
|
||||||
bool isForced(SubtitleTrack track) {
|
bool isForced(MpvSubtitleTrack track) {
|
||||||
final title = track.title?.toLowerCase() ?? '';
|
final title = track.title?.toLowerCase() ?? '';
|
||||||
return title.contains('forced');
|
return title.contains('forced');
|
||||||
}
|
}
|
||||||
@@ -423,8 +423,8 @@ class TrackSelectionService {
|
|||||||
|
|
||||||
/// Log available tracks for debugging
|
/// Log available tracks for debugging
|
||||||
void logAvailableTracks(
|
void logAvailableTracks(
|
||||||
List<AudioTrack> audioTracks,
|
List<MpvAudioTrack> audioTracks,
|
||||||
List<SubtitleTrack> subtitleTracks,
|
List<MpvSubtitleTrack> subtitleTracks,
|
||||||
) {
|
) {
|
||||||
appLogger.d('Available audio tracks: ${audioTracks.length}');
|
appLogger.d('Available audio tracks: ${audioTracks.length}');
|
||||||
for (var track in audioTracks) {
|
for (var track in audioTracks) {
|
||||||
@@ -445,13 +445,13 @@ class TrackSelectionService {
|
|||||||
/// Priority 2: Per-media language preference
|
/// Priority 2: Per-media language preference
|
||||||
/// Priority 3: User profile preferences
|
/// Priority 3: User profile preferences
|
||||||
/// Priority 4: Default or first track
|
/// Priority 4: Default or first track
|
||||||
AudioTrack? selectAudioTrack(
|
MpvAudioTrack? selectAudioTrack(
|
||||||
List<AudioTrack> availableTracks,
|
List<MpvAudioTrack> availableTracks,
|
||||||
AudioTrack? preferredAudioTrack,
|
MpvAudioTrack? preferredAudioTrack,
|
||||||
) {
|
) {
|
||||||
if (availableTracks.isEmpty) return null;
|
if (availableTracks.isEmpty) return null;
|
||||||
|
|
||||||
AudioTrack? trackToSelect;
|
MpvAudioTrack? trackToSelect;
|
||||||
|
|
||||||
// Priority 1: Try to match preferred track from navigation
|
// Priority 1: Try to match preferred track from navigation
|
||||||
if (preferredAudioTrack != null) {
|
if (preferredAudioTrack != null) {
|
||||||
@@ -522,19 +522,19 @@ class TrackSelectionService {
|
|||||||
/// Priority 3: User profile preferences
|
/// Priority 3: User profile preferences
|
||||||
/// Priority 4: Default track
|
/// Priority 4: Default track
|
||||||
/// Priority 5: Off
|
/// Priority 5: Off
|
||||||
SubtitleTrack selectSubtitleTrack(
|
MpvSubtitleTrack selectSubtitleTrack(
|
||||||
List<SubtitleTrack> availableTracks,
|
List<MpvSubtitleTrack> availableTracks,
|
||||||
SubtitleTrack? preferredSubtitleTrack,
|
MpvSubtitleTrack? preferredSubtitleTrack,
|
||||||
AudioTrack? selectedAudioTrack,
|
MpvAudioTrack? selectedAudioTrack,
|
||||||
) {
|
) {
|
||||||
SubtitleTrack? subtitleToSelect;
|
MpvSubtitleTrack? subtitleToSelect;
|
||||||
|
|
||||||
// Priority 1: Try preferred track from navigation (always wins)
|
// Priority 1: Try preferred track from navigation (always wins)
|
||||||
if (preferredSubtitleTrack != null) {
|
if (preferredSubtitleTrack != null) {
|
||||||
appLogger.d('Priority 1: Checking preferred track from navigation');
|
appLogger.d('Priority 1: Checking preferred track from navigation');
|
||||||
if (preferredSubtitleTrack.id == 'no') {
|
if (preferredSubtitleTrack.id == 'no') {
|
||||||
appLogger.d(' Preferred: OFF');
|
appLogger.d(' Preferred: OFF');
|
||||||
return SubtitleTrack.no();
|
return MpvSubtitleTrack.off;
|
||||||
} else if (availableTracks.isNotEmpty) {
|
} else if (availableTracks.isNotEmpty) {
|
||||||
appLogger.d(
|
appLogger.d(
|
||||||
' Preferred: ${preferredSubtitleTrack.title ?? "Track ${preferredSubtitleTrack.id}"} (${preferredSubtitleTrack.language ?? "unknown"})',
|
' Preferred: ${preferredSubtitleTrack.title ?? "Track ${preferredSubtitleTrack.id}"} (${preferredSubtitleTrack.language ?? "unknown"})',
|
||||||
@@ -566,7 +566,7 @@ class TrackSelectionService {
|
|||||||
if (metadata.subtitleLanguage == 'none' ||
|
if (metadata.subtitleLanguage == 'none' ||
|
||||||
metadata.subtitleLanguage!.isEmpty) {
|
metadata.subtitleLanguage!.isEmpty) {
|
||||||
appLogger.d(' Per-media preference: Subtitles OFF');
|
appLogger.d(' Per-media preference: Subtitles OFF');
|
||||||
return SubtitleTrack.no();
|
return MpvSubtitleTrack.off;
|
||||||
} else if (availableTracks.isNotEmpty) {
|
} else if (availableTracks.isNotEmpty) {
|
||||||
final matchedTrack = availableTracks.firstWhere(
|
final matchedTrack = availableTracks.firstWhere(
|
||||||
(track) => languageMatches(track.language, metadata.subtitleLanguage),
|
(track) => languageMatches(track.language, metadata.subtitleLanguage),
|
||||||
@@ -615,16 +615,16 @@ class TrackSelectionService {
|
|||||||
|
|
||||||
// Priority 5: If still no subtitle selected, turn off
|
// Priority 5: If still no subtitle selected, turn off
|
||||||
appLogger.d('Priority 5: No subtitle selected - Subtitles 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
|
/// Select and apply audio and subtitle tracks based on preferences
|
||||||
Future<void> selectAndApplyTracks({
|
Future<void> selectAndApplyTracks({
|
||||||
AudioTrack? preferredAudioTrack,
|
MpvAudioTrack? preferredAudioTrack,
|
||||||
SubtitleTrack? preferredSubtitleTrack,
|
MpvSubtitleTrack? preferredSubtitleTrack,
|
||||||
double? preferredPlaybackRate,
|
double? preferredPlaybackRate,
|
||||||
Function(AudioTrack)? onAudioTrackChanged,
|
Function(MpvAudioTrack)? onAudioTrackChanged,
|
||||||
Function(SubtitleTrack)? onSubtitleTrackChanged,
|
Function(MpvSubtitleTrack)? onSubtitleTrackChanged,
|
||||||
}) async {
|
}) async {
|
||||||
// Wait for tracks to be loaded
|
// Wait for tracks to be loaded
|
||||||
int attempts = 0;
|
int attempts = 0;
|
||||||
@@ -658,7 +658,7 @@ class TrackSelectionService {
|
|||||||
appLogger.i(
|
appLogger.i(
|
||||||
'Final audio selection: ${selectedAudioTrack.title ?? "Track ${selectedAudioTrack.id}"} (${selectedAudioTrack.language ?? "unknown"})',
|
'Final audio selection: ${selectedAudioTrack.title ?? "Track ${selectedAudioTrack.id}"} (${selectedAudioTrack.language ?? "unknown"})',
|
||||||
);
|
);
|
||||||
player.setAudioTrack(selectedAudioTrack);
|
player.selectAudioTrack(selectedAudioTrack);
|
||||||
} else {
|
} else {
|
||||||
appLogger.d('No audio tracks available');
|
appLogger.d('No audio tracks available');
|
||||||
}
|
}
|
||||||
@@ -674,7 +674,7 @@ class TrackSelectionService {
|
|||||||
? 'OFF'
|
? 'OFF'
|
||||||
: '${selectedSubtitleTrack.title ?? "Track ${selectedSubtitleTrack.id}"} (${selectedSubtitleTrack.language ?? "unknown"})';
|
: '${selectedSubtitleTrack.title ?? "Track ${selectedSubtitleTrack.id}"} (${selectedSubtitleTrack.language ?? "unknown"})';
|
||||||
appLogger.i('Final subtitle selection: $finalSubtitle');
|
appLogger.i('Final subtitle selection: $finalSubtitle');
|
||||||
player.setSubtitleTrack(selectedSubtitleTrack);
|
player.selectSubtitleTrack(selectedSubtitleTrack);
|
||||||
|
|
||||||
// Set playback rate if preferred rate was provided
|
// Set playback rate if preferred rate was provided
|
||||||
if (preferredPlaybackRate != null) {
|
if (preferredPlaybackRate != null) {
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:media_kit/media_kit.dart';
|
|
||||||
import 'package:rate_limiter/rate_limiter.dart';
|
import 'package:rate_limiter/rate_limiter.dart';
|
||||||
|
|
||||||
|
import '../mpv/mpv.dart';
|
||||||
|
|
||||||
import '../models/plex_media_version.dart';
|
import '../models/plex_media_version.dart';
|
||||||
import '../utils/app_logger.dart';
|
import '../utils/app_logger.dart';
|
||||||
|
|
||||||
@@ -13,7 +14,7 @@ import '../utils/app_logger.dart';
|
|||||||
/// - Subtitle positioning adjustments based on crop parameters
|
/// - Subtitle positioning adjustments based on crop parameters
|
||||||
/// - Debounced video filter updates on resize events
|
/// - Debounced video filter updates on resize events
|
||||||
class VideoFilterManager {
|
class VideoFilterManager {
|
||||||
final Player player;
|
final MpvPlayer player;
|
||||||
final List<PlexMediaVersion> availableVersions;
|
final List<PlexMediaVersion> availableVersions;
|
||||||
final int selectedMediaIndex;
|
final int selectedMediaIndex;
|
||||||
|
|
||||||
@@ -203,8 +204,6 @@ class VideoFilterManager {
|
|||||||
/// Update the video filter based on current crop mode
|
/// Update the video filter based on current crop mode
|
||||||
void updateVideoFilter() async {
|
void updateVideoFilter() async {
|
||||||
try {
|
try {
|
||||||
final nativePlayer = player.platform as dynamic;
|
|
||||||
|
|
||||||
if (_boxFitMode == 1) {
|
if (_boxFitMode == 1) {
|
||||||
// Fill screen mode - apply crop filter
|
// Fill screen mode - apply crop filter
|
||||||
_videoSize = _getCurrentVideoSize();
|
_videoSize = _getCurrentVideoSize();
|
||||||
@@ -218,7 +217,7 @@ class VideoFilterManager {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Apply crop filter
|
// Apply crop filter
|
||||||
await nativePlayer.setProperty('vf', cropFilter);
|
await player.setProperty('vf', cropFilter);
|
||||||
|
|
||||||
// Apply subtitle margins and scaling to compensate for crop zoom
|
// Apply subtitle margins and scaling to compensate for crop zoom
|
||||||
final subMarginX = cropParams['subMarginX']!;
|
final subMarginX = cropParams['subMarginX']!;
|
||||||
@@ -229,28 +228,28 @@ class VideoFilterManager {
|
|||||||
'Applying subtitle properties - margins: x=$subMarginX, y=$subMarginY, scale=$subScale',
|
'Applying subtitle properties - margins: x=$subMarginX, y=$subMarginY, scale=$subScale',
|
||||||
);
|
);
|
||||||
|
|
||||||
await nativePlayer.setProperty('sub-margin-x', subMarginX.toString());
|
await player.setProperty('sub-margin-x', subMarginX.toString());
|
||||||
await nativePlayer.setProperty('sub-margin-y', subMarginY.toString());
|
await player.setProperty('sub-margin-y', subMarginY.toString());
|
||||||
await nativePlayer.setProperty('sub-scale', subScale.toString());
|
await player.setProperty('sub-scale', subScale.toString());
|
||||||
} else {
|
} else {
|
||||||
// Clear filter but apply base margins if no cropping needed
|
// Clear filter but apply base margins if no cropping needed
|
||||||
appLogger.d(
|
appLogger.d(
|
||||||
'Clearing video filter - aspect ratios similar, applying base margins (player: $_playerSize, video: $_videoSize)',
|
'Clearing video filter - aspect ratios similar, applying base margins (player: $_playerSize, video: $_videoSize)',
|
||||||
);
|
);
|
||||||
await nativePlayer.setProperty('vf', '');
|
await player.setProperty('vf', '');
|
||||||
await nativePlayer.setProperty('sub-margin-x', '20'); // Base margin
|
await player.setProperty('sub-margin-x', '20'); // Base margin
|
||||||
await nativePlayer.setProperty('sub-margin-y', '40'); // Base margin
|
await player.setProperty('sub-margin-y', '40'); // Base margin
|
||||||
await nativePlayer.setProperty('sub-scale', '1.0'); // Reset scale
|
await player.setProperty('sub-scale', '1.0'); // Reset scale
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Other modes - clear video filter but apply base margins
|
// Other modes - clear video filter but apply base margins
|
||||||
appLogger.d(
|
appLogger.d(
|
||||||
'Clearing video filter, applying base margins - BoxFit mode $_boxFitMode',
|
'Clearing video filter, applying base margins - BoxFit mode $_boxFitMode',
|
||||||
);
|
);
|
||||||
await nativePlayer.setProperty('vf', '');
|
await player.setProperty('vf', '');
|
||||||
await nativePlayer.setProperty('sub-margin-x', '20'); // Base margin
|
await player.setProperty('sub-margin-x', '20'); // Base margin
|
||||||
await nativePlayer.setProperty('sub-margin-y', '40'); // Base margin
|
await player.setProperty('sub-margin-y', '40'); // Base margin
|
||||||
await nativePlayer.setProperty('sub-scale', '1.0'); // Reset scale
|
await player.setProperty('sub-scale', '1.0'); // Reset scale
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
appLogger.w('Failed to update video filter', error: 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
|
/// Seeks by the given offset (can be positive or negative) while clamping
|
||||||
/// the result between 0 and the video duration
|
/// 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 currentPosition = player.state.position;
|
||||||
final duration = player.state.duration;
|
final duration = player.state.duration;
|
||||||
final newPosition = currentPosition + offset;
|
final newPosition = currentPosition + offset;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:media_kit/media_kit.dart';
|
|
||||||
|
import '../mpv/mpv.dart';
|
||||||
import '../models/plex_metadata.dart';
|
import '../models/plex_metadata.dart';
|
||||||
import '../screens/video_player_screen.dart';
|
import '../screens/video_player_screen.dart';
|
||||||
import '../services/settings_service.dart';
|
import '../services/settings_service.dart';
|
||||||
@@ -26,8 +27,8 @@ import '../services/settings_service.dart';
|
|||||||
Future<bool?> navigateToVideoPlayer(
|
Future<bool?> navigateToVideoPlayer(
|
||||||
BuildContext context, {
|
BuildContext context, {
|
||||||
required PlexMetadata metadata,
|
required PlexMetadata metadata,
|
||||||
AudioTrack? preferredAudioTrack,
|
MpvAudioTrack? preferredAudioTrack,
|
||||||
SubtitleTrack? preferredSubtitleTrack,
|
MpvSubtitleTrack? preferredSubtitleTrack,
|
||||||
double? preferredPlaybackRate,
|
double? preferredPlaybackRate,
|
||||||
int? selectedMediaIndex,
|
int? selectedMediaIndex,
|
||||||
bool usePushReplacement = false,
|
bool usePushReplacement = false,
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import 'dart:io' show Platform;
|
import 'dart:io' show Platform;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
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_info.dart';
|
||||||
import '../../models/plex_metadata.dart';
|
import '../../models/plex_metadata.dart';
|
||||||
import '../../services/fullscreen_state_manager.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
|
/// Desktop-specific video controls layout with top bar and bottom controls
|
||||||
class DesktopVideoControls extends StatelessWidget {
|
class DesktopVideoControls extends StatelessWidget {
|
||||||
final Player player;
|
final MpvPlayer player;
|
||||||
final PlexMetadata metadata;
|
final PlexMetadata metadata;
|
||||||
final VoidCallback? onNext;
|
final VoidCallback? onNext;
|
||||||
final VoidCallback? onPrevious;
|
final VoidCallback? onPrevious;
|
||||||
@@ -158,11 +158,11 @@ class DesktopVideoControls extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
// Row 1: Timeline with time indicators
|
// Row 1: Timeline with time indicators
|
||||||
StreamBuilder<Duration>(
|
StreamBuilder<Duration>(
|
||||||
stream: player.stream.position,
|
stream: player.streams.position,
|
||||||
initialData: player.state.position,
|
initialData: player.state.position,
|
||||||
builder: (context, positionSnapshot) {
|
builder: (context, positionSnapshot) {
|
||||||
return StreamBuilder<Duration>(
|
return StreamBuilder<Duration>(
|
||||||
stream: player.stream.duration,
|
stream: player.streams.duration,
|
||||||
initialData: player.state.duration,
|
initialData: player.state.duration,
|
||||||
builder: (context, durationSnapshot) {
|
builder: (context, durationSnapshot) {
|
||||||
final position = positionSnapshot.data ?? Duration.zero;
|
final position = positionSnapshot.data ?? Duration.zero;
|
||||||
@@ -238,7 +238,7 @@ class DesktopVideoControls extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
// Play/Pause
|
// Play/Pause
|
||||||
StreamBuilder<bool>(
|
StreamBuilder<bool>(
|
||||||
stream: player.stream.playing,
|
stream: player.streams.playing,
|
||||||
initialData: player.state.playing,
|
initialData: player.state.playing,
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
final isPlaying = snapshot.data ?? false;
|
final isPlaying = snapshot.data ?? false;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
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_info.dart';
|
||||||
import '../../models/plex_metadata.dart';
|
import '../../models/plex_metadata.dart';
|
||||||
import '../../utils/desktop_window_padding.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)
|
/// - Center: Large playback controls (seek backward, play/pause, seek forward)
|
||||||
/// - Bottom bar: Timeline slider with chapter markers and timestamps
|
/// - Bottom bar: Timeline slider with chapter markers and timestamps
|
||||||
class MobileVideoControls extends StatelessWidget {
|
class MobileVideoControls extends StatelessWidget {
|
||||||
final Player player;
|
final MpvPlayer player;
|
||||||
final PlexMetadata metadata;
|
final PlexMetadata metadata;
|
||||||
final List<PlexChapter> chapters;
|
final List<PlexChapter> chapters;
|
||||||
final bool chaptersLoaded;
|
final bool chaptersLoaded;
|
||||||
@@ -114,7 +114,7 @@ class MobileVideoControls extends StatelessWidget {
|
|||||||
|
|
||||||
Widget _buildPlaybackControls(BuildContext context) {
|
Widget _buildPlaybackControls(BuildContext context) {
|
||||||
return StreamBuilder<bool>(
|
return StreamBuilder<bool>(
|
||||||
stream: player.stream.playing,
|
stream: player.streams.playing,
|
||||||
initialData: player.state.playing,
|
initialData: player.state.playing,
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
final isPlaying = snapshot.data ?? false;
|
final isPlaying = snapshot.data ?? false;
|
||||||
@@ -214,11 +214,11 @@ class MobileVideoControls extends StatelessWidget {
|
|||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: StreamBuilder<Duration>(
|
child: StreamBuilder<Duration>(
|
||||||
stream: player.stream.position,
|
stream: player.streams.position,
|
||||||
initialData: player.state.position,
|
initialData: player.state.position,
|
||||||
builder: (context, positionSnapshot) {
|
builder: (context, positionSnapshot) {
|
||||||
return StreamBuilder<Duration>(
|
return StreamBuilder<Duration>(
|
||||||
stream: player.stream.duration,
|
stream: player.streams.duration,
|
||||||
initialData: player.state.duration,
|
initialData: player.state.duration,
|
||||||
builder: (context, durationSnapshot) {
|
builder: (context, durationSnapshot) {
|
||||||
final position = positionSnapshot.data ?? Duration.zero;
|
final position = positionSnapshot.data ?? Duration.zero;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:media_kit/media_kit.dart';
|
|
||||||
|
import '../../../mpv/mpv.dart';
|
||||||
import '../../../i18n/strings.g.dart';
|
import '../../../i18n/strings.g.dart';
|
||||||
import 'track_selection_sheet.dart';
|
import 'track_selection_sheet.dart';
|
||||||
|
|
||||||
@@ -7,10 +8,10 @@ import 'track_selection_sheet.dart';
|
|||||||
class AudioTrackSheet {
|
class AudioTrackSheet {
|
||||||
static void show(
|
static void show(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
Player player, {
|
MpvPlayer player, {
|
||||||
Function(AudioTrack)? onTrackChanged,
|
Function(MpvAudioTrack)? onTrackChanged,
|
||||||
}) {
|
}) {
|
||||||
TrackSelectionSheet.show<AudioTrack>(
|
TrackSelectionSheet.show<MpvAudioTrack>(
|
||||||
context: context,
|
context: context,
|
||||||
player: player,
|
player: player,
|
||||||
title: t.videoControls.audioLabel,
|
title: t.videoControls.audioLabel,
|
||||||
@@ -28,12 +29,12 @@ class AudioTrackSheet {
|
|||||||
if (audioTrack.codec != null && audioTrack.codec!.isNotEmpty) {
|
if (audioTrack.codec != null && audioTrack.codec!.isNotEmpty) {
|
||||||
parts.add(audioTrack.codec!.toUpperCase());
|
parts.add(audioTrack.codec!.toUpperCase());
|
||||||
}
|
}
|
||||||
if (audioTrack.channelscount != null) {
|
if (audioTrack.channelsCount != null) {
|
||||||
parts.add('${audioTrack.channelscount}ch');
|
parts.add('${audioTrack.channelsCount}ch');
|
||||||
}
|
}
|
||||||
return parts.isEmpty ? 'Audio Track ${index + 1}' : parts.join(' · ');
|
return parts.isEmpty ? 'Audio Track ${index + 1}' : parts.join(' · ');
|
||||||
},
|
},
|
||||||
setTrack: (track) => player.setAudioTrack(track),
|
setTrack: (track) => player.selectAudioTrack(track),
|
||||||
onTrackChanged: onTrackChanged,
|
onTrackChanged: onTrackChanged,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:media_kit/media_kit.dart';
|
|
||||||
|
import '../../../mpv/mpv.dart';
|
||||||
import '../../../client/plex_client.dart';
|
import '../../../client/plex_client.dart';
|
||||||
import '../../../models/plex_media_info.dart';
|
import '../../../models/plex_media_info.dart';
|
||||||
import '../../../utils/duration_formatter.dart';
|
import '../../../utils/duration_formatter.dart';
|
||||||
@@ -8,7 +9,7 @@ import 'base_video_control_sheet.dart';
|
|||||||
|
|
||||||
/// Bottom sheet for selecting chapters
|
/// Bottom sheet for selecting chapters
|
||||||
class ChapterSheet extends StatelessWidget {
|
class ChapterSheet extends StatelessWidget {
|
||||||
final Player player;
|
final MpvPlayer player;
|
||||||
final List<PlexChapter> chapters;
|
final List<PlexChapter> chapters;
|
||||||
final bool chaptersLoaded;
|
final bool chaptersLoaded;
|
||||||
final String? serverId; // Server ID for the metadata these chapters belong to
|
final String? serverId; // Server ID for the metadata these chapters belong to
|
||||||
@@ -23,7 +24,7 @@ class ChapterSheet extends StatelessWidget {
|
|||||||
|
|
||||||
static void show(
|
static void show(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
Player player,
|
MpvPlayer player,
|
||||||
List<PlexChapter> chapters,
|
List<PlexChapter> chapters,
|
||||||
bool chaptersLoaded, {
|
bool chaptersLoaded, {
|
||||||
String? serverId,
|
String? serverId,
|
||||||
@@ -47,7 +48,7 @@ class ChapterSheet extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return StreamBuilder<Duration>(
|
return StreamBuilder<Duration>(
|
||||||
stream: player.stream.position,
|
stream: player.streams.position,
|
||||||
initialData: player.state.position,
|
initialData: player.state.position,
|
||||||
builder: (context, positionSnapshot) {
|
builder: (context, positionSnapshot) {
|
||||||
final currentPosition = positionSnapshot.data ?? Duration.zero;
|
final currentPosition = positionSnapshot.data ?? Duration.zero;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:media_kit/media_kit.dart';
|
|
||||||
|
import '../../../mpv/mpv.dart';
|
||||||
import '../../../i18n/strings.g.dart';
|
import '../../../i18n/strings.g.dart';
|
||||||
import 'track_selection_sheet.dart';
|
import 'track_selection_sheet.dart';
|
||||||
|
|
||||||
@@ -7,10 +8,10 @@ import 'track_selection_sheet.dart';
|
|||||||
class SubtitleTrackSheet {
|
class SubtitleTrackSheet {
|
||||||
static void show(
|
static void show(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
Player player, {
|
MpvPlayer player, {
|
||||||
Function(SubtitleTrack)? onTrackChanged,
|
Function(MpvSubtitleTrack)? onTrackChanged,
|
||||||
}) {
|
}) {
|
||||||
TrackSelectionSheet.show<SubtitleTrack>(
|
TrackSelectionSheet.show<MpvSubtitleTrack>(
|
||||||
context: context,
|
context: context,
|
||||||
player: player,
|
player: player,
|
||||||
title: t.videoControls.subtitlesLabel,
|
title: t.videoControls.subtitlesLabel,
|
||||||
@@ -41,10 +42,10 @@ class SubtitleTrackSheet {
|
|||||||
}
|
}
|
||||||
return parts.isEmpty ? 'Track ${index + 1}' : parts.join(' · ');
|
return parts.isEmpty ? 'Track ${index + 1}' : parts.join(' · ');
|
||||||
},
|
},
|
||||||
setTrack: (track) => player.setSubtitleTrack(track),
|
setTrack: (track) => player.selectSubtitleTrack(track),
|
||||||
onTrackChanged: onTrackChanged,
|
onTrackChanged: onTrackChanged,
|
||||||
showOffOption: true,
|
showOffOption: true,
|
||||||
createOffTrack: () => SubtitleTrack.no(),
|
createOffTrack: () => MpvSubtitleTrack.off,
|
||||||
isOffTrack: (track) => track.id == 'no',
|
isOffTrack: (track) => track.id == 'no',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:media_kit/media_kit.dart';
|
|
||||||
|
import '../../../mpv/mpv.dart';
|
||||||
import 'base_video_control_sheet.dart';
|
import 'base_video_control_sheet.dart';
|
||||||
|
|
||||||
/// Generic track selection sheet for audio and subtitle tracks
|
/// 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 {
|
class TrackSelectionSheet<T> extends StatelessWidget {
|
||||||
final Player player;
|
final MpvPlayer player;
|
||||||
final String title;
|
final String title;
|
||||||
final IconData icon;
|
final IconData icon;
|
||||||
final List<T> Function(Tracks?) extractTracks;
|
final List<T> Function(MpvTracks?) extractTracks;
|
||||||
final T Function(Track) getCurrentTrack;
|
final T? Function(MpvTrack) getCurrentTrack;
|
||||||
final String Function(T track, int index) buildLabel;
|
final String Function(T track, int index) buildLabel;
|
||||||
final void Function(T track) setTrack;
|
final void Function(T track) setTrack;
|
||||||
final Function(T)? onTrackChanged;
|
final Function(T)? onTrackChanged;
|
||||||
@@ -35,11 +36,11 @@ class TrackSelectionSheet<T> extends StatelessWidget {
|
|||||||
|
|
||||||
static void show<T>({
|
static void show<T>({
|
||||||
required BuildContext context,
|
required BuildContext context,
|
||||||
required Player player,
|
required MpvPlayer player,
|
||||||
required String title,
|
required String title,
|
||||||
required IconData icon,
|
required IconData icon,
|
||||||
required List<T> Function(Tracks?) extractTracks,
|
required List<T> Function(MpvTracks?) extractTracks,
|
||||||
required T Function(Track) getCurrentTrack,
|
required T? Function(MpvTrack) getCurrentTrack,
|
||||||
required String Function(T track, int index) buildLabel,
|
required String Function(T track, int index) buildLabel,
|
||||||
required void Function(T track) setTrack,
|
required void Function(T track) setTrack,
|
||||||
Function(T)? onTrackChanged,
|
Function(T)? onTrackChanged,
|
||||||
@@ -66,9 +67,9 @@ class TrackSelectionSheet<T> extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
String _getEmptyMessage() {
|
String _getEmptyMessage() {
|
||||||
if (T == SubtitleTrack) {
|
if (T == MpvSubtitleTrack) {
|
||||||
return 'No subtitles available';
|
return 'No subtitles available';
|
||||||
} else if (T == AudioTrack) {
|
} else if (T == MpvAudioTrack) {
|
||||||
return 'No audio tracks available';
|
return 'No audio tracks available';
|
||||||
}
|
}
|
||||||
return 'No tracks available';
|
return 'No tracks available';
|
||||||
@@ -76,16 +77,16 @@ class TrackSelectionSheet<T> extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return StreamBuilder<Tracks>(
|
return StreamBuilder<MpvTracks>(
|
||||||
stream: player.stream.tracks,
|
stream: player.streams.tracks,
|
||||||
initialData: player.state.tracks,
|
initialData: player.state.tracks,
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
final tracks = snapshot.data;
|
final tracks = snapshot.data;
|
||||||
final availableTracks = extractTracks(tracks).where((track) {
|
final availableTracks = extractTracks(tracks).where((track) {
|
||||||
// Filter out 'auto' and 'no' tracks from the list
|
// Filter out 'auto' and 'no' tracks from the list
|
||||||
if (track is AudioTrack) {
|
if (track is MpvAudioTrack) {
|
||||||
return track.id != 'auto' && track.id != 'no';
|
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 track.id != 'auto' && track.id != 'no';
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@@ -101,17 +102,17 @@ class TrackSelectionSheet<T> extends StatelessWidget {
|
|||||||
style: const TextStyle(color: Colors.white70),
|
style: const TextStyle(color: Colors.white70),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: StreamBuilder<Track>(
|
: StreamBuilder<MpvTrack>(
|
||||||
stream: player.stream.track,
|
stream: player.streams.track,
|
||||||
initialData: player.state.track,
|
initialData: player.state.track,
|
||||||
builder: (context, selectedSnapshot) {
|
builder: (context, selectedSnapshot) {
|
||||||
final currentTrack =
|
final currentTrack =
|
||||||
selectedSnapshot.data ?? player.state.track;
|
selectedSnapshot.data ?? player.state.track;
|
||||||
final selectedTrack = getCurrentTrack(currentTrack);
|
final selectedTrack = getCurrentTrack(currentTrack);
|
||||||
|
|
||||||
// Determine if "Off" is selected
|
// Determine if "Off" is selected (null or explicit off)
|
||||||
final isOffSelected =
|
final isOffSelected = selectedTrack == null ||
|
||||||
isOffTrack?.call(selectedTrack) ?? false;
|
(isOffTrack?.call(selectedTrack) ?? false);
|
||||||
|
|
||||||
final itemCount =
|
final itemCount =
|
||||||
availableTracks.length + (showOffOption ? 1 : 0);
|
availableTracks.length + (showOffOption ? 1 : 0);
|
||||||
@@ -150,18 +151,20 @@ class TrackSelectionSheet<T> extends StatelessWidget {
|
|||||||
|
|
||||||
// Check if this track is selected
|
// Check if this track is selected
|
||||||
String trackId;
|
String trackId;
|
||||||
if (track is AudioTrack) {
|
if (track is MpvAudioTrack) {
|
||||||
trackId = track.id;
|
trackId = track.id;
|
||||||
} else if (track is SubtitleTrack) {
|
} else if (track is MpvSubtitleTrack) {
|
||||||
trackId = track.id;
|
trackId = track.id;
|
||||||
} else {
|
} else {
|
||||||
trackId = '';
|
trackId = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
String selectedId;
|
String selectedId;
|
||||||
if (selectedTrack is AudioTrack) {
|
if (selectedTrack == null) {
|
||||||
|
selectedId = '';
|
||||||
|
} else if (selectedTrack is MpvAudioTrack) {
|
||||||
selectedId = selectedTrack.id;
|
selectedId = selectedTrack.id;
|
||||||
} else if (selectedTrack is SubtitleTrack) {
|
} else if (selectedTrack is MpvSubtitleTrack) {
|
||||||
selectedId = selectedTrack.id;
|
selectedId = selectedTrack.id;
|
||||||
} else {
|
} else {
|
||||||
selectedId = '';
|
selectedId = '';
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:media_kit/media_kit.dart';
|
|
||||||
|
import '../../../mpv/mpv.dart';
|
||||||
import '../../../services/settings_service.dart';
|
import '../../../services/settings_service.dart';
|
||||||
import '../../../services/sleep_timer_service.dart';
|
import '../../../services/sleep_timer_service.dart';
|
||||||
import '../../../utils/platform_detector.dart';
|
import '../../../utils/platform_detector.dart';
|
||||||
@@ -58,7 +59,7 @@ class _SettingsMenuItem extends StatelessWidget {
|
|||||||
|
|
||||||
/// Unified settings sheet for playback adjustments with in-sheet navigation
|
/// Unified settings sheet for playback adjustments with in-sheet navigation
|
||||||
class VideoSettingsSheet extends StatefulWidget {
|
class VideoSettingsSheet extends StatefulWidget {
|
||||||
final Player player;
|
final MpvPlayer player;
|
||||||
final int audioSyncOffset;
|
final int audioSyncOffset;
|
||||||
final int subtitleSyncOffset;
|
final int subtitleSyncOffset;
|
||||||
|
|
||||||
@@ -71,7 +72,7 @@ class VideoSettingsSheet extends StatefulWidget {
|
|||||||
|
|
||||||
static Future<void> show(
|
static Future<void> show(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
Player player,
|
MpvPlayer player,
|
||||||
int audioSyncOffset,
|
int audioSyncOffset,
|
||||||
int subtitleSyncOffset,
|
int subtitleSyncOffset,
|
||||||
) {
|
) {
|
||||||
@@ -200,7 +201,7 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
|
|||||||
children: [
|
children: [
|
||||||
// Playback Speed
|
// Playback Speed
|
||||||
StreamBuilder<double>(
|
StreamBuilder<double>(
|
||||||
stream: widget.player.stream.rate,
|
stream: widget.player.streams.rate,
|
||||||
initialData: widget.player.state.rate,
|
initialData: widget.player.state.rate,
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
final currentRate = snapshot.data ?? 1.0;
|
final currentRate = snapshot.data ?? 1.0;
|
||||||
@@ -248,8 +249,8 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
|
|||||||
|
|
||||||
// Audio Output Device (Desktop only)
|
// Audio Output Device (Desktop only)
|
||||||
if (isDesktop)
|
if (isDesktop)
|
||||||
StreamBuilder<AudioDevice>(
|
StreamBuilder<MpvAudioDevice>(
|
||||||
stream: widget.player.stream.audioDevice,
|
stream: widget.player.streams.audioDevice,
|
||||||
initialData: widget.player.state.audioDevice,
|
initialData: widget.player.state.audioDevice,
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
final currentDevice =
|
final currentDevice =
|
||||||
@@ -273,7 +274,7 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
|
|||||||
|
|
||||||
Widget _buildSpeedView() {
|
Widget _buildSpeedView() {
|
||||||
return StreamBuilder<double>(
|
return StreamBuilder<double>(
|
||||||
stream: widget.player.stream.rate,
|
stream: widget.player.streams.rate,
|
||||||
initialData: widget.player.state.rate,
|
initialData: widget.player.state.rate,
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
final currentRate = snapshot.data ?? 1.0;
|
final currentRate = snapshot.data ?? 1.0;
|
||||||
@@ -352,14 +353,14 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildAudioDeviceView() {
|
Widget _buildAudioDeviceView() {
|
||||||
return StreamBuilder<List<AudioDevice>>(
|
return StreamBuilder<List<MpvAudioDevice>>(
|
||||||
stream: widget.player.stream.audioDevices,
|
stream: widget.player.streams.audioDevices,
|
||||||
initialData: widget.player.state.audioDevices,
|
initialData: widget.player.state.audioDevices,
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
final devices = snapshot.data ?? [];
|
final devices = snapshot.data ?? [];
|
||||||
|
|
||||||
return StreamBuilder<AudioDevice>(
|
return StreamBuilder<MpvAudioDevice>(
|
||||||
stream: widget.player.stream.audioDevice,
|
stream: widget.player.streams.audioDevice,
|
||||||
initialData: widget.player.state.audioDevice,
|
initialData: widget.player.state.audioDevice,
|
||||||
builder: (context, selectedSnapshot) {
|
builder: (context, selectedSnapshot) {
|
||||||
final currentDevice =
|
final currentDevice =
|
||||||
|
|||||||
@@ -5,9 +5,10 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:rate_limiter/rate_limiter.dart';
|
import 'package:rate_limiter/rate_limiter.dart';
|
||||||
import 'package:flutter/services.dart' show SystemChrome, DeviceOrientation;
|
import 'package:flutter/services.dart' show SystemChrome, DeviceOrientation;
|
||||||
import 'package:macos_window_utils/macos_window_utils.dart';
|
import 'package:macos_window_utils/macos_window_utils.dart';
|
||||||
import 'package:media_kit/media_kit.dart';
|
|
||||||
import 'package:window_manager/window_manager.dart';
|
import 'package:window_manager/window_manager.dart';
|
||||||
|
|
||||||
|
import '../../mpv/mpv.dart';
|
||||||
|
|
||||||
import '../../client/plex_client.dart';
|
import '../../client/plex_client.dart';
|
||||||
import '../../models/plex_media_info.dart';
|
import '../../models/plex_media_info.dart';
|
||||||
import '../../models/plex_media_version.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
|
/// Custom video controls builder for Plex with chapter, audio, and subtitle support
|
||||||
Widget plexVideoControlsBuilder(
|
Widget plexVideoControlsBuilder(
|
||||||
Player player,
|
MpvPlayer player,
|
||||||
PlexMetadata metadata, {
|
PlexMetadata metadata, {
|
||||||
VoidCallback? onNext,
|
VoidCallback? onNext,
|
||||||
VoidCallback? onPrevious,
|
VoidCallback? onPrevious,
|
||||||
@@ -35,8 +36,8 @@ Widget plexVideoControlsBuilder(
|
|||||||
int? selectedMediaIndex,
|
int? selectedMediaIndex,
|
||||||
int boxFitMode = 0,
|
int boxFitMode = 0,
|
||||||
VoidCallback? onCycleBoxFitMode,
|
VoidCallback? onCycleBoxFitMode,
|
||||||
Function(AudioTrack)? onAudioTrackChanged,
|
Function(MpvAudioTrack)? onAudioTrackChanged,
|
||||||
Function(SubtitleTrack)? onSubtitleTrackChanged,
|
Function(MpvSubtitleTrack)? onSubtitleTrackChanged,
|
||||||
}) {
|
}) {
|
||||||
return PlexVideoControls(
|
return PlexVideoControls(
|
||||||
player: player,
|
player: player,
|
||||||
@@ -53,7 +54,7 @@ Widget plexVideoControlsBuilder(
|
|||||||
}
|
}
|
||||||
|
|
||||||
class PlexVideoControls extends StatefulWidget {
|
class PlexVideoControls extends StatefulWidget {
|
||||||
final Player player;
|
final MpvPlayer player;
|
||||||
final PlexMetadata metadata;
|
final PlexMetadata metadata;
|
||||||
final VoidCallback? onNext;
|
final VoidCallback? onNext;
|
||||||
final VoidCallback? onPrevious;
|
final VoidCallback? onPrevious;
|
||||||
@@ -61,8 +62,8 @@ class PlexVideoControls extends StatefulWidget {
|
|||||||
final int selectedMediaIndex;
|
final int selectedMediaIndex;
|
||||||
final int boxFitMode;
|
final int boxFitMode;
|
||||||
final VoidCallback? onCycleBoxFitMode;
|
final VoidCallback? onCycleBoxFitMode;
|
||||||
final Function(AudioTrack)? onAudioTrackChanged;
|
final Function(MpvAudioTrack)? onAudioTrackChanged;
|
||||||
final Function(SubtitleTrack)? onSubtitleTrackChanged;
|
final Function(MpvSubtitleTrack)? onSubtitleTrackChanged;
|
||||||
|
|
||||||
const PlexVideoControls({
|
const PlexVideoControls({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -114,6 +115,9 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
|
|||||||
bool _markersLoaded = false;
|
bool _markersLoaded = false;
|
||||||
// Playback state subscription for auto-hide timer
|
// Playback state subscription for auto-hide timer
|
||||||
StreamSubscription<bool>? _playingSubscription;
|
StreamSubscription<bool>? _playingSubscription;
|
||||||
|
// Window resize pause state
|
||||||
|
Timer? _resizeDebounceTimer;
|
||||||
|
bool _wasPlayingBeforeResize = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -145,7 +149,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _listenToPosition() {
|
void _listenToPosition() {
|
||||||
widget.player.stream.position.listen((position) {
|
widget.player.streams.position.listen((position) {
|
||||||
if (_markers.isEmpty || !_markersLoaded) {
|
if (_markers.isEmpty || !_markersLoaded) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -170,7 +174,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
|
|||||||
|
|
||||||
/// Listen to playback state changes to manage auto-hide timer on iOS/mobile
|
/// Listen to playback state changes to manage auto-hide timer on iOS/mobile
|
||||||
void _listenToPlayingState() {
|
void _listenToPlayingState() {
|
||||||
_playingSubscription = widget.player.stream.playing.listen((isPlaying) {
|
_playingSubscription = widget.player.streams.playing.listen((isPlaying) {
|
||||||
if (isPlaying && _showControls) {
|
if (isPlaying && _showControls) {
|
||||||
_startHideTimer();
|
_startHideTimer();
|
||||||
} else if (!isPlaying) {
|
} else if (!isPlaying) {
|
||||||
@@ -240,6 +244,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
|
|||||||
void dispose() {
|
void dispose() {
|
||||||
_hideTimer?.cancel();
|
_hideTimer?.cancel();
|
||||||
_feedbackTimer?.cancel();
|
_feedbackTimer?.cancel();
|
||||||
|
_resizeDebounceTimer?.cancel();
|
||||||
_seekThrottle.cancel();
|
_seekThrottle.cancel();
|
||||||
_playingSubscription?.cancel();
|
_playingSubscription?.cancel();
|
||||||
_focusNode.dispose();
|
_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() {
|
void _startHideTimer() {
|
||||||
_hideTimer?.cancel();
|
_hideTimer?.cancel();
|
||||||
// Only auto-hide if playing
|
// Only auto-hide if playing
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:media_kit/media_kit.dart';
|
|
||||||
|
import '../../../mpv/mpv.dart';
|
||||||
import '../../../services/sleep_timer_service.dart';
|
import '../../../services/sleep_timer_service.dart';
|
||||||
import 'sleep_timer_active_status.dart';
|
import 'sleep_timer_active_status.dart';
|
||||||
import 'sleep_timer_duration_list.dart';
|
import 'sleep_timer_duration_list.dart';
|
||||||
|
|
||||||
/// Shared UI for sleep timer selection and active status.
|
/// Shared UI for sleep timer selection and active status.
|
||||||
class SleepTimerContent extends StatelessWidget {
|
class SleepTimerContent extends StatelessWidget {
|
||||||
final Player player;
|
final MpvPlayer player;
|
||||||
final SleepTimerService sleepTimer;
|
final SleepTimerService sleepTimer;
|
||||||
final int? defaultDuration;
|
final int? defaultDuration;
|
||||||
final VoidCallback? onCancel;
|
final VoidCallback? onCancel;
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:media_kit/media_kit.dart';
|
|
||||||
|
import '../../../mpv/mpv.dart';
|
||||||
import '../../../services/sleep_timer_service.dart';
|
import '../../../services/sleep_timer_service.dart';
|
||||||
import '../../../utils/duration_formatter.dart';
|
import '../../../utils/duration_formatter.dart';
|
||||||
import '../../../i18n/strings.g.dart';
|
import '../../../i18n/strings.g.dart';
|
||||||
|
|
||||||
/// Widget displaying list of sleep timer durations for selection
|
/// Widget displaying list of sleep timer durations for selection
|
||||||
class SleepTimerDurationList extends StatelessWidget {
|
class SleepTimerDurationList extends StatelessWidget {
|
||||||
final Player player;
|
final MpvPlayer player;
|
||||||
final SleepTimerService sleepTimer;
|
final SleepTimerService sleepTimer;
|
||||||
final int? defaultDuration;
|
final int? defaultDuration;
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:media_kit/media_kit.dart';
|
|
||||||
|
import '../../../mpv/mpv.dart';
|
||||||
import '../../../i18n/strings.g.dart';
|
import '../../../i18n/strings.g.dart';
|
||||||
import '../../../utils/duration_formatter.dart';
|
import '../../../utils/duration_formatter.dart';
|
||||||
|
|
||||||
/// Reusable widget for adjusting sync offsets (audio or subtitle)
|
/// Reusable widget for adjusting sync offsets (audio or subtitle)
|
||||||
class SyncOffsetControl extends StatefulWidget {
|
class SyncOffsetControl extends StatefulWidget {
|
||||||
final Player player;
|
final MpvPlayer player;
|
||||||
final String propertyName; // 'audio-delay' or 'sub-delay'
|
final String propertyName; // 'audio-delay' or 'sub-delay'
|
||||||
final int initialOffset;
|
final int initialOffset;
|
||||||
final String labelText; // 'Audio' or 'Subtitles'
|
final String labelText; // 'Audio' or 'Subtitles'
|
||||||
@@ -44,11 +45,11 @@ class _SyncOffsetControlState extends State<SyncOffsetControl> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _applyOffset(double offsetMs) async {
|
Future<void> _applyOffset(double offsetMs) async {
|
||||||
// Convert milliseconds to seconds for media_kit
|
// Convert milliseconds to seconds for mpv
|
||||||
final offsetSeconds = offsetMs / 1000.0;
|
final offsetSeconds = offsetMs / 1000.0;
|
||||||
|
|
||||||
// Apply to player using setProperty
|
// Apply to player using setProperty
|
||||||
await (widget.player.platform as dynamic).setProperty(
|
await widget.player.setProperty(
|
||||||
widget.propertyName,
|
widget.propertyName,
|
||||||
offsetSeconds.toString(),
|
offsetSeconds.toString(),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import 'dart:io' show Platform;
|
import 'dart:io' show Platform;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
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_info.dart';
|
||||||
import '../../../models/plex_media_version.dart';
|
import '../../../models/plex_media_version.dart';
|
||||||
import '../../../services/sleep_timer_service.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
|
/// Row of track and chapter control buttons for the video player
|
||||||
class TrackChapterControls extends StatelessWidget {
|
class TrackChapterControls extends StatelessWidget {
|
||||||
final Player player;
|
final MpvPlayer player;
|
||||||
final List<PlexChapter> chapters;
|
final List<PlexChapter> chapters;
|
||||||
final bool chaptersLoaded;
|
final bool chaptersLoaded;
|
||||||
final List<PlexMediaVersion> availableVersions;
|
final List<PlexMediaVersion> availableVersions;
|
||||||
@@ -31,8 +31,8 @@ class TrackChapterControls extends StatelessWidget {
|
|||||||
final VoidCallback? onToggleRotationLock;
|
final VoidCallback? onToggleRotationLock;
|
||||||
final VoidCallback? onToggleFullscreen;
|
final VoidCallback? onToggleFullscreen;
|
||||||
final Function(int)? onSwitchVersion;
|
final Function(int)? onSwitchVersion;
|
||||||
final Function(AudioTrack)? onAudioTrackChanged;
|
final Function(MpvAudioTrack)? onAudioTrackChanged;
|
||||||
final Function(SubtitleTrack)? onSubtitleTrackChanged;
|
final Function(MpvSubtitleTrack)? onSubtitleTrackChanged;
|
||||||
final VoidCallback? onLoadSeekTimes;
|
final VoidCallback? onLoadSeekTimes;
|
||||||
final String serverId;
|
final String serverId;
|
||||||
|
|
||||||
@@ -60,8 +60,8 @@ class TrackChapterControls extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return StreamBuilder<Tracks>(
|
return StreamBuilder<MpvTracks>(
|
||||||
stream: player.stream.tracks,
|
stream: player.streams.tracks,
|
||||||
initialData: player.state.tracks,
|
initialData: player.state.tracks,
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
final tracks = snapshot.data;
|
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;
|
if (tracks == null) return false;
|
||||||
final audioTracks = tracks.audio
|
final audioTracks = tracks.audio
|
||||||
.where((track) => track.id != 'auto' && track.id != 'no')
|
.where((track) => track.id != 'auto' && track.id != 'no')
|
||||||
@@ -183,7 +183,7 @@ class TrackChapterControls extends StatelessWidget {
|
|||||||
return audioTracks.length > 1;
|
return audioTracks.length > 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool _hasSubtitles(Tracks? tracks) {
|
bool _hasSubtitles(MpvTracks? tracks) {
|
||||||
if (tracks == null) return false;
|
if (tracks == null) return false;
|
||||||
final subtitles = tracks.subtitle
|
final subtitles = tracks.subtitle
|
||||||
.where((track) => track.id != 'auto' && track.id != 'no')
|
.where((track) => track.id != 'auto' && track.id != 'no')
|
||||||
|
|||||||
@@ -1,22 +1,22 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:media_kit/media_kit.dart';
|
|
||||||
|
|
||||||
|
import '../../../mpv/mpv.dart';
|
||||||
import '../../../services/settings_service.dart';
|
import '../../../services/settings_service.dart';
|
||||||
import '../../../i18n/strings.g.dart';
|
import '../../../i18n/strings.g.dart';
|
||||||
|
|
||||||
/// A volume control widget that displays a mute/unmute button and volume slider.
|
/// 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].
|
/// the volume setting using [SettingsService].
|
||||||
class VolumeControl extends StatelessWidget {
|
class VolumeControl extends StatelessWidget {
|
||||||
final Player player;
|
final MpvPlayer player;
|
||||||
|
|
||||||
const VolumeControl({super.key, required this.player});
|
const VolumeControl({super.key, required this.player});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return StreamBuilder<double>(
|
return StreamBuilder<double>(
|
||||||
stream: player.stream.volume,
|
stream: player.streams.volume,
|
||||||
initialData: player.state.volume,
|
initialData: player.state.volume,
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
final volume = snapshot.data ?? 100.0;
|
final volume = snapshot.data ?? 100.0;
|
||||||
|
|||||||
@@ -7,24 +7,15 @@
|
|||||||
#include "generated_plugin_registrant.h"
|
#include "generated_plugin_registrant.h"
|
||||||
|
|
||||||
#include <hotkey_manager_linux/hotkey_manager_linux_plugin.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 <os_media_controls/os_media_controls_plugin.h>
|
||||||
#include <screen_retriever_linux/screen_retriever_linux_plugin.h>
|
#include <screen_retriever_linux/screen_retriever_linux_plugin.h>
|
||||||
#include <url_launcher_linux/url_launcher_plugin.h>
|
#include <url_launcher_linux/url_launcher_plugin.h>
|
||||||
#include <volume_controller/volume_controller_plugin.h>
|
|
||||||
#include <window_manager/window_manager_plugin.h>
|
#include <window_manager/window_manager_plugin.h>
|
||||||
|
|
||||||
void fl_register_plugins(FlPluginRegistry* registry) {
|
void fl_register_plugins(FlPluginRegistry* registry) {
|
||||||
g_autoptr(FlPluginRegistrar) hotkey_manager_linux_registrar =
|
g_autoptr(FlPluginRegistrar) hotkey_manager_linux_registrar =
|
||||||
fl_plugin_registry_get_registrar_for_plugin(registry, "HotkeyManagerLinuxPlugin");
|
fl_plugin_registry_get_registrar_for_plugin(registry, "HotkeyManagerLinuxPlugin");
|
||||||
hotkey_manager_linux_plugin_register_with_registrar(hotkey_manager_linux_registrar);
|
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 =
|
g_autoptr(FlPluginRegistrar) os_media_controls_registrar =
|
||||||
fl_plugin_registry_get_registrar_for_plugin(registry, "OsMediaControlsPlugin");
|
fl_plugin_registry_get_registrar_for_plugin(registry, "OsMediaControlsPlugin");
|
||||||
os_media_controls_plugin_register_with_registrar(os_media_controls_registrar);
|
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 =
|
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
|
||||||
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
|
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
|
||||||
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
|
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 =
|
g_autoptr(FlPluginRegistrar) window_manager_registrar =
|
||||||
fl_plugin_registry_get_registrar_for_plugin(registry, "WindowManagerPlugin");
|
fl_plugin_registry_get_registrar_for_plugin(registry, "WindowManagerPlugin");
|
||||||
window_manager_plugin_register_with_registrar(window_manager_registrar);
|
window_manager_plugin_register_with_registrar(window_manager_registrar);
|
||||||
|
|||||||
@@ -4,12 +4,9 @@
|
|||||||
|
|
||||||
list(APPEND FLUTTER_PLUGIN_LIST
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
hotkey_manager_linux
|
hotkey_manager_linux
|
||||||
media_kit_libs_linux
|
|
||||||
media_kit_video
|
|
||||||
os_media_controls
|
os_media_controls
|
||||||
screen_retriever_linux
|
screen_retriever_linux
|
||||||
url_launcher_linux
|
url_launcher_linux
|
||||||
volume_controller
|
|
||||||
window_manager
|
window_manager
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ import Foundation
|
|||||||
import connectivity_plus
|
import connectivity_plus
|
||||||
import hotkey_manager_macos
|
import hotkey_manager_macos
|
||||||
import macos_window_utils
|
import macos_window_utils
|
||||||
import media_kit_libs_macos_video
|
|
||||||
import media_kit_video
|
|
||||||
import os_media_controls
|
import os_media_controls
|
||||||
import package_info_plus
|
import package_info_plus
|
||||||
import path_provider_foundation
|
import path_provider_foundation
|
||||||
@@ -17,16 +15,12 @@ import screen_retriever_macos
|
|||||||
import shared_preferences_foundation
|
import shared_preferences_foundation
|
||||||
import sqflite_darwin
|
import sqflite_darwin
|
||||||
import url_launcher_macos
|
import url_launcher_macos
|
||||||
import volume_controller
|
|
||||||
import wakelock_plus
|
|
||||||
import window_manager
|
import window_manager
|
||||||
|
|
||||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||||
ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin"))
|
ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin"))
|
||||||
HotkeyManagerMacosPlugin.register(with: registry.registrar(forPlugin: "HotkeyManagerMacosPlugin"))
|
HotkeyManagerMacosPlugin.register(with: registry.registrar(forPlugin: "HotkeyManagerMacosPlugin"))
|
||||||
MacOSWindowUtilsPlugin.register(with: registry.registrar(forPlugin: "MacOSWindowUtilsPlugin"))
|
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"))
|
OsMediaControlsPlugin.register(with: registry.registrar(forPlugin: "OsMediaControlsPlugin"))
|
||||||
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
||||||
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
||||||
@@ -34,7 +28,5 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
|||||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||||
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
|
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
|
||||||
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
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"))
|
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.
|
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
|
||||||
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
|
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
|
||||||
|
|||||||
+1
-25
@@ -8,10 +8,6 @@ PODS:
|
|||||||
- HotKey
|
- HotKey
|
||||||
- macos_window_utils (1.0.0):
|
- macos_window_utils (1.0.0):
|
||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
- media_kit_libs_macos_video (1.0.4):
|
|
||||||
- FlutterMacOS
|
|
||||||
- media_kit_video (0.0.1):
|
|
||||||
- FlutterMacOS
|
|
||||||
- os_media_controls (0.0.1):
|
- os_media_controls (0.0.1):
|
||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
- package_info_plus (0.0.1):
|
- package_info_plus (0.0.1):
|
||||||
@@ -29,10 +25,6 @@ PODS:
|
|||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
- url_launcher_macos (0.0.1):
|
- url_launcher_macos (0.0.1):
|
||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
- volume_controller (0.0.1):
|
|
||||||
- FlutterMacOS
|
|
||||||
- wakelock_plus (0.0.1):
|
|
||||||
- FlutterMacOS
|
|
||||||
- window_manager (0.2.0):
|
- window_manager (0.2.0):
|
||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
|
|
||||||
@@ -41,8 +33,6 @@ DEPENDENCIES:
|
|||||||
- FlutterMacOS (from `Flutter/ephemeral`)
|
- FlutterMacOS (from `Flutter/ephemeral`)
|
||||||
- hotkey_manager_macos (from `Flutter/ephemeral/.symlinks/plugins/hotkey_manager_macos/macos`)
|
- hotkey_manager_macos (from `Flutter/ephemeral/.symlinks/plugins/hotkey_manager_macos/macos`)
|
||||||
- macos_window_utils (from `Flutter/ephemeral/.symlinks/plugins/macos_window_utils/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`)
|
- os_media_controls (from `Flutter/ephemeral/.symlinks/plugins/os_media_controls/macos`)
|
||||||
- package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/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`)
|
- 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`)
|
- shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`)
|
||||||
- sqflite_darwin (from `Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin`)
|
- sqflite_darwin (from `Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin`)
|
||||||
- url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`)
|
- 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`)
|
- window_manager (from `Flutter/ephemeral/.symlinks/plugins/window_manager/macos`)
|
||||||
|
|
||||||
SPEC REPOS:
|
SPEC REPOS:
|
||||||
@@ -67,10 +55,6 @@ EXTERNAL SOURCES:
|
|||||||
:path: Flutter/ephemeral/.symlinks/plugins/hotkey_manager_macos/macos
|
:path: Flutter/ephemeral/.symlinks/plugins/hotkey_manager_macos/macos
|
||||||
macos_window_utils:
|
macos_window_utils:
|
||||||
:path: Flutter/ephemeral/.symlinks/plugins/macos_window_utils/macos
|
: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:
|
os_media_controls:
|
||||||
:path: Flutter/ephemeral/.symlinks/plugins/os_media_controls/macos
|
:path: Flutter/ephemeral/.symlinks/plugins/os_media_controls/macos
|
||||||
package_info_plus:
|
package_info_plus:
|
||||||
@@ -85,10 +69,6 @@ EXTERNAL SOURCES:
|
|||||||
:path: Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin
|
:path: Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin
|
||||||
url_launcher_macos:
|
url_launcher_macos:
|
||||||
:path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/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:
|
window_manager:
|
||||||
:path: Flutter/ephemeral/.symlinks/plugins/window_manager/macos
|
:path: Flutter/ephemeral/.symlinks/plugins/window_manager/macos
|
||||||
|
|
||||||
@@ -98,8 +78,6 @@ SPEC CHECKSUMS:
|
|||||||
HotKey: 400beb7caa29054ea8d864c96f5ba7e5b4852277
|
HotKey: 400beb7caa29054ea8d864c96f5ba7e5b4852277
|
||||||
hotkey_manager_macos: a4317849af96d2430fa89944d3c58977ca089fbe
|
hotkey_manager_macos: a4317849af96d2430fa89944d3c58977ca089fbe
|
||||||
macos_window_utils: 23f54331a0fd51eea9e0ed347253bf48fd379d1d
|
macos_window_utils: 23f54331a0fd51eea9e0ed347253bf48fd379d1d
|
||||||
media_kit_libs_macos_video: 85a23e549b5f480e72cae3e5634b5514bc692f65
|
|
||||||
media_kit_video: fa6564e3799a0a28bff39442334817088b7ca758
|
|
||||||
os_media_controls: c07c04c4afdf59dda0a3f398457a46823c4ce0ed
|
os_media_controls: c07c04c4afdf59dda0a3f398457a46823c4ce0ed
|
||||||
package_info_plus: f0052d280d17aa382b932f399edf32507174e870
|
package_info_plus: f0052d280d17aa382b932f399edf32507174e870
|
||||||
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
|
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
|
||||||
@@ -107,10 +85,8 @@ SPEC CHECKSUMS:
|
|||||||
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
|
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
|
||||||
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
|
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
|
||||||
url_launcher_macos: f87a979182d112f911de6820aefddaf56ee9fbfd
|
url_launcher_macos: f87a979182d112f911de6820aefddaf56ee9fbfd
|
||||||
volume_controller: 5c068e6d085c80dadd33fc2c918d2114b775b3dd
|
|
||||||
wakelock_plus: 917609be14d812ddd9e9528876538b2263aaa03b
|
|
||||||
window_manager: 1d01fa7ac65a6e6f83b965471b1a7fdd3f06166c
|
window_manager: 1d01fa7ac65a6e6f83b965471b1a7fdd3f06166c
|
||||||
|
|
||||||
PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009
|
PODFILE CHECKSUM: d16f5e5d196d1ca9863d7a71d5885cad7ffa7d2d
|
||||||
|
|
||||||
COCOAPODS: 1.16.2
|
COCOAPODS: 1.16.2
|
||||||
|
|||||||
@@ -26,7 +26,10 @@
|
|||||||
33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; };
|
33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; };
|
||||||
33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; };
|
33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; };
|
||||||
33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; };
|
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 */; };
|
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 */; };
|
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 */; };
|
FE52EE1D2D489FA88507D14E /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 84260356652A3270E664FED4 /* Pods_RunnerTests.framework */; };
|
||||||
/* End PBXBuildFile section */
|
/* 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>"; };
|
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>"; };
|
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>"; };
|
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; };
|
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>"; };
|
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; };
|
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;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
9AECA605E2E1BECE3CA5BD01 /* Pods_Runner.framework in Frameworks */,
|
9AECA605E2E1BECE3CA5BD01 /* Pods_Runner.framework in Frameworks */,
|
||||||
|
6AB6B0952EDB27C100EAC8DB /* MPVKit-GPL in Frameworks */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -190,6 +196,7 @@
|
|||||||
children = (
|
children = (
|
||||||
33CC10F02044A3C60003C045 /* AppDelegate.swift */,
|
33CC10F02044A3C60003C045 /* AppDelegate.swift */,
|
||||||
33CC11122044BFA00003C045 /* MainFlutterWindow.swift */,
|
33CC11122044BFA00003C045 /* MainFlutterWindow.swift */,
|
||||||
|
6AD8B1652ED7B50000E9E1B4 /* MpvPlayer */,
|
||||||
33E51913231747F40026EE4D /* DebugProfile.entitlements */,
|
33E51913231747F40026EE4D /* DebugProfile.entitlements */,
|
||||||
33E51914231749380026EE4D /* Release.entitlements */,
|
33E51914231749380026EE4D /* Release.entitlements */,
|
||||||
33CC11242044D66E0003C045 /* Resources */,
|
33CC11242044D66E0003C045 /* Resources */,
|
||||||
@@ -207,6 +214,15 @@
|
|||||||
name = Frameworks;
|
name = Frameworks;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
6AD8B1652ED7B50000E9E1B4 /* MpvPlayer */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
6AD8B1632ED7B50000E9E1B4 /* MpvPlayerCore.swift */,
|
||||||
|
6AD8B1642ED7B50000E9E1B4 /* MpvPlayerPlugin.swift */,
|
||||||
|
);
|
||||||
|
path = MpvPlayer;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
/* End PBXGroup section */
|
/* End PBXGroup section */
|
||||||
|
|
||||||
/* Begin PBXNativeTarget section */
|
/* Begin PBXNativeTarget section */
|
||||||
@@ -291,6 +307,9 @@
|
|||||||
Base,
|
Base,
|
||||||
);
|
);
|
||||||
mainGroup = 33CC10E42044A3C60003C045;
|
mainGroup = 33CC10E42044A3C60003C045;
|
||||||
|
packageReferences = (
|
||||||
|
6AB6B0932EDB27C100EAC8DB /* XCRemoteSwiftPackageReference "MPVKit" */,
|
||||||
|
);
|
||||||
productRefGroup = 33CC10EE2044A3C60003C045 /* Products */;
|
productRefGroup = 33CC10EE2044A3C60003C045 /* Products */;
|
||||||
projectDirPath = "";
|
projectDirPath = "";
|
||||||
projectRoot = "";
|
projectRoot = "";
|
||||||
@@ -439,6 +458,8 @@
|
|||||||
33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */,
|
33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */,
|
||||||
33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */,
|
33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */,
|
||||||
335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */,
|
335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */,
|
||||||
|
6AD8B1612ED7B50000E9E1B4 /* MpvPlayerCore.swift in Sources */,
|
||||||
|
6AD8B1622ED7B50000E9E1B4 /* MpvPlayerPlugin.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -556,7 +577,7 @@
|
|||||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 10.15;
|
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||||
MTL_ENABLE_DEBUG_INFO = NO;
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
SDKROOT = macosx;
|
SDKROOT = macosx;
|
||||||
SWIFT_COMPILATION_MODE = wholemodule;
|
SWIFT_COMPILATION_MODE = wholemodule;
|
||||||
@@ -638,7 +659,7 @@
|
|||||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 10.15;
|
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||||
MTL_ENABLE_DEBUG_INFO = YES;
|
MTL_ENABLE_DEBUG_INFO = YES;
|
||||||
ONLY_ACTIVE_ARCH = YES;
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
SDKROOT = macosx;
|
SDKROOT = macosx;
|
||||||
@@ -688,7 +709,7 @@
|
|||||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 10.15;
|
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||||
MTL_ENABLE_DEBUG_INFO = NO;
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
SDKROOT = macosx;
|
SDKROOT = macosx;
|
||||||
SWIFT_COMPILATION_MODE = wholemodule;
|
SWIFT_COMPILATION_MODE = wholemodule;
|
||||||
@@ -795,6 +816,25 @@
|
|||||||
defaultConfigurationName = Release;
|
defaultConfigurationName = Release;
|
||||||
};
|
};
|
||||||
/* End XCConfigurationList section */
|
/* 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 */;
|
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 {
|
class MainFlutterWindow: NSWindow {
|
||||||
override func awakeFromNib() {
|
override func awakeFromNib() {
|
||||||
let flutterViewController = FlutterViewController()
|
let flutterViewController = FlutterViewController()
|
||||||
|
|
||||||
|
// Enable transparency for Metal layer behind Flutter
|
||||||
|
self.backgroundColor = NSColor.clear
|
||||||
|
flutterViewController.backgroundColor = NSColor.clear
|
||||||
|
|
||||||
let windowFrame = self.frame
|
let windowFrame = self.frame
|
||||||
self.contentViewController = flutterViewController
|
self.contentViewController = flutterViewController
|
||||||
self.setFrame(windowFrame, display: true)
|
self.setFrame(windowFrame, display: true)
|
||||||
|
|
||||||
|
// Register MPV player plugin for video playback
|
||||||
|
MpvPlayerPlugin.register(with: flutterViewController.registrar(forPlugin: "MpvPlayerPlugin"))
|
||||||
|
|
||||||
RegisterGeneratedPlugins(registry: flutterViewController)
|
RegisterGeneratedPlugins(registry: flutterViewController)
|
||||||
|
|
||||||
super.awakeFromNib()
|
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"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.11.1"
|
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:
|
meta:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -913,30 +841,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.28.0"
|
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:
|
screen_retriever:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1222,22 +1126,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.1.3"
|
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:
|
url_launcher:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -1326,30 +1214,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "15.0.2"
|
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:
|
watcher:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -13,9 +13,6 @@ dependencies:
|
|||||||
json_annotation: ^4.8.1
|
json_annotation: ^4.8.1
|
||||||
shared_preferences: ^2.2.2
|
shared_preferences: ^2.2.2
|
||||||
cached_network_image: ^3.4.1
|
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
|
macos_window_utils: ^1.9.0
|
||||||
url_launcher: ^6.3.0
|
url_launcher: ^6.3.0
|
||||||
uuid: ^4.4.0
|
uuid: ^4.4.0
|
||||||
@@ -36,38 +33,6 @@ dependencies:
|
|||||||
ref: 75dc5642cd148c54c03fe96976702b8d31c48599
|
ref: 75dc5642cd148c54c03fe96976702b8d31c48599
|
||||||
rate_limiter: ^1.0.0
|
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:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
|||||||
@@ -8,12 +8,9 @@
|
|||||||
|
|
||||||
#include <connectivity_plus/connectivity_plus_windows_plugin.h>
|
#include <connectivity_plus/connectivity_plus_windows_plugin.h>
|
||||||
#include <hotkey_manager_windows/hotkey_manager_windows_plugin_c_api.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 <os_media_controls/os_media_controls_plugin_c_api.h>
|
||||||
#include <screen_retriever_windows/screen_retriever_windows_plugin_c_api.h>
|
#include <screen_retriever_windows/screen_retriever_windows_plugin_c_api.h>
|
||||||
#include <url_launcher_windows/url_launcher_windows.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>
|
#include <window_manager/window_manager_plugin.h>
|
||||||
|
|
||||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||||
@@ -21,18 +18,12 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
|
|||||||
registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin"));
|
registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin"));
|
||||||
HotkeyManagerWindowsPluginCApiRegisterWithRegistrar(
|
HotkeyManagerWindowsPluginCApiRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("HotkeyManagerWindowsPluginCApi"));
|
registry->GetRegistrarForPlugin("HotkeyManagerWindowsPluginCApi"));
|
||||||
MediaKitLibsWindowsVideoPluginCApiRegisterWithRegistrar(
|
|
||||||
registry->GetRegistrarForPlugin("MediaKitLibsWindowsVideoPluginCApi"));
|
|
||||||
MediaKitVideoPluginCApiRegisterWithRegistrar(
|
|
||||||
registry->GetRegistrarForPlugin("MediaKitVideoPluginCApi"));
|
|
||||||
OsMediaControlsPluginCApiRegisterWithRegistrar(
|
OsMediaControlsPluginCApiRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("OsMediaControlsPluginCApi"));
|
registry->GetRegistrarForPlugin("OsMediaControlsPluginCApi"));
|
||||||
ScreenRetrieverWindowsPluginCApiRegisterWithRegistrar(
|
ScreenRetrieverWindowsPluginCApiRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("ScreenRetrieverWindowsPluginCApi"));
|
registry->GetRegistrarForPlugin("ScreenRetrieverWindowsPluginCApi"));
|
||||||
UrlLauncherWindowsRegisterWithRegistrar(
|
UrlLauncherWindowsRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
|
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
|
||||||
VolumeControllerPluginCApiRegisterWithRegistrar(
|
|
||||||
registry->GetRegistrarForPlugin("VolumeControllerPluginCApi"));
|
|
||||||
WindowManagerPluginRegisterWithRegistrar(
|
WindowManagerPluginRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("WindowManagerPlugin"));
|
registry->GetRegistrarForPlugin("WindowManagerPlugin"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,9 @@
|
|||||||
list(APPEND FLUTTER_PLUGIN_LIST
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
connectivity_plus
|
connectivity_plus
|
||||||
hotkey_manager_windows
|
hotkey_manager_windows
|
||||||
media_kit_libs_windows_video
|
|
||||||
media_kit_video
|
|
||||||
os_media_controls
|
os_media_controls
|
||||||
screen_retriever_windows
|
screen_retriever_windows
|
||||||
url_launcher_windows
|
url_launcher_windows
|
||||||
volume_controller
|
|
||||||
window_manager
|
window_manager
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user