From 935d6fe176fe47ad115d6d6443ec1a4d7f42d4d7 Mon Sep 17 00:00:00 2001 From: PartyDonut <42371342+PartyDonut@users.noreply.github.com> Date: Sun, 23 Feb 2025 13:29:59 +0100 Subject: [PATCH] feature: Video quality options (#234) Co-authored-by: PartyDonut --- lib/l10n/app_en.arb | 9 +- lib/models/items/media_streams_model.dart | 3 + .../playback/direct_playback_model.dart | 67 ++++------- .../playback/offline_playback_model.dart | 38 ++----- lib/models/playback/playback_model.dart | 105 +++++++++++------- .../playback/transcode_playback_model.dart | 69 ++++-------- lib/models/settings/home_settings_model.dart | 10 ++ .../settings/video_player_settings.dart | 3 + .../video_player_settings.freezed.dart | 44 +++++++- .../settings/video_player_settings.g.dart | 27 +++++ lib/providers/connectivity_provider.dart | 39 +++++++ lib/providers/connectivity_provider.g.dart | 27 +++++ lib/providers/service_provider.dart | 33 ++---- .../settings/player_settings_page.dart | 76 +++++++++++++ .../video_player_options_sheet.dart | 21 ++++ .../video_player_quality_controls.dart | 71 ++++++++++++ .../video_player/video_player_controls.dart | 17 ++- lib/util/bitrate_helper.dart | 90 +++++++++++++++ lib/util/string_extensions.dart | 6 +- lib/widgets/shared/enum_selection.dart | 90 +++++++++------ macos/Flutter/GeneratedPluginRegistrant.swift | 2 + pubspec.lock | 24 ++++ pubspec.yaml | 1 + .../flutter/generated_plugin_registrant.cc | 3 + windows/flutter/generated_plugins.cmake | 1 + 25 files changed, 644 insertions(+), 232 deletions(-) create mode 100644 lib/providers/connectivity_provider.dart create mode 100644 lib/providers/connectivity_provider.g.dart create mode 100644 lib/screens/video_player/components/video_player_quality_controls.dart create mode 100644 lib/util/bitrate_helper.dart diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index ee7c3fb..cd42be8 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1171,5 +1171,12 @@ "copiedToClipboard": "Copied to clipboard", "episodeAvailable": "Available", "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" } \ No newline at end of file diff --git a/lib/models/items/media_streams_model.dart b/lib/models/items/media_streams_model.dart index af9c9df..7d8c885 100644 --- a/lib/models/items/media_streams_model.dart +++ b/lib/models/items/media_streams_model.dart @@ -157,6 +157,7 @@ class StreamModel { class VideoStreamModel extends StreamModel { final int width; final int height; + final int? bitRate; final double frameRate; final String? videoDoViTitle; final VideoRangeType? videoRangeType; @@ -168,6 +169,7 @@ class VideoStreamModel extends StreamModel { required super.index, required this.videoDoViTitle, required this.videoRangeType, + required this.bitRate, required this.width, required this.height, required this.frameRate, @@ -179,6 +181,7 @@ class VideoStreamModel extends StreamModel { isDefault: stream.isDefault ?? false, codec: stream.codec ?? "", videoDoViTitle: stream.videoDoViTitle, + bitRate: stream.bitRate, videoRangeType: stream.videoRangeType, width: stream.width ?? 0, height: stream.height ?? 0, diff --git a/lib/models/playback/direct_playback_model.dart b/lib/models/playback/direct_playback_model.dart index 90fd00e..ab03db8 100644 --- a/lib/models/playback/direct_playback_model.dart +++ b/lib/models/playback/direct_playback_model.dart @@ -12,52 +12,23 @@ import 'package:fladder/models/items/trick_play_model.dart'; import 'package:fladder/models/playback/playback_model.dart'; import 'package:fladder/providers/api_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/list_extensions.dart'; import 'package:fladder/wrappers/media_control_wrapper.dart'; -class DirectPlaybackModel implements PlaybackModel { +class DirectPlaybackModel extends PlaybackModel { DirectPlaybackModel({ - required this.item, - required this.media, - required this.playbackInfo, - this.mediaStreams, - this.mediaSegments, - this.chapters, - this.trickPlay, - this.queue = const [], + required super.item, + required super.media, + super.playbackInfo, + super.mediaStreams, + super.mediaSegments, + super.chapters, + super.trickPlay, + 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? chapters; - - @override - final TrickPlayModel? trickPlay; - - @override - ItemBaseModel? get nextVideo => queue.nextOrNull(item); - - @override - ItemBaseModel? get previousVideo => queue.previousOrNull(item); - - @override - Future? startDuration() async => item.userData.playBackPosition; - @override List get subStreams => [SubStreamModel.no(), ...mediaStreams?.subStreams ?? []]; @@ -79,6 +50,11 @@ class DirectPlaybackModel implements PlaybackModel { return copyWith(mediaStreams: () => mediaStreams?.copyWith(defaultAudioStreamIndex: newIndex)); } + @override + Future? setQualityOption(Map map) async { + return copyWith(bitRateOptions: map); + } + @override Future playbackStarted(Duration position, Ref ref) async { await ref.read(jellyApiProvider).sessionsPlayingPost( @@ -86,7 +62,7 @@ class DirectPlaybackModel implements PlaybackModel { canSeek: true, itemId: item.id, mediaSourceId: item.id, - playSessionId: playbackInfo.playSessionId, + playSessionId: playbackInfo?.playSessionId, subtitleStreamIndex: item.streamModel?.defaultSubStreamIndex, audioStreamIndex: item.streamModel?.defaultAudioStreamIndex, volumeLevel: 100, @@ -108,7 +84,7 @@ class DirectPlaybackModel implements PlaybackModel { body: PlaybackStopInfo( itemId: item.id, mediaSourceId: item.id, - playSessionId: playbackInfo.playSessionId, + playSessionId: playbackInfo?.playSessionId, positionTicks: position.toRuntimeTicks, ), ); @@ -124,7 +100,7 @@ class DirectPlaybackModel implements PlaybackModel { canSeek: true, itemId: item.id, mediaSourceId: item.id, - playSessionId: playbackInfo.playSessionId, + playSessionId: playbackInfo?.playSessionId, subtitleStreamIndex: item.streamModel?.defaultSubStreamIndex, audioStreamIndex: item.streamModel?.defaultAudioStreamIndex, volumeLevel: 100, @@ -142,9 +118,6 @@ class DirectPlaybackModel implements PlaybackModel { @override String toString() => 'DirectPlaybackModel(item: $item, playbackInfo: $playbackInfo)'; - @override - final List queue; - @override DirectPlaybackModel copyWith({ ItemBaseModel? item, @@ -156,6 +129,7 @@ class DirectPlaybackModel implements PlaybackModel { ValueGetter?>? chapters, ValueGetter? trickPlay, List? queue, + Map? bitRateOptions, }) { return DirectPlaybackModel( item: item ?? this.item, @@ -166,6 +140,7 @@ class DirectPlaybackModel implements PlaybackModel { chapters: chapters != null ? chapters() : this.chapters, trickPlay: trickPlay != null ? trickPlay() : this.trickPlay, queue: queue ?? this.queue, + bitRateOptions: bitRateOptions ?? this.bitRateOptions, ); } } diff --git a/lib/models/playback/offline_playback_model.dart b/lib/models/playback/offline_playback_model.dart index 71e07cb..074f166 100644 --- a/lib/models/playback/offline_playback_model.dart +++ b/lib/models/playback/offline_playback_model.dart @@ -2,7 +2,6 @@ import 'package:flutter/widgets.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/items/chapters_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/wrappers/media_control_wrapper.dart'; -class OfflinePlaybackModel implements PlaybackModel { +class OfflinePlaybackModel extends PlaybackModel { OfflinePlaybackModel({ - required this.item, - required this.media, required this.syncedItem, - this.mediaStreams, - this.playbackInfo, - this.mediaSegments, - this.trickPlay, - this.queue = const [], + super.mediaStreams, + super.playbackInfo, + required super.item, + required super.media, + super.mediaSegments, + super.trickPlay, + super.queue = const [], this.syncedQueue = const [], }); - @override - final ItemBaseModel item; - - @override - final PlaybackInfoResponse? playbackInfo; - - @override - final Media? media; - final SyncedItem syncedItem; - @override - final MediaStreamsModel? mediaStreams; - - @override - final MediaSegmentsModel? mediaSegments; - @override List? get chapters => syncedItem.chapters; - @override - final TrickPlayModel? trickPlay; - @override Future? startDuration() async => item.userData.playBackPosition; @@ -118,9 +99,6 @@ class OfflinePlaybackModel implements PlaybackModel { @override String toString() => 'OfflinePlaybackModel(item: $item, syncedItem: $syncedItem)'; - @override - final List queue; - final List syncedQueue; @override diff --git a/lib/models/playback/playback_model.dart b/lib/models/playback/playback_model.dart index e5451e0..466edd9 100644 --- a/lib/models/playback/playback_model.dart +++ b/lib/models/playback/playback_model.dart @@ -22,11 +22,15 @@ import 'package:fladder/models/video_stream_model.dart'; import 'package:fladder/profiles/default_profile.dart'; import 'package:fladder/providers/api_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_provider.dart'; import 'package:fladder/providers/user_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/list_extensions.dart'; +import 'package:fladder/util/map_bool_helper.dart'; import 'package:fladder/wrappers/media_control_wrapper.dart'; class Media { @@ -52,15 +56,17 @@ extension PlaybackModelExtension on PlaybackModel? { }; } -abstract class PlaybackModel { - final ItemBaseModel item = throw UnimplementedError(); - final Media? media = throw UnimplementedError(); - final List queue = const []; - final MediaSegmentsModel? mediaSegments = null; - final PlaybackInfoResponse? playbackInfo = throw UnimplementedError(); +class PlaybackModel { + final ItemBaseModel item; + final Media? media; + final List queue; + final MediaSegmentsModel? mediaSegments; + final PlaybackInfoResponse? playbackInfo; - List? get chapters; - TrickPlayModel? get trickPlay; + Map bitRateOptions; + + List? chapters = []; + TrickPlayModel? trickPlay; Future updatePlaybackPosition(Duration position, bool isPlaying, Ref ref) => throw UnimplementedError(); @@ -68,21 +74,32 @@ abstract class PlaybackModel { Future playbackStopped(Duration position, Duration? totalDuration, Ref ref) => throw UnimplementedError(); - final MediaStreamsModel? mediaStreams = throw UnimplementedError(); - List? get subStreams; - List? get audioStreams; + final MediaStreamsModel? mediaStreams; + List? get subStreams => throw UnimplementedError(); + List? get audioStreams => throw UnimplementedError(); Future? startDuration() async => item.userData.playBackPosition; - Future? setSubtitle(SubStreamModel? model, MediaControlsWrapper player) { - return null; - } - Future? setAudio(AudioStreamModel? model, MediaControlsWrapper player) => null; + Future? setSubtitle(SubStreamModel? model, MediaControlsWrapper player) => throw UnimplementedError(); + Future? setAudio(AudioStreamModel? model, MediaControlsWrapper player) => throw UnimplementedError(); + Future? setQualityOption(Map map) => throw UnimplementedError(); - ItemBaseModel? get nextVideo => throw UnimplementedError(); - ItemBaseModel? get previousVideo => throw UnimplementedError(); + ItemBaseModel? get nextVideo => queue.nextOrNull(item); + 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((ref) { @@ -149,8 +166,13 @@ class PlaybackModelHelper { } } - Future createServerPlaybackModel(ItemBaseModel? item, PlaybackType? type, - {PlaybackModel? oldModel, List? libraryQueue, Duration? startPosition}) async { + Future createServerPlaybackModel( + ItemBaseModel? item, + PlaybackType? type, { + PlaybackModel? oldModel, + List? libraryQueue, + Duration? startPosition, + }) async { try { if (item == null) return null; final userId = ref.read(userProvider)?.id; @@ -165,18 +187,18 @@ class PlaybackModelHelper { final fullItem = await api.usersUserIdItemsItemIdGet(itemId: firstItemToPlay.id); + Map 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; - Response response = await api.itemsItemIdPlaybackInfoPost( + final Response response = await api.itemsItemIdPlaybackInfoPost( 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( startTimeTicks: startPosition?.toRuntimeTicks, audioStreamIndex: streamModel?.defaultAudioStreamIndex, @@ -185,6 +207,9 @@ class PlaybackModelHelper { autoOpenLiveStream: true, deviceProfile: ref.read(videoProfileProvider), userId: userId, + enableDirectPlay: type != PlaybackType.transcode, + enableDirectStream: type != PlaybackType.transcode, + maxStreamingBitrate: qualityOptions.enabledFirst.keys.firstOrNull?.bitRate, mediaSourceId: firstItemToPlay.id, ), ); @@ -234,10 +259,9 @@ class PlaybackModelHelper { chapters: chapters, playbackInfo: playbackInfo, trickPlay: trickPlay, - media: Media( - url: mediaPath ?? playbackUrl, - ), + media: Media(url: mediaPath ?? playbackUrl), mediaStreams: mediaStreamsWithUrls, + bitRateOptions: qualityOptions, ); } else if ((mediaSource.supportsTranscoding ?? false) && mediaSource.transcodingUrl != null) { return TranscodePlaybackModel( @@ -309,22 +333,17 @@ class PlaybackModelHelper { Response response = await api.itemsItemIdPlaybackInfoPost( itemId: item.id, - enableDirectPlay: true, - enableDirectStream: true, - enableTranscoding: true, - autoOpenLiveStream: true, - startTimeTicks: currentPosition.toRuntimeTicks, - audioStreamIndex: audioIndex, - subtitleStreamIndex: subIndex, - mediaSourceId: item.id, body: PlaybackInfoDto( startTimeTicks: currentPosition.toRuntimeTicks, audioStreamIndex: audioIndex, + enableDirectPlay: true, + enableDirectStream: true, subtitleStreamIndex: subIndex, enableTranscoding: true, autoOpenLiveStream: true, deviceProfile: ref.read(videoProfileProvider), userId: userId, + maxStreamingBitrate: playbackModel.bitRateOptions.enabledFirst.entries.firstOrNull?.key.bitRate, mediaSourceId: item.id, ), ); @@ -363,6 +382,8 @@ class PlaybackModelHelper { final directPlay = '${ref.read(userProvider)?.server ?? ""}/Videos/${mediaSource.id}/stream?$params'; + final mediaPath = isValidVideoUrl(mediaSource.path ?? ""); + newModel = DirectPlaybackModel( item: playbackModel.item, queue: playbackModel.queue, @@ -370,8 +391,9 @@ class PlaybackModelHelper { chapters: playbackModel.chapters, playbackInfo: playbackInfo, trickPlay: playbackModel.trickPlay, - media: Media(url: directPlay), + media: Media(url: mediaPath ?? directPlay), mediaStreams: mediaStreamsWithUrls, + bitRateOptions: playbackModel.bitRateOptions, ); } else if ((mediaSource.supportsTranscoding ?? false) && mediaSource.transcodingUrl != null) { newModel = TranscodePlaybackModel( @@ -383,6 +405,7 @@ class PlaybackModelHelper { trickPlay: playbackModel.trickPlay, media: Media(url: "${ref.read(userProvider)?.server ?? ""}${mediaSource.transcodingUrl ?? ""}"), mediaStreams: mediaStreamsWithUrls, + bitRateOptions: playbackModel.bitRateOptions, ); } if (newModel == null) return; diff --git a/lib/models/playback/transcode_playback_model.dart b/lib/models/playback/transcode_playback_model.dart index 202a1f8..165c3c4 100644 --- a/lib/models/playback/transcode_playback_model.dart +++ b/lib/models/playback/transcode_playback_model.dart @@ -12,52 +12,23 @@ import 'package:fladder/models/items/trick_play_model.dart'; import 'package:fladder/models/playback/playback_model.dart'; import 'package:fladder/providers/api_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/list_extensions.dart'; import 'package:fladder/wrappers/media_control_wrapper.dart'; -class TranscodePlaybackModel implements PlaybackModel { +class TranscodePlaybackModel extends PlaybackModel { TranscodePlaybackModel({ - required this.item, - required this.media, - required this.playbackInfo, - this.mediaStreams, - this.mediaSegments, - this.chapters, - this.trickPlay, - this.queue = const [], + required super.item, + required super.media, + required super.playbackInfo, + super.mediaStreams, + super.mediaSegments, + super.chapters, + super.trickPlay, + 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? chapters; - - @override - final TrickPlayModel? trickPlay; - - @override - ItemBaseModel? get nextVideo => queue.nextOrNull(item); - - @override - ItemBaseModel? get previousVideo => queue.previousOrNull(item); - - @override - Future? startDuration() async => item.userData.playBackPosition; - @override List get subStreams => [SubStreamModel.no(), ...mediaStreams?.subStreams ?? []]; @@ -79,6 +50,9 @@ class TranscodePlaybackModel implements PlaybackModel { return copyWith(mediaStreams: () => mediaStreams?.copyWith(defaultAudioStreamIndex: newIndex)); } + @override + Future? setQualityOption(Map map) async => copyWith(bitRateOptions: map); + @override Future playbackStarted(Duration position, Ref ref) async { await ref.read(jellyApiProvider).sessionsPlayingPost( @@ -86,8 +60,8 @@ class TranscodePlaybackModel implements PlaybackModel { canSeek: true, itemId: item.id, mediaSourceId: item.id, - playSessionId: playbackInfo.playSessionId, - sessionId: playbackInfo.playSessionId, + playSessionId: playbackInfo?.playSessionId, + sessionId: playbackInfo?.playSessionId, subtitleStreamIndex: item.streamModel?.defaultSubStreamIndex, audioStreamIndex: item.streamModel?.defaultAudioStreamIndex, volumeLevel: 100, @@ -109,7 +83,7 @@ class TranscodePlaybackModel implements PlaybackModel { body: PlaybackStopInfo( itemId: item.id, mediaSourceId: item.id, - playSessionId: playbackInfo.playSessionId, + playSessionId: playbackInfo?.playSessionId, positionTicks: position.toRuntimeTicks, ), ); @@ -125,8 +99,8 @@ class TranscodePlaybackModel implements PlaybackModel { canSeek: true, itemId: item.id, mediaSourceId: item.id, - playSessionId: playbackInfo.playSessionId, - sessionId: playbackInfo.playSessionId, + playSessionId: playbackInfo?.playSessionId, + sessionId: playbackInfo?.playSessionId, subtitleStreamIndex: item.streamModel?.defaultSubStreamIndex, audioStreamIndex: item.streamModel?.defaultAudioStreamIndex, volumeLevel: 100, @@ -143,9 +117,6 @@ class TranscodePlaybackModel implements PlaybackModel { @override String toString() => 'TranscodePlaybackModel(item: $item, playbackInfo: $playbackInfo)'; - @override - final List queue; - @override TranscodePlaybackModel copyWith({ ItemBaseModel? item, @@ -157,6 +128,7 @@ class TranscodePlaybackModel implements PlaybackModel { ValueGetter?>? chapters, ValueGetter? trickPlay, List? queue, + Map? bitRateOptions, }) { return TranscodePlaybackModel( item: item ?? this.item, @@ -167,6 +139,7 @@ class TranscodePlaybackModel implements PlaybackModel { chapters: chapters != null ? chapters() : this.chapters, trickPlay: trickPlay != null ? trickPlay() : this.trickPlay, queue: queue ?? this.queue, + bitRateOptions: bitRateOptions ?? this.bitRateOptions, ); } } diff --git a/lib/models/settings/home_settings_model.dart b/lib/models/settings/home_settings_model.dart index 82c8915..b6102b7 100644 --- a/lib/models/settings/home_settings_model.dart +++ b/lib/models/settings/home_settings_model.dart @@ -48,6 +48,11 @@ enum ViewSize { ViewSize.tablet => context.localized.tablet, 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 { @@ -60,6 +65,11 @@ enum LayoutMode { LayoutMode.single => context.localized.layoutModeSingle, 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 { diff --git a/lib/models/settings/video_player_settings.dart b/lib/models/settings/video_player_settings.dart index 6aa3d3a..52bfe3d 100644 --- a/lib/models/settings/video_player_settings.dart +++ b/lib/models/settings/video_player_settings.dart @@ -5,6 +5,7 @@ import 'package:flutter/widgets.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:fladder/util/bitrate_helper.dart'; import 'package:fladder/util/localization_helper.dart'; part 'video_player_settings.freezed.dart'; @@ -24,6 +25,8 @@ class VideoPlayerSettingsModel with _$VideoPlayerSettingsModel { @Default(100) double internalVolume, Set? allowedOrientations, @Default(AutoNextType.smart) AutoNextType nextVideoType, + @Default(Bitrate.original) Bitrate maxHomeBitrate, + @Default(Bitrate.original) Bitrate maxInternetBitrate, String? audioDevice, }) = _VideoPlayerSettingsModel; diff --git a/lib/models/settings/video_player_settings.freezed.dart b/lib/models/settings/video_player_settings.freezed.dart index 04f07ed..f23e2e9 100644 --- a/lib/models/settings/video_player_settings.freezed.dart +++ b/lib/models/settings/video_player_settings.freezed.dart @@ -31,6 +31,8 @@ mixin _$VideoPlayerSettingsModel { Set? get allowedOrientations => throw _privateConstructorUsedError; AutoNextType get nextVideoType => throw _privateConstructorUsedError; + Bitrate get maxHomeBitrate => throw _privateConstructorUsedError; + Bitrate get maxInternetBitrate => throw _privateConstructorUsedError; String? get audioDevice => throw _privateConstructorUsedError; /// Serializes this VideoPlayerSettingsModel to a JSON map. @@ -59,6 +61,8 @@ abstract class $VideoPlayerSettingsModelCopyWith<$Res> { double internalVolume, Set? allowedOrientations, AutoNextType nextVideoType, + Bitrate maxHomeBitrate, + Bitrate maxInternetBitrate, String? audioDevice}); } @@ -87,6 +91,8 @@ class _$VideoPlayerSettingsModelCopyWithImpl<$Res, Object? internalVolume = null, Object? allowedOrientations = freezed, Object? nextVideoType = null, + Object? maxHomeBitrate = null, + Object? maxInternetBitrate = null, Object? audioDevice = freezed, }) { return _then(_value.copyWith( @@ -126,6 +132,14 @@ class _$VideoPlayerSettingsModelCopyWithImpl<$Res, ? _value.nextVideoType : nextVideoType // ignore: cast_nullable_to_non_nullable 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 ? _value.audioDevice : audioDevice // ignore: cast_nullable_to_non_nullable @@ -153,6 +167,8 @@ abstract class _$$VideoPlayerSettingsModelImplCopyWith<$Res> double internalVolume, Set? allowedOrientations, AutoNextType nextVideoType, + Bitrate maxHomeBitrate, + Bitrate maxInternetBitrate, String? audioDevice}); } @@ -180,6 +196,8 @@ class __$$VideoPlayerSettingsModelImplCopyWithImpl<$Res> Object? internalVolume = null, Object? allowedOrientations = freezed, Object? nextVideoType = null, + Object? maxHomeBitrate = null, + Object? maxInternetBitrate = null, Object? audioDevice = freezed, }) { return _then(_$VideoPlayerSettingsModelImpl( @@ -219,6 +237,14 @@ class __$$VideoPlayerSettingsModelImplCopyWithImpl<$Res> ? _value.nextVideoType : nextVideoType // ignore: cast_nullable_to_non_nullable 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 ? _value.audioDevice : audioDevice // ignore: cast_nullable_to_non_nullable @@ -241,6 +267,8 @@ class _$VideoPlayerSettingsModelImpl extends _VideoPlayerSettingsModel this.internalVolume = 100, final Set? allowedOrientations, this.nextVideoType = AutoNextType.smart, + this.maxHomeBitrate = Bitrate.original, + this.maxInternetBitrate = Bitrate.original, this.audioDevice}) : _allowedOrientations = allowedOrientations, super._(); @@ -282,11 +310,17 @@ class _$VideoPlayerSettingsModelImpl extends _VideoPlayerSettingsModel @JsonKey() final AutoNextType nextVideoType; @override + @JsonKey() + final Bitrate maxHomeBitrate; + @override + @JsonKey() + final Bitrate maxInternetBitrate; + @override final String? audioDevice; @override 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 @@ -303,6 +337,8 @@ class _$VideoPlayerSettingsModelImpl extends _VideoPlayerSettingsModel ..add(DiagnosticsProperty('internalVolume', internalVolume)) ..add(DiagnosticsProperty('allowedOrientations', allowedOrientations)) ..add(DiagnosticsProperty('nextVideoType', nextVideoType)) + ..add(DiagnosticsProperty('maxHomeBitrate', maxHomeBitrate)) + ..add(DiagnosticsProperty('maxInternetBitrate', maxInternetBitrate)) ..add(DiagnosticsProperty('audioDevice', audioDevice)); } @@ -334,6 +370,8 @@ abstract class _VideoPlayerSettingsModel extends VideoPlayerSettingsModel { final double internalVolume, final Set? allowedOrientations, final AutoNextType nextVideoType, + final Bitrate maxHomeBitrate, + final Bitrate maxInternetBitrate, final String? audioDevice}) = _$VideoPlayerSettingsModelImpl; _VideoPlayerSettingsModel._() : super._(); @@ -359,6 +397,10 @@ abstract class _VideoPlayerSettingsModel extends VideoPlayerSettingsModel { @override AutoNextType get nextVideoType; @override + Bitrate get maxHomeBitrate; + @override + Bitrate get maxInternetBitrate; + @override String? get audioDevice; /// Create a copy of VideoPlayerSettingsModel diff --git a/lib/models/settings/video_player_settings.g.dart b/lib/models/settings/video_player_settings.g.dart index 2fd1260..63f0e01 100644 --- a/lib/models/settings/video_player_settings.g.dart +++ b/lib/models/settings/video_player_settings.g.dart @@ -24,6 +24,12 @@ _$VideoPlayerSettingsModelImpl _$$VideoPlayerSettingsModelImplFromJson( nextVideoType: $enumDecodeNullable(_$AutoNextTypeEnumMap, json['nextVideoType']) ?? AutoNextType.smart, + maxHomeBitrate: + $enumDecodeNullable(_$BitrateEnumMap, json['maxHomeBitrate']) ?? + Bitrate.original, + maxInternetBitrate: + $enumDecodeNullable(_$BitrateEnumMap, json['maxInternetBitrate']) ?? + Bitrate.original, audioDevice: json['audioDevice'] as String?, ); @@ -41,6 +47,8 @@ Map _$$VideoPlayerSettingsModelImplToJson( ?.map((e) => _$DeviceOrientationEnumMap[e]!) .toList(), 'nextVideoType': _$AutoNextTypeEnumMap[instance.nextVideoType]!, + 'maxHomeBitrate': _$BitrateEnumMap[instance.maxHomeBitrate]!, + 'maxInternetBitrate': _$BitrateEnumMap[instance.maxInternetBitrate]!, 'audioDevice': instance.audioDevice, }; @@ -71,3 +79,22 @@ const _$AutoNextTypeEnumMap = { AutoNextType.smart: 'smart', 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', +}; diff --git a/lib/providers/connectivity_provider.dart b/lib/providers/connectivity_provider.dart new file mode 100644 index 0000000..692136b --- /dev/null +++ b/lib/providers/connectivity_provider.dart @@ -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) { + 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; + } + } +} diff --git a/lib/providers/connectivity_provider.g.dart b/lib/providers/connectivity_provider.g.dart new file mode 100644 index 0000000..e9aeec6 --- /dev/null +++ b/lib/providers/connectivity_provider.g.dart @@ -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.internal( + ConnectivityStatus.new, + name: r'connectivityStatusProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$connectivityStatusHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$ConnectivityStatus = Notifier; +// 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 diff --git a/lib/providers/service_provider.dart b/lib/providers/service_provider.dart index 681a00a..c395d9c 100644 --- a/lib/providers/service_provider.dart +++ b/lib/providers/service_provider.dart @@ -499,34 +499,21 @@ class JellyService { Future> itemsItemIdPlaybackInfoPost({ 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, }) async => api.itemsItemIdPlaybackInfoPost( itemId: itemId, userId: account?.id, - enableDirectPlay: enableDirectPlay, - enableDirectStream: enableDirectStream, - enableTranscoding: enableTranscoding, - autoOpenLiveStream: autoOpenLiveStream, - maxStreamingBitrate: maxStreamingBitrate, - liveStreamId: liveStreamId, - startTimeTicks: startTimeTicks, - mediaSourceId: mediaSourceId, - audioStreamIndex: audioStreamIndex, - subtitleStreamIndex: subtitleStreamIndex, + enableDirectPlay: body?.enableDirectPlay, + enableDirectStream: body?.enableDirectStream, + enableTranscoding: body?.enableTranscoding, + autoOpenLiveStream: body?.autoOpenLiveStream, + maxStreamingBitrate: body?.maxStreamingBitrate, + liveStreamId: body?.liveStreamId, + startTimeTicks: body?.startTimeTicks, + mediaSourceId: body?.mediaSourceId, + audioStreamIndex: body?.audioStreamIndex, + subtitleStreamIndex: body?.subtitleStreamIndex, body: body, ); diff --git a/lib/screens/settings/player_settings_page.dart b/lib/screens/settings/player_settings_page.dart index d42e2c2..08c65fe 100644 --- a/lib/screens/settings/player_settings_page.dart +++ b/lib/screens/settings/player_settings_page.dart @@ -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/video_player_settings.dart'; +import 'package:fladder/providers/connectivity_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_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/video_player/components/video_player_options_sheet.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/localization_helper.dart'; import 'package:fladder/util/option_dialogue.dart'; @@ -37,6 +39,9 @@ class _PlayerSettingsPageState extends ConsumerState { final provider = ref.read(videoPlayerSettingsProvider.notifier); final showBackground = AdaptiveLayout.viewSizeOf(context) != ViewSize.phone && AdaptiveLayout.layoutModeOf(context) != LayoutMode.single; + + final connectionState = ref.watch(connectivityStatusProvider); + return Card( elevation: showBackground ? 2 : 0, child: SettingsScaffold( @@ -80,6 +85,50 @@ class _PlayerSettingsPageState extends ConsumerState { ), ), ), + 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(), SettingsLabelDivider(label: context.localized.advanced), if (PlayerOptions.available.length != 1) @@ -205,3 +254,30 @@ class _PlayerSettingsPageState extends ConsumerState { ); } } + +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, + ], + ); + } +} diff --git a/lib/screens/video_player/components/video_player_options_sheet.dart b/lib/screens/video_player/components/video_player_options_sheet.dart index ab89962..ae35590 100644 --- a/lib/screens/video_player/components/video_player_options_sheet.dart +++ b/lib/screens/video_player/components/video_player_options_sheet.dart @@ -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/metadata/info_screen.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_subtitle_controls.dart'; import 'package:fladder/util/adaptive_layout.dart'; import 'package:fladder/util/device_orientation_extension.dart'; import 'package:fladder/util/list_padding.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/string_extensions.dart'; import 'package:fladder/widgets/shared/enum_selection.dart'; @@ -63,6 +65,7 @@ class _VideoOptionsMobileState extends ConsumerState { final currentItem = ref.watch(playBackModel.select((value) => value?.item)); final videoSettings = ref.watch(videoPlayerSettingsProvider); final currentMediaStreams = ref.watch(playBackModel.select((value) => value?.mediaStreams)); + final bitRateOptions = ref.watch(playBackModel.select((value) => value?.bitRateOptions)); Widget mainPage() { return ListView( @@ -202,6 +205,24 @@ class _VideoOptionsMobileState extends ConsumerState { ], ), ), + 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); + }, + ) ], ); } diff --git a/lib/screens/video_player/components/video_player_quality_controls.dart b/lib/screens/video_player/components/video_player_quality_controls.dart new file mode 100644 index 0000000..8d883bc --- /dev/null +++ b/lib/screens/video_player/components/video_player_quality_controls.dart @@ -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 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() ?? + [], + ), + ), + ], + ), + ); + } +} diff --git a/lib/screens/video_player/video_player_controls.dart b/lib/screens/video_player/video_player_controls.dart index 5ac9fbe..f0a42cc 100644 --- a/lib/screens/video_player/video_player_controls.dart +++ b/lib/screens/video_player/video_player_controls.dart @@ -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_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_quality_controls.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_volume_slider.dart'; @@ -270,6 +271,7 @@ class _DesktopControlsState extends ConsumerState { Widget bottomButtons(BuildContext context) { return Consumer(builder: (context, ref, child) { final mediaPlayback = ref.watch(mediaPlaybackProvider); + final bitRateOptions = ref.watch(playBackModel.select((value) => value?.bitRateOptions)); return Container( decoration: BoxDecoration( gradient: LinearGradient( @@ -370,13 +372,16 @@ class _DesktopControlsState extends ConsumerState { child: IconButton( onPressed: () => closePlayer(), icon: const Icon(IconsaxOutline.close_square))), const Spacer(), - if (AdaptiveLayout.of(context).inputDevice == InputDevice.pointer && + if (AdaptiveLayout.viewSizeOf(context) >= ViewSize.desktop && ref.read(videoPlayerProvider).hasPlayer) ...{ - // OpenQueueButton(x), - // ChapterButton( - // position: position, - // player: ref.read(videoPlayerProvider).player!, - // ), + if (bitRateOptions?.isNotEmpty == true) + Tooltip( + message: context.localized.qualityOptionsTitle, + child: IconButton( + onPressed: () => openQualityOptions(context), + icon: const Icon(IconsaxOutline.speedometer), + ), + ), Listener( onPointerSignal: (event) { if (event is PointerScrollEvent) { diff --git a/lib/util/bitrate_helper.dart b/lib/util/bitrate_helper.dart new file mode 100644 index 0000000..5e7daef --- /dev/null +++ b/lib/util/bitrate_helper.dart @@ -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 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.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, + ), + ); +} diff --git a/lib/util/string_extensions.dart b/lib/util/string_extensions.dart index d3b479f..18da0aa 100644 --- a/lib/util/string_extensions.dart +++ b/lib/util/string_extensions.dart @@ -31,13 +31,15 @@ extension StringExtensions on String { return buffer.toString(); } - String toUpperCaseSplit() { + String toUpperCaseSplit({RegExp? regExp}) { String result = ''; + RegExp defaultRegex = regExp ?? RegExp(r'^[a-zA-Z]+$'); + for (int i = 0; i < length; i++) { if (i == 0) { 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()}'; } else { result += this[i]; diff --git a/lib/widgets/shared/enum_selection.dart b/lib/widgets/shared/enum_selection.dart index 128259b..b37f2f7 100644 --- a/lib/widgets/shared/enum_selection.dart +++ b/lib/widgets/shared/enum_selection.dart @@ -1,5 +1,9 @@ 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 extends StatelessWidget { final String current; final List> Function(BuildContext context) itemBuilder; @@ -11,45 +15,62 @@ class EnumBox extends StatelessWidget { final textStyle = Theme.of(context).textTheme.titleMedium; const padding = EdgeInsets.symmetric(horizontal: 12, vertical: 6); 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( color: Theme.of(context).colorScheme.primaryContainer, shadowColor: Colors.transparent, elevation: 0, - child: PopupMenuButton( - tooltip: '', - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), - enabled: itemList.length > 1, - itemBuilder: itemBuilder, - padding: padding, - child: 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, - ), + child: useBottomSheet + ? FlatButton( + child: labelWidget, + onTap: () => showModalBottomSheet( + context: context, + builder: (context) => ListView( + shrinkWrap: true, + children: [ + const SizedBox(height: 6), + ...itemBuilder(context), + ], ), - const SizedBox(width: 6), - if (itemList.length > 1) - Icon( - Icons.keyboard_arrow_down, - color: Theme.of(context).colorScheme.onPrimaryContainer, - ) - ], + ), + ) + : PopupMenuButton( + tooltip: '', + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + enabled: itemList.length > 1, + itemBuilder: itemBuilder, + padding: padding, + child: labelWidget, ), - ), - ), - ), ); } } @@ -58,7 +79,8 @@ class EnumSelection extends StatelessWidget { final Text label; final String current; final List> Function(BuildContext context) itemBuilder; - const EnumSelection({super.key, + const EnumSelection({ + super.key, required this.label, required this.current, required this.itemBuilder, diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 0d50e8f..30ae31c 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -7,6 +7,7 @@ import Foundation import audio_service import audio_session +import connectivity_plus import desktop_drop import dynamic_color import file_picker @@ -32,6 +33,7 @@ import window_manager func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { AudioServicePlugin.register(with: registry.registrar(forPlugin: "AudioServicePlugin")) AudioSessionPlugin.register(with: registry.registrar(forPlugin: "AudioSessionPlugin")) + ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin")) DesktopDropPlugin.register(with: registry.registrar(forPlugin: "DesktopDropPlugin")) DynamicColorPlugin.register(with: registry.registrar(forPlugin: "DynamicColorPlugin")) FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) diff --git a/pubspec.lock b/pubspec.lock index b1f2375..206509e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -326,6 +326,22 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -1165,6 +1181,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" + nm: + dependency: transitive + description: + name: nm + sha256: "2c9aae4127bdc8993206464fcc063611e0e36e72018696cd9631023a31b24254" + url: "https://pub.dev" + source: hosted + version: "0.5.0" octo_image: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 6b4322e..40faa1a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -44,6 +44,7 @@ dependencies: cached_network_image: ^3.4.1 http: ^1.3.0 flutter_cache_manager: ^3.4.1 + connectivity_plus: ^6.1.3 # State Management flutter_riverpod: ^2.6.1 diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index bebddaf..3f9d4c1 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -6,6 +6,7 @@ #include "generated_plugin_registrant.h" +#include #include #include #include @@ -20,6 +21,8 @@ #include void RegisterPlugins(flutter::PluginRegistry* registry) { + ConnectivityPlusWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin")); DesktopDropPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("DesktopDropPlugin")); DynamicColorPluginCApiRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 0abcce9..beebea6 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + connectivity_plus desktop_drop dynamic_color fvp