From d0d6a2ffa6343c53da659b6b9a8925ebbaf5b473 Mon Sep 17 00:00:00 2001 From: PartyDonut <42371342+PartyDonut@users.noreply.github.com> Date: Sat, 9 Aug 2025 10:19:53 +0200 Subject: [PATCH] feat: Add volume indicator and scroll handles on entire screen (#443) Co-authored-by: PartyDonut --- lib/l10n/app_en.arb | 10 +- .../video_player_volume_indicator.dart | 84 ++++++++ .../components/video_volume_slider.dart | 2 +- .../video_player/video_player_controls.dart | 188 +++++++++--------- lib/util/input_handler.dart | 2 +- 5 files changed, 191 insertions(+), 95 deletions(-) create mode 100644 lib/screens/video_player/components/video_player_volume_indicator.dart diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index ec84166..9d39506 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1317,5 +1317,13 @@ } } }, - "blurred": "Blurred" + "blurred": "Blurred", + "volumeIndicator": "Volume: {volume}", + "@volumeIndicator": { + "placeholders": { + "volume": { + "type": "int" + } + } + } } \ No newline at end of file diff --git a/lib/screens/video_player/components/video_player_volume_indicator.dart b/lib/screens/video_player/components/video_player_volume_indicator.dart new file mode 100644 index 0000000..6e3418c --- /dev/null +++ b/lib/screens/video_player/components/video_player_volume_indicator.dart @@ -0,0 +1,84 @@ +import 'package:flutter/material.dart'; + +import 'package:async/async.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:iconsax_plus/iconsax_plus.dart'; + +import 'package:fladder/providers/settings/video_player_settings_provider.dart'; +import 'package:fladder/util/localization_helper.dart'; + +class VideoPlayerVolumeIndicator extends ConsumerStatefulWidget { + const VideoPlayerVolumeIndicator({super.key}); + + @override + ConsumerState createState() => _VideoPlayerVolumeIndicatorState(); +} + +class _VideoPlayerVolumeIndicatorState extends ConsumerState { + late double currentVolume = ref.read(videoPlayerSettingsProvider.select((value) => value.volume)); + + bool showIndicator = false; + late final timer = RestartableTimer(const Duration(seconds: 1), () { + setState(() { + showIndicator = false; + }); + }); + + @override + void dispose() { + showIndicator = false; + timer.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + ref.listen( + videoPlayerSettingsProvider.select((value) => value.volume), + (previous, next) { + setState(() { + showIndicator = true; + currentVolume = next; + }); + timer.reset(); + }, + ); + return IgnorePointer( + child: AnimatedOpacity( + duration: const Duration(milliseconds: 250), + opacity: showIndicator ? 1 : 0, + child: Center( + child: Container( + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.85), + borderRadius: BorderRadius.circular(16), + ), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Row( + mainAxisSize: MainAxisSize.min, + spacing: 12, + children: [ + Icon( + volumeIcon(currentVolume), + ), + Text(context.localized.volumeIndicator(currentVolume.round())) + ], + ), + ), + ), + ), + ), + ); + } +} + +IconData volumeIcon(double value) { + if (value <= 0) { + return IconsaxPlusLinear.volume_mute; + } + if (value < 50) { + return IconsaxPlusLinear.volume_low_1; + } + return IconsaxPlusLinear.volume_high; +} diff --git a/lib/screens/video_player/components/video_volume_slider.dart b/lib/screens/video_player/components/video_volume_slider.dart index 82a6a7a..b336173 100644 --- a/lib/screens/video_player/components/video_volume_slider.dart +++ b/lib/screens/video_player/components/video_volume_slider.dart @@ -80,7 +80,7 @@ IconData volumeIcon(double value) { return IconsaxPlusLinear.volume_mute; } if (value < 50) { - return IconsaxPlusLinear.volume_low; + return IconsaxPlusLinear.volume_low_1; } return IconsaxPlusLinear.volume_high; } diff --git a/lib/screens/video_player/video_player_controls.dart b/lib/screens/video_player/video_player_controls.dart index 2332f74..73302df 100644 --- a/lib/screens/video_player/video_player_controls.dart +++ b/lib/screens/video_player/video_player_controls.dart @@ -26,6 +26,7 @@ import 'package:fladder/screens/video_player/components/video_player_controls_ex import 'package:fladder/screens/video_player/components/video_player_options_sheet.dart'; import 'package:fladder/screens/video_player/components/video_player_quality_controls.dart'; import 'package:fladder/screens/video_player/components/video_player_seek_indicator.dart'; +import 'package:fladder/screens/video_player/components/video_player_volume_indicator.dart'; import 'package:fladder/screens/video_player/components/video_progress_bar.dart'; import 'package:fladder/screens/video_player/components/video_volume_slider.dart'; import 'package:fladder/util/adaptive_layout/adaptive_layout.dart'; @@ -71,89 +72,93 @@ class _DesktopControlsState extends ConsumerState { final mediaSegments = ref.watch(playBackModel.select((value) => value?.mediaSegments)); final player = ref.watch(videoPlayerProvider); final subtitleWidget = player.subtitleWidget(showOverlay, controlsKey: _bottomControlsKey); - return InputHandler( - autoFocus: true, - keyMap: ref.watch(videoPlayerSettingsProvider.select((value) => value.currentShortcuts)), - keyMapResult: (result) => _onKey(result), - child: PopScope( - canPop: false, - onPopInvokedWithResult: (didPop, result) { - if (!didPop) { - closePlayer(); - } - }, - child: MouseRegion( - cursor: showOverlay ? SystemMouseCursors.basic : SystemMouseCursors.none, - onExit: (event) => toggleOverlay(value: false), - onEnter: (event) => toggleOverlay(value: true), - onHover: AdaptiveLayout.of(context).isDesktop || kIsWeb ? (event) => toggleOverlay(value: true) : null, - child: Stack( - children: [ - Positioned.fill( - child: GestureDetector( - onTap: AdaptiveLayout.of(context).inputDevice == InputDevice.pointer - ? () => player.playOrPause() - : () => toggleOverlay(), - onDoubleTap: AdaptiveLayout.of(context).inputDevice == InputDevice.pointer - ? () => fullScreenHelper.toggleFullScreen(ref) - : null, - ), - ), - if (subtitleWidget != null) subtitleWidget, - if (AdaptiveLayout.of(context).isDesktop) - Consumer(builder: (context, ref, child) { - final playing = ref.watch(mediaPlaybackProvider.select((value) => value.playing)); - final buffering = ref.watch(mediaPlaybackProvider.select((value) => value.buffering)); - return playButton(playing, buffering); - }), - IgnorePointer( - ignoring: !showOverlay, - child: AnimatedOpacity( - duration: fadeDuration, - opacity: showOverlay ? 1 : 0, - child: Column( - children: [ - topButtons(context), - const Spacer(), - bottomButtons(context), - ], + return Listener( + onPointerSignal: setVolume, + child: InputHandler( + autoFocus: true, + keyMap: ref.watch(videoPlayerSettingsProvider.select((value) => value.currentShortcuts)), + keyMapResult: (result) => _onKey(result), + child: PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, result) { + if (!didPop) { + closePlayer(); + } + }, + child: MouseRegion( + cursor: showOverlay ? SystemMouseCursors.basic : SystemMouseCursors.none, + onExit: (event) => toggleOverlay(value: false), + onEnter: (event) => toggleOverlay(value: true), + onHover: AdaptiveLayout.of(context).isDesktop || kIsWeb ? (event) => toggleOverlay(value: true) : null, + child: Stack( + children: [ + Positioned.fill( + child: GestureDetector( + onTap: AdaptiveLayout.of(context).inputDevice == InputDevice.pointer + ? () => player.playOrPause() + : () => toggleOverlay(), + onDoubleTap: AdaptiveLayout.of(context).inputDevice == InputDevice.pointer + ? () => fullScreenHelper.toggleFullScreen(ref) + : null, ), ), - ), - const VideoPlayerSeekIndicator(), - Consumer( - builder: (context, ref, child) { - final position = ref.watch(mediaPlaybackProvider.select((value) => value.position)); - MediaSegment? segment = mediaSegments?.atPosition(position); - SegmentVisibility forceShow = - segment?.visibility(position, force: showOverlay) ?? SegmentVisibility.hidden; - final segmentSkipType = ref - .watch(videoPlayerSettingsProvider.select((value) => value.segmentSkipSettings[segment?.type])); - final autoSkip = forceShow != SegmentVisibility.hidden && - segmentSkipType == SegmentSkip.skip && - player.lastState?.buffering == false; - if (autoSkip) { - skipToSegmentEnd(segment); - } - return Stack( - children: [ - Align( - alignment: Alignment.centerRight, - child: Padding( - padding: const EdgeInsets.all(32), - child: SkipSegmentButton( - segment: segment, - skipType: segmentSkipType, - visibility: forceShow, - pressedSkip: () => skipToSegmentEnd(segment), + if (subtitleWidget != null) subtitleWidget, + if (AdaptiveLayout.of(context).isDesktop) + Consumer(builder: (context, ref, child) { + final playing = ref.watch(mediaPlaybackProvider.select((value) => value.playing)); + final buffering = ref.watch(mediaPlaybackProvider.select((value) => value.buffering)); + return playButton(playing, buffering); + }), + IgnorePointer( + ignoring: !showOverlay, + child: AnimatedOpacity( + duration: fadeDuration, + opacity: showOverlay ? 1 : 0, + child: Column( + children: [ + topButtons(context), + const Spacer(), + bottomButtons(context), + ], + ), + ), + ), + const VideoPlayerSeekIndicator(), + const VideoPlayerVolumeIndicator(), + Consumer( + builder: (context, ref, child) { + final position = ref.watch(mediaPlaybackProvider.select((value) => value.position)); + MediaSegment? segment = mediaSegments?.atPosition(position); + SegmentVisibility forceShow = + segment?.visibility(position, force: showOverlay) ?? SegmentVisibility.hidden; + final segmentSkipType = ref + .watch(videoPlayerSettingsProvider.select((value) => value.segmentSkipSettings[segment?.type])); + final autoSkip = forceShow != SegmentVisibility.hidden && + segmentSkipType == SegmentSkip.skip && + player.lastState?.buffering == false; + if (autoSkip) { + skipToSegmentEnd(segment); + } + return Stack( + children: [ + Align( + alignment: Alignment.centerRight, + child: Padding( + padding: const EdgeInsets.all(32), + child: SkipSegmentButton( + segment: segment, + skipType: segmentSkipType, + visibility: forceShow, + pressedSkip: () => skipToSegmentEnd(segment), + ), ), ), - ), - ], - ); - }, - ), - ], + ], + ); + }, + ), + ], + ), ), ), ), @@ -374,19 +379,8 @@ class _DesktopControlsState extends ConsumerState { }, if (AdaptiveLayout.of(context).inputDevice == InputDevice.pointer && AdaptiveLayout.viewSizeOf(context) > ViewSize.phone) ...[ - 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(), - ), + VideoVolumeSlider( + onChanged: () => resetTimer(), ), const FullScreenButton(), ] @@ -673,6 +667,16 @@ class _DesktopControlsState extends ConsumerState { fullScreenHelper.closeFullScreen(ref); } + void setVolume(PointerEvent event) { + if (event is PointerScrollEvent) { + if (event.scrollDelta.dy > 0) { + ref.read(videoPlayerSettingsProvider.notifier).steppedVolume(-5); + } else { + ref.read(videoPlayerSettingsProvider.notifier).steppedVolume(5); + } + } + } + bool _onKey(VideoHotKeys value) { final mediaSegments = ref.read(playBackModel.select((value) => value?.mediaSegments)); final position = ref.read(mediaPlaybackProvider).position; diff --git a/lib/util/input_handler.dart b/lib/util/input_handler.dart index a844d2d..1996020 100644 --- a/lib/util/input_handler.dart +++ b/lib/util/input_handler.dart @@ -44,7 +44,7 @@ class _InputHandlerState extends State> { focusNode.requestFocus(); } }, - onKeyEvent: (node, event) => _onKey(event), + onKeyEvent: widget.onKeyEvent ?? (node, event) => _onKey(event), child: widget.child, ); }