From 9594e963bc86586dc0f942f01926d3677f0d3b54 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sat, 14 Feb 2026 16:53:51 +0100 Subject: [PATCH] album art on lock scree and test fix --- .gitea/workflows/build-and-test.yml | 40 ++++- .../jellytau/player/AlbumArtCache.kt | 142 ++++++++++++++++++ .../jellytau/player/JellyTauPlayer.kt | 21 ++- 3 files changed, 200 insertions(+), 3 deletions(-) create mode 100644 src-tauri/android/src/main/java/com/dtourolle/jellytau/player/AlbumArtCache.kt diff --git a/.gitea/workflows/build-and-test.yml b/.gitea/workflows/build-and-test.yml index 87eb578..320ec77 100644 --- a/.gitea/workflows/build-and-test.yml +++ b/.gitea/workflows/build-and-test.yml @@ -14,8 +14,8 @@ on: workflow_dispatch: jobs: - build: - name: Build APK and Run Tests + test: + name: Run Tests runs-on: linux/amd64 container: image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest @@ -60,6 +60,42 @@ jobs: cargo test 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 run: bun run build diff --git a/src-tauri/android/src/main/java/com/dtourolle/jellytau/player/AlbumArtCache.kt b/src-tauri/android/src/main/java/com/dtourolle/jellytau/player/AlbumArtCache.kt new file mode 100644 index 0000000..33fdb4d --- /dev/null +++ b/src-tauri/android/src/main/java/com/dtourolle/jellytau/player/AlbumArtCache.kt @@ -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(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 } + } + } + } +} diff --git a/src-tauri/android/src/main/java/com/dtourolle/jellytau/player/JellyTauPlayer.kt b/src-tauri/android/src/main/java/com/dtourolle/jellytau/player/JellyTauPlayer.kt index ad88c7a..f0284b7 100644 --- a/src-tauri/android/src/main/java/com/dtourolle/jellytau/player/JellyTauPlayer.kt +++ b/src-tauri/android/src/main/java/com/dtourolle/jellytau/player/JellyTauPlayer.kt @@ -143,6 +143,8 @@ class JellyTauPlayer(private val appContext: Context) { private var currentArtist: String = "" private var currentAlbum: String? = null private var currentDurationMs: Long = 0 + private var currentArtworkUrl: String? = null + private var currentArtworkBitmap: android.graphics.Bitmap? = null /** Media type enum */ enum class MediaType { AUDIO, VIDEO } @@ -550,6 +552,8 @@ class JellyTauPlayer(private val appContext: Context) { currentArtist = artist ?: "" currentAlbum = album currentDurationMs = durationMs + currentArtworkUrl = artworkUrl + currentArtworkBitmap = null // Reset on new track // Detect media type currentMediaType = if (mediaType.equals("video", ignoreCase = true)) { @@ -666,6 +670,20 @@ class JellyTauPlayer(private val appContext: Context) { // Start the foreground service for lockscreen controls 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, duration = currentDurationMs, position = (exoPlayer.currentPosition).coerceAtLeast(0), - isPlaying = isPlaying + isPlaying = isPlaying, + artworkBitmap = currentArtworkBitmap ) } else { android.util.Log.w("JellyTauPlayer", "Playback service not available for notification update")