First working POC
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+527
@@ -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>
|
||||
Reference in New Issue
Block a user