First working POC
This commit is contained in:
@@ -0,0 +1,285 @@
|
||||
package com.dtourolle.jellytau.player
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.PlaybackException
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import kotlinx.coroutines.*
|
||||
|
||||
/**
|
||||
* JellyTau media player wrapper using ExoPlayer (Media3).
|
||||
*
|
||||
* This class is designed to be called from Rust via JNI.
|
||||
* All player operations are marshalled to the main thread.
|
||||
*/
|
||||
@OptIn(UnstableApi::class)
|
||||
class JellyTauPlayer(context: Context) {
|
||||
|
||||
companion object {
|
||||
/** Position update interval in milliseconds */
|
||||
private const val POSITION_UPDATE_INTERVAL_MS = 250L
|
||||
|
||||
/** Singleton instance for JNI access */
|
||||
@Volatile
|
||||
private var instance: JellyTauPlayer? = null
|
||||
|
||||
init {
|
||||
// Load the native library for JNI callbacks
|
||||
System.loadLibrary("jellytau_lib")
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the player singleton.
|
||||
* Called from Rust via JNI during Android startup.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun initialize(context: Context) {
|
||||
if (instance == null) {
|
||||
synchronized(this) {
|
||||
if (instance == null) {
|
||||
instance = JellyTauPlayer(context.applicationContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the singleton instance.
|
||||
* @throws IllegalStateException if not initialized
|
||||
*/
|
||||
@JvmStatic
|
||||
fun getInstance(): JellyTauPlayer {
|
||||
return instance ?: throw IllegalStateException("JellyTauPlayer not initialized")
|
||||
}
|
||||
}
|
||||
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
private val exoPlayer: ExoPlayer
|
||||
private val coroutineScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
|
||||
private var positionUpdateJob: Job? = null
|
||||
|
||||
/** Current media ID being played */
|
||||
private var currentMediaId: String? = null
|
||||
|
||||
init {
|
||||
// Create ExoPlayer on main thread
|
||||
exoPlayer = ExoPlayer.Builder(context).build()
|
||||
|
||||
// Set up player listener
|
||||
exoPlayer.addListener(object : Player.Listener {
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
when (playbackState) {
|
||||
Player.STATE_READY -> {
|
||||
// Media loaded and ready
|
||||
val duration = exoPlayer.duration / 1000.0
|
||||
nativeOnMediaLoaded(duration)
|
||||
|
||||
val state = if (exoPlayer.isPlaying) "playing" else "paused"
|
||||
nativeOnStateChanged(state, currentMediaId)
|
||||
}
|
||||
Player.STATE_ENDED -> {
|
||||
// Playback completed
|
||||
stopPositionUpdates()
|
||||
nativeOnPlaybackEnded()
|
||||
}
|
||||
Player.STATE_BUFFERING -> {
|
||||
nativeOnBuffering(0)
|
||||
}
|
||||
Player.STATE_IDLE -> {
|
||||
// Player is idle
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||
val state = if (isPlaying) "playing" else "paused"
|
||||
nativeOnStateChanged(state, currentMediaId)
|
||||
|
||||
if (isPlaying) {
|
||||
startPositionUpdates()
|
||||
} else {
|
||||
stopPositionUpdates()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPlayerError(error: PlaybackException) {
|
||||
val message = error.message ?: "Unknown playback error"
|
||||
val recoverable = error.errorCode != PlaybackException.ERROR_CODE_UNSPECIFIED
|
||||
nativeOnError(message, recoverable)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Load media from a URL.
|
||||
* @param url The media URL to load
|
||||
* @param mediaId The unique ID for this media item
|
||||
*/
|
||||
fun load(url: String, mediaId: String) {
|
||||
mainHandler.post {
|
||||
currentMediaId = mediaId
|
||||
val mediaItem = MediaItem.fromUri(url)
|
||||
exoPlayer.setMediaItem(mediaItem)
|
||||
exoPlayer.prepare()
|
||||
exoPlayer.playWhenReady = true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start or resume playback.
|
||||
*/
|
||||
fun play() {
|
||||
mainHandler.post {
|
||||
exoPlayer.play()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause playback.
|
||||
*/
|
||||
fun pause() {
|
||||
mainHandler.post {
|
||||
exoPlayer.pause()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop playback and release media.
|
||||
*/
|
||||
fun stop() {
|
||||
mainHandler.post {
|
||||
stopPositionUpdates()
|
||||
exoPlayer.stop()
|
||||
exoPlayer.clearMediaItems()
|
||||
currentMediaId = null
|
||||
nativeOnStateChanged("idle", null)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Seek to a position.
|
||||
* @param positionSeconds Position in seconds
|
||||
*/
|
||||
fun seek(positionSeconds: Double) {
|
||||
mainHandler.post {
|
||||
val positionMs = (positionSeconds * 1000).toLong()
|
||||
exoPlayer.seekTo(positionMs)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the volume.
|
||||
* @param volume Volume level from 0.0 to 1.0
|
||||
*/
|
||||
fun setVolume(volume: Float) {
|
||||
mainHandler.post {
|
||||
exoPlayer.volume = volume.coerceIn(0f, 1f)
|
||||
nativeOnVolumeChanged(exoPlayer.volume, false)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current playback position in seconds.
|
||||
*/
|
||||
fun getPosition(): Double {
|
||||
return exoPlayer.currentPosition / 1000.0
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the total duration in seconds.
|
||||
*/
|
||||
fun getDuration(): Double {
|
||||
val duration = exoPlayer.duration
|
||||
return if (duration > 0) duration / 1000.0 else 0.0
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current volume.
|
||||
*/
|
||||
fun getVolume(): Float {
|
||||
return exoPlayer.volume
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if media is currently loaded.
|
||||
*/
|
||||
fun isLoaded(): Boolean {
|
||||
return exoPlayer.playbackState == Player.STATE_READY ||
|
||||
exoPlayer.playbackState == Player.STATE_BUFFERING
|
||||
}
|
||||
|
||||
/**
|
||||
* Release player resources.
|
||||
* Call when the app is closing.
|
||||
*/
|
||||
fun release() {
|
||||
mainHandler.post {
|
||||
stopPositionUpdates()
|
||||
coroutineScope.cancel()
|
||||
exoPlayer.release()
|
||||
instance = null
|
||||
}
|
||||
}
|
||||
|
||||
private fun startPositionUpdates() {
|
||||
positionUpdateJob?.cancel()
|
||||
positionUpdateJob = coroutineScope.launch {
|
||||
while (isActive) {
|
||||
if (exoPlayer.isPlaying) {
|
||||
val position = exoPlayer.currentPosition / 1000.0
|
||||
val duration = if (exoPlayer.duration > 0) exoPlayer.duration / 1000.0 else 0.0
|
||||
nativeOnPositionUpdate(position, duration)
|
||||
}
|
||||
delay(POSITION_UPDATE_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopPositionUpdates() {
|
||||
positionUpdateJob?.cancel()
|
||||
positionUpdateJob = null
|
||||
}
|
||||
|
||||
// Native methods to call back to Rust via JNI
|
||||
// These will be implemented in the Rust android module
|
||||
|
||||
/**
|
||||
* Called when position updates during playback.
|
||||
*/
|
||||
private external fun nativeOnPositionUpdate(position: Double, duration: Double)
|
||||
|
||||
/**
|
||||
* Called when player state changes.
|
||||
*/
|
||||
private external fun nativeOnStateChanged(state: String, mediaId: String?)
|
||||
|
||||
/**
|
||||
* Called when media has finished loading.
|
||||
*/
|
||||
private external fun nativeOnMediaLoaded(duration: Double)
|
||||
|
||||
/**
|
||||
* Called when playback reaches the end.
|
||||
*/
|
||||
private external fun nativeOnPlaybackEnded()
|
||||
|
||||
/**
|
||||
* Called when buffering state changes.
|
||||
*/
|
||||
private external fun nativeOnBuffering(percent: Int)
|
||||
|
||||
/**
|
||||
* Called when a playback error occurs.
|
||||
*/
|
||||
private external fun nativeOnError(message: String, recoverable: Boolean)
|
||||
|
||||
/**
|
||||
* Called when volume changes.
|
||||
*/
|
||||
private external fun nativeOnVolumeChanged(volume: Float, muted: Boolean)
|
||||
}
|
||||
Reference in New Issue
Block a user