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
+51
View File
@@ -0,0 +1,51 @@
# ⚠️ IMPORTANT: Android Build File Locations
## Critical Information for Future Development
**DO NOT EDIT FILES IN `src-tauri/gen/android/` DIRECTLY!**
### File Structure
This project has **TWO** sets of Android source files:
1. **`src-tauri/android/`** - **SOURCE FILES** (edit these!)
- This is the template directory
- Changes here need to be copied to the generated directory
2. **`src-tauri/gen/android/`** - **GENERATED BUILD DIRECTORY** (do not edit directly!)
- This is where Gradle actually builds the APK
- Files here may be overwritten during builds
### How to Make Changes to Android Code
When you need to modify Android/Kotlin files:
1. **Edit the files in `src-tauri/android/src/main/java/`**
2. **Build using the provided script (which auto-syncs files)**
```bash
./scripts/build-android.sh
```
The build script automatically runs `./scripts/sync-android-sources.sh` which copies:
- `src-tauri/android/src/main/java/com/dtourolle/jellytau/player/` → generated directory
- `src-tauri/android/src/main/java/com/dtourolle/jellytau/security/` → generated directory
3. **Manual sync (if needed)**
```bash
./scripts/sync-android-sources.sh
```
### Why This Matters
- If you only edit `src-tauri/gen/android/`, your changes will be lost
- If you only edit `src-tauri/android/`, your changes won't be in the build
- **You must edit both** (or edit source and copy to generated)
### Key Files
Player-related Kotlin files:
- `player/JellyTauPlayer.kt` - Main player implementation
- `player/JellyTauPlaybackService.kt` - MediaSession service for lockscreen controls
- `security/SecureStorage.kt` - Android Keystore integration for secure credential storage
Always check BOTH locations exist and match after making changes!
@@ -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)
}
+39
View File
@@ -0,0 +1,39 @@
plugins {
id("com.android.library")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "com.dtourolle.jellytau.player"
compileSdk = 36
defaultConfig {
minSdk = 24
}
buildTypes {
getByName("debug") {
}
getByName("release") {
isMinifyEnabled = false
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
}
dependencies {
implementation("androidx.media3:media3-exoplayer:1.5.1")
implementation("androidx.media3:media3-exoplayer-hls:1.5.1")
implementation("androidx.media3:media3-common:1.5.1")
implementation("androidx.media3:media3-session:1.5.1")
implementation("androidx.media:media:1.7.0") // For MediaSessionCompat and VolumeProviderCompat
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
}
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Enable hardware acceleration for video playback performance -->
<application android:hardwareAccelerated="true" />
</manifest>
@@ -0,0 +1,226 @@
package com.dtourolle.jellytau
import android.content.Context
import android.media.AudioAttributes
import android.media.AudioFocusRequest
import android.media.AudioManager
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.webkit.JavascriptInterface
import android.webkit.WebChromeClient
import android.webkit.WebSettings
import android.webkit.WebView
import android.view.View
import androidx.activity.enableEdgeToEdge
class MainActivity : TauriActivity() {
private val handler = Handler(Looper.getMainLooper())
private var configAttempts = 0
private val maxConfigAttempts = 10
private var audioFocusRequest: AudioFocusRequest? = null
private val audioManager by lazy { getSystemService(Context.AUDIO_SERVICE) as AudioManager }
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
// Configure WebView for media playback after Tauri initialization
handler.postDelayed({
configureWebViewForMedia()
}, 500)
}
override fun onResume() {
super.onResume()
configureWebViewForMedia()
}
private fun configureWebViewForMedia() {
try {
val webView = findWebView(window.decorView)
if (webView == null) {
android.util.Log.w("MainActivity", "WebView not found (attempt ${configAttempts + 1}/$maxConfigAttempts)")
if (configAttempts < maxConfigAttempts) {
configAttempts++
handler.postDelayed({
configureWebViewForMedia()
}, 200)
} else {
android.util.Log.e("MainActivity", "Failed to find WebView after $maxConfigAttempts attempts")
}
return
}
android.util.Log.d("MainActivity", "WebView found! Configuring settings...")
// Add JavaScript interface for audio focus control
webView.addJavascriptInterface(object : Any() {
@JavascriptInterface
fun requestAudioFocus() {
handler.post { this@MainActivity.requestAudioFocus() }
}
@JavascriptInterface
fun abandonAudioFocus() {
handler.post { this@MainActivity.abandonAudioFocus() }
}
}, "AndroidAudioFocus")
android.util.Log.d("MainActivity", "JavaScript interface 'AndroidAudioFocus' added")
// Set WebChromeClient to handle video playback and audio focus
webView.webChromeClient = object : WebChromeClient() {
override fun onShowCustomView(view: View?, callback: CustomViewCallback?) {
super.onShowCustomView(view, callback)
android.util.Log.d("MainActivity", "Video entered fullscreen")
}
override fun onHideCustomView() {
super.onHideCustomView()
android.util.Log.d("MainActivity", "Video exited fullscreen")
}
}
android.util.Log.d("MainActivity", "WebChromeClient configured")
webView.settings.apply {
// CRITICAL: Enable media playback without user gesture requirement
mediaPlaybackRequiresUserGesture = false
android.util.Log.d("MainActivity", "Set mediaPlaybackRequiresUserGesture = false")
javaScriptEnabled = true
domStorageEnabled = true
allowFileAccess = true
allowContentAccess = true
setRenderPriority(WebSettings.RenderPriority.HIGH)
mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
android.util.Log.d("MainActivity", "WebView fully configured for media playback")
}
// Execute JavaScript to ensure any video elements are unmuted and request audio focus
webView.post {
webView.evaluateJavascript("""
(function() {
console.log('[Android] Ensuring video elements are unmuted');
const videos = document.getElementsByTagName('video');
for (let video of videos) {
video.muted = false;
video.volume = 1.0;
console.log('[Android] Video unmuted, volume:', video.volume, 'muted:', video.muted);
// Add event listeners to manage audio focus
video.addEventListener('play', function() {
console.log('[Android] Video play event - requesting audio focus');
if (typeof AndroidAudioFocus !== 'undefined') {
AndroidAudioFocus.requestAudioFocus();
}
console.log('[Android] Video state - muted:', this.muted, 'volume:', this.volume);
});
video.addEventListener('pause', function() {
console.log('[Android] Video pause event - abandoning audio focus');
if (typeof AndroidAudioFocus !== 'undefined') {
AndroidAudioFocus.abandonAudioFocus();
}
});
video.addEventListener('ended', function() {
console.log('[Android] Video ended event - abandoning audio focus');
if (typeof AndroidAudioFocus !== 'undefined') {
AndroidAudioFocus.abandonAudioFocus();
}
});
video.addEventListener('volumechange', function() {
console.log('[Android] Video volume changed - volume:', this.volume, 'muted:', this.muted);
});
}
// Monitor for new video elements
const observer = new MutationObserver(() => {
const videos = document.getElementsByTagName('video');
for (let video of videos) {
if (video.muted) {
video.muted = false;
video.volume = 1.0;
console.log('[Android] New video found and unmuted');
}
}
});
observer.observe(document.body, { childList: true, subtree: true });
console.log('[Android] Video unmute observer installed');
})();
""".trimIndent(), null)
}
} catch (e: Exception) {
android.util.Log.e("MainActivity", "Failed to configure WebView for media", e)
}
}
private fun findWebView(view: android.view.View): WebView? {
if (view is WebView) {
android.util.Log.d("MainActivity", "Found WebView!")
return view
}
if (view is android.view.ViewGroup) {
for (i in 0 until view.childCount) {
val child = view.getChildAt(i)
val webView = findWebView(child)
if (webView != null) {
return webView
}
}
}
return null
}
private fun requestAudioFocus() {
android.util.Log.d("MainActivity", "Requesting audio focus for video playback")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val audioAttributes = AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_MOVIE)
.build()
audioFocusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
.setAudioAttributes(audioAttributes)
.setAcceptsDelayedFocusGain(true)
.setOnAudioFocusChangeListener { focusChange ->
android.util.Log.d("MainActivity", "Audio focus changed: $focusChange")
}
.build()
val result = audioManager.requestAudioFocus(audioFocusRequest!!)
android.util.Log.d("MainActivity", "Audio focus request result: $result")
} else {
@Suppress("DEPRECATION")
val result = audioManager.requestAudioFocus(
{ focusChange ->
android.util.Log.d("MainActivity", "Audio focus changed: $focusChange")
},
AudioManager.STREAM_MUSIC,
AudioManager.AUDIOFOCUS_GAIN
)
android.util.Log.d("MainActivity", "Audio focus request result (legacy): $result")
}
}
private fun abandonAudioFocus() {
android.util.Log.d("MainActivity", "Abandoning audio focus")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
audioFocusRequest?.let {
audioManager.abandonAudioFocusRequest(it)
}
} else {
@Suppress("DEPRECATION")
audioManager.abandonAudioFocus { }
}
}
}
@@ -0,0 +1,130 @@
package com.dtourolle.jellytau.player
import android.media.MediaCodecList
import android.util.Log
/**
* Detects hardware codec capabilities using MediaCodecList.
*
* This class queries the device's media codec capabilities and reports
* them to the Rust backend via JNI for accurate DeviceProfile generation.
*/
object CodecDetector {
private const val TAG = "CodecDetector"
/**
* Data class to hold detected codec capabilities.
*/
data class CodecCapabilities(
val videoCodecs: List<String>,
val audioCodecs: List<String>
)
/**
* Detect all hardware decoders available on this device.
*
* Uses Android's MediaCodecList API to query supported MIME types
* and maps them to Jellyfin codec names.
*
* @return CodecCapabilities containing lists of supported video and audio codecs
*/
fun detectHardwareCodecs(): CodecCapabilities {
val videoCodecs = mutableSetOf<String>()
val audioCodecs = mutableSetOf<String>()
try {
// Get all codec infos (including both hardware and software codecs)
val codecList = MediaCodecList(MediaCodecList.ALL_CODECS)
for (codecInfo in codecList.codecInfos) {
// Only interested in decoders (not encoders)
if (codecInfo.isEncoder) continue
// Check if it's a hardware codec
val isHardware = !codecInfo.isSoftwareOnly
for (type in codecInfo.supportedTypes) {
when {
type.startsWith("video/") -> {
val codec = mapMimeTypeToCodecName(type, isVideo = true)
if (codec != null) {
videoCodecs.add(codec)
Log.d(TAG, "Video codec: $codec (MIME: $type, Hardware: $isHardware)")
}
}
type.startsWith("audio/") -> {
val codec = mapMimeTypeToCodecName(type, isVideo = false)
if (codec != null) {
audioCodecs.add(codec)
Log.d(TAG, "Audio codec: $codec (MIME: $type, Hardware: $isHardware)")
}
}
}
}
}
Log.i(TAG, "Detected ${videoCodecs.size} video codecs: ${videoCodecs.sorted()}")
Log.i(TAG, "Detected ${audioCodecs.size} audio codecs: ${audioCodecs.sorted()}")
} catch (e: Exception) {
Log.e(TAG, "Error detecting codecs", e)
}
return CodecCapabilities(
videoCodecs = videoCodecs.sorted(),
audioCodecs = audioCodecs.sorted()
)
}
/**
* Map Android MIME types to Jellyfin codec names.
*
* Based on Jellyfin's codec naming conventions and Android's
* supported MIME type constants.
*
* @param mimeType Android MIME type (e.g., "video/avc", "audio/mp4a-latm")
* @param isVideo Whether this is a video codec
* @return Jellyfin codec name or null if unknown
*/
private fun mapMimeTypeToCodecName(mimeType: String, isVideo: Boolean): String? {
return when (mimeType) {
// Video codecs
"video/avc" -> "h264"
"video/hevc" -> "hevc"
"video/x-vnd.on2.vp8" -> "vp8"
"video/x-vnd.on2.vp9" -> "vp9"
"video/av01" -> "av1"
"video/mp4v-es" -> "mpeg4"
"video/3gpp" -> "h263"
"video/mpeg2" -> "mpeg2video"
"video/divx" -> "divx"
"video/xvid" -> "xvid"
"video/x-ms-wmv" -> "wmv"
"video/vc1" -> "vc1"
// Audio codecs
"audio/mp4a-latm" -> "aac"
"audio/mpeg" -> "mp3"
"audio/mpeg-L1" -> "mp1"
"audio/mpeg-L2" -> "mp2"
"audio/opus" -> "opus"
"audio/vorbis" -> "vorbis"
"audio/flac" -> "flac"
"audio/alac" -> "alac"
"audio/ac3" -> "ac3"
"audio/eac3" -> "eac3"
"audio/eac3-joc" -> "eac3"
"audio/dts" -> "dts"
"audio/vnd.dts.hd" -> "dts"
"audio/x-ms-wma" -> "wma"
"audio/amr-nb" -> "amrnb"
"audio/amr-wb" -> "amrwb"
"audio/3gpp" -> "amrnb"
"audio/raw" -> "pcm"
else -> {
Log.d(TAG, "Unknown MIME type: $mimeType")
null
}
}
}
}
@@ -0,0 +1,527 @@
package com.dtourolle.jellytau.player
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Intent
import android.os.Build
import android.support.v4.media.session.MediaSessionCompat
import android.support.v4.media.session.PlaybackStateCompat
import androidx.annotation.OptIn
import androidx.core.app.NotificationCompat
import androidx.media.VolumeProviderCompat
import androidx.media3.common.ForwardingPlayer
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.session.MediaSession
import androidx.media3.session.MediaSessionService
import com.google.common.util.concurrent.Futures
import com.google.common.util.concurrent.ListenableFuture
/**
* MediaSessionService for lockscreen controls and media notifications.
*
* This service creates a MediaSession that integrates with the system's
* media controls (lockscreen, notification shade, Bluetooth devices).
*
* Media commands are routed back to Rust via JNI to ensure proper
* queue management for next/previous track operations.
*/
@OptIn(UnstableApi::class)
class JellyTauPlaybackService : MediaSessionService() {
private var mediaSession: MediaSession? = null
private var mediaSessionCompat: MediaSessionCompat? = null
private var wrappedPlayer: androidx.media3.common.ForwardingPlayer? = null
private var volumeProvider: VolumeProviderCompat? = null
private var isRemoteVolumeEnabled = false
private var remoteVolumeLevel = 50 // 0-100
companion object {
private const val NOTIFICATION_ID = 1
private const val NOTIFICATION_CHANNEL_ID = "playback_channel"
private const val NOTIFICATION_CHANNEL_NAME = "Playback"
@Volatile
private var instance: JellyTauPlaybackService? = null
/**
* Get the service instance if running.
*/
@JvmStatic
fun getInstance(): JellyTauPlaybackService? = instance
init {
// Ensure native library is loaded for JNI callbacks
System.loadLibrary("jellytau_lib")
}
}
override fun onCreate() {
super.onCreate()
instance = this
// Create notification channel for Android O+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
NOTIFICATION_CHANNEL_ID,
NOTIFICATION_CHANNEL_NAME,
NotificationManager.IMPORTANCE_LOW
).apply {
description = "Media playback controls"
setShowBadge(false)
}
val notificationManager = getSystemService(NotificationManager::class.java)
notificationManager.createNotificationChannel(channel)
}
// Check if JellyTauPlayer is initialized
if (!JellyTauPlayer.isInitialized()) {
android.util.Log.w("JellyTauPlaybackService", "JellyTauPlayer not initialized, initializing now")
// Initialize the player with application context
JellyTauPlayer.initialize(applicationContext)
}
// Get the existing JellyTauPlayer instance with its ExoPlayer
val jellyTauPlayer = JellyTauPlayer.getInstance()
val exoPlayer = jellyTauPlayer.getExoPlayer()
// Wrap the ExoPlayer to intercept commands
wrappedPlayer = object : ForwardingPlayer(exoPlayer) {
override fun play() {
// Execute immediately for instant lockscreen response
super.play()
// Then notify Rust for state management
nativeOnMediaCommand("play")
}
override fun pause() {
// Execute immediately for instant lockscreen response
super.pause()
// Then notify Rust for state management
nativeOnMediaCommand("pause")
}
override fun seekToNext() {
// Execute immediately for instant lockscreen response
super.seekToNext()
// Then notify Rust for queue management
nativeOnMediaCommand("next")
}
override fun seekToPrevious() {
// Execute immediately for instant lockscreen response
super.seekToPrevious()
// Then notify Rust for queue management
nativeOnMediaCommand("previous")
}
override fun seekTo(positionMs: Long) {
// Execute immediately for instant lockscreen response
super.seekTo(positionMs)
// Then notify Rust of seek
val positionSeconds = positionMs / 1000.0
nativeOnMediaCommand("seek:$positionSeconds")
}
override fun stop() {
// Execute immediately for instant lockscreen response
super.stop()
// Then notify Rust for state management
nativeOnMediaCommand("stop")
}
}
// Create MediaSession with the wrapped player and callback for command handling
mediaSession = MediaSession.Builder(this, wrappedPlayer!!)
.setCallback(object : MediaSession.Callback {
override fun onSetMediaItems(
mediaSession: MediaSession,
controller: MediaSession.ControllerInfo,
mediaItems: MutableList<androidx.media3.common.MediaItem>,
startIndex: Int,
startPositionMs: Long
): ListenableFuture<MediaSession.MediaItemsWithStartPosition> {
return Futures.immediateFuture(
MediaSession.MediaItemsWithStartPosition(mediaItems, startIndex, startPositionMs)
)
}
})
.build()
// Create MediaSessionCompat for volume control and lock screen button handling
// We need this alongside Media3's MediaSession because MediaSessionCompat provides
// VolumeProviderCompat support for remote volume control (routing hardware button presses)
mediaSessionCompat = MediaSessionCompat(this, "JellyTauMediaSession").apply {
setFlags(
MediaSessionCompat.FLAG_HANDLES_MEDIA_BUTTONS or
MediaSessionCompat.FLAG_HANDLES_TRANSPORT_CONTROLS
)
isActive = true
// Set callback to handle lock screen button presses
setCallback(object : MediaSessionCompat.Callback() {
override fun onPlay() {
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Play pressed")
wrappedPlayer?.play()
}
override fun onPause() {
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Pause pressed")
wrappedPlayer?.pause()
}
override fun onSkipToNext() {
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Next pressed")
wrappedPlayer?.seekToNext()
}
override fun onSkipToPrevious() {
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Previous pressed")
wrappedPlayer?.seekToPrevious()
}
override fun onStop() {
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Stop pressed")
wrappedPlayer?.stop()
}
override fun onSeekTo(position: Long) {
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Seek to $position")
wrappedPlayer?.seekTo(position)
}
})
}
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
// Start as foreground service immediately to avoid crash
// Media3 will replace this with its own notification
val notification = createBasicNotification()
startForeground(NOTIFICATION_ID, notification)
return super.onStartCommand(intent, flags, startId)
}
private fun createBasicNotification(): Notification {
// Create a media-style notification with lockscreen controls
val intent = packageManager.getLaunchIntentForPackage(packageName)
val pendingIntent = PendingIntent.getActivity(
this,
0,
intent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
return NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
.setContentTitle("JellyTau")
.setContentText("Playing")
.setSmallIcon(android.R.drawable.ic_media_play)
.setContentIntent(pendingIntent)
.setStyle(
androidx.media.app.NotificationCompat.MediaStyle()
.setMediaSession(mediaSessionCompat?.sessionToken)
.setShowActionsInCompactView(0, 1, 2) // Show all 3 buttons in compact view
)
.addAction(
android.R.drawable.ic_media_previous,
"Previous",
androidx.media.session.MediaButtonReceiver.buildMediaButtonPendingIntent(
this,
PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS
)
)
.addAction(
android.R.drawable.ic_media_pause,
"Pause",
androidx.media.session.MediaButtonReceiver.buildMediaButtonPendingIntent(
this,
PlaybackStateCompat.ACTION_PAUSE
)
)
.addAction(
android.R.drawable.ic_media_next,
"Next",
androidx.media.session.MediaButtonReceiver.buildMediaButtonPendingIntent(
this,
PlaybackStateCompat.ACTION_SKIP_TO_NEXT
)
)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setOngoing(true)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC) // Show on lockscreen
.build()
}
/**
* Update the MediaSession metadata and playback state.
* This updates both the MediaSession and the notification.
*/
fun updateMediaMetadata(
title: String,
artist: String,
album: String?,
duration: Long,
position: Long,
isPlaying: Boolean
) {
val session = mediaSessionCompat ?: return
// Update MediaSession metadata
val metadataBuilder = android.support.v4.media.MediaMetadataCompat.Builder()
.putString(android.support.v4.media.MediaMetadataCompat.METADATA_KEY_TITLE, title)
.putString(android.support.v4.media.MediaMetadataCompat.METADATA_KEY_ARTIST, artist)
.putLong(android.support.v4.media.MediaMetadataCompat.METADATA_KEY_DURATION, duration)
album?.let {
metadataBuilder.putString(android.support.v4.media.MediaMetadataCompat.METADATA_KEY_ALBUM, it)
}
session.setMetadata(metadataBuilder.build())
// Update MediaSession playback state
val stateBuilder = PlaybackStateCompat.Builder()
.setActions(
PlaybackStateCompat.ACTION_PLAY or
PlaybackStateCompat.ACTION_PAUSE or
PlaybackStateCompat.ACTION_STOP or
PlaybackStateCompat.ACTION_SKIP_TO_NEXT or
PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS or
PlaybackStateCompat.ACTION_SEEK_TO
)
.setState(
if (isPlaying) PlaybackStateCompat.STATE_PLAYING else PlaybackStateCompat.STATE_PAUSED,
position,
1.0f
)
session.setPlaybackState(stateBuilder.build())
// Update the notification
updateNotification(title, artist, isPlaying)
}
/**
* Update the notification with current media metadata and playback state.
* This should be called whenever metadata or playback state changes.
*/
private fun updateNotification(title: String, artist: String, isPlaying: Boolean) {
val intent = packageManager.getLaunchIntentForPackage(packageName)
val pendingIntent = PendingIntent.getActivity(
this,
0,
intent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
val notification = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
.setContentTitle(title)
.setContentText(artist)
.setSmallIcon(android.R.drawable.ic_media_play)
.setContentIntent(pendingIntent)
.setStyle(
androidx.media.app.NotificationCompat.MediaStyle()
.setMediaSession(mediaSessionCompat?.sessionToken)
.setShowActionsInCompactView(0, 1, 2) // Show all 3 buttons in compact view
)
.addAction(
android.R.drawable.ic_media_previous,
"Previous",
androidx.media.session.MediaButtonReceiver.buildMediaButtonPendingIntent(
this,
PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS
)
)
.addAction(
if (isPlaying) android.R.drawable.ic_media_pause else android.R.drawable.ic_media_play,
if (isPlaying) "Pause" else "Play",
androidx.media.session.MediaButtonReceiver.buildMediaButtonPendingIntent(
this,
if (isPlaying) PlaybackStateCompat.ACTION_PAUSE else PlaybackStateCompat.ACTION_PLAY
)
)
.addAction(
android.R.drawable.ic_media_next,
"Next",
androidx.media.session.MediaButtonReceiver.buildMediaButtonPendingIntent(
this,
PlaybackStateCompat.ACTION_SKIP_TO_NEXT
)
)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setOngoing(isPlaying)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC) // Show on lockscreen
.build()
val notificationManager = getSystemService(NotificationManager::class.java)
notificationManager.notify(NOTIFICATION_ID, notification)
}
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? {
return mediaSession
}
/**
* Enable remote volume control for remote playback (e.g., casting to Jellyfin session).
* Volume button presses will be sent to Rust for forwarding to the remote session.
*
* Uses MediaSessionCompat with VolumeProviderCompat to intercept hardware volume buttons.
*
* @param initialVolume Initial volume level (0-100)
*/
fun enableRemoteVolume(initialVolume: Int) {
android.util.Log.d("JellyTauPlaybackService", "Enabling remote volume control (volume=$initialVolume)")
isRemoteVolumeEnabled = true
remoteVolumeLevel = initialVolume.coerceIn(0, 100)
val session = mediaSessionCompat ?: run {
android.util.Log.w("JellyTauPlaybackService", "MediaSessionCompat not initialized")
return
}
// Create a VolumeProvider for remote volume control
volumeProvider = object : VolumeProviderCompat(
VolumeProviderCompat.VOLUME_CONTROL_ABSOLUTE, // Control type: absolute volume
100, // Max volume (0-100)
remoteVolumeLevel // Initial volume
) {
override fun onSetVolumeTo(volume: Int) {
if (!isRemoteVolumeEnabled) return
remoteVolumeLevel = volume.coerceIn(0, 100)
android.util.Log.d("JellyTauPlaybackService", "Remote volume set to $remoteVolumeLevel")
nativeOnRemoteVolumeChange("SetVolume", remoteVolumeLevel)
}
override fun onAdjustVolume(direction: Int) {
if (!isRemoteVolumeEnabled) return
when (direction) {
android.media.AudioManager.ADJUST_RAISE -> {
remoteVolumeLevel = (remoteVolumeLevel + 2).coerceAtMost(100)
android.util.Log.d("JellyTauPlaybackService", "Remote volume up to $remoteVolumeLevel")
nativeOnRemoteVolumeChange("VolumeUp", remoteVolumeLevel)
// Update the current volume so slider reflects the change
currentVolume = remoteVolumeLevel
}
android.media.AudioManager.ADJUST_LOWER -> {
remoteVolumeLevel = (remoteVolumeLevel - 2).coerceAtLeast(0)
android.util.Log.d("JellyTauPlaybackService", "Remote volume down to $remoteVolumeLevel")
nativeOnRemoteVolumeChange("VolumeDown", remoteVolumeLevel)
// Update the current volume so slider reflects the change
currentVolume = remoteVolumeLevel
}
}
}
}
// Set the volume provider on the media session to route hardware volume buttons
session.setPlaybackToRemote(volumeProvider!!)
// Set playback state to make Android show the volume UI
// This tells Android that this session is actively controlling media playback
val playbackState = PlaybackStateCompat.Builder()
.setState(
PlaybackStateCompat.STATE_PLAYING,
PlaybackStateCompat.PLAYBACK_POSITION_UNKNOWN,
1.0f
)
.setActions(
PlaybackStateCompat.ACTION_PLAY or
PlaybackStateCompat.ACTION_PAUSE or
PlaybackStateCompat.ACTION_SKIP_TO_NEXT or
PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS
)
.build()
session.setPlaybackState(playbackState)
android.util.Log.d("JellyTauPlaybackService", "Remote volume control enabled")
}
/**
* Disable remote volume control and return to local volume control.
* Volume buttons will control system media volume (ExoPlayer volume).
*/
fun disableRemoteVolume() {
android.util.Log.d("JellyTauPlaybackService", "Disabling remote volume control")
isRemoteVolumeEnabled = false
val session = mediaSessionCompat ?: run {
android.util.Log.w("JellyTauPlaybackService", "MediaSessionCompat not initialized")
return
}
// Switch back to local audio stream (device volume)
session.setPlaybackToLocal(android.media.AudioManager.STREAM_MUSIC)
// Clear the playback state
val idleState = PlaybackStateCompat.Builder()
.setState(
PlaybackStateCompat.STATE_NONE,
PlaybackStateCompat.PLAYBACK_POSITION_UNKNOWN,
0.0f
)
.build()
session.setPlaybackState(idleState)
// Clear the volume provider
volumeProvider = null
// Reset volume level to default
remoteVolumeLevel = 50
android.util.Log.d("JellyTauPlaybackService", "Remote volume control disabled")
}
/**
* Update the remote volume level.
* Call this when volume changes on the remote session to sync the local state.
*
* @param volume Volume level (0-100)
*/
fun updateRemoteVolume(volume: Int) {
remoteVolumeLevel = volume.coerceIn(0, 100)
// Update the volume provider's current volume so the UI slider reflects the change
volumeProvider?.currentVolume = remoteVolumeLevel
android.util.Log.d("JellyTauPlaybackService", "Remote volume updated to $remoteVolumeLevel")
}
override fun onDestroy() {
mediaSession?.run {
release()
}
mediaSession = null
mediaSessionCompat?.run {
isActive = false
release()
}
mediaSessionCompat = null
volumeProvider = null
instance = null
super.onDestroy()
}
override fun onTaskRemoved(rootIntent: Intent?) {
// Stop the service when the app is swiped away, unless audio is playing
val player = mediaSession?.player
if (player == null || !player.playWhenReady || player.mediaItemCount == 0) {
stopSelf()
}
}
/**
* JNI callback to Rust for media commands.
* Commands: "play", "pause", "next", "previous", "stop", "seek:123.45"
*/
private external fun nativeOnMediaCommand(command: String)
/**
* JNI callback to Rust for remote volume changes.
* Commands: "SetVolume", "VolumeUp", "VolumeDown"
* @param command The volume command
* @param volume The volume level (0-100)
*/
private external fun nativeOnRemoteVolumeChange(command: String, volume: Int)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,167 @@
package com.dtourolle.jellytau.security
import android.content.Context
import android.content.SharedPreferences
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import android.util.Log
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
/**
* Secure storage for credentials using Android Keystore.
* Provides encrypted storage for sensitive data like API tokens.
*/
class SecureStorage private constructor(context: Context) {
companion object {
private const val TAG = "SecureStorage"
private const val KEYSTORE_PROVIDER = "AndroidKeyStore"
private const val KEY_ALIAS = "jellytau_credentials_key"
private const val TRANSFORMATION = "AES/GCM/NoPadding"
private const val PREFS_NAME = "jellytau_secure_prefs"
@Volatile
private var instance: SecureStorage? = null
@JvmStatic
fun initialize(context: Context) {
if (instance == null) {
synchronized(this) {
if (instance == null) {
instance = SecureStorage(context.applicationContext)
}
}
}
}
@JvmStatic
fun getInstance(): SecureStorage {
return instance ?: throw IllegalStateException("SecureStorage not initialized")
}
}
private val keyStore: KeyStore = KeyStore.getInstance(KEYSTORE_PROVIDER).apply {
load(null)
}
private val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
init {
// Ensure encryption key exists
if (!keyStore.containsAlias(KEY_ALIAS)) {
generateKey()
}
}
private fun generateKey() {
val keyGenerator = KeyGenerator.getInstance(
KeyProperties.KEY_ALGORITHM_AES,
KEYSTORE_PROVIDER
)
val keyGenParameterSpec = KeyGenParameterSpec.Builder(
KEY_ALIAS,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setRandomizedEncryptionRequired(true)
.build()
keyGenerator.init(keyGenParameterSpec)
keyGenerator.generateKey()
}
private fun getSecretKey(): SecretKey {
return keyStore.getKey(KEY_ALIAS, null) as SecretKey
}
fun saveCredential(key: String, value: String) {
try {
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.ENCRYPT_MODE, getSecretKey())
val iv = cipher.iv
val encrypted = cipher.doFinal(value.toByteArray(Charsets.UTF_8))
// Store IV + encrypted data as base64
val combined = iv + encrypted
val encoded = Base64.encodeToString(combined, Base64.DEFAULT)
prefs.edit().putString(key, encoded).apply()
Log.d(TAG, "Saved credential: $key")
} catch (e: Exception) {
Log.e(TAG, "Failed to save credential: $key", e)
throw e
}
}
fun getCredential(key: String): String? {
try {
val encoded = prefs.getString(key, null) ?: return null
val combined = Base64.decode(encoded, Base64.DEFAULT)
// Extract IV (first 12 bytes for GCM)
val iv = combined.copyOfRange(0, 12)
val encrypted = combined.copyOfRange(12, combined.size)
val cipher = Cipher.getInstance(TRANSFORMATION)
val spec = GCMParameterSpec(128, iv)
cipher.init(Cipher.DECRYPT_MODE, getSecretKey(), spec)
val decrypted = cipher.doFinal(encrypted)
return String(decrypted, Charsets.UTF_8)
} catch (e: Exception) {
Log.e(TAG, "Failed to get credential: $key", e)
return null
}
}
fun deleteCredential(key: String) {
prefs.edit().remove(key).apply()
Log.d(TAG, "Deleted credential: $key")
}
// JNI-compatible methods (called from Rust)
/**
* Save a token (JNI-compatible version).
* @return true if successful, false otherwise
*/
@JvmOverloads
fun saveToken(key: String, value: String): Boolean {
return try {
saveCredential(key, value)
true
} catch (e: Exception) {
Log.e(TAG, "saveToken failed for key: $key", e)
false
}
}
/**
* Get a token (JNI-compatible version).
* @return token string or null if not found
*/
fun getToken(key: String): String? {
return getCredential(key)
}
/**
* Delete a token (JNI-compatible version).
* @return true if successful, false otherwise
*/
fun deleteToken(key: String): Boolean {
return try {
deleteCredential(key)
true
} catch (e: Exception) {
Log.e(TAG, "deleteToken failed for key: $key", e)
false
}
}
}
@@ -0,0 +1,13 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme -->
<style name="Theme.jellytau" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<!-- Status bar color -->
<item name="android:statusBarColor">@android:color/transparent</item>
<!-- Make status bar icons dark or light based on background -->
<item name="android:windowLightStatusBar" tools:targetApi="m">false</item>
<!-- Don't draw behind status bar -->
<item name="android:windowDrawsSystemBarBackgrounds">true</item>
<!-- Ensure content doesn't extend into system bars -->
<item name="android:fitsSystemWindows">true</item>
</style>
</resources>