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",
"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"
}

View file

@ -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,

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/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<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
List<SubStreamModel> get subStreams => [SubStreamModel.no(), ...mediaStreams?.subStreams ?? []];
@ -79,6 +50,11 @@ class DirectPlaybackModel implements PlaybackModel {
return copyWith(mediaStreams: () => mediaStreams?.copyWith(defaultAudioStreamIndex: newIndex));
}
@override
Future<DirectPlaybackModel>? setQualityOption(Map<Bitrate, bool> map) async {
return copyWith(bitRateOptions: map);
}
@override
Future<PlaybackModel?> 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<ItemBaseModel> queue;
@override
DirectPlaybackModel copyWith({
ItemBaseModel? item,
@ -156,6 +129,7 @@ class DirectPlaybackModel implements PlaybackModel {
ValueGetter<List<Chapter>?>? chapters,
ValueGetter<TrickPlayModel?>? trickPlay,
List<ItemBaseModel>? queue,
Map<Bitrate, bool>? 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,
);
}
}

View file

@ -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<Chapter>? get chapters => syncedItem.chapters;
@override
final TrickPlayModel? trickPlay;
@override
Future<Duration>? startDuration() async => item.userData.playBackPosition;
@ -118,9 +99,6 @@ class OfflinePlaybackModel implements PlaybackModel {
@override
String toString() => 'OfflinePlaybackModel(item: $item, syncedItem: $syncedItem)';
@override
final List<ItemBaseModel> queue;
final List<SyncedItem> syncedQueue;
@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/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<ItemBaseModel> queue = const [];
final MediaSegmentsModel? mediaSegments = null;
final PlaybackInfoResponse? playbackInfo = throw UnimplementedError();
class PlaybackModel {
final ItemBaseModel item;
final Media? media;
final List<ItemBaseModel> queue;
final MediaSegmentsModel? mediaSegments;
final PlaybackInfoResponse? playbackInfo;
List<Chapter>? get chapters;
TrickPlayModel? get trickPlay;
Map<Bitrate, bool> bitRateOptions;
List<Chapter>? chapters = [];
TrickPlayModel? trickPlay;
Future<PlaybackModel?> updatePlaybackPosition(Duration position, bool isPlaying, Ref ref) =>
throw UnimplementedError();
@ -68,21 +74,32 @@ abstract class PlaybackModel {
Future<PlaybackModel?> playbackStopped(Duration position, Duration? totalDuration, Ref ref) =>
throw UnimplementedError();
final MediaStreamsModel? mediaStreams = throw UnimplementedError();
List<SubStreamModel>? get subStreams;
List<AudioStreamModel>? get audioStreams;
final MediaStreamsModel? mediaStreams;
List<SubStreamModel>? get subStreams => throw UnimplementedError();
List<AudioStreamModel>? get audioStreams => throw UnimplementedError();
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 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<PlaybackModelHelper>((ref) {
@ -149,8 +166,13 @@ class PlaybackModelHelper {
}
}
Future<PlaybackModel?> createServerPlaybackModel(ItemBaseModel? item, PlaybackType? type,
{PlaybackModel? oldModel, List<ItemBaseModel>? libraryQueue, Duration? startPosition}) async {
Future<PlaybackModel?> createServerPlaybackModel(
ItemBaseModel? item,
PlaybackType? type, {
PlaybackModel? oldModel,
List<ItemBaseModel>? 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<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;
Response<PlaybackInfoResponse> response = await api.itemsItemIdPlaybackInfoPost(
final Response<PlaybackInfoResponse> 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<PlaybackInfoResponse> 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;

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/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<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
List<SubStreamModel> get subStreams => [SubStreamModel.no(), ...mediaStreams?.subStreams ?? []];
@ -79,6 +50,9 @@ class TranscodePlaybackModel implements PlaybackModel {
return copyWith(mediaStreams: () => mediaStreams?.copyWith(defaultAudioStreamIndex: newIndex));
}
@override
Future<TranscodePlaybackModel>? setQualityOption(Map<Bitrate, bool> map) async => copyWith(bitRateOptions: map);
@override
Future<PlaybackModel?> 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<ItemBaseModel> queue;
@override
TranscodePlaybackModel copyWith({
ItemBaseModel? item,
@ -157,6 +128,7 @@ class TranscodePlaybackModel implements PlaybackModel {
ValueGetter<List<Chapter>?>? chapters,
ValueGetter<TrickPlayModel?>? trickPlay,
List<ItemBaseModel>? queue,
Map<Bitrate, bool>? 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,
);
}
}

View file

@ -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 {

View file

@ -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<DeviceOrientation>? allowedOrientations,
@Default(AutoNextType.smart) AutoNextType nextVideoType,
@Default(Bitrate.original) Bitrate maxHomeBitrate,
@Default(Bitrate.original) Bitrate maxInternetBitrate,
String? audioDevice,
}) = _VideoPlayerSettingsModel;

View file

@ -31,6 +31,8 @@ mixin _$VideoPlayerSettingsModel {
Set<DeviceOrientation>? 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<DeviceOrientation>? 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<DeviceOrientation>? 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<DeviceOrientation>? 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<DeviceOrientation>? 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

View file

@ -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<String, dynamic> _$$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',
};

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({
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,
);

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/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<PlayerSettingsPage> {
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<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(),
SettingsLabelDivider(label: context.localized.advanced),
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/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<VideoOptions> {
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<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_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<DesktopControls> {
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<DesktopControls> {
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) {

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

View file

@ -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<T> extends StatelessWidget {
final String current;
final List<PopupMenuEntry<T>> Function(BuildContext context) itemBuilder;
@ -11,45 +15,62 @@ class EnumBox<T> 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<T> extends StatelessWidget {
final Text label;
final String current;
final List<PopupMenuEntry<T>> Function(BuildContext context) itemBuilder;
const EnumSelection({super.key,
const EnumSelection({
super.key,
required this.label,
required this.current,
required this.itemBuilder,

View file

@ -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"))

View file

@ -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:

View file

@ -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

View file

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

View file

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