Show real programme artwork behind the channel logo (0.57.0)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user