feature: Added LibMDK video player backend (#162)

Co-authored-by: PartyDonut <PartyDonut@users.noreply.github.com>
This commit is contained in:
PartyDonut 2024-11-22 18:53:31 +01:00 committed by GitHub
parent 6e32018183
commit da354437e3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
53 changed files with 1499 additions and 1006 deletions

View file

@ -0,0 +1,50 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fladder/models/items/media_streams_model.dart';
import 'package:fladder/models/playback/playback_model.dart';
import 'package:fladder/wrappers/players/player_states.dart';
const libassFallbackFont = "assets/mp-font.ttf";
abstract class BasePlayer {
Stream<PlayerState> get stateStream;
PlayerState lastState = PlayerState();
Future<void> init(Ref ref);
Widget? videoWidget(
Key key,
BoxFit fit,
);
Widget? subtitles(
bool showOverlay,
);
Future<void> dispose();
Future<void> open(String url, bool play);
Future<void> seek(Duration position);
Future<void> play();
Future<void> setVolume(double volume);
Future<void> setSpeed(double speed);
Future<void> pause();
Future<void> stop();
Future<void> playOrPause();
Future<void> loop(bool loop);
Future<int> setSubtitleTrack(SubStreamModel? model, PlaybackModel playbackModel);
Future<int> setAudioTrack(AudioStreamModel? model, PlaybackModel playbackModel);
Uri? isValidUrl(String input) {
try {
final uri = Uri.tryParse(input);
if (uri != null && uri.isAbsolute && (uri.scheme == 'http' || uri.scheme == 'https')) {
return uri;
} else {
return null;
}
} catch (e) {
return null;
}
}
}

View file

@ -0,0 +1,183 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fvp/fvp.dart' as fvp;
import 'package:fvp/mdk.dart';
import 'package:video_player/video_player.dart';
import 'package:fladder/models/items/media_streams_model.dart';
import 'package:fladder/models/playback/playback_model.dart';
import 'package:fladder/wrappers/players/base_player.dart';
import 'package:fladder/wrappers/players/player_states.dart';
class LibMDK extends BasePlayer {
VideoPlayerController? _controller;
late final player = Player();
bool externalSubEnabled = false;
final StreamController<PlayerState> _stateController = StreamController.broadcast();
@override
Stream<PlayerState> get stateStream => _stateController.stream;
@override
Future<void> init(Ref ref) async {
dispose();
fvp.registerWith(options: {
'global': {'log': 'off'},
'subtitleFontFile': libassFallbackFont,
});
}
@override
Future<void> dispose() async {
_controller?.dispose();
_controller = null;
}
@override
Future<void> open(String url, bool play) async {
final validUrl = isValidUrl(url);
if (validUrl != null) {
_controller = VideoPlayerController.networkUrl(validUrl);
} else {
_controller = VideoPlayerController.file(File(url));
}
await _controller?.initialize();
_controller?.addListener(() => updateState());
if (play) {
await _controller?.play();
}
return setState(lastState.update(
buffering: true,
));
}
void setState(PlayerState state) {
lastState = state;
_stateController.add(state);
}
void updateState() {
setState(lastState.update(
playing: _controller?.value.isPlaying ?? false,
completed: _controller?.value.isCompleted ?? false,
position: _controller?.value.position ?? Duration.zero,
duration: _controller?.value.duration ?? Duration.zero,
volume: (_controller?.value.volume ?? 1.0) * 100,
rate: _controller?.value.playbackSpeed ?? 1.0,
buffering: _controller?.value.isBuffering ?? true,
buffer: _controller?.value.buffered.last.end ?? Duration.zero,
));
}
@override
Future<void> pause() async => _controller?.pause();
@override
Future<void> play() async => _controller?.play();
@override
Future<void> playOrPause() async => lastState.playing ? _controller?.pause() : _controller?.play();
@override
Future<void> seek(Duration position) async => _controller?.seekTo(position);
@override
Future<int> setAudioTrack(AudioStreamModel? model, PlaybackModel playbackModel) async {
final wantedAudioStream = model ?? playbackModel.defaultAudioStream;
if (wantedAudioStream == AudioStreamModel.no() || wantedAudioStream == null) {
_controller?.setAudioTracks([-1]);
return -1;
} else {
final indexOf = playbackModel.audioStreams?.indexOf(wantedAudioStream);
if (indexOf != null) {
_controller?.setAudioTracks([indexOf - 1]);
}
return wantedAudioStream.index;
}
}
@override
Future<void> setSpeed(double speed) async => _controller?.setPlaybackSpeed(speed);
@override
Future<int> setSubtitleTrack(SubStreamModel? model, PlaybackModel playbackModel) async {
final wantedSubtitle = model ?? playbackModel.defaultSubStream;
if (wantedSubtitle == SubStreamModel.no()) {
externalSubEnabled = false;
_controller?.setSubtitleTracks([-1]);
return -1;
}
if (wantedSubtitle != null) {
if (wantedSubtitle.isExternal && wantedSubtitle.url != null) {
externalSubEnabled = true;
_controller?.setExternalSubtitle(wantedSubtitle.url!);
return wantedSubtitle.index;
} else {
if (externalSubEnabled) {
externalSubEnabled = false;
_controller?.setExternalSubtitle("");
}
final indexOf = playbackModel.subStreams?.indexOf(wantedSubtitle);
if (indexOf != null) {
_controller?.setSubtitleTracks([indexOf - 1]);
}
return wantedSubtitle.index;
}
}
return -1;
}
@override
Future<void> stop() async => _controller?.dispose();
@override
Widget? videoWidget(
Key key,
BoxFit fit,
) =>
_controller == null
? null
: Container(
key: key,
color: Colors.transparent,
child: LayoutBuilder(
builder: (context, constraints) => Stack(
fit: StackFit.expand,
children: [
FittedBox(
fit: fit,
alignment: Alignment.center,
child: ValueListenableBuilder<VideoPlayerValue>(
valueListenable: _controller!,
builder: (context, value, child) {
final aspectRatio = value.isInitialized ? value.aspectRatio : 1.77;
return SizedBox(
width: constraints.maxWidth,
child: AspectRatio(
aspectRatio: aspectRatio,
child: VideoPlayer(_controller!),
),
);
},
),
),
],
),
),
);
@override
Widget? subtitles(bool showOverlay) => null;
@override
Future<void> setVolume(double volume) async => _controller?.setVolume(volume / 100);
@override
Future<void> loop(bool loop) async => _controller?.setLooping(loop);
}

View file

@ -0,0 +1,251 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:collection/collection.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:media_kit/media_kit.dart' as mpv;
import 'package:media_kit_video/media_kit_video.dart';
import 'package:fladder/models/items/media_streams_model.dart';
import 'package:fladder/models/playback/playback_model.dart';
import 'package:fladder/models/settings/subtitle_settings_model.dart';
import 'package:fladder/providers/settings/subtitle_settings_provider.dart';
import 'package:fladder/providers/settings/video_player_settings_provider.dart';
import 'package:fladder/wrappers/players/base_player.dart';
import 'package:fladder/wrappers/players/player_states.dart';
class LibMPV extends BasePlayer {
mpv.Player? _player;
VideoController? _controller;
final StreamController<PlayerState> _stateController = StreamController.broadcast();
@override
Stream<PlayerState> get stateStream => _stateController.stream;
StreamSubscription<bool>? _onCompleted;
@override
Future<void> init(Ref ref) async {
dispose();
mpv.MediaKit.ensureInitialized();
_player = mpv.Player(
configuration: mpv.PlayerConfiguration(
title: "nl.jknaapen.fladder",
libassAndroidFont: libassFallbackFont,
libass: !kIsWeb &&
ref.read(
videoPlayerSettingsProvider.select((value) => value.useLibass),
),
),
);
if (_player != null) {
_controller = VideoController(
_player!,
configuration: VideoControllerConfiguration(
enableHardwareAcceleration: ref.read(
videoPlayerSettingsProvider.select((value) => value.hardwareAccel),
),
),
);
_player!.stream.playing.listen((value) => setState(lastState.update(playing: value)));
_player!.stream.buffering.listen((value) => setState(lastState.update(buffering: value)));
_player!.stream.position.listen((value) => setState(lastState.update(position: value)));
_player!.stream.duration.listen((value) => setState(lastState.update(duration: value)));
_player!.stream.volume.listen((value) => setState(lastState.update(volume: value)));
_player!.stream.rate.listen((value) => setState(lastState.update(rate: value)));
_player!.stream.buffer.listen((value) => setState(lastState.update(buffer: value)));
}
if (_player?.platform is mpv.NativePlayer) {
await (_player?.platform as dynamic).setProperty(
'force-seekable',
'yes',
);
}
}
@override
Future<void> dispose() async {
_onCompleted?.cancel();
_onCompleted = null;
_player?.stop();
_player?.dispose();
_player = null;
}
void setState(PlayerState state) {
lastState = state;
_stateController.add(state);
}
@override
Future<void> open(String url, bool play) async {
await _player?.open(mpv.Media(url), play: play);
return setState(lastState.update(buffering: true));
}
List<mpv.SubtitleTrack> get subTracks => _player?.state.tracks.subtitle ?? [];
mpv.SubtitleTrack get subtitleTrack => _player?.state.track.subtitle ?? mpv.SubtitleTrack.no();
List<mpv.AudioTrack> get audioTracks => _player?.state.tracks.audio ?? [];
mpv.AudioTrack get audioTrack => _player?.state.track.audio ?? mpv.AudioTrack.no();
@override
Future<void> pause() async => _player?.pause();
@override
Future<void> play() async => _player?.play();
@override
Future<void> playOrPause() async => _player?.playOrPause();
@override
Future<void> seek(Duration position) async => _player?.seek(position);
@override
Future<int> setAudioTrack(AudioStreamModel? model, PlaybackModel playbackModel) async {
final wantedAudioStream = model ?? playbackModel.defaultAudioStream;
if (wantedAudioStream == null) return -1;
if (wantedAudioStream.index == AudioStreamModel.no().index) {
await _player?.setAudioTrack(mpv.AudioTrack.no());
} else {
final internalTracks = audioTracks.getRange(2, audioTracks.length).toList();
final audioTrack =
internalTracks.elementAtOrNull((playbackModel.audioStreams?.indexOf(wantedAudioStream) ?? -1) - 1);
if (audioTrack != null) {
await _player?.setAudioTrack(audioTrack);
}
}
return wantedAudioStream.index;
}
@override
Future<void> setSpeed(double speed) async => _player?.setRate(speed);
@override
Future<int> setSubtitleTrack(SubStreamModel? model, PlaybackModel playbackModel) async {
if (_player == null) return -1;
final wantedSubtitle = model ?? playbackModel.defaultSubStream;
if (wantedSubtitle == null) return -1;
if (wantedSubtitle.index == SubStreamModel.no().index) {
await _player?.setSubtitleTrack(mpv.SubtitleTrack.no());
} else {
final internalTrack = subTracks.getRange(2, subTracks.length).toList();
final index = playbackModel.subStreams?.sublist(1).indexWhere((element) => element.id == wantedSubtitle.id);
final subTrack = internalTrack.elementAtOrNull(index ?? -1);
if (wantedSubtitle.isExternal && wantedSubtitle.url != null && subTrack == null) {
await _player?.setSubtitleTrack(mpv.SubtitleTrack.uri(wantedSubtitle.url!));
} else if (subTrack != null) {
await _player?.setSubtitleTrack(subTrack);
}
}
return wantedSubtitle.index;
}
@override
Future<void> stop() async => _player?.stop();
@override
Widget? videoWidget(
Key key,
BoxFit fit,
) =>
_controller == null
? null
: Video(
key: key,
controller: _controller!,
wakelock: true,
fill: Colors.transparent,
fit: fit,
subtitleViewConfiguration: const SubtitleViewConfiguration(visible: false),
controls: NoVideoControls,
);
@override
Widget? subtitles(
bool showOverlay,
) =>
_controller != null
? _VideoSubtitles(
controller: _controller!,
showOverlay: showOverlay,
)
: null;
@override
Future<void> setVolume(double volume) async => _player?.setVolume(volume);
@override
Future<void> loop(bool loop) async {
if (loop && _onCompleted == null) {
_onCompleted = _player?.stream.completed.listen((completed) {
if (completed) {
_player?.play();
}
});
} else {
_onCompleted?.cancel();
}
}
}
class _VideoSubtitles extends ConsumerStatefulWidget {
final VideoController controller;
final bool showOverlay;
const _VideoSubtitles({
required this.controller,
this.showOverlay = false,
});
@override
ConsumerState<ConsumerStatefulWidget> createState() => _VideoSubtitlesState();
}
class _VideoSubtitlesState extends ConsumerState<_VideoSubtitles> {
late List<String> subtitle = widget.controller.player.state.subtitle;
StreamSubscription<List<String>>? subscription;
@override
void initState() {
subscription = widget.controller.player.stream.subtitle.listen((value) {
setState(() {
subtitle = value;
});
});
super.initState();
}
@override
void dispose() {
subscription?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final settings = ref.watch(subtitleSettingsProvider);
final padding = MediaQuery.of(context).padding;
final text = [
for (final line in subtitle)
if (line.trim().isNotEmpty) line.trim(),
].join('\n');
if (widget.controller.player.platform?.configuration.libass ?? false) {
return const IgnorePointer(child: SizedBox.shrink());
} else {
return SubtitleText(
subModel: settings,
padding: padding,
offset: (widget.showOverlay ? 0.5 : settings.verticalOffset),
text: text,
);
}
}
}

View file

@ -0,0 +1,74 @@
class PlayerState {
bool playing;
bool completed;
Duration position;
Duration duration;
double volume;
double rate;
bool buffering;
Duration buffer;
PlayerState({
this.playing = false,
this.completed = false,
this.position = Duration.zero,
this.duration = Duration.zero,
this.volume = 100,
this.rate = 1.0,
this.buffering = true,
this.buffer = Duration.zero,
});
PlayerState update({
bool? playing,
bool? completed,
bool? buffering,
Duration? position,
Duration? duration,
double? volume,
double? rate,
Duration? buffer,
}) {
if (playing != null) this.playing = playing;
if (completed != null) this.completed = completed;
if (buffering != null) this.buffering = buffering;
if (position != null) this.position = position;
if (duration != null) this.duration = duration;
if (volume != null) this.volume = volume;
if (rate != null) this.rate = rate;
if (buffer != null) this.buffer = buffer;
return this;
}
}
class PlayerStream {
final Stream<bool> playing;
final Stream<bool> completed;
final Stream<Duration> position;
final Stream<Duration> duration;
final Stream<double> volume;
final Stream<double> rate;
final Stream<bool> buffering;
final Stream<Duration> buffer;
const PlayerStream(
this.playing,
this.completed,
this.position,
this.duration,
this.volume,
this.rate,
this.buffering,
this.buffer,
);
void bindToState(PlayerState state) {
playing.listen((value) => state.update(playing: value));
buffering.listen((value) => state.update(buffering: value));
position.listen((value) => state.update(position: value));
duration.listen((value) => state.update(duration: value));
volume.listen((value) => state.update(volume: value));
rate.listen((value) => state.update(rate: value));
buffer.listen((value) => state.update(buffer: value));
}
}