From 27137c488da683493387af974db10cb949c4dacd Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Thu, 15 Oct 2026 10:53:30 -0400 Subject: [PATCH] replace media_kit's video widget with a custom impl Fixes #48 This helps Linux touchscreen devices like Linux phones, where the old widget didn't work correctly. --- lib/widgets/players/audio.dart | 74 ++------------------- lib/widgets/players/player_controls.dart | 84 ++++++++++++++++++++++++ lib/widgets/players/video.dart | 81 ++++++++++++++++++++++- 3 files changed, 169 insertions(+), 70 deletions(-) create mode 100644 lib/widgets/players/player_controls.dart diff --git a/lib/widgets/players/audio.dart b/lib/widgets/players/audio.dart index 8e3d83d..cd69126 100644 --- a/lib/widgets/players/audio.dart +++ b/lib/widgets/players/audio.dart @@ -6,6 +6,7 @@ import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:media_kit/media_kit.dart"; import "package:nexus/controllers/client.dart"; import "package:nexus/models/info/audio.dart"; +import "package:nexus/widgets/players/player_controls.dart"; class const AudioPlayer( final Uri uri, @@ -18,90 +19,27 @@ class const AudioPlayer( final player = useMemoized( () => Player(configuration: .new(bufferSize: 128 * 1024 * 1024)), ); - - final playing = useState(false); - final position = useState(Duration.zero); - final duration = useState(Duration.zero); + final loaded = useState(false); useEffect(() { - player.platform?.state = player.platform!.state.copyWith(buffering: true); scheduleMicrotask(() async { - final video = await ref + final audio = await ref .read(ClientController.provider.notifier) .downloadMedia(.new(mxc: uri, encrypted: encrypted)); - await player.open(Media(video.path), play: false); - - player.stream.playing.listen((value) { - playing.value = value; - }); - - player.stream.position.listen((value) { - position.value = value; - }); - - player.stream.duration.listen((value) { - duration.value = value; - }); + await player.open(Media(audio.path), play: false); + if (context.mounted) loaded.value = true; }); return player.dispose; }, []); - String format(Duration duration) { - final minutes = duration.inMinutes - .remainder(60) - .toString() - .padLeft(2, "0"); - final seconds = duration.inSeconds - .remainder(60) - .toString() - .padLeft(2, "0"); - - return "$minutes:$seconds"; - } - return SizedBox( height: 60, child: Card( color: Theme.of(context).colorScheme.surfaceContainer, child: Padding( padding: .only(left: 8, right: 16), - child: Row( - children: [ - if (player.state.buffering) - SizedBox.square( - dimension: 24, - child: CircularProgressIndicator(padding: .all(4)), - ) - else - IconButton( - onPressed: player.playOrPause, - icon: Icon( - playing.value ? Icons.pause_circle : Icons.play_circle, - ), - ), - SizedBox(width: 8), - Text( - format(position.value), - style: Theme.of(context).textTheme.bodySmall, - ), - Expanded( - child: Slider( - min: 0, - max: duration.value.inMilliseconds <= 0 - ? 1 - : duration.value.inMilliseconds.toDouble(), - value: position.value.inMilliseconds.toDouble(), - onChanged: (value) => - player.seek(.new(milliseconds: value.toInt())), - ), - ), - Text( - format(duration.value), - style: Theme.of(context).textTheme.bodySmall, - ), - ], - ), + child: PlayerControls(player, loading: !loaded.value), ), ), ); diff --git a/lib/widgets/players/player_controls.dart b/lib/widgets/players/player_controls.dart new file mode 100644 index 0000000..3945f59 --- /dev/null +++ b/lib/widgets/players/player_controls.dart @@ -0,0 +1,84 @@ +import "dart:math"; + +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:material_ui/material_ui.dart"; +import "package:media_kit/media_kit.dart"; + +/// Play/pause, position and seeking controls for a media_kit [Player]. +class const PlayerControls( + final Player player, { + final bool loading = false, + final Color? color, + final IList actions = const .empty(), + super.key, +}) extends HookWidget { + @override + Widget build(BuildContext context) { + final playing = useStream( + player.stream.playing, + initialData: player.state.playing, + ).requireData; + final buffering = useStream( + player.stream.buffering, + initialData: player.state.buffering, + ).requireData; + final position = useStream( + player.stream.position, + initialData: player.state.position, + ).requireData; + final duration = useStream( + player.stream.duration, + initialData: player.state.duration, + ).requireData; + + String format(Duration duration) { + final minutes = duration.inMinutes + .remainder(60) + .toString() + .padLeft(2, "0"); + final seconds = duration.inSeconds + .remainder(60) + .toString() + .padLeft(2, "0"); + + return "$minutes:$seconds"; + } + + final textStyle = Theme.of(context).textTheme.bodySmall + ?.copyWith(color: color); + final max = duration.inMilliseconds <= 0 + ? 1.0 + : duration.inMilliseconds.toDouble(); + + return Row( + children: [ + if (loading || buffering) + SizedBox.square( + dimension: 48, + child: CircularProgressIndicator(padding: .all(12), color: color), + ) + else + IconButton( + onPressed: player.playOrPause, + color: color, + tooltip: playing ? "Pause" : "Play", + icon: Icon(playing ? Icons.pause_circle : Icons.play_circle), + ), + SizedBox(width: 8), + Text(format(position), style: textStyle), + Expanded( + child: Slider( + max: max, + value: min(position.inMilliseconds.toDouble(), max), + onChanged: loading + ? null + : (value) => player.seek(.new(milliseconds: value.toInt())), + ), + ), + Text(format(duration), style: textStyle), + ...actions, + ], + ); + } +} diff --git a/lib/widgets/players/video.dart b/lib/widgets/players/video.dart index cf3c949..385602d 100644 --- a/lib/widgets/players/video.dart +++ b/lib/widgets/players/video.dart @@ -7,6 +7,7 @@ import "package:nexus/models/info/video.dart"; import "package:flutter_hooks/flutter_hooks.dart"; import "package:media_kit/media_kit.dart"; import "package:media_kit_video/media_kit_video.dart"; +import "package:nexus/widgets/players/player_controls.dart"; class const VideoPlayer( final Uri uri, @@ -20,19 +21,95 @@ class const VideoPlayer( () => Player(configuration: .new(bufferSize: 128 * 1024 * 1024)), ); final controller = useMemoized(() => VideoController(player)); + final loaded = useState(false); useEffect(() { - player.platform?.state = player.platform!.state.copyWith(buffering: true); scheduleMicrotask(() async { final video = await ref .read(ClientController.provider.notifier) .downloadMedia(.new(mxc: uri, encrypted: encrypted)); await player.open(Media(video.path), play: false); + if (context.mounted) loaded.value = true; }); return player.dispose; }, []); - return SizedBox(height: 300, child: Video(controller: controller)); + final aspectRatio = switch (info) { + VideoInfo(:final width?, :final height?) when width > 0 && height > 0 => + width / height, + _ => 16 / 9, + }; + + return ConstrainedBox( + constraints: const .new(maxHeight: 300), + child: AspectRatio( + aspectRatio: aspectRatio, + child: ClipRRect( + borderRadius: .circular(12), + child: Video( + controller: controller, + controls: (_) => + // Until media_kit moves to material_ui, this Material is needed. + Material( + type: .transparency, + child: Stack( + children: [ + Positioned.fill( + child: GestureDetector( + behavior: .opaque, + onTap: loaded.value ? player.playOrPause : null, + ), + ), + Positioned( + left: 0, + right: 0, + bottom: 0, + child: DecoratedBox( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: .bottomCenter, + end: .topCenter, + colors: [Colors.black54, Colors.transparent], + ), + ), + child: Padding( + padding: const .only(left: 4, right: 4, top: 16), + child: PlayerControls( + player, + loading: !loaded.value, + color: Colors.white, + actions: .new([ + Builder( + builder: (context) { + final fullscreen = isFullscreen(context); + + return IconButton( + onPressed: () => + toggleFullscreen(context), + color: Colors.white, + tooltip: fullscreen + ? "Exit fullscreen" + : "Fullscreen", + icon: Icon( + fullscreen + ? Icons.fullscreen_exit + : Icons.fullscreen, + ), + ); + }, + ), + ]), + ), + ), + ), + ), + ], + ), + ), + ), + ), + ), + ); } }