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

@ -6,6 +6,13 @@ on:
- "v*" - "v*"
branches: branches:
- master - master
pull_request:
paths:
- pubspec.yaml
- .github/workflows/build.yml
types:
- opened
- reopened
workflow_dispatch: workflow_dispatch:
jobs: jobs:
@ -228,6 +235,7 @@ jobs:
build-linux-flatpak: build-linux-flatpak:
name: "Flatpak" name: "Flatpak"
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/v')
needs: [fetch-info, build-linux] needs: [fetch-info, build-linux]
container: container:
image: bilelmoussaoui/flatpak-github-actions:gnome-46 image: bilelmoussaoui/flatpak-github-actions:gnome-46

View file

@ -1125,5 +1125,13 @@
"stop": "Stop", "stop": "Stop",
"resumeVideo": "Resume video", "resumeVideo": "Resume video",
"closeVideo": "Close video", "closeVideo": "Close video",
"playNextVideo": "Play next video" "playNextVideo": "Play next video",
"playerSettingsBackendTitle": "Video player Backend",
"playerSettingsBackendDesc": "Choose your preferred media player for optimal playback experience",
"defaultLabel": "Default",
"@defaultLabel": {
"description": "To indicate a default value, default video player backend"
},
"noVideoPlayerOptions": "The selected backend has no options",
"mdkExperimental": "MDK is still in a experimental stage"
} }

View file

@ -13,7 +13,6 @@ import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:isar/isar.dart'; import 'package:isar/isar.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:media_kit/media_kit.dart';
import 'package:package_info_plus/package_info_plus.dart'; import 'package:package_info_plus/package_info_plus.dart';
import 'package:path/path.dart' as path; import 'package:path/path.dart' as path;
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
@ -67,7 +66,6 @@ Future<Map<String, dynamic>> loadConfig() async {
void main() async { void main() async {
_setupLogging(); _setupLogging();
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
MediaKit.ensureInitialized();
if (kIsWeb) { if (kIsWeb) {
html.document.onContextMenu.listen((event) => event.preventDefault()); html.document.onContextMenu.listen((event) => event.preventDefault());

View file

@ -22,8 +22,8 @@ class TrickPlayModel with _$TrickPlayModel {
int get imagesPerTile => tileWidth * tileHeight; int get imagesPerTile => tileWidth * tileHeight;
String? getTile(Duration position) { String? getTile(Duration position) {
final int currentIndex = (position.inMilliseconds ~/ interval.inMilliseconds).clamp(0, thumbnailCount - 1); final int currentIndex = (position.inMilliseconds ~/ interval.inMilliseconds).clamp(0, thumbnailCount);
final int indexOfTile = (currentIndex ~/ imagesPerTile).clamp(0, (images.length - 1)); final int indexOfTile = (currentIndex ~/ imagesPerTile).clamp(0, images.length);
return images.elementAtOrNull(indexOfTile); return images.elementAtOrNull(indexOfTile);
} }

View file

@ -1,6 +1,7 @@
import 'package:flutter/widgets.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:media_kit/media_kit.dart';
import 'package:fladder/jellyfin/jellyfin_open_api.swagger.dart'; import 'package:fladder/jellyfin/jellyfin_open_api.swagger.dart';
import 'package:fladder/models/item_base_model.dart'; import 'package:fladder/models/item_base_model.dart';
@ -13,9 +14,7 @@ import 'package:fladder/providers/api_provider.dart';
import 'package:fladder/providers/video_player_provider.dart'; import 'package:fladder/providers/video_player_provider.dart';
import 'package:fladder/util/duration_extensions.dart'; import 'package:fladder/util/duration_extensions.dart';
import 'package:fladder/util/list_extensions.dart'; import 'package:fladder/util/list_extensions.dart';
import 'package:fladder/wrappers/media_control_wrapper.dart' import 'package:fladder/wrappers/media_control_wrapper.dart';
if (dart.library.html) 'package:fladder/wrappers/media_control_wrapper_web.dart';
import 'package:flutter/widgets.dart';
class DirectPlaybackModel implements PlaybackModel { class DirectPlaybackModel implements PlaybackModel {
DirectPlaybackModel({ DirectPlaybackModel({
@ -67,22 +66,8 @@ class DirectPlaybackModel implements PlaybackModel {
@override @override
Future<DirectPlaybackModel> setSubtitle(SubStreamModel? model, MediaControlsWrapper player) async { Future<DirectPlaybackModel> setSubtitle(SubStreamModel? model, MediaControlsWrapper player) async {
final wantedSubtitle = final newIndex = await player.setSubtitleTrack(model, this);
model ?? subStreams.firstWhereOrNull((element) => element.index == mediaStreams?.defaultSubStreamIndex); return copyWith(mediaStreams: () => mediaStreams?.copyWith(defaultSubStreamIndex: newIndex));
if (wantedSubtitle == null) return this;
if (wantedSubtitle.index == SubStreamModel.no().index) {
await player.setSubtitleTrack(SubtitleTrack.no());
} else {
final subTracks = player.subTracks.getRange(2, player.subTracks.length).toList();
final index = subStreams.sublist(1).indexWhere((element) => element.id == wantedSubtitle.id);
final subTrack = subTracks.elementAtOrNull(index);
if (wantedSubtitle.isExternal && wantedSubtitle.url != null && subTrack == null) {
await player.setSubtitleTrack(SubtitleTrack.uri(wantedSubtitle.url!));
} else if (subTrack != null) {
await player.setSubtitleTrack(subTrack);
}
}
return copyWith(mediaStreams: () => mediaStreams?.copyWith(defaultSubStreamIndex: wantedSubtitle.index));
} }
@override @override
@ -90,19 +75,8 @@ class DirectPlaybackModel implements PlaybackModel {
@override @override
Future<DirectPlaybackModel>? setAudio(AudioStreamModel? model, MediaControlsWrapper player) async { Future<DirectPlaybackModel>? setAudio(AudioStreamModel? model, MediaControlsWrapper player) async {
final wantedAudioStream = final newIndex = await player.setAudioTrack(model, this);
model ?? audioStreams.firstWhereOrNull((element) => element.index == mediaStreams?.defaultAudioStreamIndex); return copyWith(mediaStreams: () => mediaStreams?.copyWith(defaultAudioStreamIndex: newIndex));
if (wantedAudioStream == null) return this;
if (wantedAudioStream.index == AudioStreamModel.no().index) {
await player.setAudioTrack(AudioTrack.no());
} else {
final audioTracks = player.audioTracks.getRange(2, player.audioTracks.length).toList();
final audioTrack = audioTracks.elementAtOrNull(audioStreams.indexOf(wantedAudioStream) - 1);
if (audioTrack != null) {
await player.setAudioTrack(audioTrack);
}
}
return copyWith(mediaStreams: () => mediaStreams?.copyWith(defaultAudioStreamIndex: wantedAudioStream.index));
} }
@override @override

View file

@ -1,6 +1,6 @@
import 'package:collection/collection.dart'; import 'package:flutter/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:media_kit/media_kit.dart';
import 'package:fladder/jellyfin/jellyfin_open_api.swagger.dart'; import 'package:fladder/jellyfin/jellyfin_open_api.swagger.dart';
import 'package:fladder/models/item_base_model.dart'; import 'package:fladder/models/item_base_model.dart';
@ -13,9 +13,7 @@ import 'package:fladder/models/syncing/sync_item.dart';
import 'package:fladder/providers/sync_provider.dart'; import 'package:fladder/providers/sync_provider.dart';
import 'package:fladder/util/duration_extensions.dart'; import 'package:fladder/util/duration_extensions.dart';
import 'package:fladder/util/list_extensions.dart'; import 'package:fladder/util/list_extensions.dart';
import 'package:fladder/wrappers/media_control_wrapper.dart' import 'package:fladder/wrappers/media_control_wrapper.dart';
if (dart.library.html) 'package:fladder/wrappers/media_control_wrapper_web.dart';
import 'package:flutter/widgets.dart';
class OfflinePlaybackModel implements PlaybackModel { class OfflinePlaybackModel implements PlaybackModel {
OfflinePlaybackModel({ OfflinePlaybackModel({
@ -66,22 +64,8 @@ class OfflinePlaybackModel implements PlaybackModel {
@override @override
Future<OfflinePlaybackModel> setSubtitle(SubStreamModel? model, MediaControlsWrapper player) async { Future<OfflinePlaybackModel> setSubtitle(SubStreamModel? model, MediaControlsWrapper player) async {
final wantedSubtitle = final newIndex = await player.setSubtitleTrack(model, this);
model ?? subStreams.firstWhereOrNull((element) => element.index == mediaStreams?.defaultSubStreamIndex); return copyWith(mediaStreams: () => mediaStreams?.copyWith(defaultSubStreamIndex: newIndex));
if (wantedSubtitle == null) return this;
if (wantedSubtitle.index == SubStreamModel.no().index) {
await player.setSubtitleTrack(SubtitleTrack.no());
} else {
final subTracks = player.subTracks.getRange(2, player.subTracks.length).toList();
final index = subStreams.sublist(1).indexWhere((element) => element.id == wantedSubtitle.id);
final subTrack = subTracks.elementAtOrNull(index);
if (wantedSubtitle.isExternal && wantedSubtitle.url != null && subTrack == null) {
await player.setSubtitleTrack(SubtitleTrack.uri(wantedSubtitle.url!));
} else if (subTrack != null) {
await player.setSubtitleTrack(subTrack);
}
}
return copyWith(mediaStreams: () => mediaStreams?.copyWith(defaultSubStreamIndex: wantedSubtitle.index));
} }
@override @override
@ -89,19 +73,8 @@ class OfflinePlaybackModel implements PlaybackModel {
@override @override
Future<OfflinePlaybackModel>? setAudio(AudioStreamModel? model, MediaControlsWrapper player) async { Future<OfflinePlaybackModel>? setAudio(AudioStreamModel? model, MediaControlsWrapper player) async {
final wantedAudioStream = final newIndex = await player.setAudioTrack(model, this);
model ?? audioStreams.firstWhereOrNull((element) => element.index == mediaStreams?.defaultAudioStreamIndex); return copyWith(mediaStreams: () => mediaStreams?.copyWith(defaultAudioStreamIndex: newIndex));
if (wantedAudioStream == null) return this;
if (wantedAudioStream.index == AudioStreamModel.no().index) {
await player.setAudioTrack(AudioTrack.no());
} else {
final audioTracks = player.audioTracks.getRange(2, player.audioTracks.length).toList();
final audioTrack = audioTracks.elementAtOrNull(audioStreams.indexOf(wantedAudioStream) - 1);
if (audioTrack != null) {
await player.setAudioTrack(audioTrack);
}
}
return copyWith(mediaStreams: () => mediaStreams?.copyWith(defaultAudioStreamIndex: wantedAudioStream.index));
} }
@override @override

View file

@ -3,7 +3,6 @@ import 'dart:developer';
import 'package:chopper/chopper.dart'; import 'package:chopper/chopper.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:media_kit/media_kit.dart';
import 'package:fladder/jellyfin/jellyfin_open_api.swagger.dart'; import 'package:fladder/jellyfin/jellyfin_open_api.swagger.dart';
import 'package:fladder/models/item_base_model.dart'; import 'package:fladder/models/item_base_model.dart';
@ -27,10 +26,23 @@ import 'package:fladder/providers/sync_provider.dart';
import 'package:fladder/providers/user_provider.dart'; import 'package:fladder/providers/user_provider.dart';
import 'package:fladder/providers/video_player_provider.dart'; import 'package:fladder/providers/video_player_provider.dart';
import 'package:fladder/util/duration_extensions.dart'; import 'package:fladder/util/duration_extensions.dart';
import 'package:fladder/wrappers/media_control_wrapper.dart' import 'package:fladder/wrappers/media_control_wrapper.dart';
if (dart.library.html) 'package:fladder/wrappers/media_control_wrapper_web.dart';
class Media {
final String url;
const Media({
required this.url,
});
}
extension PlaybackModelExtension on PlaybackModel? { extension PlaybackModelExtension on PlaybackModel? {
SubStreamModel? get defaultSubStream =>
this?.subStreams?.firstWhereOrNull((element) => element.index == this?.mediaStreams?.defaultSubStreamIndex);
AudioStreamModel? get defaultAudioStream =>
this?.audioStreams?.firstWhereOrNull((element) => element.index == this?.mediaStreams?.defaultAudioStreamIndex);
String? get label => switch (this) { String? get label => switch (this) {
DirectPlaybackModel _ => PlaybackType.directStream.name, DirectPlaybackModel _ => PlaybackType.directStream.name,
TranscodePlaybackModel _ => PlaybackType.transcode.name, TranscodePlaybackModel _ => PlaybackType.transcode.name,
@ -119,7 +131,7 @@ class PlaybackModelHelper {
syncedItem: syncedItem, syncedItem: syncedItem,
trickPlay: syncedItem.trickPlayModel, trickPlay: syncedItem.trickPlayModel,
mediaSegments: syncedItem.mediaSegments, mediaSegments: syncedItem.mediaSegments,
media: Media(syncedItem.videoFile.path), media: Media(url: syncedItem.videoFile.path),
queue: itemQueue.whereNotNull().toList(), queue: itemQueue.whereNotNull().toList(),
syncedQueue: children, syncedQueue: children,
mediaStreams: item.streamModel ?? syncedItemModel.streamModel, mediaStreams: item.streamModel ?? syncedItemModel.streamModel,
@ -170,7 +182,7 @@ class PlaybackModelHelper {
subtitleStreamIndex: streamModel?.defaultSubStreamIndex, subtitleStreamIndex: streamModel?.defaultSubStreamIndex,
enableTranscoding: true, enableTranscoding: true,
autoOpenLiveStream: true, autoOpenLiveStream: true,
deviceProfile: defaultProfile, deviceProfile: ref.read(videoProfileProvider),
userId: userId, userId: userId,
mediaSourceId: firstItemToPlay.id, mediaSourceId: firstItemToPlay.id,
), ),
@ -218,7 +230,7 @@ class PlaybackModelHelper {
chapters: chapters, chapters: chapters,
playbackInfo: playbackInfo, playbackInfo: playbackInfo,
trickPlay: trickPlay, trickPlay: trickPlay,
media: Media('${ref.read(userProvider)?.server ?? ""}/Videos/${mediaSource.id}/stream?$params'), media: Media(url: '${ref.read(userProvider)?.server ?? ""}/Videos/${mediaSource.id}/stream?$params'),
mediaStreams: mediaStreamsWithUrls, mediaStreams: mediaStreamsWithUrls,
); );
} else if ((mediaSource.supportsTranscoding ?? false) && mediaSource.transcodingUrl != null) { } else if ((mediaSource.supportsTranscoding ?? false) && mediaSource.transcodingUrl != null) {
@ -229,7 +241,7 @@ class PlaybackModelHelper {
chapters: chapters, chapters: chapters,
trickPlay: trickPlay, trickPlay: trickPlay,
playbackInfo: playbackInfo, playbackInfo: playbackInfo,
media: Media("${ref.read(userProvider)?.server ?? ""}${mediaSource.transcodingUrl ?? ""}"), media: Media(url: "${ref.read(userProvider)?.server ?? ""}${mediaSource.transcodingUrl ?? ""}"),
mediaStreams: mediaStreamsWithUrls, mediaStreams: mediaStreamsWithUrls,
); );
} }
@ -300,7 +312,7 @@ class PlaybackModelHelper {
subtitleStreamIndex: subIndex, subtitleStreamIndex: subIndex,
enableTranscoding: true, enableTranscoding: true,
autoOpenLiveStream: true, autoOpenLiveStream: true,
deviceProfile: defaultProfile, deviceProfile: ref.read(videoProfileProvider),
userId: userId, userId: userId,
mediaSourceId: item.id, mediaSourceId: item.id,
), ),
@ -347,7 +359,7 @@ class PlaybackModelHelper {
chapters: playbackModel.chapters, chapters: playbackModel.chapters,
playbackInfo: playbackInfo, playbackInfo: playbackInfo,
trickPlay: playbackModel.trickPlay, trickPlay: playbackModel.trickPlay,
media: Media(directPlay), media: Media(url: directPlay),
mediaStreams: mediaStreamsWithUrls, mediaStreams: mediaStreamsWithUrls,
); );
} else if ((mediaSource.supportsTranscoding ?? false) && mediaSource.transcodingUrl != null) { } else if ((mediaSource.supportsTranscoding ?? false) && mediaSource.transcodingUrl != null) {
@ -358,7 +370,7 @@ class PlaybackModelHelper {
chapters: playbackModel.chapters, chapters: playbackModel.chapters,
playbackInfo: playbackInfo, playbackInfo: playbackInfo,
trickPlay: playbackModel.trickPlay, trickPlay: playbackModel.trickPlay,
media: Media("${ref.read(userProvider)?.server ?? ""}${mediaSource.transcodingUrl ?? ""}"), media: Media(url: "${ref.read(userProvider)?.server ?? ""}${mediaSource.transcodingUrl ?? ""}"),
mediaStreams: mediaStreamsWithUrls, mediaStreams: mediaStreamsWithUrls,
); );
} }

View file

@ -1,6 +1,7 @@
import 'package:flutter/widgets.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:media_kit/media_kit.dart';
import 'package:fladder/jellyfin/jellyfin_open_api.swagger.dart'; import 'package:fladder/jellyfin/jellyfin_open_api.swagger.dart';
import 'package:fladder/models/item_base_model.dart'; import 'package:fladder/models/item_base_model.dart';
@ -13,9 +14,7 @@ import 'package:fladder/providers/api_provider.dart';
import 'package:fladder/providers/video_player_provider.dart'; import 'package:fladder/providers/video_player_provider.dart';
import 'package:fladder/util/duration_extensions.dart'; import 'package:fladder/util/duration_extensions.dart';
import 'package:fladder/util/list_extensions.dart'; import 'package:fladder/util/list_extensions.dart';
import 'package:fladder/wrappers/media_control_wrapper.dart' import 'package:fladder/wrappers/media_control_wrapper.dart';
if (dart.library.html) 'package:fladder/wrappers/media_control_wrapper_web.dart';
import 'package:flutter/widgets.dart';
class TranscodePlaybackModel implements PlaybackModel { class TranscodePlaybackModel implements PlaybackModel {
TranscodePlaybackModel({ TranscodePlaybackModel({
@ -67,22 +66,8 @@ class TranscodePlaybackModel implements PlaybackModel {
@override @override
Future<TranscodePlaybackModel> setSubtitle(SubStreamModel? model, MediaControlsWrapper player) async { Future<TranscodePlaybackModel> setSubtitle(SubStreamModel? model, MediaControlsWrapper player) async {
final wantedSubtitle = final newIndex = await player.setSubtitleTrack(model, this);
model ?? subStreams.firstWhereOrNull((element) => element.index == mediaStreams?.defaultSubStreamIndex); return copyWith(mediaStreams: () => mediaStreams?.copyWith(defaultSubStreamIndex: newIndex));
if (wantedSubtitle == null) return this;
if (wantedSubtitle.index == SubStreamModel.no().index) {
await player.setSubtitleTrack(SubtitleTrack.no());
} else {
final subTracks = player.subTracks.getRange(2, player.subTracks.length).toList();
final index = subStreams.sublist(1).indexWhere((element) => element.id == wantedSubtitle.id);
final subTrack = subTracks.elementAtOrNull(index);
if (wantedSubtitle.isExternal && wantedSubtitle.url != null && subTrack == null) {
await player.setSubtitleTrack(SubtitleTrack.uri(wantedSubtitle.url!));
} else if (subTrack != null) {
await player.setSubtitleTrack(subTrack);
}
}
return copyWith(mediaStreams: () => mediaStreams?.copyWith(defaultSubStreamIndex: wantedSubtitle.index));
} }
@override @override
@ -90,19 +75,8 @@ class TranscodePlaybackModel implements PlaybackModel {
@override @override
Future<TranscodePlaybackModel>? setAudio(AudioStreamModel? model, MediaControlsWrapper player) async { Future<TranscodePlaybackModel>? setAudio(AudioStreamModel? model, MediaControlsWrapper player) async {
final wantedAudioStream = final newIndex = await player.setAudioTrack(model, this);
model ?? audioStreams.firstWhereOrNull((element) => element.index == mediaStreams?.defaultAudioStreamIndex); return copyWith(mediaStreams: () => mediaStreams?.copyWith(defaultAudioStreamIndex: newIndex));
if (wantedAudioStream == null) return this;
if (wantedAudioStream.index == AudioStreamModel.no().index) {
await player.setAudioTrack(AudioTrack.no());
} else {
final audioTracks = player.audioTracks.getRange(2, player.audioTracks.length).toList();
final audioTrack = audioTracks.elementAtOrNull(audioStreams.indexOf(wantedAudioStream) - 1);
if (audioTrack != null) {
await player.setAudioTrack(audioTrack);
}
}
return copyWith(mediaStreams: () => mediaStreams?.copyWith(defaultAudioStreamIndex: wantedAudioStream.index));
} }
@override @override

View file

@ -20,6 +20,7 @@ class VideoPlayerSettingsModel with _$VideoPlayerSettingsModel {
@Default(false) bool fillScreen, @Default(false) bool fillScreen,
@Default(true) bool hardwareAccel, @Default(true) bool hardwareAccel,
@Default(false) bool useLibass, @Default(false) bool useLibass,
PlayerOptions? playerOptions,
@Default(100) double internalVolume, @Default(100) double internalVolume,
Set<DeviceOrientation>? allowedOrientations, Set<DeviceOrientation>? allowedOrientations,
@Default(AutoNextType.smart) AutoNextType nextVideoType, @Default(AutoNextType.smart) AutoNextType nextVideoType,
@ -33,8 +34,10 @@ class VideoPlayerSettingsModel with _$VideoPlayerSettingsModel {
factory VideoPlayerSettingsModel.fromJson(Map<String, dynamic> json) => _$VideoPlayerSettingsModelFromJson(json); factory VideoPlayerSettingsModel.fromJson(Map<String, dynamic> json) => _$VideoPlayerSettingsModelFromJson(json);
PlayerOptions get wantedPlayer => playerOptions ?? PlayerOptions.platformDefaults;
bool playerSame(VideoPlayerSettingsModel other) { bool playerSame(VideoPlayerSettingsModel other) {
return other.hardwareAccel == hardwareAccel && other.useLibass == useLibass; return other.hardwareAccel == hardwareAccel && other.useLibass == useLibass && other.wantedPlayer == wantedPlayer;
} }
@override @override
@ -48,6 +51,7 @@ class VideoPlayerSettingsModel with _$VideoPlayerSettingsModel {
other.hardwareAccel == hardwareAccel && other.hardwareAccel == hardwareAccel &&
other.useLibass == useLibass && other.useLibass == useLibass &&
other.internalVolume == internalVolume && other.internalVolume == internalVolume &&
other.playerOptions == playerOptions &&
other.audioDevice == audioDevice; other.audioDevice == audioDevice;
} }
@ -63,6 +67,27 @@ class VideoPlayerSettingsModel with _$VideoPlayerSettingsModel {
} }
} }
enum PlayerOptions {
libMDK,
libMPV;
const PlayerOptions();
static Iterable<PlayerOptions> get available => kIsWeb ? {PlayerOptions.libMPV} : PlayerOptions.values;
static PlayerOptions get platformDefaults {
if (kIsWeb) return PlayerOptions.libMPV;
return switch (defaultTargetPlatform) {
_ => PlayerOptions.libMPV,
};
}
String label(BuildContext context) => switch (this) {
PlayerOptions.libMDK => "MDK",
PlayerOptions.libMPV => "MPV",
};
}
enum AutoNextType { enum AutoNextType {
off, off,
smart, smart,

View file

@ -26,6 +26,7 @@ mixin _$VideoPlayerSettingsModel {
bool get fillScreen => throw _privateConstructorUsedError; bool get fillScreen => throw _privateConstructorUsedError;
bool get hardwareAccel => throw _privateConstructorUsedError; bool get hardwareAccel => throw _privateConstructorUsedError;
bool get useLibass => throw _privateConstructorUsedError; bool get useLibass => throw _privateConstructorUsedError;
PlayerOptions? get playerOptions => throw _privateConstructorUsedError;
double get internalVolume => throw _privateConstructorUsedError; double get internalVolume => throw _privateConstructorUsedError;
Set<DeviceOrientation>? get allowedOrientations => Set<DeviceOrientation>? get allowedOrientations =>
throw _privateConstructorUsedError; throw _privateConstructorUsedError;
@ -54,6 +55,7 @@ abstract class $VideoPlayerSettingsModelCopyWith<$Res> {
bool fillScreen, bool fillScreen,
bool hardwareAccel, bool hardwareAccel,
bool useLibass, bool useLibass,
PlayerOptions? playerOptions,
double internalVolume, double internalVolume,
Set<DeviceOrientation>? allowedOrientations, Set<DeviceOrientation>? allowedOrientations,
AutoNextType nextVideoType, AutoNextType nextVideoType,
@ -81,6 +83,7 @@ class _$VideoPlayerSettingsModelCopyWithImpl<$Res,
Object? fillScreen = null, Object? fillScreen = null,
Object? hardwareAccel = null, Object? hardwareAccel = null,
Object? useLibass = null, Object? useLibass = null,
Object? playerOptions = freezed,
Object? internalVolume = null, Object? internalVolume = null,
Object? allowedOrientations = freezed, Object? allowedOrientations = freezed,
Object? nextVideoType = null, Object? nextVideoType = null,
@ -107,6 +110,10 @@ class _$VideoPlayerSettingsModelCopyWithImpl<$Res,
? _value.useLibass ? _value.useLibass
: useLibass // ignore: cast_nullable_to_non_nullable : useLibass // ignore: cast_nullable_to_non_nullable
as bool, as bool,
playerOptions: freezed == playerOptions
? _value.playerOptions
: playerOptions // ignore: cast_nullable_to_non_nullable
as PlayerOptions?,
internalVolume: null == internalVolume internalVolume: null == internalVolume
? _value.internalVolume ? _value.internalVolume
: internalVolume // ignore: cast_nullable_to_non_nullable : internalVolume // ignore: cast_nullable_to_non_nullable
@ -142,6 +149,7 @@ abstract class _$$VideoPlayerSettingsModelImplCopyWith<$Res>
bool fillScreen, bool fillScreen,
bool hardwareAccel, bool hardwareAccel,
bool useLibass, bool useLibass,
PlayerOptions? playerOptions,
double internalVolume, double internalVolume,
Set<DeviceOrientation>? allowedOrientations, Set<DeviceOrientation>? allowedOrientations,
AutoNextType nextVideoType, AutoNextType nextVideoType,
@ -168,6 +176,7 @@ class __$$VideoPlayerSettingsModelImplCopyWithImpl<$Res>
Object? fillScreen = null, Object? fillScreen = null,
Object? hardwareAccel = null, Object? hardwareAccel = null,
Object? useLibass = null, Object? useLibass = null,
Object? playerOptions = freezed,
Object? internalVolume = null, Object? internalVolume = null,
Object? allowedOrientations = freezed, Object? allowedOrientations = freezed,
Object? nextVideoType = null, Object? nextVideoType = null,
@ -194,6 +203,10 @@ class __$$VideoPlayerSettingsModelImplCopyWithImpl<$Res>
? _value.useLibass ? _value.useLibass
: useLibass // ignore: cast_nullable_to_non_nullable : useLibass // ignore: cast_nullable_to_non_nullable
as bool, as bool,
playerOptions: freezed == playerOptions
? _value.playerOptions
: playerOptions // ignore: cast_nullable_to_non_nullable
as PlayerOptions?,
internalVolume: null == internalVolume internalVolume: null == internalVolume
? _value.internalVolume ? _value.internalVolume
: internalVolume // ignore: cast_nullable_to_non_nullable : internalVolume // ignore: cast_nullable_to_non_nullable
@ -224,6 +237,7 @@ class _$VideoPlayerSettingsModelImpl extends _VideoPlayerSettingsModel
this.fillScreen = false, this.fillScreen = false,
this.hardwareAccel = true, this.hardwareAccel = true,
this.useLibass = false, this.useLibass = false,
this.playerOptions,
this.internalVolume = 100, this.internalVolume = 100,
final Set<DeviceOrientation>? allowedOrientations, final Set<DeviceOrientation>? allowedOrientations,
this.nextVideoType = AutoNextType.smart, this.nextVideoType = AutoNextType.smart,
@ -249,6 +263,8 @@ class _$VideoPlayerSettingsModelImpl extends _VideoPlayerSettingsModel
@JsonKey() @JsonKey()
final bool useLibass; final bool useLibass;
@override @override
final PlayerOptions? playerOptions;
@override
@JsonKey() @JsonKey()
final double internalVolume; final double internalVolume;
final Set<DeviceOrientation>? _allowedOrientations; final Set<DeviceOrientation>? _allowedOrientations;
@ -270,7 +286,7 @@ class _$VideoPlayerSettingsModelImpl extends _VideoPlayerSettingsModel
@override @override
String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) { String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) {
return 'VideoPlayerSettingsModel(screenBrightness: $screenBrightness, videoFit: $videoFit, fillScreen: $fillScreen, hardwareAccel: $hardwareAccel, useLibass: $useLibass, internalVolume: $internalVolume, allowedOrientations: $allowedOrientations, nextVideoType: $nextVideoType, audioDevice: $audioDevice)'; return 'VideoPlayerSettingsModel(screenBrightness: $screenBrightness, videoFit: $videoFit, fillScreen: $fillScreen, hardwareAccel: $hardwareAccel, useLibass: $useLibass, playerOptions: $playerOptions, internalVolume: $internalVolume, allowedOrientations: $allowedOrientations, nextVideoType: $nextVideoType, audioDevice: $audioDevice)';
} }
@override @override
@ -283,6 +299,7 @@ class _$VideoPlayerSettingsModelImpl extends _VideoPlayerSettingsModel
..add(DiagnosticsProperty('fillScreen', fillScreen)) ..add(DiagnosticsProperty('fillScreen', fillScreen))
..add(DiagnosticsProperty('hardwareAccel', hardwareAccel)) ..add(DiagnosticsProperty('hardwareAccel', hardwareAccel))
..add(DiagnosticsProperty('useLibass', useLibass)) ..add(DiagnosticsProperty('useLibass', useLibass))
..add(DiagnosticsProperty('playerOptions', playerOptions))
..add(DiagnosticsProperty('internalVolume', internalVolume)) ..add(DiagnosticsProperty('internalVolume', internalVolume))
..add(DiagnosticsProperty('allowedOrientations', allowedOrientations)) ..add(DiagnosticsProperty('allowedOrientations', allowedOrientations))
..add(DiagnosticsProperty('nextVideoType', nextVideoType)) ..add(DiagnosticsProperty('nextVideoType', nextVideoType))
@ -313,6 +330,7 @@ abstract class _VideoPlayerSettingsModel extends VideoPlayerSettingsModel {
final bool fillScreen, final bool fillScreen,
final bool hardwareAccel, final bool hardwareAccel,
final bool useLibass, final bool useLibass,
final PlayerOptions? playerOptions,
final double internalVolume, final double internalVolume,
final Set<DeviceOrientation>? allowedOrientations, final Set<DeviceOrientation>? allowedOrientations,
final AutoNextType nextVideoType, final AutoNextType nextVideoType,
@ -333,6 +351,8 @@ abstract class _VideoPlayerSettingsModel extends VideoPlayerSettingsModel {
@override @override
bool get useLibass; bool get useLibass;
@override @override
PlayerOptions? get playerOptions;
@override
double get internalVolume; double get internalVolume;
@override @override
Set<DeviceOrientation>? get allowedOrientations; Set<DeviceOrientation>? get allowedOrientations;

View file

@ -15,6 +15,8 @@ _$VideoPlayerSettingsModelImpl _$$VideoPlayerSettingsModelImplFromJson(
fillScreen: json['fillScreen'] as bool? ?? false, fillScreen: json['fillScreen'] as bool? ?? false,
hardwareAccel: json['hardwareAccel'] as bool? ?? true, hardwareAccel: json['hardwareAccel'] as bool? ?? true,
useLibass: json['useLibass'] as bool? ?? false, useLibass: json['useLibass'] as bool? ?? false,
playerOptions:
$enumDecodeNullable(_$PlayerOptionsEnumMap, json['playerOptions']),
internalVolume: (json['internalVolume'] as num?)?.toDouble() ?? 100, internalVolume: (json['internalVolume'] as num?)?.toDouble() ?? 100,
allowedOrientations: (json['allowedOrientations'] as List<dynamic>?) allowedOrientations: (json['allowedOrientations'] as List<dynamic>?)
?.map((e) => $enumDecode(_$DeviceOrientationEnumMap, e)) ?.map((e) => $enumDecode(_$DeviceOrientationEnumMap, e))
@ -33,6 +35,7 @@ Map<String, dynamic> _$$VideoPlayerSettingsModelImplToJson(
'fillScreen': instance.fillScreen, 'fillScreen': instance.fillScreen,
'hardwareAccel': instance.hardwareAccel, 'hardwareAccel': instance.hardwareAccel,
'useLibass': instance.useLibass, 'useLibass': instance.useLibass,
'playerOptions': _$PlayerOptionsEnumMap[instance.playerOptions],
'internalVolume': instance.internalVolume, 'internalVolume': instance.internalVolume,
'allowedOrientations': instance.allowedOrientations 'allowedOrientations': instance.allowedOrientations
?.map((e) => _$DeviceOrientationEnumMap[e]!) ?.map((e) => _$DeviceOrientationEnumMap[e]!)
@ -51,6 +54,11 @@ const _$BoxFitEnumMap = {
BoxFit.scaleDown: 'scaleDown', BoxFit.scaleDown: 'scaleDown',
}; };
const _$PlayerOptionsEnumMap = {
PlayerOptions.libMDK: 'libMDK',
PlayerOptions.libMPV: 'libMPV',
};
const _$DeviceOrientationEnumMap = { const _$DeviceOrientationEnumMap = {
DeviceOrientation.portraitUp: 'portraitUp', DeviceOrientation.portraitUp: 'portraitUp',
DeviceOrientation.landscapeLeft: 'landscapeLeft', DeviceOrientation.landscapeLeft: 'landscapeLeft',

View file

@ -4,7 +4,6 @@ import 'package:flutter/material.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:ficonsax/ficonsax.dart'; import 'package:ficonsax/ficonsax.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:media_kit/media_kit.dart';
import 'package:fladder/jellyfin/jellyfin_open_api.swagger.dart'; import 'package:fladder/jellyfin/jellyfin_open_api.swagger.dart';
import 'package:fladder/models/items/chapters_model.dart'; import 'package:fladder/models/items/chapters_model.dart';
@ -73,8 +72,6 @@ class VideoPlayback {
ItemStreamModel? currentItem, ItemStreamModel? currentItem,
SyncedItem? currentSyncedItem, SyncedItem? currentSyncedItem,
VideoStream? currentStream, VideoStream? currentStream,
Map<AudioStreamModel, AudioTrack>? audioMappings,
Map<SubStreamModel, SubtitleTrack>? subMappings,
}) { }) {
return VideoPlayback( return VideoPlayback(
queue: queue ?? this.queue, queue: queue ?? this.queue,

View file

@ -1,23 +1,31 @@
import 'package:fladder/jellyfin/jellyfin_open_api.swagger.dart';
import 'package:fladder/profiles/web_profile.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
const DeviceProfile defaultProfile = kIsWeb import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fladder/jellyfin/jellyfin_open_api.swagger.dart';
import 'package:fladder/models/settings/video_player_settings.dart';
import 'package:fladder/profiles/web_profile.dart';
import 'package:fladder/providers/video_player_provider.dart';
final videoProfileProvider = StateProvider.autoDispose<DeviceProfile>((ref) =>
defaultProfile(ref.read(videoPlayerProvider.select((value) => value.backend)) ?? PlayerOptions.platformDefaults));
DeviceProfile defaultProfile(PlayerOptions player) => kIsWeb
? webProfile ? webProfile
: DeviceProfile( : DeviceProfile(
maxStreamingBitrate: 120000000, maxStreamingBitrate: 120000000,
maxStaticBitrate: 120000000, maxStaticBitrate: 120000000,
musicStreamingTranscodingBitrate: 384000, musicStreamingTranscodingBitrate: 384000,
directPlayProfiles: [ directPlayProfiles: [
DirectPlayProfile( const DirectPlayProfile(
type: DlnaProfileType.video, type: DlnaProfileType.video,
), ),
DirectPlayProfile( const DirectPlayProfile(
type: DlnaProfileType.audio, type: DlnaProfileType.audio,
) )
], ],
transcodingProfiles: [ transcodingProfiles: [
TranscodingProfile( const TranscodingProfile(
audioCodec: 'aac,mp3,mp2', audioCodec: 'aac,mp3,mp2',
container: 'ts', container: 'ts',
maxAudioChannels: '2', maxAudioChannels: '2',
@ -28,8 +36,10 @@ const DeviceProfile defaultProfile = kIsWeb
], ],
containerProfiles: [], containerProfiles: [],
subtitleProfiles: [ subtitleProfiles: [
SubtitleProfile(format: 'vtt', method: SubtitleDeliveryMethod.$external), const SubtitleProfile(format: 'vtt', method: SubtitleDeliveryMethod.$external),
SubtitleProfile(format: 'ass', method: SubtitleDeliveryMethod.$external), const SubtitleProfile(format: 'ass', method: SubtitleDeliveryMethod.$external),
SubtitleProfile(format: 'ssa', method: SubtitleDeliveryMethod.$external), const SubtitleProfile(format: 'ssa', method: SubtitleDeliveryMethod.$external),
if (player == PlayerOptions.libMDK)
const SubtitleProfile(format: 'pgssub', method: SubtitleDeliveryMethod.$external),
], ],
); );

View file

@ -31,10 +31,7 @@ class SeasonDetailsNotifier extends StateNotifier<SeasonModel?> {
season: newState?.season, season: newState?.season,
fields: [ItemFields.overview], fields: [ItemFields.overview],
); );
newState = newState?.copyWith( newState = newState?.copyWith(episodes: EpisodeModel.episodesFromDto(episodes.body?.items, ref).toList());
episodes: EpisodeModel.episodesFromDto(episodes.body?.items, ref)
.where((element) => element.season == newState?.season)
.toList());
state = newState; state = newState;
return season; return season;
} }

View file

@ -420,11 +420,11 @@ class SyncNotifier extends StateNotifier<SyncSettingsModel> {
final playbackResponse = await api.itemsItemIdPlaybackInfoPost( final playbackResponse = await api.itemsItemIdPlaybackInfoPost(
itemId: syncItem.id, itemId: syncItem.id,
body: const PlaybackInfoDto( body: PlaybackInfoDto(
enableDirectPlay: true, enableDirectPlay: true,
enableDirectStream: true, enableDirectStream: true,
enableTranscoding: false, enableTranscoding: false,
deviceProfile: defaultProfile, deviceProfile: ref.read(videoProfileProvider),
), ),
); );

View file

@ -1,15 +1,11 @@
import 'dart:async'; import 'dart:async';
import 'package:collection/collection.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fladder/models/media_playback_model.dart'; import 'package:fladder/models/media_playback_model.dart';
import 'package:fladder/models/playback/playback_model.dart'; import 'package:fladder/models/playback/playback_model.dart';
import 'package:fladder/providers/settings/video_player_settings_provider.dart'; import 'package:fladder/providers/settings/video_player_settings_provider.dart';
import 'package:fladder/wrappers/media_control_wrapper.dart' import 'package:fladder/wrappers/media_control_wrapper.dart';
if (dart.library.html) 'package:fladder/wrappers/media_control_wrapper_web.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:media_kit/media_kit.dart';
final mediaPlaybackProvider = StateProvider<MediaPlaybackModel>((ref) => MediaPlaybackModel()); final mediaPlaybackProvider = StateProvider<MediaPlaybackModel>((ref) => MediaPlaybackModel());
@ -30,45 +26,37 @@ class VideoPlayerNotifier extends StateNotifier<MediaControlsWrapper> {
late final mediaState = ref.read(mediaPlaybackProvider.notifier); late final mediaState = ref.read(mediaPlaybackProvider.notifier);
bool initMediaControls = false; MediaPlaybackModel get playbackState => ref.read(mediaPlaybackProvider);
void init() async { void init() async {
state.player?.dispose(); await state.dispose();
if (!initMediaControls && !kDebugMode) { await state.init();
state.init();
initMediaControls = true;
}
for (final s in subscriptions) { for (final s in subscriptions) {
s.cancel(); s.cancel();
} }
final player = state.setup();
if (player.platform is NativePlayer) { final subscription = state.stateStream?.listen((value) {
await (player.platform as dynamic).setProperty( mediaState.update((state) => state.copyWith(buffering: value.buffering));
'force-seekable', mediaState.update((state) => state.copyWith(buffer: value.buffer));
'yes', updatePlaying(value.playing);
); updatePosition(value.position);
mediaState.update((state) => state.copyWith(duration: value.duration));
});
if (subscription != null) {
subscriptions.add(subscription);
} }
subscriptions.addAll(
[
player.stream.buffering.listen((event) => mediaState.update((state) => state.copyWith(buffering: event))),
player.stream.buffer.listen((event) => mediaState.update((state) => state.copyWith(buffer: event))),
player.stream.playing.listen((event) => updatePlaying(event)),
player.stream.position.listen((event) => updatePosition(event)),
player.stream.duration.listen((event) => mediaState.update((state) => state.copyWith(duration: event))),
].whereNotNull(),
);
} }
Future<void> updatePlaying(bool event) async { Future<void> updatePlaying(bool event) async {
final player = state.player; if (!state.hasPlayer) return;
if (player == null) return;
mediaState.update((state) => state.copyWith(playing: event)); mediaState.update((state) => state.copyWith(playing: event));
} }
Future<void> updatePosition(Duration event) async { Future<void> updatePosition(Duration event) async {
final player = state.player; if (!state.hasPlayer) return;
if (player == null) return; if (playbackState.playing == false) return;
if (!player.state.playing) return;
final position = event; final position = event;
final lastPosition = ref.read(mediaPlaybackProvider.select((value) => value.lastPosition)); final lastPosition = ref.read(mediaPlaybackProvider.select((value) => value.lastPosition));
@ -77,16 +65,16 @@ class VideoPlayerNotifier extends StateNotifier<MediaControlsWrapper> {
if (diff > const Duration(seconds: 1, milliseconds: 500).inMilliseconds) { if (diff > const Duration(seconds: 1, milliseconds: 500).inMilliseconds) {
mediaState.update((value) => value.copyWith( mediaState.update((value) => value.copyWith(
position: event, position: event,
playing: player.state.playing, playing: playbackState.playing,
duration: player.state.duration, duration: playbackState.duration,
lastPosition: position, lastPosition: position,
)); ));
ref.read(playBackModel)?.updatePlaybackPosition(position, player.state.playing, ref); ref.read(playBackModel)?.updatePlaybackPosition(position, playbackState.playing, ref);
} else { } else {
mediaState.update((value) => value.copyWith( mediaState.update((value) => value.copyWith(
position: event, position: event,
playing: player.state.playing, playing: playbackState.playing,
duration: player.state.duration, duration: playbackState.duration,
)); ));
} }
} }
@ -100,22 +88,24 @@ class VideoPlayerNotifier extends StateNotifier<MediaControlsWrapper> {
PlaybackModel? newPlaybackModel = model; PlaybackModel? newPlaybackModel = model;
if (media != null) { if (media != null) {
await state.open(media.url, false);
await state.setVolume(ref.read(videoPlayerSettingsProvider).volume); await state.setVolume(ref.read(videoPlayerSettingsProvider).volume);
await state.open(media, play: false); state.stateStream?.takeWhile((event) => event.buffering == true).listen(
state.player?.stream.buffering.takeWhile((event) => event == true).listen(
null, null,
onDone: () async { onDone: () async {
final start = startPosition ?? await model.startDuration(); final start = startPosition ?? await model.startDuration();
if (start != null) { if (start != null) {
await state.seek(start); await state.seek(start);
} }
newPlaybackModel = await newPlaybackModel?.setAudio(null, state); await state.setAudioTrack(null, model);
newPlaybackModel = await newPlaybackModel?.setSubtitle(null, state); await state.setSubtitleTrack(null, model);
ref.read(playBackModel.notifier).update((state) => newPlaybackModel);
state.play(); state.play();
ref.read(playBackModel.notifier).update((state) => newPlaybackModel);
}, },
); );
ref.read(playBackModel.notifier).update((state) => model); ref.read(playBackModel.notifier).update((state) => model);
return true; return true;
} }

View file

@ -37,7 +37,7 @@ class MediaStreamInformation extends ConsumerWidget {
_StreamOptionSelect( _StreamOptionSelect(
label: Text(context.localized.audio), label: Text(context.localized.audio),
current: mediaStream.currentAudioStream?.displayTitle ?? "", current: mediaStream.currentAudioStream?.displayTitle ?? "",
itemBuilder: (context) => mediaStream.audioStreams itemBuilder: (context) => [AudioStreamModel.no(), ...mediaStream.audioStreams]
.map( .map(
(e) => PopupMenuItem( (e) => PopupMenuItem(
value: e, value: e,

View file

@ -8,7 +8,6 @@ import 'package:fladder/models/account_model.dart';
import 'package:fladder/providers/auth_provider.dart'; import 'package:fladder/providers/auth_provider.dart';
import 'package:fladder/screens/shared/flat_button.dart'; import 'package:fladder/screens/shared/flat_button.dart';
import 'package:fladder/screens/shared/user_icon.dart'; import 'package:fladder/screens/shared/user_icon.dart';
import 'package:fladder/util/adaptive_layout.dart';
import 'package:fladder/util/list_padding.dart'; import 'package:fladder/util/list_padding.dart';
class LoginUserGrid extends ConsumerWidget { class LoginUserGrid extends ConsumerWidget {
@ -37,79 +36,87 @@ class LoginUserGrid extends ConsumerWidget {
itemCount: users.length, itemCount: users.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final user = users[index]; final user = users[index];
return _CardHolder( return FlatButton(
key: Key(user.id), key: Key(user.id),
content: Stack( onTap: () => editMode ? onLongPress?.call(user) : onPressed?.call(user),
children: [ onLongPress: () => onLongPress?.call(user),
Column( child: _CardHolder(
mainAxisAlignment: MainAxisAlignment.spaceAround, content: Stack(
mainAxisSize: MainAxisSize.min, children: [
children: [ Padding(
Flexible( padding: const EdgeInsets.all(8.0),
child: UserIcon( child: Column(
labelStyle: Theme.of(context).textTheme.headlineMedium, mainAxisAlignment: MainAxisAlignment.spaceAround,
user: user, mainAxisSize: MainAxisSize.min,
),
),
Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.max,
children: [ children: [
Icon(
user.authMethod.icon,
size: 18,
),
const SizedBox(width: 4),
Flexible( Flexible(
child: Text( child: UserIcon(
user.name, labelStyle: Theme.of(context).textTheme.headlineMedium,
maxLines: 2, user: user,
softWrap: true, ),
)), ),
], Row(
),
if (user.credentials.serverName.isNotEmpty)
Opacity(
opacity: 0.75,
child: Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.max, mainAxisSize: MainAxisSize.max,
children: [ children: [
const Icon( Icon(
IconsaxBold.driver_2, user.authMethod.icon,
size: 14, size: 18,
), ),
const SizedBox(width: 4), const SizedBox(width: 4),
Flexible( Flexible(
child: Text( child: Text(
user.credentials.serverName, user.name,
maxLines: 2, maxLines: 2,
softWrap: true, softWrap: true,
), )),
),
], ],
), ),
) if (user.credentials.serverName.isNotEmpty)
].addInBetween(const SizedBox(width: 4, height: 4)), Opacity(
), opacity: 0.75,
if (editMode) child: Row(
Align( mainAxisAlignment: MainAxisAlignment.center,
alignment: Alignment.topRight, mainAxisSize: MainAxisSize.max,
child: Card( children: [
color: Theme.of(context).colorScheme.errorContainer, const Icon(
child: const Padding( IconsaxBold.driver_2,
padding: EdgeInsets.all(8.0), size: 14,
child: Icon( ),
IconsaxBold.edit_2, const SizedBox(width: 4),
size: 14, Flexible(
child: Text(
user.credentials.serverName,
maxLines: 2,
softWrap: true,
),
),
],
),
)
].addInBetween(const SizedBox(width: 4, height: 4)),
),
),
if (editMode)
Align(
alignment: Alignment.topRight,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Card(
color: Theme.of(context).colorScheme.errorContainer,
child: const Padding(
padding: EdgeInsets.all(8.0),
child: Icon(
IconsaxBold.edit_2,
size: 14,
),
),
), ),
), ),
), ),
) ],
], ),
), ),
onTap: () => editMode ? onLongPress?.call(user) : onPressed?.call(user),
onLongPress: () => onLongPress?.call(user),
); );
}, },
); );
@ -118,13 +125,9 @@ class LoginUserGrid extends ConsumerWidget {
class _CardHolder extends StatelessWidget { class _CardHolder extends StatelessWidget {
final Widget content; final Widget content;
final Function() onTap;
final Function() onLongPress;
const _CardHolder({ const _CardHolder({
required this.content, required this.content,
required this.onTap,
required this.onLongPress,
super.key, super.key,
}); });
@ -137,14 +140,7 @@ class _CardHolder extends StatelessWidget {
margin: EdgeInsets.zero, margin: EdgeInsets.zero,
child: ConstrainedBox( child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 150, maxWidth: 150), constraints: const BoxConstraints(maxHeight: 150, maxWidth: 150),
child: FlatButton( child: content,
onTap: onTap,
onLongPress: AdaptiveLayout.of(context).isDesktop ? onLongPress : null,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: content,
),
),
), ),
); );
} }

View file

@ -1,20 +1,24 @@
import 'dart:async'; import 'dart:async';
import 'package:ficonsax/ficonsax.dart';
import 'package:fladder/providers/settings/photo_view_settings_provider.dart';
import 'package:fladder/util/fladder_image.dart';
import 'package:fladder/widgets/shared/fladder_slider.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:media_kit/media_kit.dart';
import 'package:media_kit_video/media_kit_video.dart';
import 'package:fladder/models/items/photos_model.dart'; import 'package:ficonsax/ficonsax.dart';
import 'package:fladder/providers/user_provider.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fladder/util/duration_extensions.dart';
import 'package:wakelock_plus/wakelock_plus.dart'; import 'package:wakelock_plus/wakelock_plus.dart';
import 'package:window_manager/window_manager.dart'; import 'package:window_manager/window_manager.dart';
import 'package:fladder/models/items/photos_model.dart';
import 'package:fladder/models/settings/video_player_settings.dart';
import 'package:fladder/providers/settings/photo_view_settings_provider.dart';
import 'package:fladder/providers/settings/video_player_settings_provider.dart';
import 'package:fladder/providers/user_provider.dart';
import 'package:fladder/util/duration_extensions.dart';
import 'package:fladder/util/fladder_image.dart';
import 'package:fladder/widgets/shared/fladder_slider.dart';
import 'package:fladder/wrappers/players/lib_mdk.dart'
if (dart.library.html) 'package:fladder/stubs/web/lib_mdk_web.dart';
import 'package:fladder/wrappers/players/lib_mpv.dart';
class SimpleVideoPlayer extends ConsumerStatefulWidget { class SimpleVideoPlayer extends ConsumerStatefulWidget {
final PhotoModel video; final PhotoModel video;
final bool showOverlay; final bool showOverlay;
@ -26,10 +30,10 @@ class SimpleVideoPlayer extends ConsumerStatefulWidget {
} }
class _SimpleVideoPlayerState extends ConsumerState<SimpleVideoPlayer> with WindowListener, WidgetsBindingObserver { class _SimpleVideoPlayerState extends ConsumerState<SimpleVideoPlayer> with WindowListener, WidgetsBindingObserver {
final Player player = Player( late final player = switch (ref.read(videoPlayerSettingsProvider.select((value) => value.wantedPlayer))) {
configuration: const PlayerConfiguration(title: "nl.jknaapen.fladder", libass: true), PlayerOptions.libMDK => LibMDK(),
); PlayerOptions.libMPV => LibMPV(),
late VideoController controller = VideoController(player); };
late String videoUrl = ""; late String videoUrl = "";
bool playing = false; bool playing = false;
@ -61,9 +65,9 @@ class _SimpleVideoPlayerState extends ConsumerState<SimpleVideoPlayer> with Wind
super.initState(); super.initState();
windowManager.addListener(this); windowManager.addListener(this);
WidgetsBinding.instance.addObserver(this); WidgetsBinding.instance.addObserver(this);
playing = player.state.playing; playing = player.lastState.playing;
position = player.state.position; position = player.lastState.position;
duration = player.state.duration; duration = player.lastState.duration;
Future.microtask(() async => {_init()}); Future.microtask(() async => {_init()});
} }
@ -84,43 +88,21 @@ class _SimpleVideoPlayerState extends ConsumerState<SimpleVideoPlayer> with Wind
videoUrl = '${ref.read(userProvider)?.server ?? ""}/Videos/${widget.video.id}/stream?$params'; videoUrl = '${ref.read(userProvider)?.server ?? ""}/Videos/${widget.video.id}/stream?$params';
subscriptions.addAll( subscriptions.add(player.stateStream.listen((event) {
[ setState(() {
player.stream.playing.listen((event) { playing = event.playing;
setState(() { position = event.position;
playing = event; duration = event.duration;
}); });
if (playing) { if (playing) {
WakelockPlus.enable(); WakelockPlus.enable();
} else { } else {
WakelockPlus.disable(); WakelockPlus.disable();
} }
}), }));
player.stream.position.listen((event) { await player.open(videoUrl, !ref.watch(photoViewSettingsProvider).autoPlay);
setState(() { await player.loop(ref.watch(photoViewSettingsProvider.select((value) => value.repeat)));
position = event;
});
}),
player.stream.completed.listen((event) {
if (event) {
_restartVideo();
}
}),
player.stream.duration.listen((event) {
setState(() {
duration = event;
});
}),
],
);
await player.setVolume(ref.watch(photoViewSettingsProvider.select((value) => value.mute)) ? 0 : 100); await player.setVolume(ref.watch(photoViewSettingsProvider.select((value) => value.mute)) ? 0 : 100);
await player.open(Media(videoUrl), play: !ref.watch(photoViewSettingsProvider).autoPlay);
}
void _restartVideo() {
if (ref.read(photoViewSettingsProvider.select((value) => value.repeat))) {
player.play();
}
} }
@override @override
@ -142,6 +124,9 @@ class _SimpleVideoPlayerState extends ConsumerState<SimpleVideoPlayer> with Wind
.textTheme .textTheme
.titleMedium .titleMedium
?.copyWith(fontWeight: FontWeight.bold, shadows: [const Shadow(blurRadius: 2)]); ?.copyWith(fontWeight: FontWeight.bold, shadows: [const Shadow(blurRadius: 2)]);
ref.listen(photoViewSettingsProvider.select((value) => value.repeat), (previous, next) {
player.loop(next);
});
ref.listen( ref.listen(
photoViewSettingsProvider.select((value) => value.mute), photoViewSettingsProvider.select((value) => value.mute),
(previous, next) { (previous, next) {
@ -165,13 +150,17 @@ class _SimpleVideoPlayerState extends ConsumerState<SimpleVideoPlayer> with Wind
//Fixes small overlay problems with thumbnail //Fixes small overlay problems with thumbnail
Transform.scale( Transform.scale(
scaleY: 1.004, scaleY: 1.004,
child: Video( child: player.videoWidget(
fit: BoxFit.contain, UniqueKey(),
fill: const Color.fromARGB(0, 123, 62, 62), BoxFit.contain,
controller: controller,
controls: NoVideoControls,
wakelock: false,
), ),
// child: Video(
// fit: BoxFit.contain,
// fill: const Color.fromARGB(0, 123, 62, 62),
// controller: controller,
// controls: NoVideoControls,
// wakelock: false,
// ),
), ),
IgnorePointer( IgnorePointer(
ignoring: !widget.showOverlay, ignoring: !widget.showOverlay,
@ -211,7 +200,7 @@ class _SimpleVideoPlayerState extends ConsumerState<SimpleVideoPlayer> with Wind
} }
}, },
onChangeStart: (value) { onChangeStart: (value) {
wasPlaying = player.state.playing; wasPlaying = player.lastState.playing;
player.pause(); player.pause();
}, },
onChanged: (e) { onChanged: (e) {
@ -239,7 +228,7 @@ class _SimpleVideoPlayerState extends ConsumerState<SimpleVideoPlayer> with Wind
player.playOrPause(); player.playOrPause();
}, },
icon: Icon( icon: Icon(
player.state.playing ? IconsaxBold.pause_circle : IconsaxBold.play_circle, player.lastState.playing ? IconsaxBold.pause_circle : IconsaxBold.play_circle,
shadows: [ shadows: [
BoxShadow(blurRadius: 16, spreadRadius: 2, color: Colors.black.withOpacity(0.15)) BoxShadow(blurRadius: 16, spreadRadius: 2, color: Colors.black.withOpacity(0.15))
], ],

View file

@ -82,34 +82,90 @@ class _PlayerSettingsPageState extends ConsumerState<PlayerSettingsPage> {
), ),
const Divider(), const Divider(),
SettingsLabelDivider(label: context.localized.advanced), SettingsLabelDivider(label: context.localized.advanced),
SettingsListTile( if (PlayerOptions.available.length != 1)
label: Text(context.localized.settingsPlayerVideoHWAccelTitle),
subLabel: Text(context.localized.settingsPlayerVideoHWAccelDesc),
onTap: () => provider.setHardwareAccel(!videoSettings.hardwareAccel),
trailing: Switch(
value: videoSettings.hardwareAccel,
onChanged: (value) => provider.setHardwareAccel(value),
),
),
if (!kIsWeb) ...[
SettingsListTile( SettingsListTile(
label: Text(context.localized.settingsPlayerNativeLibassAccelTitle), label: Text(context.localized.playerSettingsBackendTitle),
subLabel: Text(context.localized.settingsPlayerNativeLibassAccelDesc), subLabel: Text(context.localized.playerSettingsBackendDesc),
onTap: () => provider.setUseLibass(!videoSettings.useLibass), trailing: Builder(builder: (context) {
trailing: Switch( final wantedPlayer = ref.watch(videoPlayerSettingsProvider.select((value) => value.wantedPlayer));
value: videoSettings.useLibass, final currentPlayer = ref.watch(videoPlayerSettingsProvider.select((value) => value.playerOptions));
onChanged: (value) => provider.setUseLibass(value), return EnumBox(
), current: currentPlayer == null
), ? "${context.localized.defaultLabel} (${PlayerOptions.platformDefaults.label(context)})"
AnimatedFadeSize( : wantedPlayer.label(context),
child: videoSettings.useLibass && videoSettings.hardwareAccel && Platform.isAndroid itemBuilder: (context) => [
? SettingsMessageBox( PopupMenuItem(
context.localized.settingsPlayerMobileWarning, value: null,
messageType: MessageType.warning, child:
Text("${context.localized.defaultLabel} (${PlayerOptions.platformDefaults.label(context)})"),
onTap: () => ref.read(videoPlayerSettingsProvider.notifier).state =
ref.read(videoPlayerSettingsProvider).copyWith(playerOptions: null),
),
...PlayerOptions.available.map(
(entry) => PopupMenuItem(
value: entry,
child: Text(entry.label(context)),
onTap: () => ref.read(videoPlayerSettingsProvider.notifier).state =
ref.read(videoPlayerSettingsProvider).copyWith(playerOptions: entry),
),
) )
: Container(), ],
);
}),
), ),
], AnimatedFadeSize(
child: switch (ref.read(videoPlayerSettingsProvider.select((value) => value.wantedPlayer))) {
PlayerOptions.libMPV => Column(
children: [
SettingsListTile(
label: Text(context.localized.settingsPlayerVideoHWAccelTitle),
subLabel: Text(context.localized.settingsPlayerVideoHWAccelDesc),
onTap: () => provider.setHardwareAccel(!videoSettings.hardwareAccel),
trailing: Switch(
value: videoSettings.hardwareAccel,
onChanged: (value) => provider.setHardwareAccel(value),
),
),
if (!kIsWeb) ...[
SettingsListTile(
label: Text(context.localized.settingsPlayerNativeLibassAccelTitle),
subLabel: Text(context.localized.settingsPlayerNativeLibassAccelDesc),
onTap: () => provider.setUseLibass(!videoSettings.useLibass),
trailing: Switch(
value: videoSettings.useLibass,
onChanged: (value) => provider.setUseLibass(value),
),
),
AnimatedFadeSize(
child: videoSettings.useLibass && videoSettings.hardwareAccel && Platform.isAndroid
? SettingsMessageBox(
context.localized.settingsPlayerMobileWarning,
messageType: MessageType.warning,
)
: Container(),
),
],
SettingsListTile(
label: Text(context.localized.settingsPlayerCustomSubtitlesTitle),
subLabel: Text(context.localized.settingsPlayerCustomSubtitlesDesc),
onTap: videoSettings.useLibass
? null
: () {
showDialog(
context: context,
barrierDismissible: false,
useSafeArea: false,
builder: (context) => const SubtitleEditor(),
);
},
),
],
),
_ => SettingsMessageBox(
messageType: MessageType.info,
"${context.localized.noVideoPlayerOptions}\n${context.localized.mdkExperimental}")
},
),
SettingsListTile( SettingsListTile(
label: Text(context.localized.settingsAutoNextTitle), label: Text(context.localized.settingsAutoNextTitle),
subLabel: Text(context.localized.settingsAutoNextDesc), subLabel: Text(context.localized.settingsAutoNextDesc),
@ -138,20 +194,6 @@ class _PlayerSettingsPageState extends ConsumerState<PlayerSettingsPage> {
_ => const SizedBox.shrink(), _ => const SizedBox.shrink(),
}, },
), ),
SettingsListTile(
label: Text(context.localized.settingsPlayerCustomSubtitlesTitle),
subLabel: Text(context.localized.settingsPlayerCustomSubtitlesDesc),
onTap: videoSettings.useLibass
? null
: () {
showDialog(
context: context,
barrierDismissible: false,
useSafeArea: false,
builder: (context) => const SubtitleEditor(),
);
},
),
if (!AdaptiveLayout.of(context).isDesktop && !kIsWeb) if (!AdaptiveLayout.of(context).isDesktop && !kIsWeb)
SettingsListTile( SettingsListTile(
label: Text(context.localized.playerSettingsOrientationTitle), label: Text(context.localized.playerSettingsOrientationTitle),

View file

@ -1,3 +1,8 @@
import 'package:flutter/material.dart';
import 'package:flutter_blurhash/flutter_blurhash.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fladder/models/settings/subtitle_settings_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/subtitle_settings_provider.dart';
import 'package:fladder/providers/settings/video_player_settings_provider.dart'; import 'package:fladder/providers/settings/video_player_settings_provider.dart';
@ -5,10 +10,6 @@ import 'package:fladder/screens/video_player/components/video_subtitle_controls.
import 'package:fladder/util/adaptive_layout.dart'; import 'package:fladder/util/adaptive_layout.dart';
import 'package:fladder/util/localization_helper.dart'; import 'package:fladder/util/localization_helper.dart';
import 'package:fladder/widgets/navigation_scaffold/components/fladder_appbar.dart'; import 'package:fladder/widgets/navigation_scaffold/components/fladder_appbar.dart';
import 'package:flutter/material.dart';
import 'package:flutter_blurhash/flutter_blurhash.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
// ignore: depend_on_referenced_packages
class SubtitleEditor extends ConsumerStatefulWidget { class SubtitleEditor extends ConsumerStatefulWidget {
const SubtitleEditor({super.key}); const SubtitleEditor({super.key});

View file

@ -14,35 +14,43 @@ class FlatButton extends ConsumerWidget {
final Color? splashColor; final Color? splashColor;
final double elevation; final double elevation;
final Clip clipBehavior; final Clip clipBehavior;
const FlatButton( const FlatButton({
{this.child, this.child,
this.onTap, this.onTap,
this.onLongPress, this.onLongPress,
this.onDoubleTap, this.onDoubleTap,
this.onSecondaryTapDown, this.onSecondaryTapDown,
this.borderRadiusGeometry, this.borderRadiusGeometry,
this.splashColor, this.splashColor,
this.elevation = 0, this.elevation = 0,
this.clipBehavior = Clip.none, this.clipBehavior = Clip.none,
super.key}); super.key,
});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
return Material( return Stack(
color: Colors.transparent, fit: StackFit.passthrough,
clipBehavior: clipBehavior, children: [
borderRadius: borderRadiusGeometry ?? FladderTheme.defaultShape.borderRadius, child ?? Container(),
elevation: 0, Positioned.fill(
child: InkWell( child: Material(
onTap: onTap, color: Colors.transparent,
onLongPress: onLongPress, clipBehavior: clipBehavior,
onDoubleTap: onDoubleTap, borderRadius: borderRadiusGeometry ?? FladderTheme.defaultShape.borderRadius,
onSecondaryTapDown: onSecondaryTapDown, elevation: 0,
borderRadius: borderRadiusGeometry ?? BorderRadius.circular(10), child: InkWell(
splashColor: splashColor ?? Theme.of(context).colorScheme.primary.withOpacity(0.5), onTap: onTap,
splashFactory: InkSparkle.splashFactory, onLongPress: onLongPress,
child: child ?? Container(), onDoubleTap: onDoubleTap,
), onSecondaryTapDown: onSecondaryTapDown,
borderRadius: borderRadiusGeometry ?? BorderRadius.circular(10),
splashColor: splashColor ?? Theme.of(context).colorScheme.primary.withOpacity(0.5),
splashFactory: InkSparkle.splashFactory,
),
),
),
],
); );
} }
} }

View file

@ -1,9 +1,12 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fladder/models/playback/playback_model.dart'; import 'package:fladder/models/playback/playback_model.dart';
import 'package:fladder/providers/session_info_provider.dart'; import 'package:fladder/providers/session_info_provider.dart';
import 'package:fladder/providers/video_player_provider.dart'; import 'package:fladder/providers/video_player_provider.dart';
import 'package:fladder/util/list_padding.dart'; import 'package:fladder/util/list_padding.dart';
import 'package:flutter/material.dart'; import 'package:fladder/util/localization_helper.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
Future<void> showVideoPlaybackInformation(BuildContext context) { Future<void> showVideoPlaybackInformation(BuildContext context) {
return showDialog( return showDialog(
@ -19,6 +22,7 @@ class _VideoPlaybackInformation extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final playbackModel = ref.watch(playBackModel); final playbackModel = ref.watch(playBackModel);
final sessionInfo = ref.watch(sessionInfoProvider); final sessionInfo = ref.watch(sessionInfoProvider);
final backend = ref.read(videoPlayerProvider.select((value) => value.backend));
return Dialog( return Dialog(
child: Padding( child: Padding(
padding: const EdgeInsets.all(12.0), padding: const EdgeInsets.all(12.0),
@ -27,47 +31,81 @@ class _VideoPlaybackInformation extends ConsumerWidget {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text("Playback information", style: Theme.of(context).textTheme.titleMedium), Text("Player info", style: Theme.of(context).textTheme.titleMedium),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4).copyWith(top: 4),
child: Opacity(
opacity: 0.80,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [const Text('backend: '), Text(backend?.label(context) ?? context.localized.unknown)],
)
].addPadding(const EdgeInsets.symmetric(vertical: 3)),
),
),
),
const Divider(), const Divider(),
...[ Text("Playback information", style: Theme.of(context).textTheme.titleMedium),
Row( const SizedBox(height: 4),
mainAxisSize: MainAxisSize.min, Padding(
children: [const Text('type: '), Text(playbackModel.label ?? "")], padding: const EdgeInsets.symmetric(horizontal: 4).copyWith(top: 4),
), child: Opacity(
if (sessionInfo.transCodeInfo != null) ...[ opacity: 0.8,
const SizedBox(height: 6), child: Column(
Text("Transcoding", style: Theme.of(context).textTheme.titleMedium), crossAxisAlignment: CrossAxisAlignment.start,
if (sessionInfo.transCodeInfo?.transcodeReasons?.isNotEmpty == true) children: [
Row( Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [const Text('reason: '), Text(sessionInfo.transCodeInfo?.transcodeReasons.toString() ?? "")], children: [const Text('type: '), Text(playbackModel.label ?? "")],
), ),
if (sessionInfo.transCodeInfo?.completionPercentage != null) if (sessionInfo.transCodeInfo != null) ...[
Row( Text("Transcoding", style: Theme.of(context).textTheme.titleMedium),
mainAxisSize: MainAxisSize.min, if (sessionInfo.transCodeInfo?.transcodeReasons?.isNotEmpty == true)
children: [ Row(
const Text('transcode progress: '), mainAxisSize: MainAxisSize.min,
Text("${sessionInfo.transCodeInfo?.completionPercentage?.toStringAsFixed(2)} %") children: [
const Text('reason: '),
Text(sessionInfo.transCodeInfo?.transcodeReasons.toString() ?? "")
],
),
if (sessionInfo.transCodeInfo?.completionPercentage != null)
Row(
mainAxisSize: MainAxisSize.min,
children: [
const Text('transcode progress: '),
Text("${sessionInfo.transCodeInfo?.completionPercentage?.toStringAsFixed(2)} %")
],
),
if (sessionInfo.transCodeInfo?.container != null)
Row(
mainAxisSize: MainAxisSize.min,
children: [
const Text('container: '),
Text(sessionInfo.transCodeInfo!.container.toString())
],
),
], ],
), Row(
if (sessionInfo.transCodeInfo?.container != null) mainAxisSize: MainAxisSize.min,
Row( children: [
mainAxisSize: MainAxisSize.min, const Text('resolution: '),
children: [const Text('container: '), Text(sessionInfo.transCodeInfo!.container.toString())], Text(playbackModel?.item.streamModel?.resolutionText ?? "")
), ],
], ),
Row( Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [const Text('resolution: '), Text(playbackModel?.item.streamModel?.resolutionText ?? "")], children: [
const Text('container: '),
Text(playbackModel?.playbackInfo?.mediaSources?.firstOrNull?.container ?? "")
],
)
].addPadding(const EdgeInsets.symmetric(vertical: 3)),
),
), ),
Row( ),
mainAxisSize: MainAxisSize.min,
children: [
const Text('container: '),
Text(playbackModel?.playbackInfo?.mediaSources?.firstOrNull?.container ?? "")
],
),
].addPadding(const EdgeInsets.symmetric(vertical: 3))
], ],
), ),
), ),

View file

@ -1,7 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:media_kit/media_kit.dart';
import 'package:fladder/providers/video_player_provider.dart'; import 'package:fladder/providers/video_player_provider.dart';
import 'package:fladder/screens/video_player/components/video_player_chapters.dart'; import 'package:fladder/screens/video_player/components/video_player_chapters.dart';
@ -9,8 +8,7 @@ import 'package:fladder/screens/video_player/components/video_player_queue.dart'
class ChapterButton extends ConsumerWidget { class ChapterButton extends ConsumerWidget {
final Duration position; final Duration position;
final Player player; const ChapterButton({super.key, required this.position});
const ChapterButton({super.key, required this.position, required this.player});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
@ -22,9 +20,9 @@ class ChapterButton extends ConsumerWidget {
context, context,
chapters: currentChapters, chapters: currentChapters,
currentPosition: position, currentPosition: position,
onChapterTapped: (chapter) => player.seek( onChapterTapped: (chapter) => ref.read(videoPlayerProvider).seek(
chapter.startPosition, chapter.startPosition,
), ),
); );
}, },
icon: const Icon( icon: const Icon(

View file

@ -447,13 +447,13 @@ class _SimpleControls extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final player = ref.watch(videoPlayerProvider.select((value) => value.controller?.player)); final player = ref.watch(videoPlayerProvider);
final isPlaying = ref.watch(mediaPlaybackProvider.select((value) => value.playing)); final isPlaying = ref.watch(mediaPlaybackProvider.select((value) => value.playing));
return Row( return Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
IconButton.filledTonal( IconButton.filledTonal(
onPressed: () => player?.playOrPause(), onPressed: () => player.playOrPause(),
icon: Icon(isPlaying ? IconsaxBold.pause : IconsaxBold.play), icon: Icon(isPlaying ? IconsaxBold.pause : IconsaxBold.play),
), ),
if (skip != null) if (skip != null)

View file

@ -12,6 +12,7 @@ import 'package:fladder/models/playback/direct_playback_model.dart';
import 'package:fladder/models/playback/offline_playback_model.dart'; import 'package:fladder/models/playback/offline_playback_model.dart';
import 'package:fladder/models/playback/playback_model.dart'; import 'package:fladder/models/playback/playback_model.dart';
import 'package:fladder/models/playback/transcode_playback_model.dart'; import 'package:fladder/models/playback/transcode_playback_model.dart';
import 'package:fladder/models/settings/video_player_settings.dart';
import 'package:fladder/providers/settings/video_player_settings_provider.dart'; import 'package:fladder/providers/settings/video_player_settings_provider.dart';
import 'package:fladder/providers/user_provider.dart'; import 'package:fladder/providers/user_provider.dart';
import 'package:fladder/providers/video_player_provider.dart'; import 'package:fladder/providers/video_player_provider.dart';
@ -375,15 +376,16 @@ Future<void> showSubSelection(BuildContext context) {
children: [ children: [
Text(context.localized.subtitle), Text(context.localized.subtitle),
const Spacer(), const Spacer(),
IconButton.outlined( if (player.backend == PlayerOptions.libMPV)
onPressed: () { IconButton.outlined(
Navigator.pop(context); onPressed: () {
showSubtitleControls( Navigator.pop(context);
context: context, showSubtitleControls(
label: context.localized.subtitleConfiguration, context: context,
); label: context.localized.subtitleConfiguration,
}, );
icon: const Icon(Icons.display_settings_rounded)) },
icon: const Icon(Icons.display_settings_rounded))
], ],
), ),
children: playbackModel?.subStreams?.mapIndexed( children: playbackModel?.subStreams?.mapIndexed(
@ -459,7 +461,7 @@ Future<void> showPlaybackSpeed(BuildContext context) {
return StatefulBuilder(builder: (context, setState) { return StatefulBuilder(builder: (context, setState) {
return Consumer( return Consumer(
builder: (context, ref, child) { builder: (context, ref, child) {
final player = ref.watch(videoPlayerProvider.select((value) => value.player)); final player = ref.watch(videoPlayerProvider);
final lastSpeed = ref.watch(playbackRateProvider); final lastSpeed = ref.watch(playbackRateProvider);
return SimpleDialog( return SimpleDialog(
contentPadding: const EdgeInsets.only(top: 8, bottom: 24), contentPadding: const EdgeInsets.only(top: 8, bottom: 24),
@ -484,7 +486,7 @@ Future<void> showPlaybackSpeed(BuildContext context) {
divisions: 39, divisions: 39,
onChanged: (value) { onChanged: (value) {
ref.read(playbackRateProvider.notifier).state = value; ref.read(playbackRateProvider.notifier).state = value;
player?.setRate(value); player.setSpeed(value);
}, },
), ),
), ),

View file

@ -122,7 +122,7 @@ class _ChapterProgressSliderState extends ConsumerState<VideoProgressBar> {
setState(() { setState(() {
onHoverStart = true; onHoverStart = true;
}); });
widget.wasPlayingChanged.call(player.player?.state.playing ?? false); widget.wasPlayingChanged.call(player.lastState?.playing ?? false);
player.pause(); player.pause();
}, },
onChanged: (e) { onChanged: (e) {

View file

@ -1,64 +0,0 @@
import 'dart:async';
import 'package:fladder/providers/settings/subtitle_settings_provider.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:media_kit_video/media_kit_video.dart';
import 'package:fladder/models/settings/subtitle_settings_model.dart';
class VideoSubtitles extends ConsumerStatefulWidget {
final VideoController controller;
final bool overLayed;
const VideoSubtitles({
required this.controller,
this.overLayed = false,
super.key,
});
@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());
} else {
return SubtitleText(
subModel: settings,
padding: padding,
offset: (widget.overLayed ? 0.5 : settings.verticalOffset),
text: text,
);
}
}
}

View file

@ -5,7 +5,6 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:media_kit_video/media_kit_video.dart';
import 'package:fladder/models/media_playback_model.dart'; import 'package:fladder/models/media_playback_model.dart';
import 'package:fladder/providers/settings/video_player_settings_provider.dart'; import 'package:fladder/providers/settings/video_player_settings_provider.dart';
@ -73,7 +72,7 @@ class _VideoPlayerState extends ConsumerState<VideoPlayer> with WidgetsBindingOb
final videoFit = ref.watch(videoPlayerSettingsProvider.select((value) => value.videoFit)); final videoFit = ref.watch(videoPlayerSettingsProvider.select((value) => value.videoFit));
final padding = MediaQuery.of(context).padding; final padding = MediaQuery.of(context).padding;
final playerController = ref.watch(videoPlayerProvider.select((value) => value.controller)); final playerController = ref.watch(videoPlayerProvider.select((value) => value));
ref.listen( ref.listen(
videoPlayerSettingsProvider.select((value) => value.allowedOrientations), videoPlayerSettingsProvider.select((value) => value.allowedOrientations),
@ -103,24 +102,15 @@ class _VideoPlayerState extends ConsumerState<VideoPlayer> with WidgetsBindingOb
lastScale = 0.0; lastScale = 0.0;
}, },
child: VideoPlayerNextWrapper( child: VideoPlayerNextWrapper(
video: playerController != null video: Padding(
? Padding( padding: fillScreen ? EdgeInsets.zero : EdgeInsets.only(left: padding.left, right: padding.right),
padding: fillScreen ? EdgeInsets.zero : EdgeInsets.only(left: padding.left, right: padding.right), child: playerController.videoWidget(
child: OrientationBuilder(builder: (context, orientation) { const Key("VideoPlayer"),
return Video( fillScreen
key: Key(orientation.toString()), ? (MediaQuery.of(context).orientation == Orientation.portrait ? videoFit : BoxFit.cover)
controller: playerController, : videoFit,
fill: Colors.transparent, ),
wakelock: true, ),
fit: fillScreen
? (MediaQuery.of(context).orientation == Orientation.portrait ? videoFit : BoxFit.cover)
: videoFit,
subtitleViewConfiguration: const SubtitleViewConfiguration(visible: false),
controls: NoVideoControls,
);
}),
)
: const SizedBox.shrink(),
controls: const DesktopControls(), controls: const DesktopControls(),
overlays: [ overlays: [
if (errorPlaying) const _VideoErrorWidget(), if (errorPlaying) const _VideoErrorWidget(),

View file

@ -1,6 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
@ -23,7 +22,6 @@ import 'package:fladder/screens/video_player/components/video_player_controls_ex
import 'package:fladder/screens/video_player/components/video_player_options_sheet.dart'; import 'package:fladder/screens/video_player/components/video_player_options_sheet.dart';
import 'package:fladder/screens/video_player/components/video_player_seek_indicator.dart'; import 'package:fladder/screens/video_player/components/video_player_seek_indicator.dart';
import 'package:fladder/screens/video_player/components/video_progress_bar.dart'; import 'package:fladder/screens/video_player/components/video_progress_bar.dart';
import 'package:fladder/screens/video_player/components/video_subtitles.dart';
import 'package:fladder/screens/video_player/components/video_volume_slider.dart'; import 'package:fladder/screens/video_player/components/video_volume_slider.dart';
import 'package:fladder/util/adaptive_layout.dart'; import 'package:fladder/util/adaptive_layout.dart';
import 'package:fladder/util/duration_extensions.dart'; import 'package:fladder/util/duration_extensions.dart';
@ -31,8 +29,7 @@ import 'package:fladder/util/input_handler.dart';
import 'package:fladder/util/list_padding.dart'; import 'package:fladder/util/list_padding.dart';
import 'package:fladder/util/localization_helper.dart'; import 'package:fladder/util/localization_helper.dart';
import 'package:fladder/util/string_extensions.dart'; import 'package:fladder/util/string_extensions.dart';
import 'package:fladder/widgets/shared/full_screen_button.dart' import 'package:fladder/widgets/shared/full_screen_button.dart';
if (dart.library.html) 'package:fladder/widgets/shared/full_screen_button_web.dart';
class DesktopControls extends ConsumerStatefulWidget { class DesktopControls extends ConsumerStatefulWidget {
const DesktopControls({super.key}); const DesktopControls({super.key});
@ -115,7 +112,8 @@ class _DesktopControlsState extends ConsumerState<DesktopControls> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final mediaSegments = ref.watch(playBackModel.select((value) => value?.mediaSegments)); final mediaSegments = ref.watch(playBackModel.select((value) => value?.mediaSegments));
final player = ref.watch(videoPlayerProvider.select((value) => value.controller)); final player = ref.watch(videoPlayerProvider);
final subtitleWidget = player.subtitleWidget(showOverlay);
return InputHandler( return InputHandler(
autoFocus: false, autoFocus: false,
onKeyEvent: (node, event) => _onKey(event) ? KeyEventResult.handled : KeyEventResult.ignored, onKeyEvent: (node, event) => _onKey(event) ? KeyEventResult.handled : KeyEventResult.ignored,
@ -135,12 +133,7 @@ class _DesktopControlsState extends ConsumerState<DesktopControls> {
onHover: AdaptiveLayout.of(context).isDesktop ? (event) => toggleOverlay(value: true) : null, onHover: AdaptiveLayout.of(context).isDesktop ? (event) => toggleOverlay(value: true) : null,
child: Stack( child: Stack(
children: [ children: [
if (player != null) if (subtitleWidget != null) subtitleWidget,
VideoSubtitles(
key: const Key('subtitles'),
controller: player,
overLayed: showOverlay,
),
if (AdaptiveLayout.of(context).isDesktop) if (AdaptiveLayout.of(context).isDesktop)
Consumer(builder: (context, ref, child) { Consumer(builder: (context, ref, child) {
final playing = ref.watch(mediaPlaybackProvider.select((value) => value.playing)); final playing = ref.watch(mediaPlaybackProvider.select((value) => value.playing));
@ -385,7 +378,7 @@ class _DesktopControlsState extends ConsumerState<DesktopControls> {
onPressed: () => closePlayer(), icon: const Icon(IconsaxOutline.close_square))), onPressed: () => closePlayer(), icon: const Icon(IconsaxOutline.close_square))),
const Spacer(), const Spacer(),
if (AdaptiveLayout.of(context).inputDevice == InputDevice.pointer && if (AdaptiveLayout.of(context).inputDevice == InputDevice.pointer &&
ref.read(videoPlayerProvider).player != null) ...{ ref.read(videoPlayerProvider).hasPlayer) ...{
// OpenQueueButton(x), // OpenQueueButton(x),
// ChapterButton( // ChapterButton(
// position: position, // position: position,
@ -471,7 +464,7 @@ class _DesktopControlsState extends ConsumerState<DesktopControls> {
), ),
), ),
), ),
} },
].addPadding(const EdgeInsets.symmetric(horizontal: 4)), ].addPadding(const EdgeInsets.symmetric(horizontal: 4)),
), ),
const SizedBox(height: 4), const SizedBox(height: 4),

View file

@ -0,0 +1,75 @@
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/base_player.dart';
import 'package:fladder/wrappers/players/player_states.dart';
// This is a stub class that provides the same method signatures as the original
// implementation, ensuring web builds compile without requiring changes elsewhere.
class LibMDK extends BasePlayer {
final StreamController<PlayerState> _stateController = StreamController.broadcast();
@override
Stream<PlayerState> get stateStream => _stateController.stream;
@override
Future<void> init(Ref ref) async {}
@override
Future<void> dispose() async {}
@override
Future<void> open(String url, bool play) async {}
void setState(PlayerState state) {}
void updateState() {}
@override
Future<void> pause() async {}
@override
Future<void> play() async {}
@override
Future<void> playOrPause() async {}
@override
Future<void> seek(Duration position) async {}
@override
Future<int> setAudioTrack(AudioStreamModel? model, PlaybackModel playbackModel) async {
return -1;
}
@override
Future<void> setSpeed(double speed) async {}
@override
Future<int> setSubtitleTrack(SubStreamModel? model, PlaybackModel playbackModel) async {
return -1;
}
@override
Future<void> stop() async {}
@override
Widget? videoWidget(
Key key,
BoxFit fit,
) =>
null;
@override
Widget? subtitles(bool showOverlay) => null;
@override
Future<void> setVolume(double volume) async {}
@override
Future<void> loop(bool loop) async {}
}

195
lib/stubs/web/smtc_web.dart Normal file
View file

@ -0,0 +1,195 @@
// ignore_for_file: implementation_imports, constant_identifier_names
import 'dart:async';
// This is a stub class that provides the same method signatures as the original
// implementation, ensuring web builds compile without requiring changes elsewhere.
class SMTCWindows {
SMTCWindows({
SMTCConfig? config,
PlaybackTimeline? timeline,
MusicMetadata? metadata,
PlaybackStatus? status,
bool? shuffleEnabled,
RepeatMode? repeatMode,
bool? enabled,
});
get buttonPressStream => null;
Future<void> updateConfig(SMTCConfig config) async {}
Future<void> updateTimeline(PlaybackTimeline timeline) async {}
Future<void> updateMetadata(MusicMetadata metadata) async {}
Future<void> clearMetadata() async {}
Future<void> dispose() async {}
Future<void> disableSmtc() async {}
Future<void> enableSmtc() async {}
Future<void> setPlaybackStatus(PlaybackStatus status) async {}
Future<void> setIsPlayEnabled(bool enabled) async {}
Future<void> setIsPauseEnabled(bool enabled) async {}
Future<void> setIsStopEnabled(bool enabled) async {}
Future<void> setIsNextEnabled(bool enabled) async {}
Future<void> setIsPrevEnabled(bool enabled) async {}
Future<void> setIsFastForwardEnabled(bool enabled) async {}
Future<void> setIsRewindEnabled(bool enabled) async {}
Future<void> setTimeline(PlaybackTimeline timeline) async {}
Future<void> setTitle(String title) async {}
Future<void> setArtist(String artist) async {}
Future<void> setAlbum(String album) async {}
Future<void> setAlbumArtist(String albumArtist) async {}
Future<void> setThumbnail(String thumbnail) async {}
Future<void> setPosition(Duration position) async {}
Future<void> setStartTime(Duration startTime) async {}
Future<void> setEndTime(Duration endTime) async {}
Future<void> setMaxSeekTime(Duration maxSeekTime) async {}
Future<void> setMinSeekTime(Duration minSeekTime) async {}
Future<void> setShuffleEnabled(bool enabled) async {}
Future<void> setRepeatMode(RepeatMode repeatMode) async {}
}
class MusicMetadata {
final String? title;
final String? artist;
final String? album;
final String? albumArtist;
final String? thumbnail;
const MusicMetadata({
this.title,
this.artist,
this.album,
this.albumArtist,
this.thumbnail,
});
}
enum PlaybackStatus {
Closed,
Changing,
Stopped,
Playing,
Paused,
}
enum PressedButton {
play,
pause,
next,
previous,
fastForward,
rewind,
stop,
record,
channelUp,
channelDown;
static PressedButton fromString(String button) {
switch (button) {
case 'play':
return PressedButton.play;
case 'pause':
return PressedButton.pause;
case 'next':
return PressedButton.next;
case 'previous':
return PressedButton.previous;
case 'fast_forward':
return PressedButton.fastForward;
case 'rewind':
return PressedButton.rewind;
case 'stop':
return PressedButton.stop;
case 'record':
return PressedButton.record;
case 'channel_up':
return PressedButton.channelUp;
case 'channel_down':
return PressedButton.channelDown;
default:
throw Exception('Unknown button: $button');
}
}
}
class PlaybackTimeline {
final int startTimeMs;
final int endTimeMs;
final int positionMs;
final int? minSeekTimeMs;
final int? maxSeekTimeMs;
const PlaybackTimeline({
required this.startTimeMs,
required this.endTimeMs,
required this.positionMs,
this.minSeekTimeMs,
this.maxSeekTimeMs,
});
}
class SMTCConfig {
final bool playEnabled;
final bool pauseEnabled;
final bool stopEnabled;
final bool nextEnabled;
final bool prevEnabled;
final bool fastForwardEnabled;
final bool rewindEnabled;
const SMTCConfig({
required this.playEnabled,
required this.pauseEnabled,
required this.stopEnabled,
required this.nextEnabled,
required this.prevEnabled,
required this.fastForwardEnabled,
required this.rewindEnabled,
});
}
enum RepeatMode {
none,
track,
list;
static RepeatMode fromString(String value) {
switch (value) {
case 'none':
return none;
case 'track':
return track;
case 'list':
return list;
default:
throw Exception('Unknown repeat mode: $value');
}
}
String get asString => toString().split('.').last;
}

View file

@ -199,7 +199,7 @@ extension ItemBaseModelExtensions on ItemBaseModel? {
switch (this) { switch (this) {
PhotoAlbumModel album => album.play(context, ref), PhotoAlbumModel album => album.play(context, ref),
BookModel book => book.play(context, ref), BookModel book => book.play(context, ref),
_ => _default(context, this, ref, startPosition: startPosition), _ => _default(context, this, ref, startPosition: startPosition, showPlaybackOption: showPlaybackOption),
}; };
Future<void> _default( Future<void> _default(

View file

@ -1,16 +0,0 @@
import 'package:collection/collection.dart';
import 'package:fladder/models/items/media_streams_model.dart';
import 'package:media_kit/media_kit.dart';
import 'dart:io' show Platform;
extension PlayerExtensions on Player {
Future<void> addSubtitles(List<SubStreamModel> subtitles) async {
final separator = Platform.isWindows ? ";" : ":";
await (platform as NativePlayer).setProperty(
"sub-files",
subtitles
.mapIndexed((index, e) => "${Platform.isWindows ? e.url : e.url?.replaceFirst(":", "\\:")}@${e.displayTitle}")
.join(separator),
);
}
}

View file

@ -1,6 +0,0 @@
import 'package:fladder/models/items/media_streams_model.dart';
import 'package:media_kit/media_kit.dart';
extension PlayerExtensions on Player {
Future<void> addSubtitles(List<SubStreamModel> subtitles) async {}
}

View file

@ -1,45 +0,0 @@
import 'package:media_kit/media_kit.dart';
import 'package:validators/validators.dart';
import 'string_extensions.dart';
extension SubtitleExtension on SubtitleTrack {
String get cleanName {
final names = {
id,
title,
};
return names
.where((element) => element != null)
.map((e) {
if (e == null) return e;
if (isNumeric(e)) return '';
if (e == "no") {
return "Off";
}
return e.capitalize();
})
.where((element) => element != null && element.isNotEmpty)
.join(" - ");
}
}
extension AudioTrackExtension on AudioTrack {
String get cleanName {
final names = {
id,
title,
};
return names
.where((element) => element != null)
.map((e) {
if (e == null) return e;
if (isNumeric(e)) return '';
if (e == "no") {
return "Off";
}
return e.capitalize();
})
.where((element) => element != null && element.isNotEmpty)
.join(" - ");
}
}

View file

@ -3,7 +3,6 @@ import 'package:flutter/material.dart';
import 'package:ficonsax/ficonsax.dart'; import 'package:ficonsax/ficonsax.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:media_kit_video/media_kit_video.dart';
import 'package:window_manager/window_manager.dart'; import 'package:window_manager/window_manager.dart';
import 'package:fladder/models/media_playback_model.dart'; import 'package:fladder/models/media_playback_model.dart';
@ -103,12 +102,11 @@ class _CurrentlyPlayingBarState extends ConsumerState<FloatingPlayerBar> {
children: [ children: [
Hero( Hero(
tag: videoPlayerHeroTag, tag: videoPlayerHeroTag,
child: Video( child: player.videoWidget(
controller: player.controller!, UniqueKey(),
fit: BoxFit.fitHeight, BoxFit.fitHeight,
controls: NoVideoControls, ) ??
wakelock: playbackInfo.playing, const SizedBox.shrink(),
),
), ),
Positioned.fill( Positioned.fill(
child: Tooltip( child: Tooltip(
@ -169,8 +167,7 @@ class _CurrentlyPlayingBarState extends ConsumerState<FloatingPlayerBar> {
if (constraints.maxWidth > 500) ...{ if (constraints.maxWidth > 500) ...{
IconButton( IconButton(
onPressed: () { onPressed: () {
final volume = player.player?.state.volume == 0 ? 100.0 : 0.0; final volume = player.lastState?.volume == 0 ? 100.0 : 0.0;
ref.read(videoPlayerSettingsProvider.notifier).setVolume(volume);
player.setVolume(volume); player.setVolume(volume);
}, },
icon: Icon( icon: Icon(

View file

@ -1,63 +0,0 @@
import 'package:audio_service/audio_service.dart';
import 'package:media_kit/media_kit.dart';
AudioServiceConfig get audioServiceConfig => const AudioServiceConfig(
androidNotificationChannelId: 'nl.jknaapen.fladder.channel.playback',
androidNotificationChannelName: 'Video playback',
androidNotificationOngoing: true,
androidStopForegroundOnPause: true,
rewindInterval: Duration(seconds: 10),
fastForwardInterval: Duration(seconds: 15),
androidNotificationChannelDescription: "Playback",
androidShowNotificationBadge: true,
);
abstract class MediaControlBase {
Future<void> init() {
throw UnimplementedError();
}
Player setup() {
throw UnimplementedError();
}
Future<void> seek(Duration position) {
throw UnimplementedError();
}
Future<void> play() {
throw UnimplementedError();
}
Future<void> fastForward() {
throw UnimplementedError();
}
Future<void> rewind() {
throw UnimplementedError();
}
Future<void> setSpeed(double speed) {
throw UnimplementedError();
}
Future<void> pause() {
throw UnimplementedError();
}
Future<void> stop() {
throw UnimplementedError();
}
void playOrPause() {
throw UnimplementedError();
}
Future<void> setSubtitleTrack(SubtitleTrack subtitleTrack) {
throw UnimplementedError();
}
Future<void> setAudioTrack(AudioTrack subtitleTrack) {
throw UnimplementedError();
}
}

View file

@ -2,76 +2,98 @@ import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:audio_service/audio_service.dart'; import 'package:audio_service/audio_service.dart';
import 'package:collection/collection.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:media_kit/media_kit.dart'; import 'package:smtc_windows/smtc_windows.dart' if (dart.library.html) 'package:fladder/stubs/web/smtc_web.dart';
import 'package:media_kit_video/media_kit_video.dart';
import 'package:smtc_windows/smtc_windows.dart';
import 'package:wakelock_plus/wakelock_plus.dart'; import 'package:wakelock_plus/wakelock_plus.dart';
import 'package:fladder/models/item_base_model.dart'; import 'package:fladder/models/item_base_model.dart';
import 'package:fladder/models/items/media_streams_model.dart';
import 'package:fladder/models/playback/playback_model.dart';
import 'package:fladder/models/settings/video_player_settings.dart';
import 'package:fladder/providers/settings/client_settings_provider.dart'; import 'package:fladder/providers/settings/client_settings_provider.dart';
import 'package:fladder/providers/settings/video_player_settings_provider.dart'; import 'package:fladder/providers/settings/video_player_settings_provider.dart';
import 'package:fladder/providers/video_player_provider.dart'; import 'package:fladder/providers/video_player_provider.dart';
import 'package:fladder/wrappers/media_control_base.dart'; import 'package:fladder/wrappers/players/base_player.dart';
import 'package:fladder/wrappers/media_wrapper_interface.dart'; import 'package:fladder/wrappers/players/lib_mdk.dart'
if (dart.library.html) 'package:fladder/stubs/web/lib_mdk_web.dart';
import 'package:fladder/wrappers/players/lib_mpv.dart';
import 'package:fladder/wrappers/players/player_states.dart';
class MediaControlsWrapper extends MediaPlayback implements MediaControlBase { class MediaControlsWrapper extends BaseAudioHandler {
MediaControlsWrapper({required this.ref}); MediaControlsWrapper({required this.ref});
BasePlayer? _player;
bool get hasPlayer => _player != null;
PlayerOptions? get backend => switch (_player) {
LibMPV _ => PlayerOptions.libMPV,
LibMDK _ => PlayerOptions.libMDK,
_ => null,
};
Stream<PlayerState>? get stateStream => _player?.stateStream;
PlayerState? get lastState => _player?.lastState;
Widget? subtitleWidget(bool showOverlay) => _player?.subtitles(showOverlay);
Widget? videoWidget(Key key, BoxFit fit) => _player?.videoWidget(key, fit);
final Ref ref; final Ref ref;
List<StreamSubscription> subscriptions = []; List<StreamSubscription> subscriptions = [];
SMTCWindows? smtc; SMTCWindows? smtc;
@override bool initMediaControls = false;
Future<void> init() async { Future<void> init() async {
await AudioService.init( if (!initMediaControls && !kDebugMode) {
builder: () => this, await AudioService.init(
config: audioServiceConfig, builder: () => this,
); config: const AudioServiceConfig(
androidNotificationChannelId: 'nl.jknaapen.fladder.channel.playback',
androidNotificationChannelName: 'Video playback',
androidNotificationOngoing: true,
androidStopForegroundOnPause: true,
rewindInterval: Duration(seconds: 10),
fastForwardInterval: Duration(seconds: 15),
androidNotificationChannelDescription: "Playback",
androidShowNotificationBadge: true,
),
);
initMediaControls = true;
}
final player = switch (ref.read(videoPlayerSettingsProvider.select((value) => value.wantedPlayer))) {
PlayerOptions.libMDK => LibMDK(),
PlayerOptions.libMPV => LibMPV(),
};
setup(player);
} }
@override Future<void> dispose() async => _player?.dispose();
Player setup() => setPlayer(_initPlayer());
Player _initPlayer() { Future<void> setup(BasePlayer newPlayer) async {
_player = newPlayer;
await newPlayer.init(ref);
_initPlayer();
}
void _initPlayer() {
for (var element in subscriptions) { for (var element in subscriptions) {
element.cancel(); element.cancel();
} }
stop(); stop();
player?.dispose();
final newPlayer = Player(
configuration: PlayerConfiguration(
title: "nl.jknaapen.fladder",
bufferSize: 64 * 1024 * 1024,
libassAndroidFont: 'assets/fonts/mp-font.ttf',
libass: !kIsWeb &&
ref.read(
videoPlayerSettingsProvider.select((value) => value.useLibass),
),
),
);
setPlayer(newPlayer);
setController(VideoController(
newPlayer,
configuration: VideoControllerConfiguration(
enableHardwareAcceleration: ref.read(
videoPlayerSettingsProvider.select((value) => value.hardwareAccel),
),
),
));
_subscribePlayer(); _subscribePlayer();
return newPlayer;
} }
Future<void> open(String url, bool play) async => _player?.open(url, play);
void _subscribePlayer() { void _subscribePlayer() {
if (Platform.isWindows) { if (Platform.isWindows && !kIsWeb) {
smtc = SMTCWindows( smtc = SMTCWindows(
config: const SMTCConfig( config: const SMTCConfig(
fastForwardEnabled: true, fastForwardEnabled: true,
@ -113,43 +135,33 @@ class MediaControlsWrapper extends MediaPlayback implements MediaControlBase {
} }
} }
subscriptions.addAll([ subscriptions.add(_player!.stateStream.listen((value) {
player?.stream.buffer.listen((buffer) { playbackState.add(playbackState.value.copyWith(
playbackState.add(playbackState.value.copyWith( bufferedPosition: value.buffer,
bufferedPosition: buffer, ));
)); playbackState.add(playbackState.value.copyWith(
}), processingState: value.buffering ? AudioProcessingState.buffering : AudioProcessingState.ready,
player?.stream.buffering.listen((buffering) { ));
playbackState.add(playbackState.value.copyWith( playbackState.add(playbackState.value.copyWith(
processingState: buffering ? AudioProcessingState.buffering : AudioProcessingState.ready, updatePosition: value.position,
)); ));
}), smtc?.setPosition(value.position);
player?.stream.position.listen((position) { if (value.playing) {
playbackState.add(playbackState.value.copyWith( WakelockPlus.enable();
updatePosition: position, } else {
)); WakelockPlus.disable();
smtc?.setPosition(position); }
}), playbackState.add(playbackState.value.copyWith(
player?.stream.playing.listen((playing) { playing: value.playing,
if (playing) { ));
WakelockPlus.enable(); smtc?.setPlaybackStatus(value.playing ? PlaybackStatus.Playing : PlaybackStatus.Paused);
} else { }));
WakelockPlus.disable();
}
playbackState.add(playbackState.value.copyWith(
playing: playing,
));
smtc?.setPlaybackStatus(playing ? PlaybackStatus.Playing : PlaybackStatus.Paused);
}),
].whereNotNull());
} }
@override @override
Future<void> play() async { Future<void> play() async {
if (!ref.read(clientSettingsProvider).enableMediaKeys) { _player?.play();
await player?.play(); if (!ref.read(clientSettingsProvider).enableMediaKeys) return;
return super.play();
}
final playBackItem = ref.read(playBackModel.select((value) => value?.item)); final playBackItem = ref.read(playBackModel.select((value) => value?.item));
final currentPosition = await ref.read(playBackModel.select((value) => value?.startDuration())); final currentPosition = await ref.read(playBackModel.select((value) => value?.startDuration()));
@ -182,7 +194,6 @@ class MediaControlsWrapper extends MediaPlayback implements MediaControlBase {
processingState: AudioProcessingState.ready, processingState: AudioProcessingState.ready,
)); ));
await player?.play();
return super.play(); return super.play();
} }
@ -211,9 +222,11 @@ class MediaControlsWrapper extends MediaPlayback implements MediaControlBase {
@override @override
Future<void> stop() async { Future<void> stop() async {
WakelockPlus.disable(); WakelockPlus.disable();
final position = player?.state.position; final position = _player?.lastState.position;
final totalDuration = player?.state.duration; final totalDuration = _player?.lastState.duration;
await player?.stop(); super.stop();
_player?.stop();
ref.read(playBackModel)?.playbackStopped(position ?? Duration.zero, totalDuration, ref); ref.read(playBackModel)?.playbackStopped(position ?? Duration.zero, totalDuration, ref);
ref.read(mediaPlaybackProvider.notifier).update((state) => state.copyWith(position: Duration.zero)); ref.read(mediaPlaybackProvider.notifier).update((state) => state.copyWith(position: Duration.zero));
smtc?.setPlaybackStatus(PlaybackStatus.Stopped); smtc?.setPlaybackStatus(PlaybackStatus.Stopped);
@ -229,16 +242,37 @@ class MediaControlsWrapper extends MediaPlayback implements MediaControlBase {
return super.stop(); return super.stop();
} }
@override Future<void> playOrPause() async {
void playOrPause() async { await _player?.playOrPause();
await player?.playOrPause();
playbackState.add(playbackState.value.copyWith( playbackState.add(playbackState.value.copyWith(
playing: player?.state.playing ?? false, playing: _player?.lastState.playing ?? false,
controls: [MediaControl.play], controls: [MediaControl.play],
)); ));
final playerState = player; final playerState = _player;
if (playerState != null) { if (playerState != null) {
ref.read(playBackModel)?.updatePlaybackPosition(playerState.state.position, playerState.state.playing, ref); ref
.read(playBackModel)
?.updatePlaybackPosition(playerState.lastState.position, playerState.lastState.playing, ref);
} }
} }
Future<int> setAudioTrack(AudioStreamModel? model, PlaybackModel playbackModel) async =>
await _player?.setAudioTrack(model, playbackModel) ?? -1;
Future<int> setSubtitleTrack(SubStreamModel? model, PlaybackModel playbackModel) async =>
await _player?.setSubtitleTrack(model, playbackModel) ?? -1;
Future<void> setVolume(double speed) async => _player?.setVolume(speed);
@override
Future<void> seek(Duration position) {
_player?.seek(position);
return super.seek(position);
}
@override
Future<void> setSpeed(double speed) {
_player?.setSpeed(speed);
return super.setSpeed(speed);
}
} }

View file

@ -1,170 +0,0 @@
import 'dart:async';
import 'package:audio_service/audio_service.dart';
import 'package:collection/collection.dart';
import 'package:fladder/providers/settings/client_settings_provider.dart';
import 'package:fladder/providers/settings/video_player_settings_provider.dart';
import 'package:fladder/providers/video_player_provider.dart';
import 'package:fladder/wrappers/media_control_base.dart';
import 'package:fladder/wrappers/media_wrapper_interface.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:media_kit/media_kit.dart';
import 'package:media_kit_video/media_kit_video.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
class MediaControlsWrapper extends MediaPlayback implements MediaControlBase {
MediaControlsWrapper({required this.ref});
final Ref ref;
List<StreamSubscription> subscriptions = [];
@override
Future<void> init() async {
await AudioService.init(
builder: () => this,
config: audioServiceConfig,
);
}
@override
Player setup() => setPlayer(_initPlayer());
Player _initPlayer() {
for (var element in subscriptions) {
element.cancel();
}
stop();
player?.dispose();
final newPlayer = Player(
configuration: PlayerConfiguration(
title: "nl.jknaapen.fladder",
bufferSize: 64 * 1024 * 1024,
libassAndroidFont: 'assets/fonts/mp-font.ttf',
libass: ref.read(
videoPlayerSettingsProvider.select((value) => value.useLibass),
),
),
);
setPlayer(newPlayer);
setController(VideoController(
newPlayer,
configuration: VideoControllerConfiguration(
enableHardwareAcceleration: ref.read(
videoPlayerSettingsProvider.select((value) => value.hardwareAccel),
),
),
));
_subscribePlayer();
return newPlayer;
}
Future<void> _subscribePlayer() async {
subscriptions.addAll([
player?.stream.buffer.listen((buffer) {
playbackState.add(playbackState.value.copyWith(
bufferedPosition: buffer,
));
}),
player?.stream.buffering.listen((buffering) {
playbackState.add(playbackState.value.copyWith(
processingState: buffering ? AudioProcessingState.buffering : AudioProcessingState.ready,
));
}),
player?.stream.position.listen((position) {
playbackState.add(playbackState.value.copyWith(
updatePosition: position,
));
}),
player?.stream.playing.listen((playing) {
playbackState.add(playbackState.value.copyWith(
playing: playing,
));
}),
].whereNotNull());
}
@override
Future<void> seek(Duration position) async => player?.seek(position);
@override
Future<void> play() async {
if (!ref.read(clientSettingsProvider).enableMediaKeys) {
await player?.play();
return super.play();
}
final playBackItem = ref.read(playBackModel.select((value) => value?.item));
if (playBackItem == null) return;
final poster = playBackItem.images?.firstOrNull;
//Everything else setup
mediaItem.add(MediaItem(
id: playBackItem.id,
title: playBackItem.title,
artist: playBackItem.subText,
rating: Rating.newHeartRating(playBackItem.userData.isFavourite),
duration: playBackItem.overview.runTime ?? const Duration(seconds: 0),
artUri: poster != null ? Uri.parse(poster.path) : null,
));
playbackState.add(playbackState.value.copyWith(
playing: true,
controls: [
MediaControl.pause,
MediaControl.stop,
],
systemActions: const {
MediaAction.seek,
MediaAction.fastForward,
MediaAction.setSpeed,
MediaAction.rewind,
},
processingState: AudioProcessingState.ready,
));
await player?.play();
return super.play();
}
@override
Future<void> pause() async {
playbackState.add(playbackState.value.copyWith(
playing: false,
controls: [MediaControl.play],
));
await player?.pause();
return super.pause();
}
@override
Future<void> stop() async {
WakelockPlus.disable();
final position = player?.state.position;
final totalDuration = player?.state.duration;
await player?.stop();
ref.read(playBackModel)?.playbackStopped(position ?? Duration.zero, totalDuration, ref);
ref.read(mediaPlaybackProvider.notifier).update((state) => state.copyWith(position: Duration.zero));
playbackState.add(
playbackState.value.copyWith(
playing: false,
processingState: AudioProcessingState.completed,
controls: [],
),
);
return super.stop();
}
@override
void playOrPause() {
player?.playOrPause();
playbackState.add(playbackState.value.copyWith(
playing: player?.state.playing ?? false,
controls: [MediaControl.play],
));
}
}

View file

@ -1,71 +0,0 @@
import 'package:audio_service/audio_service.dart';
import 'package:media_kit/media_kit.dart';
import 'package:media_kit_video/media_kit_video.dart';
class MediaPlayback extends BaseAudioHandler {
Player? _player;
VideoController? _controller;
Player? get player => _player;
VideoController? get controller => _controller;
Player setPlayer(Player player) => _player = player;
VideoController setController(VideoController player) => _controller = player;
Future<void> setVolume(double volume) async => _player?.setVolume(volume);
Future<void> setSubtitleTrack(SubtitleTrack track) async => _player?.setSubtitleTrack(track);
List<SubtitleTrack> get subTracks => _player?.state.tracks.subtitle ?? [];
SubtitleTrack get subtitleTrack => _player?.state.track.subtitle ?? SubtitleTrack.no();
Future<void> setAudioTrack(AudioTrack track) async => _player?.setAudioTrack(track);
List<AudioTrack> get audioTracks => _player?.state.tracks.audio ?? [];
AudioTrack get audioTrack => _player?.state.track.audio ?? AudioTrack.no();
@override
Future<void> seek(Duration position) async => player?.seek(position);
@override
Future<void> play() async {
await player?.play();
return super.play();
}
Future<void> open(
Playable playable, {
bool play = true,
}) async {
return player?.open(playable, play: play);
}
@override
Future<void> fastForward() async {
if (player != null) {
await player!.seek(player!.state.position + const Duration(seconds: 30));
}
return super.fastForward();
}
@override
Future<void> rewind() async {
if (player != null) {
await player?.seek(player!.state.position - const Duration(seconds: 10));
}
return super.rewind();
}
@override
Future<void> setSpeed(double speed) async {
await player?.setRate(speed);
return super.setSpeed(speed);
}
@override
Future<void> pause() async {
playbackState.add(playbackState.value.copyWith(
playing: false,
controls: [MediaControl.play],
));
await player?.pause();
return super.pause();
}
}

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));
}
}

View file

@ -8,6 +8,7 @@
#include <desktop_drop/desktop_drop_plugin.h> #include <desktop_drop/desktop_drop_plugin.h>
#include <dynamic_color/dynamic_color_plugin.h> #include <dynamic_color/dynamic_color_plugin.h>
#include <fvp/fvp_plugin.h>
#include <isar_flutter_libs/isar_flutter_libs_plugin.h> #include <isar_flutter_libs/isar_flutter_libs_plugin.h>
#include <media_kit_libs_linux/media_kit_libs_linux_plugin.h> #include <media_kit_libs_linux/media_kit_libs_linux_plugin.h>
#include <media_kit_video/media_kit_video_plugin.h> #include <media_kit_video/media_kit_video_plugin.h>
@ -22,6 +23,9 @@ void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) dynamic_color_registrar = g_autoptr(FlPluginRegistrar) dynamic_color_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "DynamicColorPlugin"); fl_plugin_registry_get_registrar_for_plugin(registry, "DynamicColorPlugin");
dynamic_color_plugin_register_with_registrar(dynamic_color_registrar); dynamic_color_plugin_register_with_registrar(dynamic_color_registrar);
g_autoptr(FlPluginRegistrar) fvp_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FvpPlugin");
fvp_plugin_register_with_registrar(fvp_registrar);
g_autoptr(FlPluginRegistrar) isar_flutter_libs_registrar = g_autoptr(FlPluginRegistrar) isar_flutter_libs_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "IsarFlutterLibsPlugin"); fl_plugin_registry_get_registrar_for_plugin(registry, "IsarFlutterLibsPlugin");
isar_flutter_libs_plugin_register_with_registrar(isar_flutter_libs_registrar); isar_flutter_libs_plugin_register_with_registrar(isar_flutter_libs_registrar);

View file

@ -5,6 +5,7 @@
list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_PLUGIN_LIST
desktop_drop desktop_drop
dynamic_color dynamic_color
fvp
isar_flutter_libs isar_flutter_libs
media_kit_libs_linux media_kit_libs_linux
media_kit_video media_kit_video

View file

@ -9,6 +9,7 @@ import audio_service
import audio_session import audio_session
import desktop_drop import desktop_drop
import dynamic_color import dynamic_color
import fvp
import isar_flutter_libs import isar_flutter_libs
import just_audio import just_audio
import local_auth_darwin import local_auth_darwin
@ -32,6 +33,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
AudioSessionPlugin.register(with: registry.registrar(forPlugin: "AudioSessionPlugin")) AudioSessionPlugin.register(with: registry.registrar(forPlugin: "AudioSessionPlugin"))
DesktopDropPlugin.register(with: registry.registrar(forPlugin: "DesktopDropPlugin")) DesktopDropPlugin.register(with: registry.registrar(forPlugin: "DesktopDropPlugin"))
DynamicColorPlugin.register(with: registry.registrar(forPlugin: "DynamicColorPlugin")) DynamicColorPlugin.register(with: registry.registrar(forPlugin: "DynamicColorPlugin"))
FvpPlugin.register(with: registry.registrar(forPlugin: "FvpPlugin"))
IsarFlutterLibsPlugin.register(with: registry.registrar(forPlugin: "IsarFlutterLibsPlugin")) IsarFlutterLibsPlugin.register(with: registry.registrar(forPlugin: "IsarFlutterLibsPlugin"))
JustAudioPlugin.register(with: registry.registrar(forPlugin: "JustAudioPlugin")) JustAudioPlugin.register(with: registry.registrar(forPlugin: "JustAudioPlugin"))
FLALocalAuthPlugin.register(with: registry.registrar(forPlugin: "FLALocalAuthPlugin")) FLALocalAuthPlugin.register(with: registry.registrar(forPlugin: "FLALocalAuthPlugin"))

View file

@ -746,6 +746,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.0.0" version: "4.0.0"
fvp:
dependency: "direct main"
description:
name: fvp
sha256: "3dd245cac5dfba36311cbf5834d8f275ba1f3e49a5cdcb4a98e01cb41e9a21d8"
url: "https://pub.dev"
source: hosted
version: "0.28.0"
fwfh_cached_network_image: fwfh_cached_network_image:
dependency: transitive dependency: transitive
description: description:
@ -2016,7 +2024,7 @@ packages:
source: hosted source: hosted
version: "2.1.4" version: "2.1.4"
video_player: video_player:
dependency: transitive dependency: "direct main"
description: description:
name: video_player name: video_player
sha256: "4a8c3492d734f7c39c2588a3206707a05ee80cef52e8c7f3b2078d430c84bc17" sha256: "4a8c3492d734f7c39c2588a3206707a05ee80cef52e8c7f3b2078d430c84bc17"

View file

@ -69,6 +69,8 @@ dependencies:
media_kit_video: ^1.2.4 # For video rendering. media_kit_video: ^1.2.4 # For video rendering.
media_kit_libs_video: ^1.0.4 # Native video dependencies. media_kit_libs_video: ^1.0.4 # Native video dependencies.
audio_service: ^0.18.12 audio_service: ^0.18.12
fvp: ^0.28.0
video_player: ^2.9.2
# UI Components # UI Components
dynamic_color: ^1.7.0 dynamic_color: ^1.7.0
@ -158,6 +160,7 @@ flutter:
- icons/ - icons/
- assets/fonts/ - assets/fonts/
- config/ - config/
- assets/mp-font.ttf
fonts: fonts:
- family: Rubik - family: Rubik
@ -173,7 +176,3 @@ flutter:
style: normal style: normal
- asset: assets/fonts/opensans/OpenSans-Italic.ttf - asset: assets/fonts/opensans/OpenSans-Italic.ttf
style: italic style: italic
- family: mp-font
fonts:
- asset: assets/fonts/mp-font.ttf

View file

@ -8,6 +8,7 @@
#include <desktop_drop/desktop_drop_plugin.h> #include <desktop_drop/desktop_drop_plugin.h>
#include <dynamic_color/dynamic_color_plugin_c_api.h> #include <dynamic_color/dynamic_color_plugin_c_api.h>
#include <fvp/fvp_plugin_c_api.h>
#include <isar_flutter_libs/isar_flutter_libs_plugin.h> #include <isar_flutter_libs/isar_flutter_libs_plugin.h>
#include <local_auth_windows/local_auth_plugin.h> #include <local_auth_windows/local_auth_plugin.h>
#include <media_kit_libs_windows_video/media_kit_libs_windows_video_plugin_c_api.h> #include <media_kit_libs_windows_video/media_kit_libs_windows_video_plugin_c_api.h>
@ -24,6 +25,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
registry->GetRegistrarForPlugin("DesktopDropPlugin")); registry->GetRegistrarForPlugin("DesktopDropPlugin"));
DynamicColorPluginCApiRegisterWithRegistrar( DynamicColorPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("DynamicColorPluginCApi")); registry->GetRegistrarForPlugin("DynamicColorPluginCApi"));
FvpPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FvpPluginCApi"));
IsarFlutterLibsPluginRegisterWithRegistrar( IsarFlutterLibsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("IsarFlutterLibsPlugin")); registry->GetRegistrarForPlugin("IsarFlutterLibsPlugin"));
LocalAuthPluginRegisterWithRegistrar( LocalAuthPluginRegisterWithRegistrar(

View file

@ -5,6 +5,7 @@
list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_PLUGIN_LIST
desktop_drop desktop_drop
dynamic_color dynamic_color
fvp
isar_flutter_libs isar_flutter_libs
local_auth_windows local_auth_windows
media_kit_libs_windows_video media_kit_libs_windows_video