mirror of
https://github.com/gabehf/Fladder.git
synced 2026-03-07 13:38:13 -08:00
feature: Video quality options (#234)
Co-authored-by: PartyDonut <PartyDonut@users.noreply.github.com>
This commit is contained in:
parent
957ad6c991
commit
935d6fe176
25 changed files with 644 additions and 232 deletions
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
};
|
||||
|
|
|
|||
39
lib/providers/connectivity_provider.dart
Normal file
39
lib/providers/connectivity_provider.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
27
lib/providers/connectivity_provider.g.dart
Normal file
27
lib/providers/connectivity_provider.g.dart
Normal 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
|
||||
|
|
@ -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,
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() ??
|
||||
[],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
90
lib/util/bitrate_helper.dart
Normal file
90
lib/util/bitrate_helper.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
24
pubspec.lock
24
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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
connectivity_plus
|
||||
desktop_drop
|
||||
dynamic_color
|
||||
fvp
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue