Skip to content

Instantly share code, notes, and snippets.

@dickermoshe
Created September 12, 2025 17:52
Show Gist options
  • Select an option

  • Save dickermoshe/01e6dbdb13892b4703342c98602980db to your computer and use it in GitHub Desktop.

Select an option

Save dickermoshe/01e6dbdb13892b4703342c98602980db to your computer and use it in GitHub Desktop.
import 'dart:async';
import 'dart:ffi';
import 'package:jni/jni.dart';
import 'package:shiur_23_app/android_utils.dart';
import 'package:shiur_23_app/player.interface.dart';
import 'package:solidart/solidart.dart';
import 'dart:core' as core show Future;
import 'dart:core' hide Future;
/// A class which contains the connection to the MediaController
/// It is used to dispose of the MediaController when the player is disposed
class _PlayerConnection {
final MediaController mediaController;
final ListenableFuture<MediaController?> mediaControllerFuture;
final Timer positionTimer;
final Effect durationEffect;
final Arena arena;
_PlayerConnection({
required this.mediaController,
required this.mediaControllerFuture,
required this.positionTimer,
required this.durationEffect,
required this.arena,
});
void dispose() {
MediaController.releaseFuture(
mediaControllerFuture.as(Future.type(MediaController.nullableType)),
);
arena.releaseAll();
}
}
class AndroidPlayer implements MyPlayer, Finalizable {
final _PlayerConnection _connection;
@override
final Signal<PlayerState> playerState;
@override
final Signal<CurrentTrack?> currentTrack;
@override
final Signal<PlayerError?> playbackError;
@override
final Signal<double> speed;
@override
final Signal<int> position;
@override
final Signal<int?> duration;
@override
final Signal<bool> isPlaying;
final Arena arena;
/// A finalizer to dispose of the player when it is disposed
static final _finalizer = Finalizer<_PlayerConnection>((o) {
o.dispose();
});
/// The instance of the player
static AndroidPlayer? _instance;
AndroidPlayer(
this._connection, {
required this.playerState,
required this.currentTrack,
required this.playbackError,
required this.position,
required this.speed,
required this.duration,
required this.isPlaying,
required this.arena,
});
static core.Future<AndroidPlayer> create() async {
// If the player is already created, return the instance
if (_instance != null) {
return _instance!;
}
// Some signals to track the player state
final playerStateSignal = Signal(PlayerState.STATE_IDLE);
final currentTrackSignal = Signal<CurrentTrack?>(null);
final playbackErrorSignal = Signal<PlayerError?>(null);
final positionSignal = Signal<int>(0);
final durationSignal = Signal<int?>(null);
final isPlayingSignal = Signal<bool>(false);
// Arena to dispose of the objects
final arena = Arena();
// Get the context and build the MediaController
final contextRef = Jni.getCachedApplicationContext();
final context = Context.fromReference(contextRef);
final serviceName = "com.shiur23.shiur_23_app.PlaybackService".toJString()
..releasedBy(arena);
final componentName = ComponentName.new$1(context, serviceName)
..releasedBy(arena);
final sessionToken = SessionToken(context, componentName)
..releasedBy(arena);
final controllerBuilder = MediaController$Builder(context, sessionToken)
..releasedBy(arena);
final future = controllerBuilder.buildAsync()?..releasedBy(arena);
if (future == null) {
throw Exception("Failed to build MediaController");
}
// Wait for Java to build the MediaController
MediaController? mediaController;
future.addListener(
Runnable.implement(
$Runnable(
run$async: true,
run: () {
mediaController = switch (future.get()) {
MediaController controller => controller..releasedBy(arena),
_ => throw Exception("Failed to build MediaController"),
};
},
),
)..releasedBy(arena),
ContextCompat.getMainExecutor(context)..releasedBy(arena),
);
await core.Future(() async {
while (mediaController == null) {
await core.Future.delayed(Duration(milliseconds: 100));
}
}).timeout(Duration(seconds: 10));
if (mediaController == null) {
throw Exception("Failed to build MediaController");
}
// A signal to track the speed
final speed = switch (mediaController!.getPlaybackParameters()?.speed) {
double speed => Signal(speed),
_ => Signal(1.0),
};
// A signal to track the duration
final durationEffect = Effect(() {
final state = playerStateSignal.value;
if (state == PlayerState.STATE_READY) {
durationSignal.value = mediaController!.getDuration();
}
});
// Add a listener to the MediaController
// to track the player state
mediaController!.addListener(
Player$Listener.implement(
$Player$Listener(
onMediaItemTransition: (MediaItem? mediaItem, int i) {
if (mediaItem != null) {
currentTrackSignal.value = trackFromMediaItem(mediaItem);
} else {
currentTrackSignal.value = null;
}
},
onPlaybackStateChanged: (int i) {
playerStateSignal.value = PlayerState.values[i - 1];
},
onPlaybackParametersChanged:
(PlaybackParameters? playbackParameters) {
if (playbackParameters != null) {
speed.value = playbackParameters.speed;
}
},
onPlayerErrorChanged: (playbackException) {
if (playbackException == null) {
playbackErrorSignal.value = null;
} else {
final message =
"Error: ${playbackException.errorCode} at ${playbackException.timestampMs}";
playbackErrorSignal.value = PlayerError(message: message);
}
},
onPositionDiscontinuity: (int i) {},
onPositionDiscontinuity$1:
(
Player$PositionInfo? positionInfo,
Player$PositionInfo? positionInfo1,
int i,
) {},
onPlayerError: (JObject? playbackException) {},
onSeekBackIncrementChanged: (int j) {},
onSeekForwardIncrementChanged: (int j) {},
onMaxSeekToPreviousPositionChanged: (int j) {},
onAudioSessionIdChanged: (int i) {},
onAudioAttributesChanged: (JObject? audioAttributes) {},
onVolumeChanged: (double f) {},
onSkipSilenceEnabledChanged: (bool z) {},
onDeviceInfoChanged: (JObject? deviceInfo) {},
onDeviceVolumeChanged: (int i, bool z) {},
onVideoSizeChanged: (JObject? videoSize) {},
onSurfaceSizeChanged: (int i, int i1) {},
onRenderedFirstFrame: () {},
onCues: (JList<JObject?>? list) {},
onCues$1: (JObject? cueGroup) {},
onMetadata: (JObject? metadata) {},
onEvents: (Player? player, Player$Events? events) {},
onTimelineChanged: (JObject? timeline, int i) {},
onTracksChanged: (JObject? tracks) {},
onMediaMetadataChanged: (MediaMetadata? mediaMetadata) {},
onPlaylistMetadataChanged: (MediaMetadata? mediaMetadata) {},
onIsLoadingChanged: (bool z) {},
onLoadingChanged: (bool z) {},
onAvailableCommandsChanged: (Player$Commands? commands) {},
onTrackSelectionParametersChanged:
(JObject? trackSelectionParameters) {},
onPlayerStateChanged: (bool z, int i) {},
onPlayWhenReadyChanged: (bool z, int i) {
isPlayingSignal.value = z;
},
onPlaybackSuppressionReasonChanged: (int i) {},
onIsPlayingChanged: (bool z) {},
onRepeatModeChanged: (int i) {},
onShuffleModeEnabledChanged: (bool z) {},
),
)..releasedBy(arena),
);
// Fetch the position of the player every 100 milliseconds
final positionTimer = Timer.periodic(Duration(milliseconds: 100), (timer) {
positionSignal.value = mediaController!.getCurrentPosition();
});
// Create the connection to the MediaController
final connection = _PlayerConnection(
mediaController: mediaController!,
mediaControllerFuture: future,
positionTimer: positionTimer,
durationEffect: durationEffect,
arena: arena,
);
// Create the player
final player = AndroidPlayer(
connection,
playerState: playerStateSignal,
currentTrack: currentTrackSignal,
playbackError: playbackErrorSignal,
position: positionSignal,
speed: speed,
duration: durationSignal,
isPlaying: isPlayingSignal,
arena: arena,
);
// Attach the finalizer to the player
_finalizer.attach(player, connection, detach: player);
return player;
}
@override
void play() {
_connection.mediaController.play();
}
@override
void pause() {
_connection.mediaController.pause();
}
@override
void seek(int position) {
_connection.mediaController.seekTo(position);
}
@override
void setTrack(Track item) {
try {
final mediaItemBuilder = MediaItem$Builder();
switch (item) {
case LocalTrack localTrack:
final path = localTrack.file.path.toJString();
mediaItemBuilder.setUri$1(Uri.fromFile(File.new$1(path)));
break;
case RemoteTrack remoteTrack:
final url = remoteTrack.url.toJString();
mediaItemBuilder.setUri$1(Uri.parse(url));
break;
}
final mediaId = item.id.toJString();
mediaItemBuilder.setMediaId(mediaId);
final mediaMetadataBuilder = MediaMetadata$Builder();
final title = item.title.toJString();
final artist = item.artist.toJString();
mediaMetadataBuilder.setTitle(title.as(CharSequence.type));
mediaMetadataBuilder.setArtist(artist.as(CharSequence.type));
final artworkUrl = item.artworkUrl.toJString();
if (artworkUrl.toDartString().isNotEmpty) {
mediaMetadataBuilder.setArtworkUri(Uri.parse(artworkUrl));
}
final mediaMetadata = mediaMetadataBuilder.build();
mediaItemBuilder.setMediaMetadata(mediaMetadata);
final mediaItem = mediaItemBuilder.build();
if (mediaItem == null) {
throw Exception("Failed to build MediaItem");
}
_connection.mediaController.setMediaItem(mediaItem);
} catch (e) {
playbackError.value = PlayerError(message: "Failed to set track: $e");
}
}
@override
void setSpeed(double speed) {
_connection.mediaController.setPlaybackSpeed(speed);
}
}
CurrentTrack trackFromMediaItem(MediaItem mediaItem) {
final mediaId = mediaItem.mediaId?.toDartString() ?? 'unknown';
final mediaMetadata = mediaItem.mediaMetadata;
final title =
mediaMetadata?.title?.toString$1()?.toDartString() ?? 'Unknown Title';
final artist =
mediaMetadata?.artist?.toString$1()?.toDartString() ?? 'Unknown Artist';
final artworkUrl =
mediaMetadata?.artworkUri?.toString$1()?.toDartString() ?? '';
return CurrentTrack(
id: mediaId,
title: title,
artist: artist,
artworkUrl: artworkUrl,
);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment