rip out media_kit, use better_player and audioplayers

This also let us rip out cocoapods stuff.

Needs testing on Windows, MacOS, and ideally, iOS.
This commit is contained in:
Henry Hiles 2026-11-11 23:25:28 -05:00
commit 37a2c150f3
Signed by: Henry-Hiles
SSH key fingerprint: SHA256:VKQUdS31Q90KvX7EkKMHMBpUspcmItAh86a+v7PGiIs
4 changed files with 375 additions and 604 deletions

View file

@ -4,7 +4,6 @@ import "package:dynamic_color/dynamic_color.dart";
import "package:flutter/foundation.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:material_emoji_picker/material_emoji_picker.dart";
import "package:media_kit/media_kit.dart";
import "package:nexus/controllers/active_account.dart";
import "package:nexus/controllers/client.dart";
import "package:nexus/controllers/client_state.dart";
@ -65,7 +64,6 @@ void showError(Object error, [StackTrace? stackTrace]) {
void main(List<String> args) async {
WidgetsFlutterBinding.ensureInitialized();
MediaKit.ensureInitialized();
LicenseRegistry.addLicense(() => .fromIterable(fontLicenses));

View file

@ -1,9 +1,9 @@
import "dart:async";
import "package:audioplayers/audioplayers.dart" as audioplayers;
import "package:material_ui/material_ui.dart";
import "package:flutter_hooks/flutter_hooks.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:media_kit/media_kit.dart";
import "package:nexus/controllers/client.dart";
import "package:nexus/helpers/extensions/save_attachment.dart";
import "package:nexus/models/info/audio.dart";
@ -18,9 +18,7 @@ class const AudioPlayer(
}) extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final player = useMemoized(
() => Player(configuration: .new(bufferSize: 128 * 1024 * 1024)),
);
final player = useMemoized(audioplayers.AudioPlayer.new);
final loaded = useState(false);
useEffect(() {
@ -29,19 +27,39 @@ class const AudioPlayer(
.read(ClientController.provider.notifier)
.downloadMedia(.new(mxc: uri, encrypted: encrypted));
if (!context.mounted) return;
await player.open(Media(audio.path), play: false);
// Keeps the audio loaded after it finishes, so it can be replayed
await player.setReleaseMode(.stop);
await player.setSource(audioplayers.DeviceFileSource(audio.path));
if (context.mounted) loaded.value = true;
});
return player.dispose;
}, []);
final state = useStream(
player.onPlayerStateChanged,
initialData: player.state,
).requireData;
final position = useStream(
player.onPositionChanged,
initialData: Duration.zero,
).requireData;
final duration = useStream(
player.onDurationChanged,
initialData: Duration.zero,
).requireData;
final playing = state == .playing;
return Card(
color: Theme.of(context).colorScheme.surfaceContainerHigh,
child: Padding(
padding: .symmetric(horizontal: 8),
child: PlayerControls(
player,
playing: playing,
position: position,
duration: duration,
onPlayPause: playing ? player.pause : player.resume,
onSeek: player.seek,
loading: !loaded.value,
size: info?.size,
actions: .new([

View file

@ -1,39 +1,25 @@
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";
import "package:nexus/helpers/extensions/size_to_string.dart";
/// Play/pause, position and seeking controls for a media_kit [Player].
class const PlayerControls(
final Player player, {
/// Play/pause, position and seeking controls, for any player.
class const PlayerControls({
required final bool playing,
required final Duration position,
required final Duration duration,
required final VoidCallback onPlayPause,
required final ValueChanged<Duration> onSeek,
final bool loading = false,
final bool buffering = false,
final Color? color,
final int? size,
final IList<Widget> actions = const .empty(),
super.key,
}) extends HookWidget {
}) extends StatelessWidget {
@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)
@ -61,7 +47,7 @@ class const PlayerControls(
child: CircularProgressIndicator(padding: .all(12), color: color),
)
: IconButton(
onPressed: player.playOrPause,
onPressed: onPlayPause,
color: color,
tooltip: playing ? "Pause" : "Play",
icon: Icon(playing ? Icons.pause_circle : Icons.play_circle),
@ -73,7 +59,7 @@ class const PlayerControls(
value: min(position.inMilliseconds.toDouble(), max),
onChanged: loading
? null
: (value) => player.seek(.new(milliseconds: value.toInt())),
: (value) => onSeek(.new(milliseconds: value.toInt())),
);
return LayoutBuilder(

View file

@ -1,13 +1,12 @@
import "dart:async";
import "package:better_player/better_player.dart";
import "package:material_ui/material_ui.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:nexus/controllers/client.dart";
import "package:nexus/helpers/extensions/save_attachment.dart";
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(
@ -19,11 +18,118 @@ class const VideoPlayer(
}) extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final player = useMemoized(
() => Player(configuration: .new(bufferSize: 128 * 1024 * 1024)),
);
final controller = useMemoized(() => VideoController(player));
final aspectRatio = switch (info) {
VideoInfo(:final width?, :final height?) when width > 0 && height > 0 =>
width / height,
_ => 16 / 9,
};
final loaded = useState(false);
final controller = useMemoized(
() => BetterPlayerController(
PlayerConfiguration(
aspectRatio: aspectRatio,
fit: .contain,
controlsConfiguration: PlayerControlsConfiguration(
playerTheme: .custom,
customControlsBuilder: (controller, _) => HookBuilder(
builder: (context) {
final isLoaded = useValueListenable(loaded);
final value = useState(controller.videoPlayerValue);
useEffect(() {
void listener() => value.value = controller.videoPlayerValue;
controller.addVideoListener(listener);
return () => controller.removeVideoListener(listener);
}, [controller]);
final playing = value.value?.isPlaying ?? false;
// better_player doesn't use material_ui, so this Material is needed.
return Material(
type: .transparency,
child: Stack(
children: [
Positioned.fill(
child: GestureDetector(
behavior: .opaque,
onTap: isLoaded
? playing
? controller.pause
: controller.play
: null,
),
),
Positioned(
top: 4,
right: 4,
child: IconButton(
onPressed: () => context.saveAttachment(
ref,
uri,
encrypted: encrypted,
filename: filename,
),
color: Colors.white,
style: IconButton.styleFrom(
backgroundColor: Colors.black38,
),
tooltip: "Download",
icon: Icon(Icons.download),
),
),
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(
playing: playing,
buffering: value.value?.isBuffering ?? false,
position: value.value?.position ?? .zero,
duration: value.value?.duration ?? .zero,
onPlayPause: playing
? controller.pause
: controller.play,
onSeek: controller.seekTo,
loading: !isLoaded,
size: info?.size,
color: Colors.white,
actions: .new([
IconButton(
onPressed: controller.toggleFullScreen,
color: Colors.white,
tooltip: controller.isFullScreen
? "Exit fullscreen"
: "Fullscreen",
icon: Icon(
controller.isFullScreen
? Icons.fullscreen_exit
: Icons.fullscreen,
),
),
]),
),
),
),
),
],
),
);
},
),
),
),
),
);
useEffect(() {
scheduleMicrotask(() async {
@ -31,109 +137,20 @@ class const VideoPlayer(
.read(ClientController.provider.notifier)
.downloadMedia(.new(mxc: uri, encrypted: encrypted));
if (!context.mounted) return;
await player.open(Media(video.path), play: false);
await controller.setupDataSource(.file(video.path));
if (context.mounted) loaded.value = true;
});
return player.dispose;
return null;
}, []);
final controls = useCallback(
(VideoState _) =>
// Until media_kit moves to material_ui, this Material is needed.
Material(
type: .transparency,
child: ValueListenableBuilder(
valueListenable: loaded,
builder: (_, loaded, _) => Stack(
children: [
Positioned.fill(
child: GestureDetector(
behavior: .opaque,
onTap: loaded ? player.playOrPause : null,
),
),
Positioned(
top: 4,
right: 4,
child: IconButton(
onPressed: () => context.saveAttachment(
ref,
uri,
encrypted: encrypted,
filename: filename,
),
color: Colors.white,
style: IconButton.styleFrom(
backgroundColor: Colors.black38,
),
tooltip: "Download",
icon: Icon(Icons.download),
),
),
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,
size: info?.size,
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,
),
);
},
),
]),
),
),
),
),
],
),
),
),
[uri, encrypted, filename, info],
);
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: controls),
child: BetterPlayer(controller: controller),
),
),
);