feature: Video quality options (#234)

Co-authored-by: PartyDonut <PartyDonut@users.noreply.github.com>
This commit is contained in:
PartyDonut 2025-02-23 13:29:59 +01:00 committed by GitHub
parent 957ad6c991
commit 935d6fe176
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 644 additions and 232 deletions

View file

@ -1171,5 +1171,12 @@
"copiedToClipboard": "Copied to clipboard", "copiedToClipboard": "Copied to clipboard",
"episodeAvailable": "Available", "episodeAvailable": "Available",
"episodeUnaired": "Unaired", "episodeUnaired": "Unaired",
"episodeMissing": "Missing" "episodeMissing": "Missing",
"internetStreamingQualityTitle": "Internet quality",
"internetStreamingQualityDesc": "Maximum streaming quality over the internet (mobile)",
"homeStreamingQualityTitle": "Home quality",
"homeStreamingQualityDesc": "Maximum streaming quality when connected to home network",
"qualityOptionsTitle": "Quality options",
"qualityOptionsOriginal": "Original",
"qualityOptionsAuto": "Auto"
} }

View file

@ -157,6 +157,7 @@ class StreamModel {
class VideoStreamModel extends StreamModel { class VideoStreamModel extends StreamModel {
final int width; final int width;
final int height; final int height;
final int? bitRate;
final double frameRate; final double frameRate;
final String? videoDoViTitle; final String? videoDoViTitle;
final VideoRangeType? videoRangeType; final VideoRangeType? videoRangeType;
@ -168,6 +169,7 @@ class VideoStreamModel extends StreamModel {
required super.index, required super.index,
required this.videoDoViTitle, required this.videoDoViTitle,
required this.videoRangeType, required this.videoRangeType,
required this.bitRate,
required this.width, required this.width,
required this.height, required this.height,
required this.frameRate, required this.frameRate,
@ -179,6 +181,7 @@ class VideoStreamModel extends StreamModel {
isDefault: stream.isDefault ?? false, isDefault: stream.isDefault ?? false,
codec: stream.codec ?? "", codec: stream.codec ?? "",
videoDoViTitle: stream.videoDoViTitle, videoDoViTitle: stream.videoDoViTitle,
bitRate: stream.bitRate,
videoRangeType: stream.videoRangeType, videoRangeType: stream.videoRangeType,
width: stream.width ?? 0, width: stream.width ?? 0,
height: stream.height ?? 0, height: stream.height ?? 0,

View file

@ -12,52 +12,23 @@ import 'package:fladder/models/items/trick_play_model.dart';
import 'package:fladder/models/playback/playback_model.dart'; import 'package:fladder/models/playback/playback_model.dart';
import 'package:fladder/providers/api_provider.dart'; 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/bitrate_helper.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/wrappers/media_control_wrapper.dart'; import 'package:fladder/wrappers/media_control_wrapper.dart';
class DirectPlaybackModel implements PlaybackModel { class DirectPlaybackModel extends PlaybackModel {
DirectPlaybackModel({ DirectPlaybackModel({
required this.item, required super.item,
required this.media, required super.media,
required this.playbackInfo, super.playbackInfo,
this.mediaStreams, super.mediaStreams,
this.mediaSegments, super.mediaSegments,
this.chapters, super.chapters,
this.trickPlay, super.trickPlay,
this.queue = const [], super.queue,
super.bitRateOptions,
}); });
@override
final ItemBaseModel item;
@override
final Media? media;
@override
final PlaybackInfoResponse playbackInfo;
@override
final MediaStreamsModel? mediaStreams;
@override
final MediaSegmentsModel? mediaSegments;
@override
final List<Chapter>? chapters;
@override
final TrickPlayModel? trickPlay;
@override
ItemBaseModel? get nextVideo => queue.nextOrNull(item);
@override
ItemBaseModel? get previousVideo => queue.previousOrNull(item);
@override
Future<Duration>? startDuration() async => item.userData.playBackPosition;
@override @override
List<SubStreamModel> get subStreams => [SubStreamModel.no(), ...mediaStreams?.subStreams ?? []]; List<SubStreamModel> get subStreams => [SubStreamModel.no(), ...mediaStreams?.subStreams ?? []];
@ -79,6 +50,11 @@ class DirectPlaybackModel implements PlaybackModel {
return copyWith(mediaStreams: () => mediaStreams?.copyWith(defaultAudioStreamIndex: newIndex)); return copyWith(mediaStreams: () => mediaStreams?.copyWith(defaultAudioStreamIndex: newIndex));
} }
@override
Future<DirectPlaybackModel>? setQualityOption(Map<Bitrate, bool> map) async {
return copyWith(bitRateOptions: map);
}
@override @override
Future<PlaybackModel?> playbackStarted(Duration position, Ref ref) async { Future<PlaybackModel?> playbackStarted(Duration position, Ref ref) async {
await ref.read(jellyApiProvider).sessionsPlayingPost( await ref.read(jellyApiProvider).sessionsPlayingPost(
@ -86,7 +62,7 @@ class DirectPlaybackModel implements PlaybackModel {
canSeek: true, canSeek: true,
itemId: item.id, itemId: item.id,
mediaSourceId: item.id, mediaSourceId: item.id,
playSessionId: playbackInfo.playSessionId, playSessionId: playbackInfo?.playSessionId,
subtitleStreamIndex: item.streamModel?.defaultSubStreamIndex, subtitleStreamIndex: item.streamModel?.defaultSubStreamIndex,
audioStreamIndex: item.streamModel?.defaultAudioStreamIndex, audioStreamIndex: item.streamModel?.defaultAudioStreamIndex,
volumeLevel: 100, volumeLevel: 100,
@ -108,7 +84,7 @@ class DirectPlaybackModel implements PlaybackModel {
body: PlaybackStopInfo( body: PlaybackStopInfo(
itemId: item.id, itemId: item.id,
mediaSourceId: item.id, mediaSourceId: item.id,
playSessionId: playbackInfo.playSessionId, playSessionId: playbackInfo?.playSessionId,
positionTicks: position.toRuntimeTicks, positionTicks: position.toRuntimeTicks,
), ),
); );
@ -124,7 +100,7 @@ class DirectPlaybackModel implements PlaybackModel {
canSeek: true, canSeek: true,
itemId: item.id, itemId: item.id,
mediaSourceId: item.id, mediaSourceId: item.id,
playSessionId: playbackInfo.playSessionId, playSessionId: playbackInfo?.playSessionId,
subtitleStreamIndex: item.streamModel?.defaultSubStreamIndex, subtitleStreamIndex: item.streamModel?.defaultSubStreamIndex,
audioStreamIndex: item.streamModel?.defaultAudioStreamIndex, audioStreamIndex: item.streamModel?.defaultAudioStreamIndex,
volumeLevel: 100, volumeLevel: 100,
@ -142,9 +118,6 @@ class DirectPlaybackModel implements PlaybackModel {
@override @override
String toString() => 'DirectPlaybackModel(item: $item, playbackInfo: $playbackInfo)'; String toString() => 'DirectPlaybackModel(item: $item, playbackInfo: $playbackInfo)';
@override
final List<ItemBaseModel> queue;
@override @override
DirectPlaybackModel copyWith({ DirectPlaybackModel copyWith({
ItemBaseModel? item, ItemBaseModel? item,
@ -156,6 +129,7 @@ class DirectPlaybackModel implements PlaybackModel {
ValueGetter<List<Chapter>?>? chapters, ValueGetter<List<Chapter>?>? chapters,
ValueGetter<TrickPlayModel?>? trickPlay, ValueGetter<TrickPlayModel?>? trickPlay,
List<ItemBaseModel>? queue, List<ItemBaseModel>? queue,
Map<Bitrate, bool>? bitRateOptions,
}) { }) {
return DirectPlaybackModel( return DirectPlaybackModel(
item: item ?? this.item, item: item ?? this.item,
@ -166,6 +140,7 @@ class DirectPlaybackModel implements PlaybackModel {
chapters: chapters != null ? chapters() : this.chapters, chapters: chapters != null ? chapters() : this.chapters,
trickPlay: trickPlay != null ? trickPlay() : this.trickPlay, trickPlay: trickPlay != null ? trickPlay() : this.trickPlay,
queue: queue ?? this.queue, queue: queue ?? this.queue,
bitRateOptions: bitRateOptions ?? this.bitRateOptions,
); );
} }
} }

View file

@ -2,7 +2,6 @@ import 'package:flutter/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.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';
import 'package:fladder/models/items/chapters_model.dart'; import 'package:fladder/models/items/chapters_model.dart';
import 'package:fladder/models/items/media_segments_model.dart'; import 'package:fladder/models/items/media_segments_model.dart';
@ -15,42 +14,24 @@ 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';
class OfflinePlaybackModel implements PlaybackModel { class OfflinePlaybackModel extends PlaybackModel {
OfflinePlaybackModel({ OfflinePlaybackModel({
required this.item,
required this.media,
required this.syncedItem, required this.syncedItem,
this.mediaStreams, super.mediaStreams,
this.playbackInfo, super.playbackInfo,
this.mediaSegments, required super.item,
this.trickPlay, required super.media,
this.queue = const [], super.mediaSegments,
super.trickPlay,
super.queue = const [],
this.syncedQueue = const [], this.syncedQueue = const [],
}); });
@override
final ItemBaseModel item;
@override
final PlaybackInfoResponse? playbackInfo;
@override
final Media? media;
final SyncedItem syncedItem; final SyncedItem syncedItem;
@override
final MediaStreamsModel? mediaStreams;
@override
final MediaSegmentsModel? mediaSegments;
@override @override
List<Chapter>? get chapters => syncedItem.chapters; List<Chapter>? get chapters => syncedItem.chapters;
@override
final TrickPlayModel? trickPlay;
@override @override
Future<Duration>? startDuration() async => item.userData.playBackPosition; Future<Duration>? startDuration() async => item.userData.playBackPosition;
@ -118,9 +99,6 @@ class OfflinePlaybackModel implements PlaybackModel {
@override @override
String toString() => 'OfflinePlaybackModel(item: $item, syncedItem: $syncedItem)'; String toString() => 'OfflinePlaybackModel(item: $item, syncedItem: $syncedItem)';
@override
final List<ItemBaseModel> queue;
final List<SyncedItem> syncedQueue; final List<SyncedItem> syncedQueue;
@override @override

View file

@ -22,11 +22,15 @@ import 'package:fladder/models/video_stream_model.dart';
import 'package:fladder/profiles/default_profile.dart'; import 'package:fladder/profiles/default_profile.dart';
import 'package:fladder/providers/api_provider.dart'; import 'package:fladder/providers/api_provider.dart';
import 'package:fladder/providers/service_provider.dart'; import 'package:fladder/providers/service_provider.dart';
import 'package:fladder/providers/settings/video_player_settings_provider.dart';
import 'package:fladder/providers/sync/sync_provider_helpers.dart'; import 'package:fladder/providers/sync/sync_provider_helpers.dart';
import 'package:fladder/providers/sync_provider.dart'; 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/bitrate_helper.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/map_bool_helper.dart';
import 'package:fladder/wrappers/media_control_wrapper.dart'; import 'package:fladder/wrappers/media_control_wrapper.dart';
class Media { class Media {
@ -52,15 +56,17 @@ extension PlaybackModelExtension on PlaybackModel? {
}; };
} }
abstract class PlaybackModel { class PlaybackModel {
final ItemBaseModel item = throw UnimplementedError(); final ItemBaseModel item;
final Media? media = throw UnimplementedError(); final Media? media;
final List<ItemBaseModel> queue = const []; final List<ItemBaseModel> queue;
final MediaSegmentsModel? mediaSegments = null; final MediaSegmentsModel? mediaSegments;
final PlaybackInfoResponse? playbackInfo = throw UnimplementedError(); final PlaybackInfoResponse? playbackInfo;
List<Chapter>? get chapters; Map<Bitrate, bool> bitRateOptions;
TrickPlayModel? get trickPlay;
List<Chapter>? chapters = [];
TrickPlayModel? trickPlay;
Future<PlaybackModel?> updatePlaybackPosition(Duration position, bool isPlaying, Ref ref) => Future<PlaybackModel?> updatePlaybackPosition(Duration position, bool isPlaying, Ref ref) =>
throw UnimplementedError(); throw UnimplementedError();
@ -68,21 +74,32 @@ abstract class PlaybackModel {
Future<PlaybackModel?> playbackStopped(Duration position, Duration? totalDuration, Ref ref) => Future<PlaybackModel?> playbackStopped(Duration position, Duration? totalDuration, Ref ref) =>
throw UnimplementedError(); throw UnimplementedError();
final MediaStreamsModel? mediaStreams = throw UnimplementedError(); final MediaStreamsModel? mediaStreams;
List<SubStreamModel>? get subStreams; List<SubStreamModel>? get subStreams => throw UnimplementedError();
List<AudioStreamModel>? get audioStreams; List<AudioStreamModel>? get audioStreams => throw UnimplementedError();
Future<Duration>? startDuration() async => item.userData.playBackPosition; Future<Duration>? startDuration() async => item.userData.playBackPosition;
Future<PlaybackModel>? setSubtitle(SubStreamModel? model, MediaControlsWrapper player) {
return null;
}
Future<PlaybackModel>? setAudio(AudioStreamModel? model, MediaControlsWrapper player) => null; Future<PlaybackModel>? setSubtitle(SubStreamModel? model, MediaControlsWrapper player) => throw UnimplementedError();
Future<PlaybackModel>? setAudio(AudioStreamModel? model, MediaControlsWrapper player) => throw UnimplementedError();
Future<PlaybackModel>? setQualityOption(Map<Bitrate, bool> map) => throw UnimplementedError();
ItemBaseModel? get nextVideo => throw UnimplementedError(); ItemBaseModel? get nextVideo => queue.nextOrNull(item);
ItemBaseModel? get previousVideo => throw UnimplementedError(); ItemBaseModel? get previousVideo => queue.previousOrNull(item);
PlaybackModel copyWith(); PlaybackModel copyWith() => throw UnimplementedError();
PlaybackModel({
required this.playbackInfo,
this.mediaStreams,
required this.item,
required this.media,
this.queue = const [],
this.bitRateOptions = const {},
this.mediaSegments,
this.chapters,
this.trickPlay,
});
} }
final playbackModelHelper = Provider<PlaybackModelHelper>((ref) { final playbackModelHelper = Provider<PlaybackModelHelper>((ref) {
@ -149,8 +166,13 @@ class PlaybackModelHelper {
} }
} }
Future<PlaybackModel?> createServerPlaybackModel(ItemBaseModel? item, PlaybackType? type, Future<PlaybackModel?> createServerPlaybackModel(
{PlaybackModel? oldModel, List<ItemBaseModel>? libraryQueue, Duration? startPosition}) async { ItemBaseModel? item,
PlaybackType? type, {
PlaybackModel? oldModel,
List<ItemBaseModel>? libraryQueue,
Duration? startPosition,
}) async {
try { try {
if (item == null) return null; if (item == null) return null;
final userId = ref.read(userProvider)?.id; final userId = ref.read(userProvider)?.id;
@ -165,18 +187,18 @@ class PlaybackModelHelper {
final fullItem = await api.usersUserIdItemsItemIdGet(itemId: firstItemToPlay.id); final fullItem = await api.usersUserIdItemsItemIdGet(itemId: firstItemToPlay.id);
Map<Bitrate, bool> qualityOptions = getVideoQualityOptions(
VideoQualitySettings(
maxBitRate: ref.read(videoPlayerSettingsProvider.select((value) => value.maxHomeBitrate)),
videoBitRate: firstItemToPlay.streamModel?.videoStreams.first.bitRate ?? 0,
videoCodec: firstItemToPlay.streamModel?.videoStreams.first.codec,
),
);
final streamModel = firstItemToPlay.streamModel; final streamModel = firstItemToPlay.streamModel;
Response<PlaybackInfoResponse> response = await api.itemsItemIdPlaybackInfoPost( final Response<PlaybackInfoResponse> response = await api.itemsItemIdPlaybackInfoPost(
itemId: firstItemToPlay.id, itemId: firstItemToPlay.id,
enableDirectPlay: type != PlaybackType.transcode,
enableDirectStream: type != PlaybackType.transcode,
enableTranscoding: true,
autoOpenLiveStream: true,
startTimeTicks: startPosition?.toRuntimeTicks,
audioStreamIndex: streamModel?.defaultAudioStreamIndex,
subtitleStreamIndex: streamModel?.defaultSubStreamIndex,
mediaSourceId: firstItemToPlay.id,
body: PlaybackInfoDto( body: PlaybackInfoDto(
startTimeTicks: startPosition?.toRuntimeTicks, startTimeTicks: startPosition?.toRuntimeTicks,
audioStreamIndex: streamModel?.defaultAudioStreamIndex, audioStreamIndex: streamModel?.defaultAudioStreamIndex,
@ -185,6 +207,9 @@ class PlaybackModelHelper {
autoOpenLiveStream: true, autoOpenLiveStream: true,
deviceProfile: ref.read(videoProfileProvider), deviceProfile: ref.read(videoProfileProvider),
userId: userId, userId: userId,
enableDirectPlay: type != PlaybackType.transcode,
enableDirectStream: type != PlaybackType.transcode,
maxStreamingBitrate: qualityOptions.enabledFirst.keys.firstOrNull?.bitRate,
mediaSourceId: firstItemToPlay.id, mediaSourceId: firstItemToPlay.id,
), ),
); );
@ -234,10 +259,9 @@ class PlaybackModelHelper {
chapters: chapters, chapters: chapters,
playbackInfo: playbackInfo, playbackInfo: playbackInfo,
trickPlay: trickPlay, trickPlay: trickPlay,
media: Media( media: Media(url: mediaPath ?? playbackUrl),
url: mediaPath ?? playbackUrl,
),
mediaStreams: mediaStreamsWithUrls, mediaStreams: mediaStreamsWithUrls,
bitRateOptions: qualityOptions,
); );
} else if ((mediaSource.supportsTranscoding ?? false) && mediaSource.transcodingUrl != null) { } else if ((mediaSource.supportsTranscoding ?? false) && mediaSource.transcodingUrl != null) {
return TranscodePlaybackModel( return TranscodePlaybackModel(
@ -309,22 +333,17 @@ class PlaybackModelHelper {
Response<PlaybackInfoResponse> response = await api.itemsItemIdPlaybackInfoPost( Response<PlaybackInfoResponse> response = await api.itemsItemIdPlaybackInfoPost(
itemId: item.id, itemId: item.id,
enableDirectPlay: true,
enableDirectStream: true,
enableTranscoding: true,
autoOpenLiveStream: true,
startTimeTicks: currentPosition.toRuntimeTicks,
audioStreamIndex: audioIndex,
subtitleStreamIndex: subIndex,
mediaSourceId: item.id,
body: PlaybackInfoDto( body: PlaybackInfoDto(
startTimeTicks: currentPosition.toRuntimeTicks, startTimeTicks: currentPosition.toRuntimeTicks,
audioStreamIndex: audioIndex, audioStreamIndex: audioIndex,
enableDirectPlay: true,
enableDirectStream: true,
subtitleStreamIndex: subIndex, subtitleStreamIndex: subIndex,
enableTranscoding: true, enableTranscoding: true,
autoOpenLiveStream: true, autoOpenLiveStream: true,
deviceProfile: ref.read(videoProfileProvider), deviceProfile: ref.read(videoProfileProvider),
userId: userId, userId: userId,
maxStreamingBitrate: playbackModel.bitRateOptions.enabledFirst.entries.firstOrNull?.key.bitRate,
mediaSourceId: item.id, mediaSourceId: item.id,
), ),
); );
@ -363,6 +382,8 @@ class PlaybackModelHelper {
final directPlay = '${ref.read(userProvider)?.server ?? ""}/Videos/${mediaSource.id}/stream?$params'; final directPlay = '${ref.read(userProvider)?.server ?? ""}/Videos/${mediaSource.id}/stream?$params';
final mediaPath = isValidVideoUrl(mediaSource.path ?? "");
newModel = DirectPlaybackModel( newModel = DirectPlaybackModel(
item: playbackModel.item, item: playbackModel.item,
queue: playbackModel.queue, queue: playbackModel.queue,
@ -370,8 +391,9 @@ class PlaybackModelHelper {
chapters: playbackModel.chapters, chapters: playbackModel.chapters,
playbackInfo: playbackInfo, playbackInfo: playbackInfo,
trickPlay: playbackModel.trickPlay, trickPlay: playbackModel.trickPlay,
media: Media(url: directPlay), media: Media(url: mediaPath ?? directPlay),
mediaStreams: mediaStreamsWithUrls, mediaStreams: mediaStreamsWithUrls,
bitRateOptions: playbackModel.bitRateOptions,
); );
} else if ((mediaSource.supportsTranscoding ?? false) && mediaSource.transcodingUrl != null) { } else if ((mediaSource.supportsTranscoding ?? false) && mediaSource.transcodingUrl != null) {
newModel = TranscodePlaybackModel( newModel = TranscodePlaybackModel(
@ -383,6 +405,7 @@ class PlaybackModelHelper {
trickPlay: playbackModel.trickPlay, trickPlay: playbackModel.trickPlay,
media: Media(url: "${ref.read(userProvider)?.server ?? ""}${mediaSource.transcodingUrl ?? ""}"), media: Media(url: "${ref.read(userProvider)?.server ?? ""}${mediaSource.transcodingUrl ?? ""}"),
mediaStreams: mediaStreamsWithUrls, mediaStreams: mediaStreamsWithUrls,
bitRateOptions: playbackModel.bitRateOptions,
); );
} }
if (newModel == null) return; if (newModel == null) return;

View file

@ -12,52 +12,23 @@ import 'package:fladder/models/items/trick_play_model.dart';
import 'package:fladder/models/playback/playback_model.dart'; import 'package:fladder/models/playback/playback_model.dart';
import 'package:fladder/providers/api_provider.dart'; 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/bitrate_helper.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/wrappers/media_control_wrapper.dart'; import 'package:fladder/wrappers/media_control_wrapper.dart';
class TranscodePlaybackModel implements PlaybackModel { class TranscodePlaybackModel extends PlaybackModel {
TranscodePlaybackModel({ TranscodePlaybackModel({
required this.item, required super.item,
required this.media, required super.media,
required this.playbackInfo, required super.playbackInfo,
this.mediaStreams, super.mediaStreams,
this.mediaSegments, super.mediaSegments,
this.chapters, super.chapters,
this.trickPlay, super.trickPlay,
this.queue = const [], super.queue = const [],
super.bitRateOptions,
}); });
@override
final ItemBaseModel item;
@override
final Media? media;
@override
final PlaybackInfoResponse playbackInfo;
@override
final MediaStreamsModel? mediaStreams;
@override
final MediaSegmentsModel? mediaSegments;
@override
final List<Chapter>? chapters;
@override
final TrickPlayModel? trickPlay;
@override
ItemBaseModel? get nextVideo => queue.nextOrNull(item);
@override
ItemBaseModel? get previousVideo => queue.previousOrNull(item);
@override
Future<Duration>? startDuration() async => item.userData.playBackPosition;
@override @override
List<SubStreamModel> get subStreams => [SubStreamModel.no(), ...mediaStreams?.subStreams ?? []]; List<SubStreamModel> get subStreams => [SubStreamModel.no(), ...mediaStreams?.subStreams ?? []];
@ -79,6 +50,9 @@ class TranscodePlaybackModel implements PlaybackModel {
return copyWith(mediaStreams: () => mediaStreams?.copyWith(defaultAudioStreamIndex: newIndex)); return copyWith(mediaStreams: () => mediaStreams?.copyWith(defaultAudioStreamIndex: newIndex));
} }
@override
Future<TranscodePlaybackModel>? setQualityOption(Map<Bitrate, bool> map) async => copyWith(bitRateOptions: map);
@override @override
Future<PlaybackModel?> playbackStarted(Duration position, Ref ref) async { Future<PlaybackModel?> playbackStarted(Duration position, Ref ref) async {
await ref.read(jellyApiProvider).sessionsPlayingPost( await ref.read(jellyApiProvider).sessionsPlayingPost(
@ -86,8 +60,8 @@ class TranscodePlaybackModel implements PlaybackModel {
canSeek: true, canSeek: true,
itemId: item.id, itemId: item.id,
mediaSourceId: item.id, mediaSourceId: item.id,
playSessionId: playbackInfo.playSessionId, playSessionId: playbackInfo?.playSessionId,
sessionId: playbackInfo.playSessionId, sessionId: playbackInfo?.playSessionId,
subtitleStreamIndex: item.streamModel?.defaultSubStreamIndex, subtitleStreamIndex: item.streamModel?.defaultSubStreamIndex,
audioStreamIndex: item.streamModel?.defaultAudioStreamIndex, audioStreamIndex: item.streamModel?.defaultAudioStreamIndex,
volumeLevel: 100, volumeLevel: 100,
@ -109,7 +83,7 @@ class TranscodePlaybackModel implements PlaybackModel {
body: PlaybackStopInfo( body: PlaybackStopInfo(
itemId: item.id, itemId: item.id,
mediaSourceId: item.id, mediaSourceId: item.id,
playSessionId: playbackInfo.playSessionId, playSessionId: playbackInfo?.playSessionId,
positionTicks: position.toRuntimeTicks, positionTicks: position.toRuntimeTicks,
), ),
); );
@ -125,8 +99,8 @@ class TranscodePlaybackModel implements PlaybackModel {
canSeek: true, canSeek: true,
itemId: item.id, itemId: item.id,
mediaSourceId: item.id, mediaSourceId: item.id,
playSessionId: playbackInfo.playSessionId, playSessionId: playbackInfo?.playSessionId,
sessionId: playbackInfo.playSessionId, sessionId: playbackInfo?.playSessionId,
subtitleStreamIndex: item.streamModel?.defaultSubStreamIndex, subtitleStreamIndex: item.streamModel?.defaultSubStreamIndex,
audioStreamIndex: item.streamModel?.defaultAudioStreamIndex, audioStreamIndex: item.streamModel?.defaultAudioStreamIndex,
volumeLevel: 100, volumeLevel: 100,
@ -143,9 +117,6 @@ class TranscodePlaybackModel implements PlaybackModel {
@override @override
String toString() => 'TranscodePlaybackModel(item: $item, playbackInfo: $playbackInfo)'; String toString() => 'TranscodePlaybackModel(item: $item, playbackInfo: $playbackInfo)';
@override
final List<ItemBaseModel> queue;
@override @override
TranscodePlaybackModel copyWith({ TranscodePlaybackModel copyWith({
ItemBaseModel? item, ItemBaseModel? item,
@ -157,6 +128,7 @@ class TranscodePlaybackModel implements PlaybackModel {
ValueGetter<List<Chapter>?>? chapters, ValueGetter<List<Chapter>?>? chapters,
ValueGetter<TrickPlayModel?>? trickPlay, ValueGetter<TrickPlayModel?>? trickPlay,
List<ItemBaseModel>? queue, List<ItemBaseModel>? queue,
Map<Bitrate, bool>? bitRateOptions,
}) { }) {
return TranscodePlaybackModel( return TranscodePlaybackModel(
item: item ?? this.item, item: item ?? this.item,
@ -167,6 +139,7 @@ class TranscodePlaybackModel implements PlaybackModel {
chapters: chapters != null ? chapters() : this.chapters, chapters: chapters != null ? chapters() : this.chapters,
trickPlay: trickPlay != null ? trickPlay() : this.trickPlay, trickPlay: trickPlay != null ? trickPlay() : this.trickPlay,
queue: queue ?? this.queue, queue: queue ?? this.queue,
bitRateOptions: bitRateOptions ?? this.bitRateOptions,
); );
} }
} }

View file

@ -48,6 +48,11 @@ enum ViewSize {
ViewSize.tablet => context.localized.tablet, ViewSize.tablet => context.localized.tablet,
ViewSize.desktop => context.localized.desktop, ViewSize.desktop => context.localized.desktop,
}; };
bool operator >(ViewSize other) => index > other.index;
bool operator >=(ViewSize other) => index >= other.index;
bool operator <(ViewSize other) => index < other.index;
bool operator <=(ViewSize other) => index <= other.index;
} }
enum LayoutMode { enum LayoutMode {
@ -60,6 +65,11 @@ enum LayoutMode {
LayoutMode.single => context.localized.layoutModeSingle, LayoutMode.single => context.localized.layoutModeSingle,
LayoutMode.dual => context.localized.layoutModeDual, LayoutMode.dual => context.localized.layoutModeDual,
}; };
bool operator >(ViewSize other) => index > other.index;
bool operator >=(ViewSize other) => index >= other.index;
bool operator <(ViewSize other) => index < other.index;
bool operator <=(ViewSize other) => index <= other.index;
} }
enum HomeBanner { enum HomeBanner {

View file

@ -5,6 +5,7 @@ import 'package:flutter/widgets.dart';
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:fladder/util/bitrate_helper.dart';
import 'package:fladder/util/localization_helper.dart'; import 'package:fladder/util/localization_helper.dart';
part 'video_player_settings.freezed.dart'; part 'video_player_settings.freezed.dart';
@ -24,6 +25,8 @@ class VideoPlayerSettingsModel with _$VideoPlayerSettingsModel {
@Default(100) double internalVolume, @Default(100) double internalVolume,
Set<DeviceOrientation>? allowedOrientations, Set<DeviceOrientation>? allowedOrientations,
@Default(AutoNextType.smart) AutoNextType nextVideoType, @Default(AutoNextType.smart) AutoNextType nextVideoType,
@Default(Bitrate.original) Bitrate maxHomeBitrate,
@Default(Bitrate.original) Bitrate maxInternetBitrate,
String? audioDevice, String? audioDevice,
}) = _VideoPlayerSettingsModel; }) = _VideoPlayerSettingsModel;

View file

@ -31,6 +31,8 @@ mixin _$VideoPlayerSettingsModel {
Set<DeviceOrientation>? get allowedOrientations => Set<DeviceOrientation>? get allowedOrientations =>
throw _privateConstructorUsedError; throw _privateConstructorUsedError;
AutoNextType get nextVideoType => throw _privateConstructorUsedError; AutoNextType get nextVideoType => throw _privateConstructorUsedError;
Bitrate get maxHomeBitrate => throw _privateConstructorUsedError;
Bitrate get maxInternetBitrate => throw _privateConstructorUsedError;
String? get audioDevice => throw _privateConstructorUsedError; String? get audioDevice => throw _privateConstructorUsedError;
/// Serializes this VideoPlayerSettingsModel to a JSON map. /// Serializes this VideoPlayerSettingsModel to a JSON map.
@ -59,6 +61,8 @@ abstract class $VideoPlayerSettingsModelCopyWith<$Res> {
double internalVolume, double internalVolume,
Set<DeviceOrientation>? allowedOrientations, Set<DeviceOrientation>? allowedOrientations,
AutoNextType nextVideoType, AutoNextType nextVideoType,
Bitrate maxHomeBitrate,
Bitrate maxInternetBitrate,
String? audioDevice}); String? audioDevice});
} }
@ -87,6 +91,8 @@ class _$VideoPlayerSettingsModelCopyWithImpl<$Res,
Object? internalVolume = null, Object? internalVolume = null,
Object? allowedOrientations = freezed, Object? allowedOrientations = freezed,
Object? nextVideoType = null, Object? nextVideoType = null,
Object? maxHomeBitrate = null,
Object? maxInternetBitrate = null,
Object? audioDevice = freezed, Object? audioDevice = freezed,
}) { }) {
return _then(_value.copyWith( return _then(_value.copyWith(
@ -126,6 +132,14 @@ class _$VideoPlayerSettingsModelCopyWithImpl<$Res,
? _value.nextVideoType ? _value.nextVideoType
: nextVideoType // ignore: cast_nullable_to_non_nullable : nextVideoType // ignore: cast_nullable_to_non_nullable
as AutoNextType, as AutoNextType,
maxHomeBitrate: null == maxHomeBitrate
? _value.maxHomeBitrate
: maxHomeBitrate // ignore: cast_nullable_to_non_nullable
as Bitrate,
maxInternetBitrate: null == maxInternetBitrate
? _value.maxInternetBitrate
: maxInternetBitrate // ignore: cast_nullable_to_non_nullable
as Bitrate,
audioDevice: freezed == audioDevice audioDevice: freezed == audioDevice
? _value.audioDevice ? _value.audioDevice
: audioDevice // ignore: cast_nullable_to_non_nullable : audioDevice // ignore: cast_nullable_to_non_nullable
@ -153,6 +167,8 @@ abstract class _$$VideoPlayerSettingsModelImplCopyWith<$Res>
double internalVolume, double internalVolume,
Set<DeviceOrientation>? allowedOrientations, Set<DeviceOrientation>? allowedOrientations,
AutoNextType nextVideoType, AutoNextType nextVideoType,
Bitrate maxHomeBitrate,
Bitrate maxInternetBitrate,
String? audioDevice}); String? audioDevice});
} }
@ -180,6 +196,8 @@ class __$$VideoPlayerSettingsModelImplCopyWithImpl<$Res>
Object? internalVolume = null, Object? internalVolume = null,
Object? allowedOrientations = freezed, Object? allowedOrientations = freezed,
Object? nextVideoType = null, Object? nextVideoType = null,
Object? maxHomeBitrate = null,
Object? maxInternetBitrate = null,
Object? audioDevice = freezed, Object? audioDevice = freezed,
}) { }) {
return _then(_$VideoPlayerSettingsModelImpl( return _then(_$VideoPlayerSettingsModelImpl(
@ -219,6 +237,14 @@ class __$$VideoPlayerSettingsModelImplCopyWithImpl<$Res>
? _value.nextVideoType ? _value.nextVideoType
: nextVideoType // ignore: cast_nullable_to_non_nullable : nextVideoType // ignore: cast_nullable_to_non_nullable
as AutoNextType, as AutoNextType,
maxHomeBitrate: null == maxHomeBitrate
? _value.maxHomeBitrate
: maxHomeBitrate // ignore: cast_nullable_to_non_nullable
as Bitrate,
maxInternetBitrate: null == maxInternetBitrate
? _value.maxInternetBitrate
: maxInternetBitrate // ignore: cast_nullable_to_non_nullable
as Bitrate,
audioDevice: freezed == audioDevice audioDevice: freezed == audioDevice
? _value.audioDevice ? _value.audioDevice
: audioDevice // ignore: cast_nullable_to_non_nullable : audioDevice // ignore: cast_nullable_to_non_nullable
@ -241,6 +267,8 @@ class _$VideoPlayerSettingsModelImpl extends _VideoPlayerSettingsModel
this.internalVolume = 100, this.internalVolume = 100,
final Set<DeviceOrientation>? allowedOrientations, final Set<DeviceOrientation>? allowedOrientations,
this.nextVideoType = AutoNextType.smart, this.nextVideoType = AutoNextType.smart,
this.maxHomeBitrate = Bitrate.original,
this.maxInternetBitrate = Bitrate.original,
this.audioDevice}) this.audioDevice})
: _allowedOrientations = allowedOrientations, : _allowedOrientations = allowedOrientations,
super._(); super._();
@ -282,11 +310,17 @@ class _$VideoPlayerSettingsModelImpl extends _VideoPlayerSettingsModel
@JsonKey() @JsonKey()
final AutoNextType nextVideoType; final AutoNextType nextVideoType;
@override @override
@JsonKey()
final Bitrate maxHomeBitrate;
@override
@JsonKey()
final Bitrate maxInternetBitrate;
@override
final String? audioDevice; final String? audioDevice;
@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, playerOptions: $playerOptions, 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, maxHomeBitrate: $maxHomeBitrate, maxInternetBitrate: $maxInternetBitrate, audioDevice: $audioDevice)';
} }
@override @override
@ -303,6 +337,8 @@ class _$VideoPlayerSettingsModelImpl extends _VideoPlayerSettingsModel
..add(DiagnosticsProperty('internalVolume', internalVolume)) ..add(DiagnosticsProperty('internalVolume', internalVolume))
..add(DiagnosticsProperty('allowedOrientations', allowedOrientations)) ..add(DiagnosticsProperty('allowedOrientations', allowedOrientations))
..add(DiagnosticsProperty('nextVideoType', nextVideoType)) ..add(DiagnosticsProperty('nextVideoType', nextVideoType))
..add(DiagnosticsProperty('maxHomeBitrate', maxHomeBitrate))
..add(DiagnosticsProperty('maxInternetBitrate', maxInternetBitrate))
..add(DiagnosticsProperty('audioDevice', audioDevice)); ..add(DiagnosticsProperty('audioDevice', audioDevice));
} }
@ -334,6 +370,8 @@ abstract class _VideoPlayerSettingsModel extends VideoPlayerSettingsModel {
final double internalVolume, final double internalVolume,
final Set<DeviceOrientation>? allowedOrientations, final Set<DeviceOrientation>? allowedOrientations,
final AutoNextType nextVideoType, final AutoNextType nextVideoType,
final Bitrate maxHomeBitrate,
final Bitrate maxInternetBitrate,
final String? audioDevice}) = _$VideoPlayerSettingsModelImpl; final String? audioDevice}) = _$VideoPlayerSettingsModelImpl;
_VideoPlayerSettingsModel._() : super._(); _VideoPlayerSettingsModel._() : super._();
@ -359,6 +397,10 @@ abstract class _VideoPlayerSettingsModel extends VideoPlayerSettingsModel {
@override @override
AutoNextType get nextVideoType; AutoNextType get nextVideoType;
@override @override
Bitrate get maxHomeBitrate;
@override
Bitrate get maxInternetBitrate;
@override
String? get audioDevice; String? get audioDevice;
/// Create a copy of VideoPlayerSettingsModel /// Create a copy of VideoPlayerSettingsModel

View file

@ -24,6 +24,12 @@ _$VideoPlayerSettingsModelImpl _$$VideoPlayerSettingsModelImplFromJson(
nextVideoType: nextVideoType:
$enumDecodeNullable(_$AutoNextTypeEnumMap, json['nextVideoType']) ?? $enumDecodeNullable(_$AutoNextTypeEnumMap, json['nextVideoType']) ??
AutoNextType.smart, AutoNextType.smart,
maxHomeBitrate:
$enumDecodeNullable(_$BitrateEnumMap, json['maxHomeBitrate']) ??
Bitrate.original,
maxInternetBitrate:
$enumDecodeNullable(_$BitrateEnumMap, json['maxInternetBitrate']) ??
Bitrate.original,
audioDevice: json['audioDevice'] as String?, audioDevice: json['audioDevice'] as String?,
); );
@ -41,6 +47,8 @@ Map<String, dynamic> _$$VideoPlayerSettingsModelImplToJson(
?.map((e) => _$DeviceOrientationEnumMap[e]!) ?.map((e) => _$DeviceOrientationEnumMap[e]!)
.toList(), .toList(),
'nextVideoType': _$AutoNextTypeEnumMap[instance.nextVideoType]!, 'nextVideoType': _$AutoNextTypeEnumMap[instance.nextVideoType]!,
'maxHomeBitrate': _$BitrateEnumMap[instance.maxHomeBitrate]!,
'maxInternetBitrate': _$BitrateEnumMap[instance.maxInternetBitrate]!,
'audioDevice': instance.audioDevice, 'audioDevice': instance.audioDevice,
}; };
@ -71,3 +79,22 @@ const _$AutoNextTypeEnumMap = {
AutoNextType.smart: 'smart', AutoNextType.smart: 'smart',
AutoNextType.static: 'static', AutoNextType.static: 'static',
}; };
const _$BitrateEnumMap = {
Bitrate.original: 'original',
Bitrate.auto: 'auto',
Bitrate.b120Mbps: 'b120Mbps',
Bitrate.b80Mbps: 'b80Mbps',
Bitrate.b60Mbps: 'b60Mbps',
Bitrate.b40Mbps: 'b40Mbps',
Bitrate.b20Mbps: 'b20Mbps',
Bitrate.b15Mbps: 'b15Mbps',
Bitrate.b10Mbps: 'b10Mbps',
Bitrate.b8Mbps: 'b8Mbps',
Bitrate.b6Mbps: 'b6Mbps',
Bitrate.b4Mbps: 'b4Mbps',
Bitrate.b3Mbps: 'b3Mbps',
Bitrate.b1_5Mbps: 'b1_5Mbps',
Bitrate.b420Kbps: 'b420Kbps',
Bitrate.b720Kbps: 'b720Kbps',
};

View file

@ -0,0 +1,39 @@
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'connectivity_provider.g.dart';
enum ConnectionState {
offline,
mobile,
wifi,
ethernet;
bool get homeInternet => switch (this) {
ConnectionState.offline => false,
ConnectionState.mobile => false,
ConnectionState.wifi => true,
ConnectionState.ethernet => true,
};
}
@Riverpod(keepAlive: true)
class ConnectivityStatus extends _$ConnectivityStatus {
@override
ConnectionState build() {
Connectivity().onConnectivityChanged.listen(onStateChange);
return ConnectionState.offline;
}
void onStateChange(List<ConnectivityResult> connectivityResult) {
if (connectivityResult.contains(ConnectivityResult.ethernet)) {
state = ConnectionState.ethernet;
} else if (connectivityResult.contains(ConnectivityResult.wifi)) {
state = ConnectionState.wifi;
} else if (connectivityResult.contains(ConnectivityResult.mobile)) {
state = ConnectionState.mobile;
} else if (connectivityResult.contains(ConnectivityResult.none)) {
state = ConnectionState.offline;
}
}
}

View file

@ -0,0 +1,27 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'connectivity_provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$connectivityStatusHash() =>
r'2e9b645c78146ed25456e1286df83761588b8e27';
/// See also [ConnectivityStatus].
@ProviderFor(ConnectivityStatus)
final connectivityStatusProvider =
NotifierProvider<ConnectivityStatus, ConnectionState>.internal(
ConnectivityStatus.new,
name: r'connectivityStatusProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$connectivityStatusHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$ConnectivityStatus = Notifier<ConnectionState>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View file

@ -499,34 +499,21 @@ class JellyService {
Future<Response<PlaybackInfoResponse>> itemsItemIdPlaybackInfoPost({ Future<Response<PlaybackInfoResponse>> itemsItemIdPlaybackInfoPost({
required String? itemId, required String? itemId,
int? maxStreamingBitrate,
int? startTimeTicks,
int? audioStreamIndex,
int? subtitleStreamIndex,
int? maxAudioChannels,
String? mediaSourceId,
String? liveStreamId,
bool? autoOpenLiveStream,
bool? enableDirectPlay,
bool? enableDirectStream,
bool? enableTranscoding,
bool? allowVideoStreamCopy,
bool? allowAudioStreamCopy,
required PlaybackInfoDto? body, required PlaybackInfoDto? body,
}) async => }) async =>
api.itemsItemIdPlaybackInfoPost( api.itemsItemIdPlaybackInfoPost(
itemId: itemId, itemId: itemId,
userId: account?.id, userId: account?.id,
enableDirectPlay: enableDirectPlay, enableDirectPlay: body?.enableDirectPlay,
enableDirectStream: enableDirectStream, enableDirectStream: body?.enableDirectStream,
enableTranscoding: enableTranscoding, enableTranscoding: body?.enableTranscoding,
autoOpenLiveStream: autoOpenLiveStream, autoOpenLiveStream: body?.autoOpenLiveStream,
maxStreamingBitrate: maxStreamingBitrate, maxStreamingBitrate: body?.maxStreamingBitrate,
liveStreamId: liveStreamId, liveStreamId: body?.liveStreamId,
startTimeTicks: startTimeTicks, startTimeTicks: body?.startTimeTicks,
mediaSourceId: mediaSourceId, mediaSourceId: body?.mediaSourceId,
audioStreamIndex: audioStreamIndex, audioStreamIndex: body?.audioStreamIndex,
subtitleStreamIndex: subtitleStreamIndex, subtitleStreamIndex: body?.subtitleStreamIndex,
body: body, body: body,
); );

View file

@ -8,6 +8,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fladder/models/settings/home_settings_model.dart'; import 'package:fladder/models/settings/home_settings_model.dart';
import 'package:fladder/models/settings/video_player_settings.dart'; import 'package:fladder/models/settings/video_player_settings.dart';
import 'package:fladder/providers/connectivity_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/screens/settings/settings_list_tile.dart'; import 'package:fladder/screens/settings/settings_list_tile.dart';
import 'package:fladder/screens/settings/settings_scaffold.dart'; import 'package:fladder/screens/settings/settings_scaffold.dart';
@ -17,6 +18,7 @@ import 'package:fladder/screens/settings/widgets/subtitle_editor.dart';
import 'package:fladder/screens/shared/animated_fade_size.dart'; import 'package:fladder/screens/shared/animated_fade_size.dart';
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/util/adaptive_layout.dart'; import 'package:fladder/util/adaptive_layout.dart';
import 'package:fladder/util/bitrate_helper.dart';
import 'package:fladder/util/box_fit_extension.dart'; import 'package:fladder/util/box_fit_extension.dart';
import 'package:fladder/util/localization_helper.dart'; import 'package:fladder/util/localization_helper.dart';
import 'package:fladder/util/option_dialogue.dart'; import 'package:fladder/util/option_dialogue.dart';
@ -37,6 +39,9 @@ class _PlayerSettingsPageState extends ConsumerState<PlayerSettingsPage> {
final provider = ref.read(videoPlayerSettingsProvider.notifier); final provider = ref.read(videoPlayerSettingsProvider.notifier);
final showBackground = AdaptiveLayout.viewSizeOf(context) != ViewSize.phone && final showBackground = AdaptiveLayout.viewSizeOf(context) != ViewSize.phone &&
AdaptiveLayout.layoutModeOf(context) != LayoutMode.single; AdaptiveLayout.layoutModeOf(context) != LayoutMode.single;
final connectionState = ref.watch(connectivityStatusProvider);
return Card( return Card(
elevation: showBackground ? 2 : 0, elevation: showBackground ? 2 : 0,
child: SettingsScaffold( child: SettingsScaffold(
@ -80,6 +85,50 @@ class _PlayerSettingsPageState extends ConsumerState<PlayerSettingsPage> {
), ),
), ),
), ),
SettingsListTile(
label: _StatusIndicator(
homeInternet: connectionState.homeInternet,
label: Text(context.localized.homeStreamingQualityTitle),
),
subLabel: Text(context.localized.homeStreamingQualityDesc),
trailing: EnumBox(
current: ref.watch(
videoPlayerSettingsProvider.select((value) => value.maxHomeBitrate.label(context)),
),
itemBuilder: (context) => Bitrate.values
.map(
(entry) => PopupMenuItem(
value: entry,
child: Text(entry.label(context)),
onTap: () => ref.read(videoPlayerSettingsProvider.notifier).state =
ref.read(videoPlayerSettingsProvider).copyWith(maxHomeBitrate: entry),
),
)
.toList(),
),
),
SettingsListTile(
label: _StatusIndicator(
homeInternet: !connectionState.homeInternet,
label: Text(context.localized.internetStreamingQualityTitle),
),
subLabel: Text(context.localized.internetStreamingQualityDesc),
trailing: EnumBox(
current: ref.watch(
videoPlayerSettingsProvider.select((value) => value.maxInternetBitrate.label(context)),
),
itemBuilder: (context) => Bitrate.values
.map(
(entry) => PopupMenuItem(
value: entry,
child: Text(entry.label(context)),
onTap: () => ref.read(videoPlayerSettingsProvider.notifier).state =
ref.read(videoPlayerSettingsProvider).copyWith(maxInternetBitrate: entry),
),
)
.toList(),
),
),
const Divider(), const Divider(),
SettingsLabelDivider(label: context.localized.advanced), SettingsLabelDivider(label: context.localized.advanced),
if (PlayerOptions.available.length != 1) if (PlayerOptions.available.length != 1)
@ -205,3 +254,30 @@ class _PlayerSettingsPageState extends ConsumerState<PlayerSettingsPage> {
); );
} }
} }
class _StatusIndicator extends StatelessWidget {
final bool homeInternet;
final Widget label;
const _StatusIndicator({required this.homeInternet, required this.label});
@override
Widget build(BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
if (homeInternet) ...[
Container(
width: 8,
height: 8,
decoration: const BoxDecoration(
color: Colors.greenAccent,
shape: BoxShape.circle,
),
),
const SizedBox(width: 6),
],
label,
],
);
}
}

View file

@ -19,12 +19,14 @@ import 'package:fladder/providers/video_player_provider.dart';
import 'package:fladder/screens/collections/add_to_collection.dart'; import 'package:fladder/screens/collections/add_to_collection.dart';
import 'package:fladder/screens/metadata/info_screen.dart'; import 'package:fladder/screens/metadata/info_screen.dart';
import 'package:fladder/screens/playlists/add_to_playlists.dart'; import 'package:fladder/screens/playlists/add_to_playlists.dart';
import 'package:fladder/screens/video_player/components/video_player_quality_controls.dart';
import 'package:fladder/screens/video_player/components/video_player_queue.dart'; import 'package:fladder/screens/video_player/components/video_player_queue.dart';
import 'package:fladder/screens/video_player/components/video_subtitle_controls.dart'; import 'package:fladder/screens/video_player/components/video_subtitle_controls.dart';
import 'package:fladder/util/adaptive_layout.dart'; import 'package:fladder/util/adaptive_layout.dart';
import 'package:fladder/util/device_orientation_extension.dart'; import 'package:fladder/util/device_orientation_extension.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/map_bool_helper.dart';
import 'package:fladder/util/refresh_state.dart'; import 'package:fladder/util/refresh_state.dart';
import 'package:fladder/util/string_extensions.dart'; import 'package:fladder/util/string_extensions.dart';
import 'package:fladder/widgets/shared/enum_selection.dart'; import 'package:fladder/widgets/shared/enum_selection.dart';
@ -63,6 +65,7 @@ class _VideoOptionsMobileState extends ConsumerState<VideoOptions> {
final currentItem = ref.watch(playBackModel.select((value) => value?.item)); final currentItem = ref.watch(playBackModel.select((value) => value?.item));
final videoSettings = ref.watch(videoPlayerSettingsProvider); final videoSettings = ref.watch(videoPlayerSettingsProvider);
final currentMediaStreams = ref.watch(playBackModel.select((value) => value?.mediaStreams)); final currentMediaStreams = ref.watch(playBackModel.select((value) => value?.mediaStreams));
final bitRateOptions = ref.watch(playBackModel.select((value) => value?.bitRateOptions));
Widget mainPage() { Widget mainPage() {
return ListView( return ListView(
@ -202,6 +205,24 @@ class _VideoOptionsMobileState extends ConsumerState<VideoOptions> {
], ],
), ),
), ),
if (bitRateOptions?.isNotEmpty == true)
ListTile(
title: Row(
mainAxisSize: MainAxisSize.min,
children: [
Expanded(
flex: 3,
child: Text(context.localized.qualityOptionsTitle),
),
const Spacer(),
Text(bitRateOptions?.enabledFirst.keys.firstOrNull?.label(context) ?? "")
],
),
onTap: () {
Navigator.of(context).pop();
openQualityOptions(context);
},
)
], ],
); );
} }

View file

@ -0,0 +1,71 @@
import 'package:flutter/material.dart';
import 'package:auto_route/auto_route.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fladder/models/playback/playback_model.dart';
import 'package:fladder/providers/video_player_provider.dart';
import 'package:fladder/util/localization_helper.dart';
Future<void> openQualityOptions(BuildContext context) async {
return showDialog(
context: context,
builder: (context) => const _QualityOptionsDialogue(),
);
}
class _QualityOptionsDialogue extends ConsumerWidget {
const _QualityOptionsDialogue();
@override
Widget build(BuildContext context, WidgetRef ref) {
final playbackModel = ref.watch(playBackModel);
final qualityOptions = playbackModel?.bitRateOptions;
return Dialog(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 6),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
context.localized.qualityOptionsTitle,
style: Theme.of(context).textTheme.titleLarge,
),
),
const SizedBox(height: 6),
const Divider(),
Flexible(
child: ListView(
shrinkWrap: true,
children: qualityOptions?.entries
.map(
(entry) => RadioListTile(
value: entry.value,
groupValue: true,
onChanged: (value) async {
final newModel = await playbackModel?.setQualityOption(
qualityOptions.map(
(key, value) => MapEntry(key, key == entry.key ? true : false),
),
);
ref.read(playBackModel.notifier).update((state) => newModel);
if (newModel != null) {
await ref.read(playbackModelHelper).shouldReload(newModel);
}
context.router.maybePop();
},
title: Text(entry.key.label(context)),
),
)
.toList() ??
[],
),
),
],
),
);
}
}

View file

@ -20,6 +20,7 @@ import 'package:fladder/screens/shared/default_title_bar.dart';
import 'package:fladder/screens/video_player/components/video_playback_information.dart'; import 'package:fladder/screens/video_player/components/video_playback_information.dart';
import 'package:fladder/screens/video_player/components/video_player_controls_extras.dart'; import 'package:fladder/screens/video_player/components/video_player_controls_extras.dart';
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_quality_controls.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_volume_slider.dart'; import 'package:fladder/screens/video_player/components/video_volume_slider.dart';
@ -270,6 +271,7 @@ class _DesktopControlsState extends ConsumerState<DesktopControls> {
Widget bottomButtons(BuildContext context) { Widget bottomButtons(BuildContext context) {
return Consumer(builder: (context, ref, child) { return Consumer(builder: (context, ref, child) {
final mediaPlayback = ref.watch(mediaPlaybackProvider); final mediaPlayback = ref.watch(mediaPlaybackProvider);
final bitRateOptions = ref.watch(playBackModel.select((value) => value?.bitRateOptions));
return Container( return Container(
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: LinearGradient( gradient: LinearGradient(
@ -370,13 +372,16 @@ class _DesktopControlsState extends ConsumerState<DesktopControls> {
child: IconButton( child: IconButton(
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.viewSizeOf(context) >= ViewSize.desktop &&
ref.read(videoPlayerProvider).hasPlayer) ...{ ref.read(videoPlayerProvider).hasPlayer) ...{
// OpenQueueButton(x), if (bitRateOptions?.isNotEmpty == true)
// ChapterButton( Tooltip(
// position: position, message: context.localized.qualityOptionsTitle,
// player: ref.read(videoPlayerProvider).player!, child: IconButton(
// ), onPressed: () => openQualityOptions(context),
icon: const Icon(IconsaxOutline.speedometer),
),
),
Listener( Listener(
onPointerSignal: (event) { onPointerSignal: (event) {
if (event is PointerScrollEvent) { if (event is PointerScrollEvent) {

View file

@ -0,0 +1,90 @@
import 'package:flutter/widgets.dart';
import 'package:collection/collection.dart';
import 'package:fladder/util/localization_helper.dart';
import 'package:fladder/util/string_extensions.dart';
enum Bitrate {
original(null),
auto(0),
b120Mbps(120000000),
b80Mbps(80000000),
b60Mbps(60000000),
b40Mbps(40000000),
b20Mbps(20000000),
b15Mbps(15000000),
b10Mbps(10000000),
b8Mbps(8000000),
b6Mbps(6000000),
b4Mbps(4000000),
b3Mbps(3000000),
b1_5Mbps(1500000),
b720Kbps(720000),
b420Kbps(420000);
final int? bitRate;
const Bitrate(this.bitRate);
int get calculatedBitRate => bitRate ?? -1;
String label(BuildContext context) => switch (this) {
Bitrate.original => context.localized.qualityOptionsOriginal,
Bitrate.auto => context.localized.qualityOptionsAuto,
_ => name.toString().replaceAll('b', '').replaceAll('_', '.').toUpperCaseSplit()
};
}
class VideoQualitySettings {
final Bitrate? maxBitRate;
final int videoBitRate;
final String? videoCodec;
VideoQualitySettings({
required this.maxBitRate,
required this.videoBitRate,
required this.videoCodec,
});
}
Map<Bitrate, bool> getVideoQualityOptions(VideoQualitySettings options) {
final maxStreamingBitrate = options.maxBitRate;
final videoBitRate = options.videoBitRate;
final videoCodec = options.videoCodec;
double referenceBitRate = videoBitRate.toDouble();
final bitRateValues = Bitrate.values.where((value) => value.calculatedBitRate > 0).toSet();
final qualityOptions = <Bitrate>{Bitrate.original, Bitrate.auto};
if (videoBitRate > 0 && videoBitRate < bitRateValues.first.calculatedBitRate) {
if (videoCodec != null && ['hevc', 'av1', 'vp9'].contains(videoCodec) && referenceBitRate <= 20000000) {
referenceBitRate *= 1.5;
}
final sourceOption = bitRateValues.where((value) => value.calculatedBitRate > referenceBitRate).lastOrNull;
if (sourceOption != null) {
qualityOptions.add(sourceOption);
}
}
qualityOptions
.addAll(bitRateValues.where((value) => videoBitRate <= 0 || value.calculatedBitRate <= referenceBitRate));
Bitrate? selectedQualityOption;
if (maxStreamingBitrate != null && maxStreamingBitrate != Bitrate.original) {
selectedQualityOption = qualityOptions
.where(
(value) => value.calculatedBitRate > 0 && value.calculatedBitRate <= maxStreamingBitrate.calculatedBitRate)
.firstOrNull;
}
return qualityOptions.toList().asMap().map(
(_, bitrate) => MapEntry(
bitrate,
bitrate == maxStreamingBitrate || bitrate == selectedQualityOption,
),
);
}

View file

@ -31,13 +31,15 @@ extension StringExtensions on String {
return buffer.toString(); return buffer.toString();
} }
String toUpperCaseSplit() { String toUpperCaseSplit({RegExp? regExp}) {
String result = ''; String result = '';
RegExp defaultRegex = regExp ?? RegExp(r'^[a-zA-Z]+$');
for (int i = 0; i < length; i++) { for (int i = 0; i < length; i++) {
if (i == 0) { if (i == 0) {
result += this[i].toUpperCase(); result += this[i].toUpperCase();
} else if ((i > 0 && this[i].toUpperCase() == this[i])) { } else if ((i > 0 && this[i].toUpperCase() == this[i]) && defaultRegex.hasMatch(this[i]) == true) {
result += ' ${this[i].toUpperCase()}'; result += ' ${this[i].toUpperCase()}';
} else { } else {
result += this[i]; result += this[i];

View file

@ -1,5 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:fladder/models/settings/home_settings_model.dart';
import 'package:fladder/screens/shared/flat_button.dart';
import 'package:fladder/util/adaptive_layout.dart';
class EnumBox<T> extends StatelessWidget { class EnumBox<T> extends StatelessWidget {
final String current; final String current;
final List<PopupMenuEntry<T>> Function(BuildContext context) itemBuilder; final List<PopupMenuEntry<T>> Function(BuildContext context) itemBuilder;
@ -11,45 +15,62 @@ class EnumBox<T> extends StatelessWidget {
final textStyle = Theme.of(context).textTheme.titleMedium; final textStyle = Theme.of(context).textTheme.titleMedium;
const padding = EdgeInsets.symmetric(horizontal: 12, vertical: 6); const padding = EdgeInsets.symmetric(horizontal: 12, vertical: 6);
final itemList = itemBuilder(context); final itemList = itemBuilder(context);
final useBottomSheet = AdaptiveLayout.viewSizeOf(context) <= ViewSize.phone;
final labelWidget = Padding(
padding: padding,
child: Material(
textStyle: textStyle?.copyWith(
fontWeight: FontWeight.bold,
color: itemList.length > 1 ? Theme.of(context).colorScheme.onPrimaryContainer : null),
color: Colors.transparent,
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.start,
children: [
Flexible(
child: Text(
current,
textAlign: TextAlign.start,
),
),
const SizedBox(width: 6),
if (itemList.length > 1)
Icon(
Icons.keyboard_arrow_down,
color: Theme.of(context).colorScheme.onPrimaryContainer,
)
],
),
),
);
return Card( return Card(
color: Theme.of(context).colorScheme.primaryContainer, color: Theme.of(context).colorScheme.primaryContainer,
shadowColor: Colors.transparent, shadowColor: Colors.transparent,
elevation: 0, elevation: 0,
child: PopupMenuButton( child: useBottomSheet
tooltip: '', ? FlatButton(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), child: labelWidget,
enabled: itemList.length > 1, onTap: () => showModalBottomSheet(
itemBuilder: itemBuilder, context: context,
padding: padding, builder: (context) => ListView(
child: Padding( shrinkWrap: true,
padding: padding, children: [
child: Material( const SizedBox(height: 6),
textStyle: textStyle?.copyWith( ...itemBuilder(context),
fontWeight: FontWeight.bold, ],
color: itemList.length > 1 ? Theme.of(context).colorScheme.onPrimaryContainer : null),
color: Colors.transparent,
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.start,
children: [
Flexible(
child: Text(
current,
textAlign: TextAlign.start,
),
), ),
const SizedBox(width: 6), ),
if (itemList.length > 1) )
Icon( : PopupMenuButton(
Icons.keyboard_arrow_down, tooltip: '',
color: Theme.of(context).colorScheme.onPrimaryContainer, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
) enabled: itemList.length > 1,
], itemBuilder: itemBuilder,
padding: padding,
child: labelWidget,
), ),
),
),
),
); );
} }
} }
@ -58,7 +79,8 @@ class EnumSelection<T> extends StatelessWidget {
final Text label; final Text label;
final String current; final String current;
final List<PopupMenuEntry<T>> Function(BuildContext context) itemBuilder; final List<PopupMenuEntry<T>> Function(BuildContext context) itemBuilder;
const EnumSelection({super.key, const EnumSelection({
super.key,
required this.label, required this.label,
required this.current, required this.current,
required this.itemBuilder, required this.itemBuilder,

View file

@ -7,6 +7,7 @@ import Foundation
import audio_service import audio_service
import audio_session import audio_session
import connectivity_plus
import desktop_drop import desktop_drop
import dynamic_color import dynamic_color
import file_picker import file_picker
@ -32,6 +33,7 @@ import window_manager
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
AudioServicePlugin.register(with: registry.registrar(forPlugin: "AudioServicePlugin")) AudioServicePlugin.register(with: registry.registrar(forPlugin: "AudioServicePlugin"))
AudioSessionPlugin.register(with: registry.registrar(forPlugin: "AudioSessionPlugin")) AudioSessionPlugin.register(with: registry.registrar(forPlugin: "AudioSessionPlugin"))
ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin"))
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"))
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))

View file

@ -326,6 +326,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.19.0" version: "1.19.0"
connectivity_plus:
dependency: "direct main"
description:
name: connectivity_plus
sha256: "04bf81bb0b77de31557b58d052b24b3eee33f09a6e7a8c68a3e247c7df19ec27"
url: "https://pub.dev"
source: hosted
version: "6.1.3"
connectivity_plus_platform_interface:
dependency: transitive
description:
name: connectivity_plus_platform_interface
sha256: "42657c1715d48b167930d5f34d00222ac100475f73d10162ddf43e714932f204"
url: "https://pub.dev"
source: hosted
version: "2.0.1"
convert: convert:
dependency: transitive dependency: transitive
description: description:
@ -1165,6 +1181,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.0" version: "1.0.0"
nm:
dependency: transitive
description:
name: nm
sha256: "2c9aae4127bdc8993206464fcc063611e0e36e72018696cd9631023a31b24254"
url: "https://pub.dev"
source: hosted
version: "0.5.0"
octo_image: octo_image:
dependency: transitive dependency: transitive
description: description:

View file

@ -44,6 +44,7 @@ dependencies:
cached_network_image: ^3.4.1 cached_network_image: ^3.4.1
http: ^1.3.0 http: ^1.3.0
flutter_cache_manager: ^3.4.1 flutter_cache_manager: ^3.4.1
connectivity_plus: ^6.1.3
# State Management # State Management
flutter_riverpod: ^2.6.1 flutter_riverpod: ^2.6.1

View file

@ -6,6 +6,7 @@
#include "generated_plugin_registrant.h" #include "generated_plugin_registrant.h"
#include <connectivity_plus/connectivity_plus_windows_plugin.h>
#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 <fvp/fvp_plugin_c_api.h>
@ -20,6 +21,8 @@
#include <window_manager/window_manager_plugin.h> #include <window_manager/window_manager_plugin.h>
void RegisterPlugins(flutter::PluginRegistry* registry) { void RegisterPlugins(flutter::PluginRegistry* registry) {
ConnectivityPlusWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin"));
DesktopDropPluginRegisterWithRegistrar( DesktopDropPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("DesktopDropPlugin")); registry->GetRegistrarForPlugin("DesktopDropPlugin"));
DynamicColorPluginCApiRegisterWithRegistrar( DynamicColorPluginCApiRegisterWithRegistrar(

View file

@ -3,6 +3,7 @@
# #
list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_PLUGIN_LIST
connectivity_plus
desktop_drop desktop_drop
dynamic_color dynamic_color
fvp fvp