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.
This commit is contained in:
Henry Hiles 2026-10-15 10:53:30 -04:00
commit 27137c488d
Signed by: Henry-Hiles
SSH key fingerprint: SHA256:VKQUdS31Q90KvX7EkKMHMBpUspcmItAh86a+v7PGiIs
3 changed files with 169 additions and 70 deletions

View file

@ -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),
),
),
);

View file

@ -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<Widget> 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,
],
);
}
}

View file

@ -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,
),
);
},
),
]),
),
),
),
),
],
),
),
),
),
),
);
}
}