Show real programme artwork behind the channel logo (0.57.0)
pauljd/davidson-ci: Davidson CI / quality (push) Failing after 1m57s
pauljd/davidson-ci: Davidson CI / ai-review (push) Failing after 1m30s

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-11-02 09:52:11 +00:00
co-authored by Claude Opus 5.5
parent 201c79bf96
commit 988a3476e0
12 changed files with 196 additions and 11 deletions
+16
View File
@@ -0,0 +1,16 @@
# Orbit TV 0.55.0 to 0.57.0 verification
A cleaner guide, and real programme artwork behind the channel logo.
## Changed
- 0.55: finished programmes aren't drawn, what's on now fills to the start of the row, no empty blocks, no time line, an arrow on shows running past the edge. Paul confirmed this looks right.
- 0.56: drawn scenes for ToonZ, Evidence, Orbit X and Rewind. Paul found them off brand, and they were never installed. They're no longer shown.
- 0.57: while tuning, and in the guide's picture until the first frame, the app shows the programme's real artwork from Emby, dimmed, with the channel logo on top. It tries the show's backdrop, then the programme's own. With none, it shows plain navy with the logo. Pictures are cached on the box, at most 150 files, and a show's picture is fetched once. Requests use the same fixed provider address and no redirects.
## Checked
- assembleDebug, testDebugUnitTest and lintDebug pass; 239 tests, 0 failures. Tests cover the order pictures are tried, failures, bad IDs, one fetch per show and the cache limit.
- APK signature verified with apksigner.
## Not checked
- Not yet run on the box. Whether Paul's provider has backdrops for his shows, how they look dimmed, and how quickly they appear can only be seen there.
- Old channel backgrounds remain in the app but are mostly unused. Removing them is a later tidy.
+2 -2
View File
@@ -9,8 +9,8 @@ android {
applicationId = "tv.orbit.classic"
minSdk = 26
targetSdk = 36
versionCode = 57
versionName = "0.56.0-channel-art"
versionCode = 58
versionName = "0.57.0-real-artwork"
}
buildFeatures { compose = true }
compileOptions { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 }
@@ -231,6 +231,7 @@ internal fun topUpPlan(existing: ChannelPlan?, aired: ScheduleCursor, pool: List
*/
internal class ScheduledChannel(val loop: SampleChannel, plan: ChannelPlan) : SampleChannel(loop.lineup, loop.name, loop.number) {
@Volatile var plan: ChannelPlan = plan
fun seriesIdAt(startMs: Long) = plan.entries.firstOrNull { it.startMs == startMs }?.seriesId
override fun at(timeMs: Long): SampleBroadcast {
val entries = plan.entries
var low = 0
@@ -6,7 +6,6 @@ import androidx.compose.foundation.layout.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -14,11 +13,11 @@ import androidx.tv.material3.Text
/** Presentation only: no timers, media requests or schedule changes. */
@Composable
internal fun ChannelTuningBackdrop(number: Int, modifier: Modifier = Modifier) {
internal fun ChannelTuningBackdrop(number: Int, modifier: Modifier = Modifier, artwork: @Composable () -> Unit = {}) {
val branding = ChannelBrands.forNumber(number)
Box(modifier.fillMaxSize().background(Navy)) {
artwork()
if (branding != null) {
Image(painterResource(branding.background), null, Modifier.matchParentSize(), contentScale = ContentScale.Crop)
Image(painterResource(branding.logo), null,
Modifier.align(Alignment.TopStart).padding(start = 40.dp, top = 32.dp).width(200.dp).height(62.dp))
}
@@ -19,6 +19,7 @@ internal class HttpsEmbyTransport(internal val deviceId: String = UUID.randomUUI
companion object {
const val SERVER = "https://bk2.appboxmanager.xyz"
const val MAX_RESPONSE_BYTES = 2 * 1024 * 1024
const val MAX_IMAGE_BYTES = 6 * 1024 * 1024
internal fun readBounded(stream: InputStream): String {
val output = ByteArrayOutputStream()
val buffer = ByteArray(8192)
@@ -31,6 +32,30 @@ internal class HttpsEmbyTransport(internal val deviceId: String = UUID.randomUUI
return output.toString(Charsets.UTF_8.name())
}
}
/** One picture from the provider, or nothing. Same fixed origin and no redirects, and failures are never shown or logged. */
fun fetchImage(path: String, token: String): ByteArray? {
require(path.startsWith("/") && !path.startsWith("//"))
val connection = URL("$SERVER/emby$path").openConnection() as HttpsURLConnection
try {
connection.instanceFollowRedirects = false
connection.connectTimeout = 10_000
connection.readTimeout = 15_000
connection.setRequestProperty("X-Emby-Token", token)
if (connection.responseCode != 200 || connection.contentType?.startsWith("image/") != true || connection.contentLengthLong > MAX_IMAGE_BYTES) return null
val output = ByteArrayOutputStream()
connection.inputStream.use { stream ->
val buffer = ByteArray(8192)
while (true) {
val count = stream.read(buffer)
if (count == -1) break
if (output.size() + count > MAX_IMAGE_BYTES) return null
output.write(buffer, 0, count)
}
}
return output.toByteArray()
} catch (_: java.io.IOException) { return null }
finally { connection.disconnect() }
}
override fun execute(request: EmbyRequest): EmbyReply {
// Paths come from the protocol below, never from a provider response or a text field.
require(request.path.startsWith("/") && !request.path.startsWith("//"))
@@ -41,7 +66,7 @@ internal class HttpsEmbyTransport(internal val deviceId: String = UUID.randomUUI
connection.connectTimeout = 10_000
connection.readTimeout = request.readTimeoutMs ?: if (request.path == "/Sessions/Logout") 5_000 else 15_000
connection.setRequestProperty("Accept", "application/json")
connection.setRequestProperty("X-Emby-Authorization", "Emby Client=\"Orbit TV\", Device=\"Orbit TV\", DeviceId=\"$deviceId\", Version=\"0.56.0\"")
connection.setRequestProperty("X-Emby-Authorization", "Emby Client=\"Orbit TV\", Device=\"Orbit TV\", DeviceId=\"$deviceId\", Version=\"0.57.0\"")
request.token?.let { connection.setRequestProperty("X-Emby-Token", it) }
if (request.method == "POST") {
val bytes = request.body.orEmpty().toByteArray(Charsets.UTF_8)
@@ -238,7 +238,7 @@ internal fun EmbyPlaybackDialog(session: EmbySampleSession, plan: EmbyPlaybackPl
}
}, update = { it.player = player }, onRelease = { it.player = null })
if (scheduled != null && !ready && failure == null && foreground)
ChannelTuningBackdrop(scheduled.channel.number)
ChannelTuningBackdrop(scheduled.channel.number) { ProgrammeArtwork(session, scheduled.channel, scheduled.programme) }
if (scheduled != null && ready && tuningNumberVisible && failure == null)
ClassicTuningNumber(scheduled.channel.number,
Modifier.align(Alignment.TopEnd).padding(top = 32.dp, end = 44.dp))
@@ -226,6 +226,7 @@ internal class EmbySampleSession(private val transport: EmbyTransport, private v
}
fun suggestFor(rules: List<SuggestionRule>, alreadyInPool: Set<String>, rejected: Set<String>, checkActive: () -> Unit = {}) =
findSuggestions(transport, userId, ::currentToken, rules, alreadyInPool, rejected, checkActive)
fun fetchArtwork(path: String): ByteArray? = (transport as? HttpsEmbyTransport)?.fetchImage(path, currentToken())
fun rememberScheduledItems(items: List<SampleItem>) { channelEpisodes = channelEpisodes + items.associateBy { it.id } }
fun checkMetadataCoverage(checkActive: () -> Unit = {}): List<MetadataCoverage> =
@@ -3,10 +3,12 @@ package tv.orbit.classic
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.ui.unit.dp
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.viewinterop.AndroidView
@@ -79,7 +81,10 @@ internal fun GuidePicture(session: EmbySampleSession, channel: SampleChannel, li
GuideLiveProgramme(session, channel.number, current, handoff, onReady = { ready = true })
}
val branding = ChannelBrands.forNumber(channel.number)
if (!ready && branding != null) Image(painterResource(branding.background), null, Modifier.matchParentSize(), contentScale = ContentScale.Crop)
if (!ready) Box(Modifier.matchParentSize().background(Panel)) {
ProgrammeArtwork(session, channel, current)
if (branding != null) Image(painterResource(branding.logo), branding.displayName, Modifier.align(androidx.compose.ui.Alignment.Center).width(150.dp).height(47.dp))
}
}
}
@@ -0,0 +1,78 @@
package tv.orbit.classic
import android.graphics.BitmapFactory
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
private const val ARTWORK_KEEP_FILES = 150
/** Where a programme's picture may be: its show's wide artwork first, as that suits a full screen, then its own. */
internal fun artworkPaths(itemId: String, seriesId: String?): List<String> =
(listOfNotNull(seriesId, itemId).distinct().filter { EmbyClient.validId(it) }.map { "/Items/$it/Images/Backdrop?maxWidth=1280&quality=80" } +
listOf(itemId).filter { EmbyClient.validId(it) }.map { "/Items/$it/Images/Primary?maxWidth=1280&quality=80" })
/** Keeps artwork on the box between visits, within a fixed number of files, so a show's picture is only fetched once. */
internal class ArtworkCache(private val directory: File) {
private fun file(key: String) = File(directory, "$key.img")
fun get(key: String): ByteArray? = file(key).takeIf { it.isFile }?.let { found ->
found.setLastModified(System.currentTimeMillis())
runCatching { found.readBytes() }.getOrNull()
}
fun put(key: String, bytes: ByteArray) {
directory.mkdirs()
val temporary = File(directory, "$key.tmp")
temporary.writeBytes(bytes)
temporary.renameTo(file(key))
trim()
}
private fun trim() {
directory.listFiles()?.sortedByDescending { it.lastModified() }?.drop(ARTWORK_KEEP_FILES)?.forEach { it.delete() }
}
}
/** The first picture available, from the box if it's there. Any failure just means no picture. */
internal fun loadArtwork(cache: ArtworkCache, itemId: String, seriesId: String?, fetch: (String) -> ByteArray?): ByteArray? {
if (!EmbyClient.validId(itemId)) return null
val key = (seriesId ?: itemId).takeIf { EmbyClient.validId(it) } ?: itemId
cache.get(key)?.let { return it }
for (path in artworkPaths(itemId, seriesId)) {
val bytes = runCatching { fetch(path) }.getOrNull() ?: continue
if (bytes.isEmpty()) continue
runCatching { cache.put(key, bytes) }
return bytes
}
return null
}
/** The programme's real picture, dimmed so the channel logo and number read on top. With no picture, the plain background shows through. */
@Composable
internal fun ProgrammeArtwork(session: EmbySampleSession, channel: SampleChannel, programme: SampleBroadcast, modifier: Modifier = Modifier) {
val context = LocalContext.current
val itemId = programme.media.item.id
val seriesId = remember(channel, programme.startMs) { (channel as? ScheduledChannel)?.seriesIdAt(programme.startMs) }
val picture by produceState<ImageBitmap?>(null, itemId, seriesId) {
value = withContext(Dispatchers.IO) {
val cache = ArtworkCache(File(context.cacheDir, "artwork"))
runCatching {
loadArtwork(cache, itemId, seriesId, session::fetchArtwork)?.let { BitmapFactory.decodeByteArray(it, 0, it.size)?.asImageBitmap() }
}.getOrNull()
}
}
picture?.let {
Box(modifier.fillMaxSize()) {
Image(it, null, Modifier.matchParentSize(), contentScale = ContentScale.Crop)
Box(Modifier.matchParentSize().background(Color.Black.copy(alpha = .45f)))
}
}
}
@@ -143,7 +143,7 @@ internal fun ScheduledChannelPlayer(session: EmbySampleSession, channel: SampleC
}
} else Dialog(onDismissRequest = onClose, properties = DialogProperties(usePlatformDefaultWidth = false,
decorFitsSystemWindows = false, securePolicy = SecureFlagPolicy.SecureOn)) {
if (problem == null) ChannelTuningBackdrop(channel.number)
if (problem == null) ChannelTuningBackdrop(channel.number) { ProgrammeArtwork(session, channel, current) }
else Box(Modifier.fillMaxSize().background(Navy)) {
Column(Modifier.align(Alignment.Center).widthIn(max = 620.dp).padding(32.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(problem.orEmpty(), color = Color.White, fontSize = 20.sp)
@@ -33,7 +33,7 @@ internal class SecureMediaDataSource(private val session: EmbySampleSession) : B
next.instanceFollowRedirects = false
next.connectTimeout = 10_000
next.readTimeout = 20_000
next.setRequestProperty("User-Agent", "OrbitTV/0.56.0")
next.setRequestProperty("User-Agent", "OrbitTV/0.57.0")
next.setRequestProperty("Accept-Encoding", "identity")
session.mediaHeaders(address).forEach { (key, value) -> next.setRequestProperty(key, value) }
if (dataSpec.position != 0L || dataSpec.length != C.LENGTH_UNSET.toLong()) {
@@ -0,0 +1,60 @@
package tv.orbit.classic
import org.junit.Assert.*
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import java.io.File
class ProgrammeArtworkTest {
@get:Rule val folder = TemporaryFolder()
private fun cache() = ArtworkCache(folder.newFolder())
private val png = byteArrayOf(1, 2, 3)
@Test fun theShowsWideArtworkIsTriedFirst() {
assertEquals(listOf("/Items/show/Images/Backdrop?maxWidth=1280&quality=80", "/Items/ep/Images/Backdrop?maxWidth=1280&quality=80", "/Items/ep/Images/Primary?maxWidth=1280&quality=80"), artworkPaths("ep", "show"))
}
@Test fun aFilmOnlyAsksForItsOwnArtwork() {
assertEquals(2, artworkPaths("film", "film").size)
assertEquals(2, artworkPaths("film", null).size)
}
@Test fun badIdsAreNeverRequested() {
assertTrue(artworkPaths("../bad", "../worse").isEmpty())
assertNull(loadArtwork(cache(), "../bad", null) { fail("should not fetch"); null })
}
@Test fun itMovesOnWhenAPictureIsMissing() {
val asked = mutableListOf<String>()
val found = loadArtwork(cache(), "ep", "show") { path -> asked += path; if (path.contains("Primary")) png else null }
assertArrayEquals(png, found)
assertEquals(3, asked.size)
}
@Test fun aFailureMeansNoPicture() {
assertNull(loadArtwork(cache(), "ep", "show") { throw EmbyCheckException("no") })
assertNull(loadArtwork(cache(), "ep", "show") { ByteArray(0) })
}
@Test fun aShowsPictureIsOnlyFetchedOnce() {
val cache = cache()
var fetches = 0
repeat(3) { i -> assertArrayEquals(png, loadArtwork(cache, "ep$i", "show") { fetches++; png }) }
assertEquals(1, fetches)
}
@Test fun theCacheStaysBounded() {
val dir = folder.newFolder(); val cache = ArtworkCache(dir)
repeat(160) { cache.put("k$it", png) }
assertTrue(File(dir.path).listFiles()!!.size <= 150)
}
@Test fun aPlannedChannelKnowsWhichShowIsOn() {
val minute = 60_000L
val media = { id: String -> ScheduledMedia(SampleItem(id, "Episode", 1, "", id, minute), "s-$id", minute) }
val channel = ScheduledChannel(SampleChannel(listOf(media("x"), media("y")), "Paradox", 152), ChannelPlan(listOf(ScheduleEntry("show", EpisodeMark(1, 1), media("e"), 1_000_000L)), ScheduleCursor()))
assertEquals("show", channel.seriesIdAt(1_000_000L))
assertNull(channel.seriesIdAt(5L))
}
}