album art on lock scree and test fix
Some checks failed
Traceability Validation / Check Requirement Traces (push) Failing after 2s
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 15s
🏗️ Build and Test JellyTau / Build Android APK (push) Has been skipped

This commit is contained in:
Duncan Tourolle 2026-02-14 16:53:51 +01:00
parent 179c51a6fe
commit 9594e963bc
3 changed files with 200 additions and 3 deletions

View File

@ -14,8 +14,8 @@ on:
workflow_dispatch: workflow_dispatch:
jobs: jobs:
build: test:
name: Build APK and Run Tests name: Run Tests
runs-on: linux/amd64 runs-on: linux/amd64
container: container:
image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest
@ -60,6 +60,42 @@ jobs:
cargo test cargo test
cd .. cd ..
build:
name: Build Android APK
runs-on: linux/amd64
needs: test
container:
image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Cache Rust dependencies
uses: actions/cache@v3
with:
path: |
~/.cargo/registry
~/.cargo/git
src-tauri/target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-
- name: Cache Node dependencies
uses: actions/cache@v3
with:
path: |
~/.bun/install/cache
node_modules
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-
- name: Install dependencies
run: |
bun install
- name: Build frontend - name: Build frontend
run: bun run build run: bun run build

View File

@ -0,0 +1,142 @@
package com.dtourolle.jellytau.player
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.util.LruCache
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.net.HttpURLConnection
import java.net.URL
/**
* Memory cache for album artwork bitmaps with LRU eviction.
*
* Features:
* - LruCache for efficient memory usage (1/8 of heap, typically 12-16MB)
* - Automatic bitmap scaling to 512x512 max (lock screen optimal size)
* - Async HTTP downloads using Dispatchers.IO (non-blocking)
* - Graceful error handling for network failures and corrupted images
* - Singleton pattern for app-wide access
*
* Cache lifecycle: In-memory only, cleared on app termination.
*/
class AlbumArtCache(context: Context) {
private val maxMemory = (Runtime.getRuntime().maxMemory() / 1024).toInt()
private val cacheSize = maxMemory / 8 // Use 1/8 of available heap
private val memoryCache = object : LruCache<String, Bitmap>(cacheSize) {
override fun sizeOf(key: String, bitmap: Bitmap): Int {
return bitmap.byteCount / 1024 // Size in KB
}
}
/**
* Get artwork bitmap for the given URL.
*
* Checks memory cache first, then downloads from network if not cached.
* Scaling and error handling are done transparently.
*
* @param url The Jellyfin server artwork URL
* @return The bitmap, or null if download failed or URL is invalid
*/
suspend fun getArtwork(url: String): Bitmap? {
// Check memory cache first
memoryCache.get(url)?.let { return it }
// Download from network if not cached
return downloadAndCache(url)
}
/**
* Download artwork from network and add to cache.
*
* Runs on IO dispatcher to avoid blocking the main thread.
* Automatically scales large images to 512x512 max.
* On failure, logs error and returns null.
*
* @param url The Jellyfin server artwork URL
* @return The cached bitmap, or null if download/decode failed
*/
private suspend fun downloadAndCache(url: String): Bitmap? = withContext(Dispatchers.IO) {
try {
val connection = URL(url).openConnection() as HttpURLConnection
connection.doInput = true
connection.connectTimeout = 5000
connection.readTimeout = 5000
connection.connect()
if (connection.responseCode != HttpURLConnection.HTTP_OK) {
android.util.Log.w("AlbumArtCache", "Failed to download artwork: HTTP ${connection.responseCode}")
return@withContext null
}
val input = connection.inputStream
val bitmap = BitmapFactory.decodeStream(input)
input.close()
connection.disconnect()
bitmap?.let {
// Scale down if too large (lock screen doesn't need full resolution)
val scaled = scaleDownIfNeeded(it, MAX_ARTWORK_SIZE)
memoryCache.put(url, scaled)
android.util.Log.d("AlbumArtCache", "Cached artwork: ${scaled.width}x${scaled.height}")
scaled
}
} catch (e: Exception) {
android.util.Log.e("AlbumArtCache", "Failed to download artwork: ${e.message}", e)
null
}
}
/**
* Scale down bitmap if it exceeds max size while maintaining aspect ratio.
*
* @param bitmap The original bitmap
* @param maxSize Maximum width or height (e.g., 512)
* @return Original bitmap if smaller than maxSize, else scaled version
*/
private fun scaleDownIfNeeded(bitmap: Bitmap, maxSize: Int): Bitmap {
if (bitmap.width <= maxSize && bitmap.height <= maxSize) return bitmap
val ratio = minOf(
maxSize.toFloat() / bitmap.width,
maxSize.toFloat() / bitmap.height
)
val newWidth = (bitmap.width * ratio).toInt()
val newHeight = (bitmap.height * ratio).toInt()
return Bitmap.createScaledBitmap(bitmap, newWidth, newHeight, true)
}
/**
* Clear all cached bitmaps from memory.
*
* Useful for memory-constrained situations or settings reset.
*/
fun clear() {
memoryCache.evictAll()
}
companion object {
private const val MAX_ARTWORK_SIZE = 512 // 512x512 max for lock screen
@Volatile
private var instance: AlbumArtCache? = null
/**
* Get or create singleton instance.
*
* Thread-safe with double-checked locking pattern.
*
* @param context Android context for initialization
* @return The singleton AlbumArtCache instance
*/
fun getInstance(context: Context): AlbumArtCache {
return instance ?: synchronized(this) {
instance ?: AlbumArtCache(context).also { instance = it }
}
}
}
}

View File

@ -143,6 +143,8 @@ class JellyTauPlayer(private val appContext: Context) {
private var currentArtist: String = "" private var currentArtist: String = ""
private var currentAlbum: String? = null private var currentAlbum: String? = null
private var currentDurationMs: Long = 0 private var currentDurationMs: Long = 0
private var currentArtworkUrl: String? = null
private var currentArtworkBitmap: android.graphics.Bitmap? = null
/** Media type enum */ /** Media type enum */
enum class MediaType { AUDIO, VIDEO } enum class MediaType { AUDIO, VIDEO }
@ -550,6 +552,8 @@ class JellyTauPlayer(private val appContext: Context) {
currentArtist = artist ?: "" currentArtist = artist ?: ""
currentAlbum = album currentAlbum = album
currentDurationMs = durationMs currentDurationMs = durationMs
currentArtworkUrl = artworkUrl
currentArtworkBitmap = null // Reset on new track
// Detect media type // Detect media type
currentMediaType = if (mediaType.equals("video", ignoreCase = true)) { currentMediaType = if (mediaType.equals("video", ignoreCase = true)) {
@ -666,6 +670,20 @@ class JellyTauPlayer(private val appContext: Context) {
// Start the foreground service for lockscreen controls // Start the foreground service for lockscreen controls
startPlaybackService() startPlaybackService()
// Download album art asynchronously (non-blocking)
currentArtworkUrl?.let { url ->
coroutineScope.launch {
try {
val bitmap = AlbumArtCache.getInstance(appContext).getArtwork(url)
currentArtworkBitmap = bitmap
updatePlaybackServiceNotification(exoPlayer.isPlaying)
android.util.Log.d("JellyTauPlayer", "Album art loaded: ${bitmap?.width}x${bitmap?.height}")
} catch (e: Exception) {
android.util.Log.e("JellyTauPlayer", "Failed to load album art", e)
}
}
}
} }
} }
@ -990,7 +1008,8 @@ class JellyTauPlayer(private val appContext: Context) {
album = currentAlbum, album = currentAlbum,
duration = currentDurationMs, duration = currentDurationMs,
position = (exoPlayer.currentPosition).coerceAtLeast(0), position = (exoPlayer.currentPosition).coerceAtLeast(0),
isPlaying = isPlaying isPlaying = isPlaying,
artworkBitmap = currentArtworkBitmap
) )
} else { } else {
android.util.Log.w("JellyTauPlayer", "Playback service not available for notification update") android.util.Log.w("JellyTauPlayer", "Playback service not available for notification update")