import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:collection/collection.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:iconsax_plus/iconsax_plus.dart'; import 'package:fladder/models/item_base_model.dart'; import 'package:fladder/models/items/episode_model.dart'; import 'package:fladder/models/playback/direct_playback_model.dart'; import 'package:fladder/models/playback/offline_playback_model.dart'; import 'package:fladder/models/playback/playback_model.dart'; import 'package:fladder/models/playback/transcode_playback_model.dart'; import 'package:fladder/models/settings/video_player_settings.dart'; import 'package:fladder/providers/settings/video_player_settings_provider.dart'; import 'package:fladder/providers/user_provider.dart'; 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/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'; import 'package:fladder/widgets/shared/fladder_slider.dart'; import 'package:fladder/widgets/shared/item_actions.dart'; import 'package:fladder/widgets/shared/modal_bottom_sheet.dart'; import 'package:fladder/widgets/shared/spaced_list_tile.dart'; Future showVideoPlayerOptions(BuildContext context, Function() minimizePlayer) { return showBottomSheetPill( context: context, content: (context, scrollController) { return VideoOptions( controller: scrollController, minimizePlayer: minimizePlayer, ); }, ); } class VideoOptions extends ConsumerStatefulWidget { final ScrollController controller; final Function() minimizePlayer; const VideoOptions({required this.controller, required this.minimizePlayer, super.key}); @override ConsumerState createState() => _VideoOptionsMobileState(); } class _VideoOptionsMobileState extends ConsumerState { late int page = 0; @override Widget build(BuildContext context) { 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( key: const Key("mainPage"), shrinkWrap: true, controller: widget.controller, children: [ InkWell( onTap: () => setState(() => page = 2), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: Row( children: [ Column( mainAxisSize: MainAxisSize.max, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( currentItem?.title ?? "", style: Theme.of(context).textTheme.titleLarge, ), ], ), const Spacer(), const Opacity(opacity: 0.1, child: Icon(Icons.info_outline_rounded)) ], ), ), ), const SizedBox(height: 12), const Divider(height: 1), const SizedBox(height: 12), if (!AdaptiveLayout.of(context).isDesktop) ListTile( title: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Flexible(flex: 1, child: Text(context.localized.screenBrightness)), Flexible( child: Row( children: [ Flexible( child: Opacity( opacity: videoSettings.screenBrightness == null ? 0.5 : 1, child: Slider( value: videoSettings.screenBrightness ?? 1.0, min: 0, max: 1, onChanged: (value) => ref.read(videoPlayerSettingsProvider.notifier).setScreenBrightness(value), ), ), ), IconButton( onPressed: () => ref.read(videoPlayerSettingsProvider.notifier).setScreenBrightness(null), icon: Opacity( opacity: videoSettings.screenBrightness != null ? 0.5 : 1, child: Icon( IconsaxPlusBold.autobrightness, color: Theme.of(context).colorScheme.primary, ), ), ) ], ), ), ], ), ), SpacedListTile( title: Text(context.localized.subtitles), content: Text(currentMediaStreams?.currentSubStream?.label(context) ?? context.localized.off), onTap: currentMediaStreams?.subStreams.isNotEmpty == true ? () => showSubSelection(context) : null, ), SpacedListTile( title: Text(context.localized.audio), content: Text(currentMediaStreams?.currentAudioStream?.label(context) ?? context.localized.off), onTap: currentMediaStreams?.audioStreams.isNotEmpty == true ? () => showAudioSelection(context) : null, ), ListTile( title: Row( mainAxisSize: MainAxisSize.min, children: [ Flexible( child: EnumSelection( label: Text(context.localized.scale), current: videoSettings.videoFit.name.toUpperCaseSplit(), itemBuilder: (context) => BoxFit.values .map((value) => ItemActionButton( label: Text(value.name.toUpperCaseSplit()), action: () => ref.read(videoPlayerSettingsProvider.notifier).setFitType(value), )) .toList(), ), ), ], ), ), if (!AdaptiveLayout.of(context).isDesktop) ListTile( onTap: () => ref.read(videoPlayerSettingsProvider.notifier).setFillScreen(!videoSettings.fillScreen), title: Row( mainAxisSize: MainAxisSize.min, children: [ Expanded( flex: 3, child: Text(context.localized.videoScalingFill), ), const Spacer(), Switch( value: videoSettings.fillScreen, onChanged: (value) => ref.read(videoPlayerSettingsProvider.notifier).setFillScreen(value), ) ], ), ), if (!AdaptiveLayout.of(context).isDesktop && !kIsWeb) SpacedListTile( title: Text(context.localized.playerSettingsOrientationTitle), onTap: () => showOrientationOptions(context, ref), ), ListTile( onTap: () { Navigator.of(context).pop(); showPlaybackSpeed(context); }, title: Row( mainAxisSize: MainAxisSize.min, children: [ Expanded( flex: 3, child: Text(context.localized.playbackRate), ), const Spacer(), Text("x${ref.watch(playbackRateProvider)}") ], ), ), 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); }, ) ], ); } Widget playbackSettings() { final playbackState = ref.watch(playBackModel); return ListView( key: const Key("PlaybackSettings"), shrinkWrap: true, controller: widget.controller, children: [ navTitle(context.localized.playBackSettings, null), if (playbackState?.queue.isNotEmpty == true) ListTile( leading: const Icon(Icons.video_collection_rounded), title: const Text("Show queue"), onTap: () { Navigator.of(context).pop(); ref.read(videoPlayerProvider).pause(); showFullScreenItemQueue( context, items: playbackState?.queue ?? [], currentItem: playbackState?.item, playSelected: (item) { throw UnimplementedError(); }, ); }, ) ], ); } return Column( children: [ AnimatedSize( duration: const Duration(milliseconds: 250), child: AnimatedSwitcher( duration: const Duration(milliseconds: 250), child: switch (page) { 1 => playbackSettings(), 2 => itemInfo(currentItem, context), _ => mainPage(), }, ), ), const SizedBox(height: 16), ], ); } ListView itemInfo(ItemBaseModel? currentItem, BuildContext context) { return ListView( shrinkWrap: true, controller: widget.controller, children: [ navTitle(currentItem?.title, currentItem?.subTextShort(context)), if (currentItem != null) ...{ if (currentItem.type == FladderItemType.episode) ListTile( onTap: () { Navigator.of(context).pop(); widget.minimizePlayer(); (this as EpisodeModel).parentBaseModel.navigateTo(context); }, title: Text(context.localized.openShow), ), ListTile( onTap: () async { Navigator.of(context).pop(); widget.minimizePlayer(); await currentItem.navigateTo(context); }, title: Text(context.localized.showDetails), ), if (currentItem.type != FladderItemType.boxset) ListTile( onTap: () async { await addItemToCollection(context, [currentItem]); if (context.mounted) { context.refreshData(); } }, title: Text(context.localized.addToCollection), ), if (currentItem.type != FladderItemType.playlist) ListTile( onTap: () async { await addItemToPlaylist(context, [currentItem]); if (context.mounted) { context.refreshData(); } }, title: Text(context.localized.addToPlaylist), ), ListTile( onTap: () async { final response = await ref .read(userProvider.notifier) .setAsFavorite(!(currentItem.userData.isFavourite == true), currentItem.id); final newItem = currentItem.copyWith(userData: response?.body); final playbackModel = switch (ref.read(playBackModel)) { DirectPlaybackModel value => value.copyWith(item: newItem), TranscodePlaybackModel value => value.copyWith(item: newItem), OfflinePlaybackModel value => value.copyWith(item: newItem), _ => null }; ref.read(playBackModel.notifier).update((state) => playbackModel); Navigator.of(context).pop(); }, title: Text(currentItem.userData.isFavourite == true ? context.localized.removeAsFavorite : context.localized.addAsFavorite), ), ListTile( onTap: () { Navigator.of(context).pop(); showInfoScreen(context, currentItem); }, title: Text(context.localized.info), ), } ], ); } Widget navTitle(String? title, String? subText) { return Column( children: [ Row( children: [ const SizedBox(width: 8), BackButton( onPressed: () => setState(() => page = 0), ), const SizedBox(width: 16), Column( children: [ if (title != null) Text( title, style: Theme.of(context).textTheme.titleLarge, ), if (subText != null) Text( subText, style: Theme.of(context).textTheme.titleMedium, ) ], ), ], ), const SizedBox(height: 12), const Divider(height: 1), const SizedBox(height: 12), ], ); } } Future showSubSelection(BuildContext context) { return showDialog( context: context, builder: (context) { return Consumer( builder: (context, ref, child) { final playbackModel = ref.watch(playBackModel); final player = ref.watch(videoPlayerProvider); return SimpleDialog( contentPadding: const EdgeInsets.only(top: 8, bottom: 24), title: Row( children: [ Text(context.localized.subtitle), const Spacer(), if (player.backend == PlayerOptions.libMPV) IconButton.outlined( onPressed: () { Navigator.pop(context); showSubtitleControls( context: context, label: context.localized.subtitleConfiguration, ); }, icon: const Icon(Icons.display_settings_rounded)) ], ), children: playbackModel?.subStreams?.mapIndexed( (index, subModel) { final selected = playbackModel.mediaStreams?.defaultSubStreamIndex == subModel.index; return ListTile( title: Text(subModel.label(context)), tileColor: selected ? Theme.of(context).colorScheme.primary.withValues(alpha: 0.3) : null, subtitle: subModel.language.isNotEmpty ? Opacity(opacity: 0.6, child: Text(subModel.language.capitalize())) : null, onTap: () async { final newModel = await playbackModel.setSubtitle(subModel, player); ref.read(playBackModel.notifier).update((state) => newModel); if (newModel != null) { await ref.read(playbackModelHelper).shouldReload(newModel); } }, ); }, ).toList(), ); }, ); }, ); } Future showAudioSelection(BuildContext context) { return showDialog( context: context, builder: (context) { return Consumer( builder: (context, ref, child) { final playbackModel = ref.watch(playBackModel); final player = ref.watch(videoPlayerProvider); return SimpleDialog( contentPadding: const EdgeInsets.only(top: 8, bottom: 24), title: Row( children: [ Text(context.localized.audio), ], ), children: playbackModel?.audioStreams?.mapIndexed( (index, audioStream) { final selected = playbackModel.mediaStreams?.defaultAudioStreamIndex == audioStream.index; return ListTile( title: Text(audioStream.label(context)), tileColor: selected ? Theme.of(context).colorScheme.primary.withValues(alpha: 0.3) : null, subtitle: audioStream.language.isNotEmpty ? Opacity(opacity: 0.6, child: Text(audioStream.language.capitalize())) : null, onTap: () async { final newModel = await playbackModel.setAudio(audioStream, player); ref.read(playBackModel.notifier).update((state) => newModel); if (newModel != null) { await ref.read(playbackModelHelper).shouldReload(newModel); } }); }, ).toList(), ); }, ); }, ); } Future showPlaybackSpeed(BuildContext context) { return showDialog( context: context, builder: (context) { return StatefulBuilder(builder: (context, setState) { return Consumer( builder: (context, ref, child) { final player = ref.watch(videoPlayerProvider); final lastSpeed = ref.watch(playbackRateProvider); return SimpleDialog( contentPadding: const EdgeInsets.only(top: 8, bottom: 24), title: Row(children: [Text(context.localized.playbackRate)]), children: [ const Divider(), Padding( padding: const EdgeInsets.symmetric(horizontal: 12).copyWith(top: 6), child: Row( mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center, children: [ Text("${context.localized.speed}: "), Flexible( child: SizedBox( width: 250, child: FladderSlider( min: 0.25, max: 3, value: lastSpeed, divisions: 55, onChanged: (value) { ref.read(playbackRateProvider.notifier).state = value; player.setSpeed(value); }, ), ), ), Text("x${lastSpeed.toStringAsFixed(2)}") ].addInBetween(const SizedBox(width: 8)), ), ) ], ); }, ); }); }, ); } Future showOrientationOptions(BuildContext context, WidgetRef ref) async { Set orientations = ref .read(videoPlayerSettingsProvider .select((value) => value.allowedOrientations ?? Set.from(DeviceOrientation.values))) .toSet(); void toggleOrientation(DeviceOrientation orientation) { if (orientations.contains(orientation) && orientations.length > 1) { orientations.remove(orientation); } else { orientations.add(orientation); } } await showDialog( context: context, builder: (context) { return StatefulBuilder(builder: (context, state) { return SimpleDialog( contentPadding: const EdgeInsets.only(top: 8, bottom: 24), title: Row(children: [Text(context.localized.playerSettingsOrientationTitle)]), children: [ Padding( padding: const EdgeInsets.symmetric(horizontal: 12).copyWith(top: 6), child: Column( mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center, children: [ const Divider(), ...DeviceOrientation.values.map( (orientation) => CheckboxListTile( title: Text(orientation.label(context)), value: orientations.contains(orientation), onChanged: (value) { state(() => toggleOrientation(orientation)); }, ), ), const Divider(), Row( mainAxisSize: MainAxisSize.max, mainAxisAlignment: MainAxisAlignment.end, children: [ ElevatedButton( onPressed: () => Navigator.of(context).pop(), child: Text(context.localized.cancel), ), FilledButton( onPressed: () { Navigator.of(context).pop(); ref.read(videoPlayerSettingsProvider.notifier).toggleOrientation(orientations); }, child: Text(context.localized.save), ), ].addInBetween(const SizedBox(width: 8)), ) ].addInBetween(const SizedBox(width: 8)), ), ) ], ); }); }, ); }