Init repo

This commit is contained in:
PartyDonut 2024-09-15 14:12:28 +02:00
commit 764b6034e3
566 changed files with 212335 additions and 0 deletions

View file

@ -0,0 +1,77 @@
import 'package:fladder/models/playback/playback_model.dart';
import 'package:fladder/providers/session_info_provider.dart';
import 'package:fladder/providers/video_player_provider.dart';
import 'package:fladder/util/list_padding.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
Future<void> showVideoPlaybackInformation(BuildContext context) {
return showDialog(
context: context,
builder: (context) => _VideoPlaybackInformation(),
);
}
class _VideoPlaybackInformation extends ConsumerWidget {
const _VideoPlaybackInformation();
@override
Widget build(BuildContext context, WidgetRef ref) {
final playbackModel = ref.watch(playBackModel);
final sessionInfo = ref.watch(sessionInfoProvider);
return Dialog(
child: Padding(
padding: const EdgeInsets.all(12.0),
child: IntrinsicWidth(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text("Playback information", style: Theme.of(context).textTheme.titleMedium),
Divider(),
...[
Row(
mainAxisSize: MainAxisSize.min,
children: [Text('type: '), Text(playbackModel.label ?? "")],
),
if (sessionInfo.transCodeInfo != null) ...[
const SizedBox(height: 6),
Text("Transcoding", style: Theme.of(context).textTheme.titleMedium),
if (sessionInfo.transCodeInfo?.transcodeReasons?.isNotEmpty == true)
Row(
mainAxisSize: MainAxisSize.min,
children: [Text('reason: '), Text(sessionInfo.transCodeInfo?.transcodeReasons.toString() ?? "")],
),
if (sessionInfo.transCodeInfo?.completionPercentage != null)
Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('transcode progress: '),
Text("${sessionInfo.transCodeInfo?.completionPercentage?.toStringAsFixed(2)} %")
],
),
if (sessionInfo.transCodeInfo?.container != null)
Row(
mainAxisSize: MainAxisSize.min,
children: [Text('container: '), Text(sessionInfo.transCodeInfo!.container.toString())],
),
],
Row(
mainAxisSize: MainAxisSize.min,
children: [Text('resolution: '), Text(playbackModel?.item.streamModel?.resolutionText ?? "")],
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('container: '),
Text(playbackModel?.playbackInfo?.mediaSources?.firstOrNull?.container ?? "")
],
),
].addPadding(EdgeInsets.symmetric(vertical: 3))
],
),
),
),
);
}
}

View file

@ -0,0 +1,92 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:fladder/models/items/chapters_model.dart';
import 'package:fladder/screens/shared/flat_button.dart';
import 'package:fladder/widgets/shared/horizontal_list.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
showPlayerChapterDialogue(
BuildContext context, {
required List<Chapter> chapters,
required Function(Chapter chapter) onChapterTapped,
required Duration currentPosition,
}) {
showDialog(
context: context,
builder: (context) => Dialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(15),
),
child: VideoPlayerChapters(
chapters: chapters,
onChapterTapped: onChapterTapped,
currentPosition: currentPosition,
),
),
);
}
class VideoPlayerChapters extends ConsumerWidget {
final List<Chapter> chapters;
final Function(Chapter chapter) onChapterTapped;
final Duration currentPosition;
const VideoPlayerChapters(
{required this.chapters, required this.onChapterTapped, required this.currentPosition, super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final currentChapter = chapters.getChapterFromDuration(currentPosition);
return SizedBox(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: HorizontalList(
label: "Chapters",
height: 200,
startIndex: chapters.indexOf(currentChapter ?? chapters.first),
contentPadding: const EdgeInsets.symmetric(horizontal: 32),
items: chapters.toList(),
itemBuilder: (context, index) {
final chapter = chapters[index];
final isCurrent = chapter == currentChapter;
return Card(
color: Colors.black,
clipBehavior: Clip.antiAlias,
child: Stack(
children: [
Center(
child: CachedNetworkImage(
imageUrl: chapter.imageUrl,
fit: BoxFit.fitWidth,
),
),
Align(
alignment: Alignment.bottomRight,
child: Card(
color: isCurrent ? Theme.of(context).colorScheme.onPrimary : null,
margin: const EdgeInsets.all(8),
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
chapter.name,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.bold),
),
),
),
),
Positioned.fill(
child: FlatButton(
onTap: () {
Navigator.of(context).pop();
onChapterTapped(chapter);
},
),
),
],
),
);
},
),
),
);
}
}

View file

@ -0,0 +1,111 @@
import 'package:fladder/providers/video_player_provider.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:media_kit/media_kit.dart';
import 'package:fladder/screens/video_player/components/video_player_chapters.dart';
import 'package:fladder/screens/video_player/components/video_player_queue.dart';
class ChapterButton extends ConsumerWidget {
final Duration position;
final Player player;
const ChapterButton({super.key, required this.position, required this.player});
@override
Widget build(BuildContext context, WidgetRef ref) {
final currentChapters = ref.watch(playBackModel.select((value) => value?.chapters));
if (currentChapters != null) {
return IconButton(
onPressed: () {
showPlayerChapterDialogue(
context,
chapters: currentChapters,
currentPosition: position,
onChapterTapped: (chapter) => player.seek(
chapter.startPosition,
),
);
},
icon: const Icon(
Icons.video_collection_rounded,
),
);
} else {
return Container();
}
}
}
class OpenQueueButton extends ConsumerWidget {
const OpenQueueButton({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(playBackModel);
return IconButton(
onPressed: state?.queue.isNotEmpty == true
? () {
ref.read(videoPlayerProvider).pause();
showFullScreenItemQueue(
context,
items: state?.queue ?? [],
currentItem: state?.item,
playSelected: (itemStreamModel) {
throw UnimplementedError();
},
);
}
: null,
icon: const Icon(Icons.view_list_rounded),
);
}
}
class IntroSkipButton extends ConsumerWidget {
final bool isOverlayVisible;
final Function()? skipIntro;
const IntroSkipButton({this.skipIntro, required this.isOverlayVisible, super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return ElevatedButton(
onPressed: () => skipIntro?.call(),
style: ElevatedButton.styleFrom(shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5))),
child: const Padding(
padding: EdgeInsets.all(8),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [Text("(S)kip Intro"), Icon(Icons.skip_next_rounded)],
),
),
);
}
}
class CreditsSkipButton extends ConsumerWidget {
final bool isOverlayVisible;
final Function()? skipCredits;
const CreditsSkipButton({this.skipCredits, required this.isOverlayVisible, super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// final semiHideSkip = skipCredits.
return AnimatedOpacity(
opacity: 1,
duration: const Duration(milliseconds: 250),
child: ElevatedButton(
onPressed: () => skipCredits?.call(),
style: ElevatedButton.styleFrom(shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5))),
child: const Padding(
padding: EdgeInsets.all(8),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [Text("(S)kip Credits"), Icon(Icons.skip_next_rounded)],
),
),
),
);
}
}

View file

@ -0,0 +1,430 @@
import 'package:collection/collection.dart';
import 'package:ficonsax/ficonsax.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/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_queue.dart';
import 'package:fladder/screens/video_player/components/video_subtitle_controls.dart';
import 'package:fladder/util/adaptive_layout.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/modal_bottom_sheet.dart';
import 'package:fladder/widgets/shared/spaced_list_tile.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
Future<void> showVideoPlayerOptions(BuildContext context) {
return showBottomSheetPill(
context: context,
content: (context, scrollController) {
return VideoOptions(
controller: scrollController,
);
},
);
}
class VideoOptions extends ConsumerStatefulWidget {
final ScrollController controller;
const VideoOptions({required this.controller, super.key});
@override
ConsumerState<ConsumerStatefulWidget> createState() => _VideoOptionsMobileState();
}
class _VideoOptionsMobileState extends ConsumerState<VideoOptions> {
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));
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: [
if (currentItem?.title.isNotEmpty == true)
Text(
currentItem?.title ?? "",
style: Theme.of(context).textTheme.titleLarge,
),
if (currentItem?.detailedName(context)?.isNotEmpty == true)
Text(
currentItem?.detailedName(context) ?? "",
style: Theme.of(context).textTheme.titleSmall,
)
],
),
const Spacer(),
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: const Text("Screen Brightness")),
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(
IconsaxBold.autobrightness,
color: Theme.of(context).colorScheme.primary,
),
),
)
],
),
),
],
),
),
SpacedListTile(
title: const Text("Subtitles"),
content: Text(currentMediaStreams?.currentSubStream?.displayTitle ?? "Off"),
onTap: currentMediaStreams?.subStreams.isNotEmpty == true ? () => showSubSelection(context) : null,
),
SpacedListTile(
title: const Text("Audio"),
content: Text(currentMediaStreams?.currentAudioStream?.displayTitle ?? "Off"),
onTap: currentMediaStreams?.audioStreams.isNotEmpty == true ? () => showAudioSelection(context) : null,
),
ListTile(
title: Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: EnumSelection(
label: const Text("Scale"),
current: videoSettings.videoFit.name.toUpperCaseSplit(),
itemBuilder: (context) => BoxFit.values
.map((value) => PopupMenuItem(
value: value,
child: Text(value.name.toUpperCaseSplit()),
onTap: () => 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: [
const Expanded(
flex: 3,
child: Text("Fill-screen"),
),
const Spacer(),
Switch.adaptive(
value: videoSettings.fillScreen,
onChanged: (value) => ref.read(videoPlayerSettingsProvider.notifier).setFillScreen(value),
)
],
),
),
// ListTile(
// title: const Text("Playback settings"),
// onTap: () => setState(() => page = 1),
// ),
],
);
}
Widget itemOptions() {
final currentItem = ref.watch(playBackModel.select((value) => value?.item));
return ListView(
shrinkWrap: true,
children: [
navTitle("${currentItem?.title} \n${currentItem?.detailedName}"),
if (currentItem != null) ...{
if (currentItem.type == FladderItemType.episode)
ListTile(
onTap: () {
//Pop twice once for sheet once for player
Navigator.of(context).pop();
Navigator.of(context).pop();
(this as EpisodeModel).parentBaseModel.navigateTo(context);
},
title: const Text("Open show"),
),
ListTile(
onTap: () async {
//Pop twice once for sheet once for player
Navigator.of(context).pop();
Navigator.of(context).pop();
await currentItem.navigateTo(context);
},
title: const Text("Show details"),
),
if (currentItem.type != FladderItemType.boxset)
ListTile(
onTap: () async {
await addItemToCollection(context, [currentItem]);
if (context.mounted) {
context.refreshData();
}
},
title: const Text("Add to collection"),
),
if (currentItem.type != FladderItemType.playlist)
ListTile(
onTap: () async {
await addItemToPlaylist(context, [currentItem]);
if (context.mounted) {
context.refreshData();
}
},
title: const Text("Add to playlist"),
),
ListTile(
onTap: () {
final favourite = !(currentItem.userData.isFavourite == true);
ref.read(userProvider.notifier).setAsFavorite(favourite, currentItem.id);
final newUserData = currentItem.userData;
final playbackModel = switch (ref.read(playBackModel)) {
DirectPlaybackModel value => value.copyWith(item: currentItem.copyWith(userData: newUserData)),
TranscodePlaybackModel value => value.copyWith(item: currentItem.copyWith(userData: newUserData)),
OfflinePlaybackModel value => value.copyWith(item: currentItem.copyWith(userData: newUserData)),
_ => null
};
if (playbackModel != null) {
ref.read(playBackModel.notifier).update((state) => playbackModel);
}
Navigator.of(context).pop();
},
title: Text(currentItem.userData.isFavourite == true ? "Remove from favorites" : "Add to favourites"),
),
ListTile(
onTap: () {
Navigator.of(context).pop();
showInfoScreen(context, currentItem);
},
title: Text('Media info'),
),
}
],
);
}
Widget playbackSettings() {
final playbackState = ref.watch(playBackModel);
return ListView(
key: const Key("PlaybackSettings"),
shrinkWrap: true,
controller: widget.controller,
children: [
navTitle("Playback Settings"),
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 => itemOptions(),
_ => mainPage(),
},
),
),
const SizedBox(height: 16),
],
);
}
Widget navTitle(String title) {
return Column(
children: [
Row(
children: [
const SizedBox(width: 8),
BackButton(
onPressed: () => setState(() => page = 0),
),
const SizedBox(width: 16),
Text(
title,
style: Theme.of(context).textTheme.titleLarge,
)
],
),
const SizedBox(height: 12),
const Divider(height: 1),
const SizedBox(height: 12),
],
);
}
}
Future<void> 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: EdgeInsets.only(top: 8, bottom: 24),
title: Row(
children: [
const Text("Subtitle"),
const Spacer(),
IconButton.outlined(
onPressed: () {
Navigator.pop(context);
showSubtitleControls(
context: context,
label: 'Subtitle configuration',
);
},
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.displayTitle),
tileColor: selected ? Theme.of(context).colorScheme.primary.withOpacity(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<void> 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: EdgeInsets.only(top: 8, bottom: 24),
title: Row(
children: [
const Text("Subtitle"),
const Spacer(),
IconButton.outlined(
onPressed: () {
Navigator.pop(context);
showSubtitleControls(
context: context,
label: 'Subtitle configuration',
);
},
icon: const Icon(Icons.display_settings_rounded))
],
),
children: playbackModel?.audioStreams?.mapIndexed(
(index, audioStream) {
final selected = playbackModel.mediaStreams?.defaultAudioStreamIndex == audioStream.index;
return ListTile(
title: Text(audioStream.displayTitle),
tileColor: selected ? Theme.of(context).colorScheme.primary.withOpacity(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(),
);
},
);
},
);
}

View file

@ -0,0 +1,153 @@
import 'package:fladder/models/item_base_model.dart';
import 'package:fladder/screens/shared/media/item_detail_list_widget.dart';
import 'package:fladder/util/widget_extensions.dart';
import 'package:fladder/widgets/navigation_scaffold/components/fladder_appbar.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
showFullScreenItemQueue(
BuildContext context, {
required List<ItemBaseModel> items,
ValueChanged<List<ItemBaseModel>>? onListChanged,
Function(ItemBaseModel itemStreamModel)? playSelected,
ItemBaseModel? currentItem,
}) {
showDialog(
useSafeArea: false,
useRootNavigator: true,
context: context,
builder: (context) {
return Dialog.fullscreen(
child: VideoPlayerQueue(
items: items,
currentItem: currentItem,
playSelected: playSelected,
),
);
},
);
}
class VideoPlayerQueue extends ConsumerStatefulWidget {
final List<ItemBaseModel> items;
final ItemBaseModel? currentItem;
final Function(ItemBaseModel)? playSelected;
final ValueChanged<List<ItemBaseModel>>? onListChanged;
const VideoPlayerQueue({super.key, required this.items, this.currentItem, this.playSelected, this.onListChanged});
@override
ConsumerState<ConsumerStatefulWidget> createState() => _VideoPlayerQueueState();
}
class _VideoPlayerQueueState extends ConsumerState<VideoPlayerQueue> {
late final List<ItemBaseModel> items = widget.items;
final controller = ScrollController();
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: const FladderAppbar(
label: "",
automaticallyImplyLeading: true,
),
body: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 64),
child: Row(
children: [
const BackButton(),
Column(
mainAxisSize: MainAxisSize.max,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"Queue",
style: Theme.of(context).textTheme.headlineLarge?.copyWith(fontWeight: FontWeight.bold),
),
Opacity(
opacity: 0.5,
child: Text(
"${items.length} items",
style: Theme.of(context).textTheme.titleMedium,
),
),
],
),
],
),
),
const Divider(),
Flexible(
child: ReorderableListView.builder(
shrinkWrap: true,
padding: const EdgeInsets.symmetric(horizontal: 64).copyWith(bottom: 64),
scrollController: controller,
itemCount: items.length,
proxyDecorator: (child, index, animation) {
return child;
},
onReorder: (int oldIndex, int newIndex) {
setState(() {
if (oldIndex < newIndex) {
newIndex -= 1;
}
final ItemBaseModel item = items.removeAt(oldIndex);
items.insert(newIndex, item);
});
// ref.read(videoPlaybackProvider.notifier).setQueue(items);
},
itemBuilder: (context, index) {
final item = items[index];
final isCurrentItem = item.id == (widget.currentItem?.id ?? "");
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: SizedBox(
height: 125,
child: ItemDetailListWidget(
item: item,
elevation: isCurrentItem ? 50 : 1,
iconOverlay: !isCurrentItem
? IconButton(
onPressed: () {
widget.playSelected?.call(item);
Navigator.of(context).pop();
},
iconSize: 80,
icon: const Icon(
Icons.play_arrow_rounded,
),
)
: const IconButton(
onPressed: null,
iconSize: 80,
icon: Icon(Icons.play_arrow_rounded),
),
actions: [
if (!isCurrentItem)
IconButton(
onPressed: () {
setState(() {
items.remove(item);
});
// ref.read(videoPlaybackProvider.notifier).setQueue(items);
},
icon: const Icon(
Icons.delete_rounded,
),
)
],
),
),
).setKey(
Key('$index'),
);
},
),
),
],
),
);
}
}

View file

@ -0,0 +1,414 @@
import 'dart:math' as math;
import 'package:collection/collection.dart';
import 'package:fladder/models/items/chapters_model.dart';
import 'package:fladder/models/items/intro_skip_model.dart';
import 'package:fladder/providers/video_player_provider.dart';
import 'package:fladder/util/duration_extensions.dart';
import 'package:fladder/util/list_padding.dart';
import 'package:fladder/util/string_extensions.dart';
import 'package:fladder/widgets/gapped_container_shape.dart';
import 'package:fladder/widgets/shared/fladder_slider.dart';
import 'package:fladder/widgets/shared/trickplay_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class ChapterProgressSlider extends ConsumerStatefulWidget {
final Function(bool value) wasPlayingChanged;
final bool wasPlaying;
final VoidCallback timerReset;
final Duration duration;
final Duration position;
final bool buffering;
final Duration buffer;
final Function(Duration duration) onPositionChanged;
const ChapterProgressSlider({
required this.wasPlayingChanged,
required this.wasPlaying,
required this.timerReset,
required this.onPositionChanged,
required this.duration,
required this.position,
required this.buffering,
required this.buffer,
super.key,
});
@override
ConsumerState<ConsumerStatefulWidget> createState() => _ChapterProgressSliderState();
}
class _ChapterProgressSliderState extends ConsumerState<ChapterProgressSlider> {
bool onHoverStart = false;
bool onDragStart = false;
double _chapterPosition = 0.0;
double imageBottomOffset = 0.0;
double chapterCardWidth = 250;
Duration currentDuration = Duration.zero;
@override
Widget build(BuildContext context) {
final List<Chapter> chapters = ref.read(playBackModel.select((value) => value?.chapters ?? []));
final isVisible = (onDragStart ? true : onHoverStart);
final player = ref.watch(videoPlayerProvider);
final position = onDragStart ? currentDuration : widget.position;
final IntroOutSkipModel? introOutro = ref.read(playBackModel.select((value) => value?.introSkipModel));
final relativeFraction = position.inMilliseconds / widget.duration.inMilliseconds;
return LayoutBuilder(
builder: (context, constraints) {
final sliderHeight = SliderTheme.of(context).trackHeight ?? (constraints.maxHeight / 3);
final bufferWidth = calculateFracionWidth(constraints, widget.buffer);
final bufferFraction = relativeFraction / (bufferWidth / constraints.maxWidth);
return Stack(
clipBehavior: Clip.none,
children: [
Align(
alignment: Alignment.center,
child: MouseRegion(
opaque: !widget.buffering,
onHover: (event) {
setState(() {
onHoverStart = true;
_updateSliderPosition(event.localPosition.dx, constraints.maxWidth);
});
},
onExit: (event) {
setState(() {
onHoverStart = false;
});
},
child: Listener(
onPointerDown: (event) {
setState(() {
onDragStart = true;
_updateSliderPosition(event.localPosition.dx, constraints.maxWidth);
});
},
onPointerMove: (event) {
_updateSliderPosition(event.localPosition.dx, constraints.maxWidth);
},
onPointerUp: (_) {
setState(() {
onDragStart = false;
});
},
child: Opacity(
opacity: widget.buffering ? 0 : 1.0,
child: FladderSlider(
min: 0.0,
max: widget.duration.inMilliseconds.toDouble(),
animation: Duration.zero,
thumbWidth: 10.0,
showThumb: false,
value: (position.inMilliseconds).toDouble().clamp(
0,
widget.duration.inMilliseconds.toDouble(),
),
onChangeEnd: (e) async {
currentDuration = Duration(milliseconds: e.toInt());
await player.seek(Duration(milliseconds: e ~/ 1));
await Future.delayed(const Duration(milliseconds: 250));
if (widget.wasPlaying) {
player.play();
}
widget.timerReset.call();
setState(() {
onHoverStart = false;
});
},
onChangeStart: (value) {
setState(() {
onHoverStart = true;
});
widget.wasPlayingChanged.call(player.player?.state.playing ?? false);
player.pause();
},
onChanged: (e) {
currentDuration = Duration(milliseconds: e.toInt());
widget.timerReset.call();
},
),
),
),
),
),
IgnorePointer(
child: Stack(
alignment: Alignment.center,
children: [
if (introOutro?.intro?.start != null && introOutro?.intro?.end != null)
Positioned(
left: calculateStartOffset(constraints, introOutro!.intro!.start),
right: calculateRightOffset(constraints, introOutro.intro!.end),
bottom: 0,
child: Container(
height: 6,
decoration: BoxDecoration(
color: Colors.greenAccent.withOpacity(0.8),
borderRadius: BorderRadius.circular(
100,
),
),
),
),
if (introOutro?.credits?.start != null && introOutro?.credits?.end != null)
Positioned(
left: calculateStartOffset(constraints, introOutro!.credits!.start),
right: calculateRightOffset(constraints, introOutro.credits!.end),
bottom: 0,
child: Container(
height: 6,
decoration: BoxDecoration(
color: Colors.orangeAccent.withOpacity(0.8),
borderRadius: BorderRadius.circular(
100,
),
),
),
),
if (!widget.buffering) ...{
//VideoBufferBar
Positioned(
left: 0,
child: SizedBox(
width: (constraints.maxWidth / (widget.duration.inMilliseconds / widget.buffer.inMilliseconds)),
height: sliderHeight,
child: GappedContainerShape(
activeColor: Theme.of(context).colorScheme.primary.withOpacity(0.5),
inActiveColor: Theme.of(context).colorScheme.primary.withOpacity(0.5),
thumbPosition: bufferFraction,
),
),
),
} else
Align(
alignment: Alignment.center,
child: ClipRRect(
borderRadius: BorderRadius.circular(100),
child: LinearProgressIndicator(
backgroundColor: Colors.transparent,
minHeight: sliderHeight,
),
),
),
if (chapters.isNotEmpty && !widget.buffering) ...{
...chapters.map(
(chapter) {
final offset = constraints.maxWidth /
(widget.duration.inMilliseconds / chapter.startPosition.inMilliseconds)
.clamp(1, constraints.maxWidth);
final activePosition = chapter.startPosition < widget.position;
if (chapter.startPosition.inSeconds == 0) return null;
return Positioned(
left: offset,
child: IgnorePointer(
child: Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
color: activePosition
? Theme.of(context).colorScheme.onPrimary
: Theme.of(context).colorScheme.onSurface.withOpacity(0.5),
),
height: constraints.maxHeight,
width: sliderHeight - (activePosition ? 2 : 4),
),
),
);
},
).whereNotNull(),
},
],
),
),
if (!widget.buffering) ...[
chapterCard(context, position, isVisible),
Positioned(
left: (constraints.maxWidth / (widget.duration.inMilliseconds / position.inMilliseconds)),
child: Transform.translate(
offset: Offset(-(constraints.maxHeight / 2), 0),
child: IgnorePointer(
child: SizedBox(
height: constraints.maxHeight,
width: constraints.maxHeight,
child: Center(
child: AnimatedContainer(
duration: const Duration(milliseconds: 125),
height: isVisible ? sliderHeight * 3.5 : sliderHeight,
width: sliderHeight,
alignment: Alignment.center,
decoration: BoxDecoration(
color: isVisible
? Theme.of(context).colorScheme.onSurface
: Theme.of(context).colorScheme.primary,
borderRadius: BorderRadius.circular(8),
),
),
),
),
),
),
),
],
],
);
},
);
}
double calculateFracionWidth(BoxConstraints constraints, Duration incoming) {
return (constraints.maxWidth * (incoming.inSeconds / widget.duration.inSeconds)).clamp(0, constraints.maxWidth);
}
double calculateStartOffset(BoxConstraints constraints, Duration start) {
return (constraints.maxWidth * (start.inSeconds / widget.duration.inSeconds)).clamp(0, constraints.maxWidth);
}
double calculateEndOffset(BoxConstraints constraints, Duration end) {
return (constraints.maxWidth * (end.inSeconds / widget.duration.inSeconds)).clamp(0, constraints.maxWidth);
}
double calculateRightOffset(BoxConstraints constraints, Duration end) {
double endOffset = calculateEndOffset(constraints, end);
return constraints.maxWidth - endOffset;
}
Widget chapterCard(BuildContext context, Duration duration, bool visible) {
const double height = 350;
final currentStream = ref.watch(playBackModel.select((value) => value));
final chapter = (currentStream?.chapters ?? []).getChapterFromDuration(currentDuration);
final trickPlay = currentStream?.trickPlay;
final screenWidth = MediaQuery.of(context).size.width;
final calculatedPosition = _chapterPosition.clamp(-50, screenWidth - (chapterCardWidth + 45)).toDouble();
final offsetDifference = _chapterPosition - calculatedPosition;
return Positioned(
left: calculatedPosition,
child: IgnorePointer(
child: AnimatedOpacity(
opacity: visible ? 1 : 0,
duration: const Duration(milliseconds: 250),
child: ConstrainedBox(
constraints: BoxConstraints(maxHeight: height, maxWidth: chapterCardWidth),
child: Transform.translate(
offset: const Offset(0, -height - 10),
child: Align(
alignment: Alignment.bottomCenter,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: AnimatedSize(
duration: const Duration(milliseconds: 250),
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
AnimatedContainer(
duration: const Duration(milliseconds: 250),
child: ConstrainedBox(
constraints: BoxConstraints(maxHeight: 250),
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: trickPlay == null || trickPlay.images.isEmpty
? chapter != null
? Image(
image: chapter.imageProvider,
fit: BoxFit.contain,
)
: SizedBox.shrink()
: AspectRatio(
aspectRatio: trickPlay.width.toDouble() / trickPlay.height.toDouble(),
child: TrickplayImage(
trickPlay,
position: currentDuration,
),
),
),
),
),
Stack(
alignment: Alignment.bottomCenter,
children: [
Transform.translate(
offset: Offset(offsetDifference, 10),
child: Transform.rotate(
angle: -math.pi / 4,
child: Container(
height: 30,
width: 30,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(8),
),
),
),
),
Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(8),
),
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Row(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
if (chapter?.name.isNotEmpty ?? false)
Flexible(
child: Text(
chapter?.name.capitalize() ?? "",
style: Theme.of(context)
.textTheme
.titleSmall
?.copyWith(fontWeight: FontWeight.bold),
),
),
Text(
currentDuration.readAbleDuration,
textAlign: TextAlign.center,
style:
Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold),
)
],
),
),
),
],
),
].addPadding(const EdgeInsets.symmetric(vertical: 4)),
),
),
),
),
),
),
),
),
);
}
void _updateSliderPosition(double xPosition, double maxWidth) {
if (widget.buffering) return;
setState(() {
_chapterPosition = xPosition - chapterCardWidth / 2;
final value = ((maxWidth - xPosition) / maxWidth - 1).abs();
currentDuration = Duration(milliseconds: (widget.duration.inMilliseconds * value).toInt());
});
}
}
class CustomTrackShape extends RoundedRectSliderTrackShape {
@override
Rect getPreferredRect({
required RenderBox parentBox,
Offset offset = Offset.zero,
required SliderThemeData sliderTheme,
bool isEnabled = false,
bool isDiscrete = false,
}) {
final trackHeight = sliderTheme.trackHeight;
final trackLeft = offset.dx;
final trackTop = offset.dy + (parentBox.size.height - trackHeight!) / 2;
final trackWidth = parentBox.size.width;
return Rect.fromLTWH(trackLeft, trackTop, trackWidth, trackHeight);
}
}

View file

@ -0,0 +1,341 @@
import 'package:fladder/providers/settings/subtitle_settings_provider.dart';
import 'package:fladder/screens/shared/flat_button.dart';
import 'package:fladder/util/list_padding.dart';
import 'package:fladder/util/localization_helper.dart';
import 'package:fladder/util/widget_extensions.dart';
import 'package:fladder/widgets/shared/fladder_slider.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
Future<void> showSubtitleControls({
required BuildContext context,
String? label,
}) async {
await showDialog(
context: context,
barrierColor: Colors.black.withOpacity(0.1),
builder: (context) => AlertDialog.adaptive(
backgroundColor: Colors.transparent,
elevation: 0,
content: ConstrainedBox(
constraints: BoxConstraints(minWidth: MediaQuery.sizeOf(context).width * 0.75),
child: VideoSubtitleControls(label: label)),
),
);
return;
}
class VideoSubtitleControls extends ConsumerStatefulWidget {
final String? label;
const VideoSubtitleControls({this.label, super.key});
@override
ConsumerState<ConsumerStatefulWidget> createState() => _VideoSubtitleControlsState();
}
class _VideoSubtitleControlsState extends ConsumerState<VideoSubtitleControls> {
late final lastSettings = ref.read(subtitleSettingsProvider);
final controller = ScrollController();
Key? activeKey;
bool showPartial = true;
bool hideControls = false;
void setOpacity(Key? key) => setState(() {
activeKey = key;
showPartial = !(activeKey != null);
});
@override
Widget build(BuildContext context) {
final subSettings = ref.watch(subtitleSettingsProvider);
final provider = ref.read(subtitleSettingsProvider.notifier);
final controlsHidden = hideControls ? false : showPartial;
return AnimatedContainer(
duration: const Duration(milliseconds: 250),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
color: controlsHidden ? Theme.of(context).dialogBackgroundColor.withOpacity(0.75) : Colors.transparent,
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (widget.label?.isNotEmpty == true)
Text(
widget.label!,
style: Theme.of(context).textTheme.headlineMedium,
),
IconButton.filledTonal(
isSelected: !hideControls,
onPressed: () => setState(() => hideControls = !hideControls),
icon: Icon(hideControls ? Icons.visibility_rounded : Icons.visibility_off_rounded),
),
Flexible(
child: IgnorePointer(
ignoring: !controlsHidden,
child: Scrollbar(
thumbVisibility: controlsHidden,
controller: controller,
child: SingleChildScrollView(
controller: controller,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
ElevatedButton(
onPressed: subSettings != lastSettings
? () => provider.resetSettings(value: lastSettings)
: null,
child: Text(context.localized.clearChanges),
),
const SizedBox(width: 32),
ElevatedButton(
onPressed: () => provider.resetSettings(),
child: Text(context.localized.useDefaults),
),
],
).addVisiblity(controlsHidden),
SegmentedButton<FontWeight>(
showSelectedIcon: false,
multiSelectionEnabled: false,
segments: [
ButtonSegment(
label: Text(context.localized.light, style: TextStyle(fontWeight: FontWeight.w100)),
value: FontWeight.w100,
),
ButtonSegment(
label: Text(context.localized.normal, style: TextStyle(fontWeight: FontWeight.w500)),
value: FontWeight.normal,
),
ButtonSegment(
label: Text(context.localized.bold, style: TextStyle(fontWeight: FontWeight.w900)),
value: FontWeight.bold,
),
],
selected: {subSettings.fontWeight},
onSelectionChanged: (p0) {
provider.setFontWeight(p0.first);
},
).addVisiblity(controlsHidden),
Column(
children: [
Row(
children: [
const Icon(Icons.format_size_rounded),
Flexible(
child: FladderSlider(
min: 8.0,
max: 160.0,
onChangeStart: (value) => setOpacity(const Key('fontSize')),
onChangeEnd: (value) => setOpacity(null),
value: subSettings.fontSize.clamp(8.0, 160.0),
onChanged: (value) => provider.setFontSize(value.ceilToDouble()),
),
),
ConstrainedBox(
constraints: const BoxConstraints(minWidth: 35),
child: Text(
subSettings.fontSize.toStringAsFixed(0),
textAlign: TextAlign.center,
),
),
],
),
Text(context.localized.fontSize),
],
).addVisiblity(activeKey == null ? controlsHidden : activeKey == const Key('fontSize')),
Column(
children: [
Row(
children: [
const Icon(Icons.height_rounded),
Flexible(
child: FladderSlider(
min: 0.0,
max: 1.0,
divisions: 80,
onChangeStart: (value) => setOpacity(const Key('verticalOffset')),
onChangeEnd: (value) => setOpacity(null),
value: subSettings.verticalOffset.clamp(0, 1),
onChanged: (value) => provider.setVerticalOffset(value),
),
),
ConstrainedBox(
constraints: const BoxConstraints(
minWidth: 35,
),
child: Text(
subSettings.verticalOffset.toStringAsFixed(2),
textAlign: TextAlign.center,
),
),
],
),
Text(context.localized.heightOffset),
],
).addVisiblity(activeKey == null ? controlsHidden : activeKey == const Key('verticalOffset')),
Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Icon(Icons.color_lens_rounded),
...[Colors.white, Colors.yellow, Colors.black, Colors.grey].map(
(e) => FlatButton(
onTap: () => provider.setSubColor(e),
borderRadiusGeometry: BorderRadius.circular(5),
clipBehavior: Clip.antiAlias,
child: Container(
height: 25,
width: 25,
color: e,
),
),
),
],
),
Text(context.localized.fontColor),
],
).addVisiblity(controlsHidden),
Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Icon(Icons.border_color_rounded),
...[Colors.white, Colors.yellow, Colors.black, Colors.grey, Colors.transparent].map(
(e) => FlatButton(
onTap: () =>
provider.setOutlineColor(e == Colors.transparent ? e : e.withOpacity(0.85)),
borderRadiusGeometry: BorderRadius.circular(5),
clipBehavior: Clip.antiAlias,
child: Container(
height: 25,
width: 25,
color: e == Colors.transparent ? Colors.white : e,
child: e == Colors.transparent
? const Icon(
Icons.disabled_by_default_outlined,
color: Colors.red,
)
: null,
),
),
),
],
),
Text(context.localized.outlineColor),
],
).addVisiblity(controlsHidden),
Column(
children: [
Row(
children: [
const Icon(Icons.border_style),
Flexible(
child: FladderSlider(
min: 1,
max: 25,
divisions: 24,
onChangeStart: (value) => setOpacity(const Key('outlineSize')),
onChangeEnd: (value) => setOpacity(null),
value: subSettings.outlineSize.clamp(1, 24),
onChanged: (value) => provider.setOutlineThickness(value),
),
),
ConstrainedBox(
constraints: const BoxConstraints(
minWidth: 35,
),
child: Text(
subSettings.outlineSize.toStringAsFixed(2),
textAlign: TextAlign.center,
),
),
],
),
Text(context.localized.outlineSize),
],
).addVisiblity(activeKey == null ? controlsHidden : activeKey == const Key('outlineSize')),
Column(
children: [
Row(
children: [
const Icon(Icons.square_rounded),
Flexible(
child: FladderSlider(
min: 0,
max: 1,
divisions: 20,
onChangeStart: (value) => setOpacity(const Key('backGroundOpacity')),
onChangeEnd: (value) => setOpacity(null),
value: subSettings.backGroundColor.opacity.clamp(0, 1),
onChanged: (value) => provider.setBackGroundOpacity(value),
),
),
ConstrainedBox(
constraints: const BoxConstraints(
minWidth: 35,
),
child: Text(
subSettings.backGroundColor.opacity.toStringAsFixed(2),
textAlign: TextAlign.center,
),
),
],
),
Text(context.localized.backgroundOpacity),
],
).addVisiblity(
activeKey == null ? controlsHidden : activeKey == const Key('backGroundOpacity')),
Column(
children: [
Row(
children: [
const Icon(Icons.blur_circular_rounded),
Flexible(
child: FladderSlider(
min: 0,
max: 1,
divisions: 20,
value: subSettings.shadow.clamp(0, 1),
onChangeStart: (value) => setOpacity(const Key('shadowSlider')),
onChangeEnd: (value) => setOpacity(null),
onChanged: (value) => provider.setShadowIntensity(value),
),
),
ConstrainedBox(
constraints: const BoxConstraints(
minWidth: 35,
),
child: Text(
subSettings.shadow.toStringAsFixed(2),
textAlign: TextAlign.center,
),
),
],
),
Text(context.localized.shadow)
],
).addVisiblity(activeKey == null ? controlsHidden : activeKey == const Key('shadowSlider')),
].addPadding(const EdgeInsets.symmetric(vertical: 12)),
),
),
),
),
),
),
],
),
),
);
}
}

View file

@ -0,0 +1,64 @@
import 'dart:async';
import 'package:fladder/providers/settings/subtitle_settings_provider.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:media_kit_video/media_kit_video.dart';
import 'package:fladder/models/settings/subtitle_settings_model.dart';
class VideoSubtitles extends ConsumerStatefulWidget {
final VideoController controller;
final bool overlayed;
const VideoSubtitles({
required this.controller,
this.overlayed = false,
super.key,
});
@override
ConsumerState<ConsumerStatefulWidget> createState() => _VideoSubtitlesState();
}
class _VideoSubtitlesState extends ConsumerState<VideoSubtitles> {
late List<String> subtitle = widget.controller.player.state.subtitle;
StreamSubscription<List<String>>? subscription;
@override
void initState() {
subscription = widget.controller.player.stream.subtitle.listen((value) {
setState(() {
subtitle = value;
});
});
super.initState();
}
@override
void dispose() {
subscription?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final settings = ref.watch(subtitleSettingsProvider);
final padding = MediaQuery.of(context).padding;
final text = [
for (final line in subtitle)
if (line.trim().isNotEmpty) line.trim(),
].join('\n');
if (widget.controller.player.platform?.configuration.libass ?? false) {
return const IgnorePointer(child: SizedBox());
} else {
return SubtitleText(
subModel: settings,
padding: padding,
offset: (widget.overlayed ? 0.5 : settings.verticalOffset),
text: text,
);
}
}
}

View file

@ -0,0 +1,76 @@
import 'package:ficonsax/ficonsax.dart';
import 'package:fladder/providers/settings/video_player_settings_provider.dart';
import 'package:fladder/util/list_padding.dart';
import 'package:fladder/widgets/shared/fladder_slider.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class VideoVolumeSlider extends ConsumerStatefulWidget {
final double? width;
final Function()? onChanged;
const VideoVolumeSlider({this.width, this.onChanged, super.key});
@override
ConsumerState<ConsumerStatefulWidget> createState() => _VideoVolumeSliderState();
}
class _VideoVolumeSliderState extends ConsumerState<VideoVolumeSlider> {
bool sliderActive = false;
@override
Widget build(BuildContext context) {
final volume = ref.watch(videoPlayerSettingsProvider.select((value) => value.volume));
return Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: Icon(volumeIcon(volume)),
onPressed: () => ref.read(videoPlayerSettingsProvider.notifier).setVolume(0),
),
AnimatedSize(
duration: const Duration(milliseconds: 250),
child: SizedBox(
height: 30,
width: 75,
child: FladderSlider(
min: 0,
max: 100,
value: volume,
onChangeStart: (value) {
setState(() {
sliderActive = true;
});
},
onChangeEnd: (value) {
setState(() {
sliderActive = false;
});
},
onChanged: (value) {
widget.onChanged?.call();
ref.read(videoPlayerSettingsProvider.notifier).setVolume(value);
},
),
),
),
SizedBox(
width: 40,
child: Text(
(volume).toStringAsFixed(0),
textAlign: TextAlign.center,
),
),
].addInBetween(SizedBox(width: 6)),
);
}
}
IconData volumeIcon(double value) {
if (value <= 0) {
return IconsaxOutline.volume_mute;
}
if (value < 50) {
return IconsaxOutline.volume_low;
}
return IconsaxOutline.volume_high;
}