mirror of
https://github.com/gabehf/Fladder.git
synced 2026-03-08 23:18:16 -07:00
Init repo
This commit is contained in:
commit
764b6034e3
566 changed files with 212335 additions and 0 deletions
|
|
@ -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))
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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)],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
153
lib/screens/video_player/components/video_player_queue.dart
Normal file
153
lib/screens/video_player/components/video_player_queue.dart
Normal 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'),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
414
lib/screens/video_player/components/video_progress_bar.dart
Normal file
414
lib/screens/video_player/components/video_progress_bar.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
341
lib/screens/video_player/components/video_subtitle_controls.dart
Normal file
341
lib/screens/video_player/components/video_subtitle_controls.dart
Normal 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)),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
64
lib/screens/video_player/components/video_subtitles.dart
Normal file
64
lib/screens/video_player/components/video_subtitles.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
76
lib/screens/video_player/components/video_volume_slider.dart
Normal file
76
lib/screens/video_player/components/video_volume_slider.dart
Normal 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;
|
||||
}
|
||||
153
lib/screens/video_player/video_player.dart
Normal file
153
lib/screens/video_player/video_player.dart
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:fladder/models/media_playback_model.dart';
|
||||
import 'package:fladder/providers/settings/video_player_settings_provider.dart';
|
||||
import 'package:fladder/screens/video_player/video_player_controls.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/providers/video_player_provider.dart';
|
||||
import 'package:fladder/util/adaptive_layout.dart';
|
||||
import 'package:fladder/util/themes_data.dart';
|
||||
|
||||
class VideoPlayer extends ConsumerStatefulWidget {
|
||||
const VideoPlayer({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ConsumerStatefulWidget> createState() => _VideoPlayerState();
|
||||
}
|
||||
|
||||
class _VideoPlayerState extends ConsumerState<VideoPlayer> with WidgetsBindingObserver {
|
||||
double lastScale = 0.0;
|
||||
|
||||
bool errorPlaying = false;
|
||||
bool playing = false;
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
//Don't pause on desktop focus loss
|
||||
if (!AdaptiveLayout.of(context).isDesktop) {
|
||||
switch (state) {
|
||||
case AppLifecycleState.resumed:
|
||||
if (playing) ref.read(videoPlayerProvider).play();
|
||||
break;
|
||||
case AppLifecycleState.hidden:
|
||||
case AppLifecycleState.paused:
|
||||
case AppLifecycleState.detached:
|
||||
if (playing) ref.read(videoPlayerProvider).pause();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
Future.microtask(() {
|
||||
ref.read(mediaPlaybackProvider.notifier).update((state) => state.copyWith(state: VideoPlayerState.fullScreen));
|
||||
return ref.read(videoPlayerSettingsProvider.notifier).setSavedBrightness();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final playerProvider = ref.watch(videoPlayerProvider);
|
||||
ref.listen(videoPlayerSettingsProvider.select((value) => value.volume), (previous, next) {
|
||||
playerProvider.setVolume(next);
|
||||
});
|
||||
final videoPlayerSettings = ref.watch(videoPlayerSettingsProvider);
|
||||
final padding = MediaQuery.of(context).padding;
|
||||
|
||||
final playerController = playerProvider.controller;
|
||||
|
||||
return Material(
|
||||
color: Colors.black,
|
||||
child: Theme(
|
||||
data: ThemesData.of(context).dark,
|
||||
child: Container(
|
||||
color: Colors.black,
|
||||
child: GestureDetector(
|
||||
onScaleUpdate: (details) {
|
||||
lastScale = details.scale;
|
||||
},
|
||||
onScaleEnd: (details) {
|
||||
if (lastScale < 1.0) {
|
||||
ref.read(videoPlayerSettingsProvider.notifier).setFillScreen(false, context: context);
|
||||
} else if (lastScale > 1.0) {
|
||||
ref.read(videoPlayerSettingsProvider.notifier).setFillScreen(true, context: context);
|
||||
}
|
||||
lastScale = 0.0;
|
||||
},
|
||||
child: Hero(
|
||||
tag: "HeroPlayer",
|
||||
child: Stack(
|
||||
children: [
|
||||
if (playerController != null)
|
||||
Padding(
|
||||
padding: videoPlayerSettings.fillScreen
|
||||
? EdgeInsets.zero
|
||||
: EdgeInsets.only(left: padding.left, right: padding.right),
|
||||
child: OrientationBuilder(builder: (context, orientation) {
|
||||
return Video(
|
||||
key: Key("$videoPlayerSettings|$orientation"),
|
||||
controller: playerController,
|
||||
fill: Colors.transparent,
|
||||
wakelock: true,
|
||||
fit: videoPlayerSettings.fillScreen
|
||||
? (MediaQuery.of(context).orientation == Orientation.portrait
|
||||
? videoPlayerSettings.videoFit
|
||||
: BoxFit.cover)
|
||||
: videoPlayerSettings.videoFit,
|
||||
subtitleViewConfiguration: const SubtitleViewConfiguration(visible: false),
|
||||
controls: NoVideoControls,
|
||||
);
|
||||
}),
|
||||
),
|
||||
DesktopControls(),
|
||||
if (errorPlaying) const _VideoErrorWidget(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _VideoErrorWidget extends StatelessWidget {
|
||||
const _VideoErrorWidget();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_rounded,
|
||||
size: 46,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
"Error playing file",
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
625
lib/screens/video_player/video_player_controls.dart
Normal file
625
lib/screens/video_player/video_player_controls.dart
Normal file
|
|
@ -0,0 +1,625 @@
|
|||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:async/async.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:ficonsax/ficonsax.dart';
|
||||
import 'package:fladder/models/items/intro_skip_model.dart';
|
||||
import 'package:fladder/models/media_playback_model.dart';
|
||||
import 'package:fladder/models/playback/playback_model.dart';
|
||||
import 'package:fladder/providers/settings/video_player_settings_provider.dart';
|
||||
import 'package:fladder/providers/video_player_provider.dart';
|
||||
import 'package:fladder/screens/shared/default_titlebar.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_progress_bar.dart';
|
||||
import 'package:fladder/screens/video_player/components/video_subtitles.dart';
|
||||
import 'package:fladder/screens/video_player/components/video_volume_slider.dart';
|
||||
import 'package:fladder/util/adaptive_layout.dart';
|
||||
import 'package:fladder/util/duration_extensions.dart';
|
||||
import 'package:fladder/util/list_padding.dart';
|
||||
import 'package:fladder/util/string_extensions.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:screen_brightness/screen_brightness.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
class DesktopControls extends ConsumerStatefulWidget {
|
||||
const DesktopControls({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ConsumerStatefulWidget> createState() => _DesktopControlsState();
|
||||
}
|
||||
|
||||
class _DesktopControlsState extends ConsumerState<DesktopControls> {
|
||||
final fadeDuration = const Duration(milliseconds: 350);
|
||||
final focusNode = FocusNode();
|
||||
bool showOverlay = true;
|
||||
bool wasPlaying = false;
|
||||
|
||||
late final double topPadding = MediaQuery.of(context).viewPadding.top;
|
||||
late final double bottomPadding = MediaQuery.of(context).viewPadding.bottom;
|
||||
|
||||
Future<void> clear() async {
|
||||
toggleOverlay(value: true);
|
||||
if (!AdaptiveLayout.of(context).isDesktop) {
|
||||
ScreenBrightness().resetScreenBrightness();
|
||||
} else {
|
||||
disableFullscreen();
|
||||
}
|
||||
timer.cancel();
|
||||
}
|
||||
|
||||
void resetTimer() => timer.reset();
|
||||
|
||||
Future<void> closePlayer() async {
|
||||
clear();
|
||||
ref.read(videoPlayerProvider).stop();
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final mediaPlayback = ref.watch(mediaPlaybackProvider);
|
||||
final introSkipModel = ref.watch(playBackModel.select((value) => value?.introSkipModel));
|
||||
final player = ref.watch(videoPlayerProvider);
|
||||
bool showIntroSkipButton = introSkipModel?.introInRange(mediaPlayback.position) ?? false;
|
||||
bool showCreditSkipButton = introSkipModel?.creditsInRange(mediaPlayback.position) ?? false;
|
||||
if (AdaptiveLayout.of(context).isDesktop) {
|
||||
focusNode.requestFocus();
|
||||
}
|
||||
|
||||
return Listener(
|
||||
onPointerSignal: (event) {
|
||||
log('Timer reset');
|
||||
resetTimer();
|
||||
},
|
||||
child: PopScope(
|
||||
canPop: false,
|
||||
onPopInvoked: (didPop) {
|
||||
if (!didPop) {
|
||||
closePlayer();
|
||||
}
|
||||
},
|
||||
child: KeyboardListener(
|
||||
focusNode: focusNode,
|
||||
autofocus: AdaptiveLayout.of(context).inputDevice == InputDevice.pointer,
|
||||
onKeyEvent: (value) {
|
||||
if (value is KeyRepeatEvent) {}
|
||||
if (value is KeyDownEvent) {
|
||||
if (value.logicalKey == LogicalKeyboardKey.keyS) {
|
||||
if (showIntroSkipButton) {
|
||||
skipIntro(introSkipModel);
|
||||
} else if (showCreditSkipButton) {
|
||||
skipCredits(introSkipModel);
|
||||
}
|
||||
focusNode.requestFocus();
|
||||
}
|
||||
if (value.logicalKey == LogicalKeyboardKey.escape) {
|
||||
disableFullscreen();
|
||||
}
|
||||
if (value.logicalKey == LogicalKeyboardKey.space) {
|
||||
ref.read(videoPlayerProvider).playOrPause();
|
||||
}
|
||||
if (value.logicalKey == LogicalKeyboardKey.arrowLeft) {
|
||||
seekBack(mediaPlayback);
|
||||
}
|
||||
if (value.logicalKey == LogicalKeyboardKey.arrowRight) {
|
||||
seekForward(mediaPlayback);
|
||||
}
|
||||
if (value.logicalKey == LogicalKeyboardKey.keyF) {
|
||||
toggleFullScreen();
|
||||
}
|
||||
if (AdaptiveLayout.of(context).isDesktop || kIsWeb) {
|
||||
if (value.logicalKey == LogicalKeyboardKey.arrowUp) {
|
||||
resetTimer();
|
||||
ref.read(videoPlayerSettingsProvider.notifier).steppedVolume(5);
|
||||
}
|
||||
if (value.logicalKey == LogicalKeyboardKey.arrowDown) {
|
||||
resetTimer();
|
||||
ref.read(videoPlayerSettingsProvider.notifier).steppedVolume(-5);
|
||||
}
|
||||
}
|
||||
focusNode.requestFocus();
|
||||
}
|
||||
},
|
||||
child: GestureDetector(
|
||||
onTap: () => toggleOverlay(),
|
||||
child: MouseRegion(
|
||||
cursor: showOverlay ? SystemMouseCursors.basic : SystemMouseCursors.none,
|
||||
onEnter: (event) => toggleOverlay(value: true),
|
||||
onExit: (event) => toggleOverlay(value: false),
|
||||
onHover: AdaptiveLayout.of(context).isDesktop || kIsWeb ? (event) => toggleOverlay(value: true) : null,
|
||||
child: Stack(
|
||||
children: [
|
||||
if (player.controller != null)
|
||||
VideoSubtitles(
|
||||
key: const Key('subtitles'),
|
||||
controller: player.controller!,
|
||||
overlayed: showOverlay,
|
||||
),
|
||||
if (AdaptiveLayout.of(context).isDesktop) playButton(mediaPlayback),
|
||||
IgnorePointer(
|
||||
ignoring: !showOverlay,
|
||||
child: AnimatedOpacity(
|
||||
duration: fadeDuration,
|
||||
opacity: showOverlay ? 1 : 0,
|
||||
child: Column(
|
||||
children: [
|
||||
topButtons(context),
|
||||
const Spacer(),
|
||||
bottomButtons(context, mediaPlayback),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (showIntroSkipButton)
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: IntroSkipButton(
|
||||
isOverlayVisible: showOverlay,
|
||||
skipIntro: () => skipIntro(introSkipModel),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (showCreditSkipButton)
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: CreditsSkipButton(
|
||||
isOverlayVisible: showOverlay,
|
||||
skipCredits: () => skipCredits(introSkipModel),
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget playButton(MediaPlaybackModel mediaPlayback) {
|
||||
return Align(
|
||||
alignment: Alignment.center,
|
||||
child: AnimatedScale(
|
||||
curve: Curves.easeInOutCubicEmphasized,
|
||||
scale: mediaPlayback.playing
|
||||
? 0
|
||||
: mediaPlayback.buffering
|
||||
? 0
|
||||
: 1,
|
||||
duration: const Duration(milliseconds: 250),
|
||||
child: IconButton.outlined(
|
||||
onPressed: () => ref.read(videoPlayerProvider).play(),
|
||||
isSelected: true,
|
||||
iconSize: 65,
|
||||
tooltip: "Resume video",
|
||||
icon: const Icon(IconsaxBold.play),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget topButtons(BuildContext context) {
|
||||
final currentItem = ref.watch(playBackModel.select((value) => value?.item));
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Colors.black.withOpacity(0.8),
|
||||
Colors.black.withOpacity(0),
|
||||
],
|
||||
)),
|
||||
child: Padding(
|
||||
padding:
|
||||
EdgeInsets.only(top: topPadding + (AdaptiveLayout.of(context).platform == TargetPlatform.macOS ? 28 : 0.0)),
|
||||
child: Container(
|
||||
alignment: Alignment.topCenter,
|
||||
height: 80,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
clear();
|
||||
ref
|
||||
.read(mediaPlaybackProvider.notifier)
|
||||
.update((state) => state.copyWith(state: VideoPlayerState.minimized));
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
icon: Icon(
|
||||
IconsaxOutline.arrow_down_1,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
if (!AdaptiveLayout.of(context).isDesktop)
|
||||
Flexible(
|
||||
child: Text(
|
||||
currentItem?.title ?? "",
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
)
|
||||
else
|
||||
Flexible(child: Align(alignment: Alignment.topRight, child: DefaultTitleBar()))
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget bottomButtons(BuildContext context, MediaPlaybackModel mediaPlayback) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.bottomCenter,
|
||||
end: Alignment.topCenter,
|
||||
colors: [
|
||||
Colors.black.withOpacity(0.8),
|
||||
Colors.black.withOpacity(0),
|
||||
],
|
||||
)),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(bottom: bottomPadding)
|
||||
.copyWith(bottom: 21)
|
||||
.add(EdgeInsets.symmetric(vertical: 16))
|
||||
.add(EdgeInsets.symmetric(horizontal: AdaptiveLayout.of(context).isDesktop ? 32 : 0)),
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: progressBar(mediaPlayback),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Flexible(
|
||||
flex: 2,
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
IconButton(
|
||||
onPressed: () => showVideoPlayerOptions(context), icon: const Icon(IconsaxOutline.more)),
|
||||
if (AdaptiveLayout.layoutOf(context) == LayoutState.tablet) ...[
|
||||
IconButton(
|
||||
onPressed: () => showSubSelection(context),
|
||||
icon: const Icon(IconsaxOutline.subtitle),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () => showAudioSelection(context),
|
||||
icon: const Icon(IconsaxOutline.audio_square),
|
||||
),
|
||||
],
|
||||
if (AdaptiveLayout.layoutOf(context) == LayoutState.desktop) ...[
|
||||
Flexible(
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () => showSubSelection(context),
|
||||
icon: const Icon(IconsaxOutline.subtitle),
|
||||
label: Text(
|
||||
ref
|
||||
.watch(playBackModel.select((value) => value?.mediaStreams?.currentSubStream))
|
||||
?.language
|
||||
.capitalize() ??
|
||||
"Off",
|
||||
maxLines: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
Flexible(
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () => showAudioSelection(context),
|
||||
icon: const Icon(IconsaxOutline.audio_square),
|
||||
label: Text(
|
||||
ref
|
||||
.watch(playBackModel.select((value) => value?.mediaStreams?.currentAudioStream))
|
||||
?.language
|
||||
.capitalize() ??
|
||||
"Off",
|
||||
maxLines: 1,
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
].addInBetween(const SizedBox(
|
||||
width: 4,
|
||||
)),
|
||||
),
|
||||
),
|
||||
previousButton,
|
||||
seekBackwardButton(mediaPlayback),
|
||||
IconButton.filledTonal(
|
||||
iconSize: 38,
|
||||
onPressed: () {
|
||||
ref.read(videoPlayerProvider).playOrPause();
|
||||
},
|
||||
icon: Icon(
|
||||
mediaPlayback.playing ? IconsaxBold.pause : IconsaxBold.play,
|
||||
),
|
||||
),
|
||||
seekForwardButton(mediaPlayback),
|
||||
nextVideoButton,
|
||||
Flexible(
|
||||
flex: 2,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
Tooltip(
|
||||
message: "Stop",
|
||||
child: IconButton(onPressed: () => closePlayer(), icon: Icon(IconsaxOutline.stop))),
|
||||
const Spacer(),
|
||||
if (AdaptiveLayout.of(context).isDesktop && ref.read(videoPlayerProvider).player != null) ...{
|
||||
// OpenQueueButton(x),
|
||||
// ChapterButton(
|
||||
// position: position,
|
||||
// player: ref.read(videoPlayerProvider).player!,
|
||||
// ),
|
||||
Listener(
|
||||
onPointerSignal: (event) {
|
||||
if (event is PointerScrollEvent) {
|
||||
if (event.scrollDelta.dy > 0) {
|
||||
ref.read(videoPlayerSettingsProvider.notifier).steppedVolume(-5);
|
||||
} else {
|
||||
ref.read(videoPlayerSettingsProvider.notifier).steppedVolume(5);
|
||||
}
|
||||
}
|
||||
},
|
||||
child: VideoVolumeSlider(
|
||||
onChanged: () => resetTimer(),
|
||||
),
|
||||
),
|
||||
FutureBuilder(
|
||||
future: windowManager.isFullScreen(),
|
||||
builder: (context, snapshot) {
|
||||
final isFullScreen = snapshot.data ?? true;
|
||||
return IconButton(
|
||||
onPressed: () => windowManager.setFullScreen(!isFullScreen),
|
||||
icon: Icon(
|
||||
isFullScreen ? IconsaxOutline.close_square : IconsaxOutline.maximize_4,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
}
|
||||
].addInBetween(const SizedBox(width: 8)),
|
||||
),
|
||||
),
|
||||
].addInBetween(const SizedBox(width: 6)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget progressBar(MediaPlaybackModel mediaPlayback) {
|
||||
return Consumer(
|
||||
builder: (context, ref, child) {
|
||||
final playbackModel = ref.watch(playBackModel);
|
||||
final item = playbackModel?.item;
|
||||
final List<String?> details = [
|
||||
if (AdaptiveLayout.of(context).isDesktop) item?.label(context),
|
||||
mediaPlayback.duration.inMinutes > 1
|
||||
? 'ends at ${DateFormat('HH:mm').format(DateTime.now().add(mediaPlayback.duration - mediaPlayback.position))}'
|
||||
: null
|
||||
];
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
details.whereNotNull().join(' - '),
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
shadows: [
|
||||
const Shadow(blurRadius: 16),
|
||||
],
|
||||
),
|
||||
maxLines: 2,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (playbackModel.label != null)
|
||||
InkWell(
|
||||
onTap: () => showVideoPlaybackInformation(context),
|
||||
child: Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
child: Text(playbackModel?.label ?? ""),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (item != null) ...{
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
child: Text('${item.streamModel?.displayProfile?.value} ${item.streamModel?.resolution?.value}'),
|
||||
),
|
||||
),
|
||||
}
|
||||
].addPadding(EdgeInsets.symmetric(horizontal: 4)),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
SizedBox(
|
||||
height: 25,
|
||||
child: ChapterProgressSlider(
|
||||
wasPlayingChanged: (value) => wasPlaying = value,
|
||||
wasPlaying: wasPlaying,
|
||||
duration: mediaPlayback.duration,
|
||||
position: mediaPlayback.position,
|
||||
buffer: mediaPlayback.buffer,
|
||||
buffering: mediaPlayback.buffering,
|
||||
timerReset: () => timer.reset(),
|
||||
onPositionChanged: (position) => ref.read(videoPlayerProvider).seek(position),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(mediaPlayback.position.readAbleDuration),
|
||||
Text("-${(mediaPlayback.duration - mediaPlayback.position).readAbleDuration}"),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget get previousButton {
|
||||
return Consumer(
|
||||
builder: (context, ref, child) {
|
||||
final previousVideo = ref.watch(playBackModel.select((value) => value?.previousVideo));
|
||||
final buffering = ref.watch(mediaPlaybackProvider.select((value) => value.buffering));
|
||||
|
||||
return Tooltip(
|
||||
message: previousVideo?.detailedName(context) ?? "",
|
||||
textAlign: TextAlign.center,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: Theme.of(context).colorScheme.surface.withOpacity(0.95),
|
||||
),
|
||||
textStyle: Theme.of(context).textTheme.labelLarge,
|
||||
child: IconButton(
|
||||
onPressed: previousVideo != null && !buffering
|
||||
? () => ref.read(playbackModelHelper).loadNewVideo(previousVideo)
|
||||
: null,
|
||||
iconSize: 30,
|
||||
icon: const Icon(
|
||||
IconsaxOutline.backward,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget get nextVideoButton {
|
||||
return Consumer(
|
||||
builder: (context, ref, child) {
|
||||
final nextVideo = ref.watch(playBackModel.select((value) => value?.nextVideo));
|
||||
final buffering = ref.watch(mediaPlaybackProvider.select((value) => value.buffering));
|
||||
return Tooltip(
|
||||
message: nextVideo?.detailedName(context) ?? "",
|
||||
textAlign: TextAlign.center,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: Theme.of(context).colorScheme.surface.withOpacity(0.95),
|
||||
),
|
||||
textStyle: Theme.of(context).textTheme.labelLarge,
|
||||
child: IconButton(
|
||||
onPressed:
|
||||
nextVideo != null && !buffering ? () => ref.read(playbackModelHelper).loadNewVideo(nextVideo) : null,
|
||||
iconSize: 30,
|
||||
icon: const Icon(
|
||||
IconsaxOutline.forward,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget seekBackwardButton(MediaPlaybackModel mediaPlaybackModel) {
|
||||
return IconButton(
|
||||
onPressed: () => seekBack(mediaPlaybackModel),
|
||||
tooltip: "-10",
|
||||
iconSize: 40,
|
||||
icon: const Icon(
|
||||
IconsaxOutline.backward_10_seconds,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget seekForwardButton(MediaPlaybackModel mediaPlaybackModel) {
|
||||
return IconButton(
|
||||
onPressed: () => seekForward(mediaPlaybackModel),
|
||||
tooltip: "15",
|
||||
iconSize: 40,
|
||||
icon: Stack(
|
||||
children: [
|
||||
const Icon(IconsaxOutline.forward_15_seconds),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void skipIntro(IntroOutSkipModel? introSkipModel) {
|
||||
resetTimer();
|
||||
final end = introSkipModel?.intro?.end;
|
||||
if (end != null) {
|
||||
ref.read(videoPlayerProvider).seek(end);
|
||||
}
|
||||
}
|
||||
|
||||
void skipCredits(IntroOutSkipModel? introSkipModel) {
|
||||
resetTimer();
|
||||
final end = introSkipModel?.credits?.end;
|
||||
if (end != null) {
|
||||
ref.read(videoPlayerProvider).seek(end);
|
||||
}
|
||||
}
|
||||
|
||||
void seekBack(MediaPlaybackModel mediaPlayback, {int seconds = 15}) {
|
||||
resetTimer();
|
||||
final newPosition = (mediaPlayback.position.inSeconds - seconds).clamp(0, mediaPlayback.duration.inSeconds);
|
||||
ref.read(videoPlayerProvider).seek(Duration(seconds: newPosition));
|
||||
}
|
||||
|
||||
void seekForward(MediaPlaybackModel mediaPlayback, {int seconds = 15}) {
|
||||
resetTimer();
|
||||
final newPosition = (mediaPlayback.position.inSeconds + seconds).clamp(0, mediaPlayback.duration.inSeconds);
|
||||
ref.read(videoPlayerProvider).seek(Duration(seconds: newPosition));
|
||||
}
|
||||
|
||||
late RestartableTimer timer = RestartableTimer(
|
||||
const Duration(seconds: 5),
|
||||
() => mounted ? toggleOverlay(value: false) : null,
|
||||
);
|
||||
|
||||
void toggleOverlay({bool? value}) {
|
||||
setState(() => showOverlay = (value ?? !showOverlay));
|
||||
resetTimer();
|
||||
SystemChrome.setEnabledSystemUIMode(showOverlay ? SystemUiMode.edgeToEdge : SystemUiMode.leanBack, overlays: []);
|
||||
SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle(
|
||||
statusBarColor: Colors.transparent,
|
||||
systemNavigationBarColor: Colors.transparent,
|
||||
systemNavigationBarDividerColor: Colors.transparent,
|
||||
));
|
||||
}
|
||||
|
||||
Future<void> disableFullscreen() async {
|
||||
resetTimer();
|
||||
final isFullScreen = await windowManager.isFullScreen();
|
||||
if (isFullScreen) {
|
||||
await windowManager.setFullScreen(false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> toggleFullScreen() async {
|
||||
final isFullScreen = await windowManager.isFullScreen();
|
||||
await windowManager.setFullScreen(!isFullScreen);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue