First working POC

This commit is contained in:
2026-01-26 22:21:54 +01:00
commit cfddc1edea
255 changed files with 77606 additions and 0 deletions
@@ -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)
}