Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ All notable changes to PixelPlayerOSS will be documented in this file.
## [Unreleased]

### Fixed
- Navidrome/Subsonic and Jellyfin album artwork no longer gets stuck on the placeholder after a few songs. The player loaded cloud covers through the app's own artwork content provider, and concurrent loads could exhaust the image decoder slots that provider waited on, freezing every artwork load until the app restarted. Server error responses are also no longer cached as cover images, and concurrent cover downloads no longer corrupt each other's cache file ([#138](https://github.com/PixelPlayerHQ/PixelPlayerOSS/issues/138)).
- System media controls can load album artwork through the app's artwork URI again. System UI reads that URI without connecting as a media controller, so it never received the per-track read grant and was refused on every track; privileged media surfaces (holders of `MEDIA_CONTENT_CONTROL`) may now open the artwork the session currently shows, while every other cover, and every other app, still needs the explicit grant. Stock Android fell back to the session bitmap, but System UIs without that fallback could show no artwork.
- Jellyfin playlists no longer go missing on Jellyfin 10.10 and newer, where playlists can hold mixed content and audio playlists are often reported without a media type.
- Pressing back with the Now Playing screen or the Next up queue open inside a folder in the Folders tab now closes the player instead of leaving the folder ([#136](https://github.com/PixelPlayerHQ/PixelPlayerOSS/issues/136)).

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,8 @@ class PixelPlayerApplication : Application(), ImageLoaderFactory, Configuration.
override fun newImageLoader(): ImageLoader {
return imageLoader.get().newBuilder()
.components {
add(com.lostf1sh.pixelplayeross.data.image.SharedCloudArtworkStringMapper(packageName))
add(com.lostf1sh.pixelplayeross.data.image.SharedCloudArtworkUriMapper(packageName))
add(localArtworkCoilFetcherFactory.get())
add(navidromeCoilFetcherFactory.get())
add(jellyfinCoilFetcherFactory.get())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,14 @@ package com.lostf1sh.pixelplayeross.data.image

import android.net.Uri
import coil.ImageLoader
import coil.decode.DataSource
import coil.decode.ImageSource
import coil.fetch.FetchResult
import coil.fetch.Fetcher
import coil.fetch.SourceResult
import coil.request.Options
import com.lostf1sh.pixelplayeross.data.jellyfin.JellyfinRepository
import okhttp3.OkHttpClient
import okhttp3.Request
import okio.FileSystem
import okio.Path.Companion.toPath
import timber.log.Timber
import java.io.File
import java.io.FileOutputStream
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject

Expand Down Expand Up @@ -63,17 +57,7 @@ class JellyfinCoilFetcher(
val sizeParam = uri.getQueryParameter("size")?.toIntOrNull() ?: 500

val cachedFile = File(cacheDir, "jellyfin_cover_${itemId}_$sizeParam.jpg")
if (cachedFile.exists() && cachedFile.length() > 0) {
Timber.v("$TAG: Using cached cover for $itemId")
return SourceResult(
source = ImageSource(
file = cachedFile.absolutePath.toPath(),
fileSystem = FileSystem.SYSTEM
),
mimeType = "image/jpeg",
dataSource = DataSource.DISK
)
}
RemoteArtworkCache.cachedResult(cachedFile)?.let { return it }

val imageUrl = repository.getImageUrl(itemId, sizeParam)
if (imageUrl.isNullOrBlank()) {
Expand All @@ -86,7 +70,12 @@ class JellyfinCoilFetcher(
val authHeader = repository.getAuthorizationHeader()

return try {
downloadImage(imageUrl, cachedFile, authHeader)
val request = Request.Builder()
.url(imageUrl)
.apply { if (authHeader != null) header("Authorization", authHeader) }
.get()
.build()
RemoteArtworkCache.download(okHttpClient, request, cachedFile)
} catch (e: Exception) {
if (shouldLogFailure("download_$itemId")) {
Timber.w(e, "$TAG: Failed to download cover art for $itemId")
Expand All @@ -95,48 +84,6 @@ class JellyfinCoilFetcher(
}
}

private fun downloadImage(url: String, cacheFile: File, authHeader: String? = null): FetchResult? {
val request = Request.Builder()
.url(url)
.apply { if (authHeader != null) header("Authorization", authHeader) }
.get()
.build()

return try {
okHttpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
Timber.w("$TAG: HTTP ${response.code} for $url")
return null
}

val bytes = response.body.bytes()

if (bytes.isEmpty()) {
Timber.w("$TAG: Empty response body for $url")
return null
}

FileOutputStream(cacheFile).use { fos ->
fos.write(bytes)
}

Timber.v("$TAG: Cached cover art (${bytes.size} bytes)")

SourceResult(
source = ImageSource(
file = cacheFile.absolutePath.toPath(),
fileSystem = FileSystem.SYSTEM
),
mimeType = response.header("Content-Type") ?: "image/jpeg",
dataSource = DataSource.NETWORK
)
}
} catch (e: Exception) {
Timber.w(e, "$TAG: Failed to download image from $url")
null
}
}

class Factory @Inject constructor(
private val repository: JellyfinRepository,
private val okHttpClient: OkHttpClient
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,12 @@ import android.net.Uri
import coil.ImageLoader
import coil.fetch.FetchResult
import coil.fetch.Fetcher
import coil.fetch.SourceResult
import coil.request.Options
import com.lostf1sh.pixelplayeross.data.navidrome.NavidromeRepository
import com.lostf1sh.pixelplayeross.data.network.navidrome.NavidromeApiService
import okhttp3.OkHttpClient
import okhttp3.Request
import okio.Path.Companion.toPath
import timber.log.Timber
import java.io.File
import java.io.FileOutputStream
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject

Expand Down Expand Up @@ -68,17 +64,7 @@ class NavidromeCoilFetcher(
val sizeParam = uri.getQueryParameter("size")?.toIntOrNull() ?: 500

val cachedFile = File(cacheDir, "navidrome_cover_${coverArtId}_$sizeParam.jpg")
if (cachedFile.exists() && cachedFile.length() > 0) {
Timber.v("$TAG: Using cached cover for $coverArtId")
return SourceResult(
source = coil.decode.ImageSource(
file = cachedFile.absolutePath.toPath(),
fileSystem = okio.FileSystem.SYSTEM
),
mimeType = "image/jpeg",
dataSource = coil.decode.DataSource.DISK
)
}
RemoteArtworkCache.cachedResult(cachedFile)?.let { return it }

val coverArtUrl = repository.getCoverArtUrl(coverArtId, sizeParam)
if (coverArtUrl.isNullOrBlank()) {
Expand All @@ -89,7 +75,8 @@ class NavidromeCoilFetcher(
}

return try {
downloadImage(coverArtUrl, cachedFile)
val request = Request.Builder().url(coverArtUrl).get().build()
RemoteArtworkCache.download(okHttpClient, request, cachedFile)
} catch (e: Exception) {
if (shouldLogFailure("download_$coverArtId")) {
Timber.w(e, "$TAG: Failed to download cover art for $coverArtId")
Expand All @@ -98,47 +85,6 @@ class NavidromeCoilFetcher(
}
}

private suspend fun downloadImage(url: String, cacheFile: File): FetchResult? {
val request = Request.Builder()
.url(url)
.get()
.build()

return try {
okHttpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
Timber.w("$TAG: HTTP ${response.code} for $url")
return null
}

val bytes = response.body.bytes()

if (bytes.isEmpty()) {
Timber.w("$TAG: Empty response body for $url")
return null
}

FileOutputStream(cacheFile).use { fos ->
fos.write(bytes)
}

Timber.v("$TAG: Cached cover art (${bytes.size} bytes)")

SourceResult(
source = coil.decode.ImageSource(
file = cacheFile.absolutePath.toPath(),
fileSystem = okio.FileSystem.SYSTEM
),
mimeType = response.header("Content-Type") ?: "image/jpeg",
dataSource = coil.decode.DataSource.NETWORK
)
}
} catch (e: Exception) {
Timber.w(e, "$TAG: Failed to download image from $url")
null
}
}

/**
* Factory for creating NavidromeCoilFetcher instances.
* Registered with Coil's ImageLoader to handle navidrome_cover:// URIs.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package com.lostf1sh.pixelplayeross.data.image

import coil.decode.DataSource
import coil.decode.ImageSource
import coil.fetch.SourceResult
import okhttp3.OkHttpClient
import okhttp3.Request
import okio.FileSystem
import okio.Path.Companion.toPath
import java.io.File
import java.io.IOException

/**
* Disk cache shared by the Navidrome and Jellyfin Coil fetchers.
*
* Only bodies that carry an image signature are cached: Subsonic servers answer failed
* `getCoverArt` calls with HTTP 200 and a JSON/XML error, and caching that body would pin a
* broken cover until the cache is cleared. Downloads land in a temp file that is renamed into
* place, so a concurrent request never decodes a file another request is still writing.
*/
internal object RemoteArtworkCache {

private const val SIGNATURE_BYTES = 12

fun cachedResult(file: File): SourceResult? {
if (!file.isFile || file.length() == 0L) return null
// A body cached before this check existed is skipped, not deleted: the next successful
// download renames over it, and deleting here could remove a fresh file another
// request renamed into place after the signature was read.
if (!hasImageSignature(readSignature(file))) return null
return SourceResult(
source = ImageSource(file = file.absolutePath.toPath(), fileSystem = FileSystem.SYSTEM),
mimeType = "image/jpeg",
dataSource = DataSource.DISK
)
}

@Throws(IOException::class)
fun download(client: OkHttpClient, request: Request, file: File): SourceResult {
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) throw IOException("HTTP ${response.code}")
val bytes = response.body.bytes()
if (!hasImageSignature(bytes)) {
throw IOException("Response is not an image (Content-Type: ${response.header("Content-Type")})")
}

val tempFile = File.createTempFile(file.name, ".tmp", file.parentFile)
try {
tempFile.writeBytes(bytes)
if (!tempFile.renameTo(file)) throw IOException("Could not move artwork into $file")
} finally {
tempFile.delete()
}

return SourceResult(
source = ImageSource(file = file.absolutePath.toPath(), fileSystem = FileSystem.SYSTEM),
mimeType = response.header("Content-Type") ?: "image/jpeg",
dataSource = DataSource.NETWORK
)
}
}

internal fun hasImageSignature(header: ByteArray): Boolean {
fun matches(offset: Int, vararg expected: Int): Boolean =
header.size >= offset + expected.size &&
expected.indices.all { header[offset + it].toInt() and 0xFF == expected[it] }

return matches(0, 0xFF, 0xD8, 0xFF) || // JPEG
matches(0, 0x89, 0x50, 0x4E, 0x47) || // PNG
matches(0, 0x47, 0x49, 0x46, 0x38) || // GIF8
(matches(0, 0x52, 0x49, 0x46, 0x46) && matches(8, 0x57, 0x45, 0x42, 0x50)) || // RIFF....WEBP
matches(4, 0x66, 0x74, 0x79, 0x70) || // ISO-BMFF ftyp (HEIF/AVIF)
matches(0, 0x42, 0x4D) // BMP
}

private fun readSignature(file: File): ByteArray = runCatching {
file.inputStream().use { input ->
val buffer = ByteArray(SIGNATURE_BYTES)
var read = 0
while (read < SIGNATURE_BYTES) {
val count = input.read(buffer, read, SIGNATURE_BYTES - read)
if (count < 0) break
read += count
}
buffer.copyOf(read)
}
}.getOrDefault(ByteArray(0))
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package com.lostf1sh.pixelplayeross.data.image

import android.net.Uri
import coil.map.Mapper
import coil.request.Options
import com.lostf1sh.pixelplayeross.data.provider.SharedArtworkContentProvider

/*
* The media session rewrites cloud artwork to `content://<pkg>.artwork/cloud/...` for every
* controller, our own included, so the player UI, notification, and widgets receive that URI.
* Loading it in-process goes through SharedArtworkContentProvider, which decodes the cover with
* this same ImageLoader while the outer decode blocks reading the pipe. Coil allows only a few
* concurrent bitmap decodes, so a handful of such loads hold every decode slot and the inner
* decodes they wait on never start: all artwork stays stuck on its placeholder until restart.
* Mapping back to the raw cloud URI lets the Navidrome/Jellyfin fetchers serve it directly.
*/

class SharedCloudArtworkStringMapper(private val packageName: String) : Mapper<String, Uri> {
override fun map(data: String, options: Options): Uri? =
SharedArtworkContentProvider.parseCloudArtworkUri(data, packageName)?.let(Uri::parse)
}

class SharedCloudArtworkUriMapper(private val packageName: String) : Mapper<Uri, Uri> {
override fun map(data: Uri, options: Options): Uri? {
if (data.authority != SharedArtworkContentProvider.authority(packageName)) return null
return SharedArtworkContentProvider.parseCloudArtworkUri(data.toString(), packageName)
?.let(Uri::parse)
Comment on lines +26 to +27

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Cloud covers lose resolution
The new mappers send in-process cloud artwork straight to the fetchers, but the mapped URIs have no size parameter. Both fetchers therefore request their 500-pixel default instead of the provider’s 1024-pixel artwork. Covers on larger player surfaces may look less sharp.

Prompt To Fix With AI
This is a comment left during a code review.
Path: app/src/main/java/com/lostf1sh/pixelplayeross/data/image/SharedCloudArtworkMappers.kt
Line: 26-27

Comment:
**Cloud covers lose resolution**
The new mappers send in-process cloud artwork straight to the fetchers, but the mapped URIs have no size parameter. Both fetchers therefore request their 500-pixel default instead of the provider’s 1024-pixel artwork. Covers on larger player surfaces may look less sharp.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code Fix in Codex

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a regression. Before this PR the provider's inner request also loaded navidrome_cover://<id> with no size parameter, so the fetchers already requested 500 px. CLOUD_ARTWORK_SIZE_PX only set the decode target, not the server request, so the image and its resolution are unchanged. Raising the default request size would be a separate change.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right — this is not a regression. Before this change, the provider's inner ImageLoader request already used navidrome_cover:///jellyfin_cover:// without a size parameter, so the fetchers requested their existing 500 px default. CLOUD_ARTWORK_SIZE_PX only controlled the decode target, not the server-requested artwork size. Mapping the URI directly therefore preserves the same source resolution; increasing the request default would be a separate improvement. I'll withdraw this finding.

}
}
Loading
Loading