diff --git a/commet/lib/config/preferences.dart b/commet/lib/config/preferences.dart index 106220c73..ba6259230 100644 --- a/commet/lib/config/preferences.dart +++ b/commet/lib/config/preferences.dart @@ -560,4 +560,9 @@ class Preferences { StringListPreference expandedSpaceGroups = StringListPreference("expanded_space_groups", defaultValue: []); + + DoublePreference playerVolume = + DoublePreference("player_volume", defaultValue: 100); + BoolPreference isPlayerMuted = + BoolPreference("is_player_muted", defaultValue: false); } diff --git a/commet/lib/ui/atoms/hover_menu.dart b/commet/lib/ui/atoms/hover_menu.dart new file mode 100644 index 000000000..7405faa97 --- /dev/null +++ b/commet/lib/ui/atoms/hover_menu.dart @@ -0,0 +1,94 @@ +import 'package:commet/utils/debounce.dart'; +import 'package:flutter/material.dart'; + +class HoverMenu extends StatefulWidget { + const HoverMenu( + {super.key, + this.menuAlignment = Alignment.topLeft, + this.parentAlignment = Alignment.topLeft, + this.onHoverStateChanged, + required this.builder, + required this.child}); + final Widget child; + final Widget Function(BuildContext context) builder; + final Function(bool hovered)? onHoverStateChanged; + final Alignment menuAlignment; + final Alignment parentAlignment; + + @override + State createState() => HoverMenuState(); +} + +class HoverMenuState extends State { + OverlayEntry? entry; + LayerLink link = LayerLink(); + + bool overlayHovered = false; + + Debouncer overlayRemoveDebounder = + Debouncer(delay: Duration(milliseconds: 20)); + + void addOverlay() { + widget.onHoverStateChanged?.call(true); + + if (entry != null) { + entry?.remove(); + entry = null; + } + + entry = OverlayEntry( + canSizeOverlay: true, + builder: (context) { + return Row( + children: [ + CompositedTransformFollower( + link: link, + followerAnchor: widget.menuAlignment, + targetAnchor: widget.parentAlignment, + child: MouseRegion( + onEnter: (event) { + overlayRemoveDebounder.cancel(); + }, + onExit: (event) { + overlayRemoveDebounder.run(removeOverlay); + }, + child: widget.builder(context))), + ], + ); + }, + ); + + Overlay.of( + context, + ).insert(entry!); + } + + void removeOverlay() { + widget.onHoverStateChanged?.call(false); + + if (entry != null) { + entry?.remove(); + entry = null; + } + } + + @override + Widget build(BuildContext context) { + return CompositedTransformTarget( + link: link, + child: MouseRegion( + onEnter: (event) { + overlayRemoveDebounder.cancel(); + if (entry == null) { + addOverlay(); + } + }, + onExit: (event) { + if (!overlayHovered) { + overlayRemoveDebounder.run(removeOverlay); + } + }, + child: widget.child), + ); + } +} diff --git a/commet/lib/ui/atoms/message_attachment.dart b/commet/lib/ui/atoms/message_attachment.dart index ae1b584cb..b669ec834 100644 --- a/commet/lib/ui/atoms/message_attachment.dart +++ b/commet/lib/ui/atoms/message_attachment.dart @@ -1,3 +1,5 @@ +import 'dart:math'; + import 'package:commet/client/attachment.dart'; import 'package:commet/config/build_config.dart'; import 'package:commet/ui/atoms/lightbox.dart'; @@ -107,12 +109,13 @@ class _MessageAttachmentState extends State { bool showInfo = widget.attachment.name != null && attachment.fileSize != null; - double height = 160; + double height = 200; return ClipRRect( borderRadius: BorderRadius.circular(10), child: SizedBox( height: widget.constrainSize ? height + (showInfo ? 30 : 0) : null, - width: widget.constrainSize ? attachment.aspectRatio * height : null, + width: max( + widget.constrainSize ? attachment.aspectRatio * height : 200, 200), child: Panel( mainAxisSize: MainAxisSize.min, header: showInfo diff --git a/commet/lib/ui/molecules/audio_player/audio_player.dart b/commet/lib/ui/molecules/audio_player/audio_player.dart index 25bd22983..db7df0be8 100644 --- a/commet/lib/ui/molecules/audio_player/audio_player.dart +++ b/commet/lib/ui/molecules/audio_player/audio_player.dart @@ -1,10 +1,12 @@ import 'dart:async'; import 'package:commet/cache/file_provider.dart'; +import 'package:commet/ui/atoms/hover_menu.dart'; import 'package:commet/utils/text_utils.dart'; import 'package:flutter/material.dart'; import 'package:media_kit/media_kit.dart'; import 'package:tiamat/tiamat.dart' as tiamat; +import 'package:commet/main.dart'; class AudioPlayer extends StatefulWidget { const AudioPlayer( @@ -32,9 +34,13 @@ class _AudioPlayerState extends State { void initState() { super.initState(); + setVolume(preferences.playerVolume.value); + if (preferences.isPlayerMuted.value) toggleIsMuted(); + subs = [ player.stream.playing.listen(onPlayingChanged), player.stream.position.listen(onPositionChanged), + player.stream.volume.listen(onVolumeChanged), if (widget.file.onProgressChanged != null) widget.file.onProgressChanged!.listen(onDownloadProgressChanged), ]; @@ -58,6 +64,12 @@ class _AudioPlayerState extends State { double displayPosition = 0; double? downloadProgress; + bool isMuted = false; + double volume = 100; + double appliedVolume = 100; + + final GlobalKey menuKey = GlobalKey(); + @override Widget build(BuildContext context) { return Container( @@ -141,7 +153,44 @@ class _AudioPlayerState extends State { onChanged: (value) => setState(() { displayPosition = value; }), - )) + )), + const SizedBox(width: 8), + HoverMenu( + key: menuKey, + menuAlignment: Alignment.bottomCenter, + parentAlignment: Alignment.topCenter, + child: tiamat.IconButton( + icon: isMuted + ? Icons.volume_mute + : volume == 0 + ? Icons.volume_off + : Icons.volume_up, + onPressed: (() { + toggleIsMuted(); + preferences.isPlayerMuted.set(isMuted); + }), + ), + builder: (context) { + return RotatedBox( + quarterTurns: 3, + child: Container( + decoration: BoxDecoration( + color: ColorScheme.of(context).surfaceContainer, + borderRadius: BorderRadius.circular(8)), + child: SizedBox( + width: 200, + height: 50, + child: tiamat.Slider( + value: volume / 100, + onChanged: (value) => setVolume(value * 100), + onChangeEnd: (value) => + preferences.playerVolume.set(appliedVolume), + ), + ), + )); + }, + ), + const SizedBox(width: 12), ], ), ], @@ -170,6 +219,18 @@ class _AudioPlayerState extends State { } } + void onPlayingChanged(bool event) { + if (event) { + setState(() { + state = AudioPlayerState.playing; + }); + } else { + setState(() { + state = AudioPlayerState.paused; + }); + } + } + void loadAudio() async { var uri = await widget.file.resolve(); @@ -183,16 +244,11 @@ class _AudioPlayerState extends State { }); } - void onPlayingChanged(bool event) { - if (event) { - setState(() { - state = AudioPlayerState.playing; - }); - } else { - setState(() { - state = AudioPlayerState.paused; - }); - } + void onDownloadProgressChanged(DownloadProgress event) { + print(event); + setState(() { + downloadProgress = event.downloaded.toDouble() / event.total.toDouble(); + }); } void onPositionChanged(Duration event) { @@ -208,10 +264,44 @@ class _AudioPlayerState extends State { } } - void onDownloadProgressChanged(DownloadProgress event) { - print(event); + void toggleIsMuted() { + if (!isMuted) { + setState(() { + isMuted = true; + appliedVolume = 0; + }); + player.setVolume(0.0); + } else { + setState(() { + isMuted = false; + appliedVolume = preferences.playerVolume.value; + }); + player.setVolume(appliedVolume); + } + + if (menuKey.currentState case HoverMenuState state) { + state.entry?.markNeedsBuild(); + } + } + + void setVolume(double value) { setState(() { - downloadProgress = event.downloaded.toDouble() / event.total.toDouble(); + volume = value; + appliedVolume = value; + isMuted = false; + }); + + if (menuKey.currentState case HoverMenuState state) { + state.entry?.markNeedsBuild(); + } + + player.setVolume(value); + } + + void onVolumeChanged(double event) { + setState(() { + volume = event; + appliedVolume = isMuted ? 0 : volume; }); } } diff --git a/commet/lib/ui/molecules/video_player/video_player.dart b/commet/lib/ui/molecules/video_player/video_player.dart index 9749ab142..0fee443d9 100644 --- a/commet/lib/ui/molecules/video_player/video_player.dart +++ b/commet/lib/ui/molecules/video_player/video_player.dart @@ -2,9 +2,11 @@ import 'dart:async'; import 'package:commet/cache/file_provider.dart'; import 'package:commet/config/build_config.dart'; +import 'package:commet/ui/atoms/hover_menu.dart'; import 'package:commet/ui/atoms/tiny_pill.dart'; import 'package:commet/ui/molecules/video_player/video_player_implementation.dart'; import 'package:commet/utils/text_utils.dart'; +import 'package:commet/main.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:tiamat/tiamat.dart' as tiamat; @@ -47,20 +49,34 @@ class VideoPlayerState extends State { bool buffering = false; DownloadProgress? downloadProgress; late bool showThumbnail; - bool shouldShowControls = true; + + bool currentlyHovered = true; bool isCompleted = false; + bool showingVolumeSlider = false; double videoProgress = 0; bool updateSlider = true; Timer? uiHideTimer; + bool isMuted = false; + double volume = 100; + double appliedVolume = 100; + + final GlobalKey menuKey = GlobalKey(); late List subscriptions; + bool get shouldShowControls => + currentlyHovered || isCompleted || showingVolumeSlider; + @override void initState() { showThumbnail = widget.doThumbnail; controller = widget.controller ?? VideoPlayerController(); + setVolume(preferences.playerVolume.value); + + isMuted = preferences.isPlayerMuted.value; + subscriptions = [ controller.isBuffering.listen((isBuffering) { setState(() { @@ -74,7 +90,6 @@ class VideoPlayerState extends State { controller.isCompleted.listen((event) { setState(() { isCompleted = event; - if (isCompleted) shouldShowControls = true; }); }), controller.onDownloadProgressed.listen((event) { @@ -93,7 +108,8 @@ class VideoPlayerState extends State { 1); }); } - }) + }), + controller.onVolumeChanged.listen(onVolumeChanged) ]; super.initState(); @@ -170,18 +186,18 @@ class VideoPlayerState extends State { onTap: () { if (BuildConfig.MOBILE) { if (shouldShowControls) { - hideControls(); + onUnhovered(); } else { - showControls(); + onHovered(); } } }, child: MouseRegion( onEnter: (_) { - showControls(); + onHovered(); }, onExit: (_) { - hideControls(); + onUnhovered(); }, child: AnimatedOpacity( opacity: shouldShowControls ? 1.0 : 0.0, @@ -249,6 +265,57 @@ class VideoPlayerState extends State { }, ), ), + if (widget.showProgressBar) + Padding( + padding: EdgeInsetsGeometry.fromLTRB(0, 0, 8, 0), + child: SizedBox( + height: 24, + width: 24, + child: HoverMenu( + key: menuKey, + menuAlignment: Alignment.bottomCenter, + parentAlignment: Alignment.topCenter, + onHoverStateChanged: (hovered) => + setState(() { + showingVolumeSlider = hovered; + }), + child: tiamat.IconButton( + icon: isMuted + ? Icons.volume_mute + : appliedVolume == 0 + ? Icons.volume_off + : Icons.volume_up, + onPressed: (() { + toggleIsMuted(); + preferences.isPlayerMuted.set(isMuted); + }), + ), + builder: (context) { + return RotatedBox( + quarterTurns: 3, + child: Container( + decoration: BoxDecoration( + color: ColorScheme.of(context) + .surfaceContainer, + borderRadius: + BorderRadius.circular(8)), + child: SizedBox( + width: 200, + height: 50, + child: tiamat.Slider( + value: appliedVolume / 100, + onChanged: (value) => + setVolume(value * 100), + onChangeEnd: (value) => + preferences.playerVolume + .set(volume), + ), + ), + )); + }, + ), + ), + ), if (widget.canGoFullscreen) Padding( padding: const EdgeInsets.fromLTRB(0, 0, 8, 0), @@ -284,9 +351,9 @@ class VideoPlayerState extends State { setState(() { inited = true; playing = true; - shouldShowControls = false; + isCompleted = false; controller.play(); - if (BuildConfig.MOBILE) hideControls(); + if (BuildConfig.MOBILE) onUnhovered(); }); } @@ -294,25 +361,25 @@ class VideoPlayerState extends State { setState(() { playing = true; controller.replay(); - shouldShowControls = false; - if (BuildConfig.MOBILE) hideControls(); + isCompleted = false; + if (BuildConfig.MOBILE) onUnhovered(); }); } - void showControls() { + void onHovered() { setState(() { - shouldShowControls = true; + currentlyHovered = true; }); if (BuildConfig.MOBILE) { uiHideTimer?.cancel(); - uiHideTimer = Timer(const Duration(seconds: 3), hideControls); + uiHideTimer = Timer(const Duration(seconds: 3), onUnhovered); } } - void hideControls() { + void onUnhovered() { setState(() { - shouldShowControls = false; + currentlyHovered = false; }); uiHideTimer?.cancel(); } @@ -324,6 +391,47 @@ class VideoPlayerState extends State { }); } + void toggleIsMuted() { + if (!isMuted) { + setState(() { + isMuted = true; + appliedVolume = 0; + }); + controller.setVolume(0.0); + } else { + setState(() { + isMuted = false; + appliedVolume = preferences.playerVolume.value; + }); + controller.setVolume(appliedVolume); + } + + if (menuKey.currentState case HoverMenuState state) { + state.entry?.markNeedsBuild(); + } + } + + void setVolume(double value) { + setState(() { + volume = value; + appliedVolume = value; + isMuted = false; + }); + + if (menuKey.currentState case HoverMenuState state) { + state.entry?.markNeedsBuild(); + } + + controller.setVolume(value); + } + + void onVolumeChanged(double event) { + setState(() { + volume = event; + appliedVolume = isMuted ? 0 : volume; + }); + } + Widget pickPlayer() { return VideoPlayerImplementation( controller: controller, diff --git a/commet/lib/ui/molecules/video_player/video_player_controller.dart b/commet/lib/ui/molecules/video_player/video_player_controller.dart index 88a9949a7..5381c9495 100644 --- a/commet/lib/ui/molecules/video_player/video_player_controller.dart +++ b/commet/lib/ui/molecules/video_player/video_player_controller.dart @@ -13,12 +13,16 @@ class VideoPlayerController { Future Function(Duration percent)? _seekTo; + Future Function(double volume)? _setVolume; + Future Function()? _screenshot; Future Function()? _getSize; Future Function()? _getLength; + Future Function()? _getVolume; + final StreamController _isBuffering = StreamController.broadcast(); final StreamController _downloadProgress = @@ -28,12 +32,17 @@ class VideoPlayerController { final StreamController _onProgressed = StreamController.broadcast(); + final StreamController _onVolumeChanged = + StreamController.broadcast(); + Stream get isBuffering => _isBuffering.stream; Stream get isCompleted => _isCompleted.stream; Stream get onProgressed => _onProgressed.stream; + Stream get onVolumeChanged => _onVolumeChanged.stream; + Stream get onDownloadProgressed => _downloadProgress.stream; void attach( @@ -42,8 +51,10 @@ class VideoPlayerController { required Future Function() replay, required Future Function() getLength, required Future Function() getSize, + required Future Function() getVolume, Future Function()? screenshot, - required Future Function(Duration percent) seekTo}) { + required Future Function(Duration percent) seekTo, + required Future Function(double volume) setVolume}) { _onPause = pause; _onPlay = play; _onReplay = replay; @@ -51,6 +62,8 @@ class VideoPlayerController { _getLength = getLength; _screenshot = screenshot; _getSize = getSize; + _getVolume = getVolume; + _setVolume = setVolume; } Future pause() async { @@ -69,6 +82,10 @@ class VideoPlayerController { await _seekTo!.call(duration); } + Future setVolume(double volume) async { + await _setVolume?.call(volume); + } + Future screenshot() async { return _screenshot?.call(); } @@ -89,6 +106,10 @@ class VideoPlayerController { _onProgressed.add(progress); } + void handleInternalVolumeChange(double volume) { + _onVolumeChanged.add(volume); + } + Future getLength() async { return await _getLength!.call(); } @@ -96,4 +117,8 @@ class VideoPlayerController { Future getSize() async { return await _getSize!.call(); } + + Future getVolume() async { + return await _getVolume!.call(); + } } diff --git a/commet/lib/ui/molecules/video_player/video_player_implementation.dart b/commet/lib/ui/molecules/video_player/video_player_implementation.dart index 5b59410b7..c49f6c5bd 100644 --- a/commet/lib/ui/molecules/video_player/video_player_implementation.dart +++ b/commet/lib/ui/molecules/video_player/video_player_implementation.dart @@ -41,14 +41,22 @@ class _VideoPlayerImplementationState extends State { player = Player(); + if (preferences.isPlayerMuted.value) { + player.setVolume(0); + } else { + player.setVolume(preferences.playerVolume.value); + } + widget.controller.attach( pause: pause, play: play, replay: replay, screenshot: screenshot, getSize: getSize, + getVolume: getVolume, seekTo: seekTo, - getLength: getLength); + getLength: getLength, + setVolume: setVolume); player.stream.position.listen((event) { widget.controller.setProgress(event); @@ -60,6 +68,8 @@ class _VideoPlayerImplementationState extends State { }, ); + player.stream.volume.listen(widget.controller.handleInternalVolumeChange); + controller = VideoController(player); if (widget.streamUrl == null) { @@ -152,6 +162,10 @@ class _VideoPlayerImplementationState extends State { await player.seek(duration); } + Future setVolume(double volume) async { + await player.setVolume(volume); + } + Future getLength() async { return player.state.duration; } @@ -164,4 +178,8 @@ class _VideoPlayerImplementationState extends State { return Size( player.state.width!.toDouble(), player.state.height!.toDouble()); } + + Future getVolume() async { + return player.state.volume; + } }