feat: Add volume indicator and scroll handles on entire screen (#443)

Co-authored-by: PartyDonut <PartyDonut@users.noreply.github.com>
This commit is contained in:
PartyDonut 2025-08-09 10:19:53 +02:00 committed by GitHub
parent 715e707bb6
commit d0d6a2ffa6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 191 additions and 95 deletions

View file

@ -1317,5 +1317,13 @@
} }
} }
}, },
"blurred": "Blurred" "blurred": "Blurred",
"volumeIndicator": "Volume: {volume}",
"@volumeIndicator": {
"placeholders": {
"volume": {
"type": "int"
}
}
}
} }

View file

@ -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<ConsumerStatefulWidget> createState() => _VideoPlayerVolumeIndicatorState();
}
class _VideoPlayerVolumeIndicatorState extends ConsumerState<VideoPlayerVolumeIndicator> {
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;
}

View file

@ -80,7 +80,7 @@ IconData volumeIcon(double value) {
return IconsaxPlusLinear.volume_mute; return IconsaxPlusLinear.volume_mute;
} }
if (value < 50) { if (value < 50) {
return IconsaxPlusLinear.volume_low; return IconsaxPlusLinear.volume_low_1;
} }
return IconsaxPlusLinear.volume_high; return IconsaxPlusLinear.volume_high;
} }

View file

@ -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_options_sheet.dart';
import 'package:fladder/screens/video_player/components/video_player_quality_controls.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_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_progress_bar.dart';
import 'package:fladder/screens/video_player/components/video_volume_slider.dart'; import 'package:fladder/screens/video_player/components/video_volume_slider.dart';
import 'package:fladder/util/adaptive_layout/adaptive_layout.dart'; import 'package:fladder/util/adaptive_layout/adaptive_layout.dart';
@ -71,89 +72,93 @@ class _DesktopControlsState extends ConsumerState<DesktopControls> {
final mediaSegments = ref.watch(playBackModel.select((value) => value?.mediaSegments)); final mediaSegments = ref.watch(playBackModel.select((value) => value?.mediaSegments));
final player = ref.watch(videoPlayerProvider); final player = ref.watch(videoPlayerProvider);
final subtitleWidget = player.subtitleWidget(showOverlay, controlsKey: _bottomControlsKey); final subtitleWidget = player.subtitleWidget(showOverlay, controlsKey: _bottomControlsKey);
return InputHandler( return Listener(
autoFocus: true, onPointerSignal: setVolume,
keyMap: ref.watch(videoPlayerSettingsProvider.select((value) => value.currentShortcuts)), child: InputHandler(
keyMapResult: (result) => _onKey(result), autoFocus: true,
child: PopScope( keyMap: ref.watch(videoPlayerSettingsProvider.select((value) => value.currentShortcuts)),
canPop: false, keyMapResult: (result) => _onKey(result),
onPopInvokedWithResult: (didPop, result) { child: PopScope(
if (!didPop) { canPop: false,
closePlayer(); onPopInvokedWithResult: (didPop, result) {
} if (!didPop) {
}, closePlayer();
child: MouseRegion( }
cursor: showOverlay ? SystemMouseCursors.basic : SystemMouseCursors.none, },
onExit: (event) => toggleOverlay(value: false), child: MouseRegion(
onEnter: (event) => toggleOverlay(value: true), cursor: showOverlay ? SystemMouseCursors.basic : SystemMouseCursors.none,
onHover: AdaptiveLayout.of(context).isDesktop || kIsWeb ? (event) => toggleOverlay(value: true) : null, onExit: (event) => toggleOverlay(value: false),
child: Stack( onEnter: (event) => toggleOverlay(value: true),
children: [ onHover: AdaptiveLayout.of(context).isDesktop || kIsWeb ? (event) => toggleOverlay(value: true) : null,
Positioned.fill( child: Stack(
child: GestureDetector( children: [
onTap: AdaptiveLayout.of(context).inputDevice == InputDevice.pointer Positioned.fill(
? () => player.playOrPause() child: GestureDetector(
: () => toggleOverlay(), onTap: AdaptiveLayout.of(context).inputDevice == InputDevice.pointer
onDoubleTap: AdaptiveLayout.of(context).inputDevice == InputDevice.pointer ? () => player.playOrPause()
? () => fullScreenHelper.toggleFullScreen(ref) : () => toggleOverlay(),
: null, 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),
],
), ),
), ),
), if (subtitleWidget != null) subtitleWidget,
const VideoPlayerSeekIndicator(), if (AdaptiveLayout.of(context).isDesktop)
Consumer( Consumer(builder: (context, ref, child) {
builder: (context, ref, child) { final playing = ref.watch(mediaPlaybackProvider.select((value) => value.playing));
final position = ref.watch(mediaPlaybackProvider.select((value) => value.position)); final buffering = ref.watch(mediaPlaybackProvider.select((value) => value.buffering));
MediaSegment? segment = mediaSegments?.atPosition(position); return playButton(playing, buffering);
SegmentVisibility forceShow = }),
segment?.visibility(position, force: showOverlay) ?? SegmentVisibility.hidden; IgnorePointer(
final segmentSkipType = ref ignoring: !showOverlay,
.watch(videoPlayerSettingsProvider.select((value) => value.segmentSkipSettings[segment?.type])); child: AnimatedOpacity(
final autoSkip = forceShow != SegmentVisibility.hidden && duration: fadeDuration,
segmentSkipType == SegmentSkip.skip && opacity: showOverlay ? 1 : 0,
player.lastState?.buffering == false; child: Column(
if (autoSkip) { children: [
skipToSegmentEnd(segment); topButtons(context),
} const Spacer(),
return Stack( bottomButtons(context),
children: [ ],
Align( ),
alignment: Alignment.centerRight, ),
child: Padding( ),
padding: const EdgeInsets.all(32), const VideoPlayerSeekIndicator(),
child: SkipSegmentButton( const VideoPlayerVolumeIndicator(),
segment: segment, Consumer(
skipType: segmentSkipType, builder: (context, ref, child) {
visibility: forceShow, final position = ref.watch(mediaPlaybackProvider.select((value) => value.position));
pressedSkip: () => skipToSegmentEnd(segment), 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<DesktopControls> {
}, },
if (AdaptiveLayout.of(context).inputDevice == InputDevice.pointer && if (AdaptiveLayout.of(context).inputDevice == InputDevice.pointer &&
AdaptiveLayout.viewSizeOf(context) > ViewSize.phone) ...[ AdaptiveLayout.viewSizeOf(context) > ViewSize.phone) ...[
Listener( VideoVolumeSlider(
onPointerSignal: (event) { onChanged: () => resetTimer(),
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(),
),
), ),
const FullScreenButton(), const FullScreenButton(),
] ]
@ -673,6 +667,16 @@ class _DesktopControlsState extends ConsumerState<DesktopControls> {
fullScreenHelper.closeFullScreen(ref); 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) { bool _onKey(VideoHotKeys value) {
final mediaSegments = ref.read(playBackModel.select((value) => value?.mediaSegments)); final mediaSegments = ref.read(playBackModel.select((value) => value?.mediaSegments));
final position = ref.read(mediaPlaybackProvider).position; final position = ref.read(mediaPlaybackProvider).position;

View file

@ -44,7 +44,7 @@ class _InputHandlerState<T> extends State<InputHandler<T>> {
focusNode.requestFocus(); focusNode.requestFocus();
} }
}, },
onKeyEvent: (node, event) => _onKey(event), onKeyEvent: widget.onKeyEvent ?? (node, event) => _onKey(event),
child: widget.child, child: widget.child,
); );
} }