Add library health check, channel logos and remote controls (0.22.0)
A read-only check of duplicates, decades, genres and filter speed, with no database change. Gives 101, 102 and 103 their logos and an on-screen mark, and makes up, down and left change channel while watching. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@@ -0,0 +1,15 @@
|
||||
# Orbit TV 0.22.0 — library health check and test channel
|
||||
|
||||
Approved scope: a read-only baseline of the imported catalogue, with no migration, plus channel logos and Paul's remote controls on the existing channels.
|
||||
|
||||
Library health check: Menu → Connect Emby → Library import test → Health check → Run check. One pass over saved rows counts titles, likely copies by external ID (Imdb, then Tmdb, then Tvdb, per type), distinct titles by normalised title and year, decades, top genres and tags, then times five filters. It uses readableDatabase, writes nothing, stays at database version 2 and makes no network request. Counts are "likely": title matching can over-group or under-group, and the check merges nothing. The pass holds short keys in memory; its memory use on the box wasn't measured.
|
||||
|
||||
Channels: 101 shows as Orbit 1 and 103 as Vortex, display only, as approved in the roadmap. Saved names and numbers are unchanged, so timelines are too. All three now have a guide logo, background and tuning screen, and a bottom-left mark at 80% opacity, hidden while the programme bar is up. There's no setting for the mark yet, so it's always on for this test.
|
||||
|
||||
Remote while watching a channel: up and channel-up go to the next channel, down and channel-down to the previous one, wrapping round. Left returns to the last channel. The programme bar's legend now matches, and its subtitle label is gone because that control isn't built. On-demand sample playback keeps its seek behaviour.
|
||||
|
||||
Not in this build: idents in the app, subtitles on right, the hold-OK number box, the new home screen, or any use of the catalogue by channels.
|
||||
|
||||
Validation: offline assembleDebug, testDebugUnitTest and lintDebug passed; 119 unit tests (113 existing, 6 new), zero failures or errors, no lint errors. New tests cover copy grouping, remakes, spelling, broken IDs, film and series sharing a number, counts, and channel wrap-round. SQLite queries, the screen, the mark on real video and the remote need the Xiaomi test. No private account, catalogue, media or device data was read by tools.
|
||||
|
||||
Rollback: reinstall outputs/revision-0.21.0, as the database and saved format are unchanged, or revert this commit.
|
||||
@@ -9,8 +9,8 @@ android {
|
||||
applicationId = "tv.orbit.classic"
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
versionCode = 21
|
||||
versionName = "0.21.0-import-position"
|
||||
versionCode = 22
|
||||
versionName = "0.22.0-library-health"
|
||||
}
|
||||
buildFeatures { compose = true }
|
||||
compileOptions { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 }
|
||||
|
||||
@@ -21,7 +21,7 @@ private fun whole(value: Long) = String.format(Locale.UK, "%,d", value)
|
||||
|
||||
@Composable
|
||||
internal fun CatalogueScreen(summary: CatalogueSummary?, busy: Boolean, canRun: Boolean, message: String?, lastRun: String?,
|
||||
modifier: Modifier, onRun: (Boolean) -> Unit, onBack: () -> Unit) {
|
||||
modifier: Modifier, onRun: (Boolean) -> Unit, onBack: () -> Unit, onHealth: () -> Unit = {}) {
|
||||
BackHandler(onBack = onBack)
|
||||
val backFocus = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) { backFocus.requestFocus() }
|
||||
@@ -37,6 +37,7 @@ internal fun CatalogueScreen(summary: CatalogueSummary?, busy: Boolean, canRun:
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OrbitButton(if (busy) "Importing…" else if (summary?.finished == true) "Import finished" else "Import everything", enabled = canRun && summary?.finished != true, onClick = { onRun(true) })
|
||||
OrbitButton("Import next 1,000", enabled = canRun && summary?.finished != true, onClick = { onRun(false) })
|
||||
OrbitButton("Health check", enabled = !busy, onClick = onHealth)
|
||||
OrbitButton("Back", Modifier.focusRequester(backFocus), onClick = onBack)
|
||||
}
|
||||
message?.let { Text(it, color = Yellow, fontSize = 15.sp, maxLines = 3) }
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package tv.orbit.classic
|
||||
|
||||
import org.json.JSONObject
|
||||
import java.util.Locale
|
||||
|
||||
internal data class CatalogueTiming(val label: String, val count: Long, val ms: Long)
|
||||
internal data class CatalogueHealth(val titles: Long, val distinctById: Long, val distinctByTitle: Long, val repeatedById: Long, val withoutId: Long,
|
||||
val decades: List<Pair<String, Long>>, val genres: List<Pair<String, Long>>, val tags: List<Pair<String, Long>>,
|
||||
val scanMs: Long, val timings: List<CatalogueTiming>, val bytes: Long)
|
||||
|
||||
private val IdOrder = listOf("Imdb", "Tmdb", "Tvdb")
|
||||
|
||||
/** The strongest external ID, per type, because a film and a series can share a number on some sites. */
|
||||
internal fun catalogueIdKey(type: String, providerIds: String): String? {
|
||||
val ids = try { JSONObject(providerIds) } catch (_: Exception) { return null }
|
||||
val match = IdOrder.firstNotNullOfOrNull { name -> ids.optString(name).takeIf { it.isNotBlank() }?.let { "$name:${it.lowercase(Locale.ROOT)}" } } ?: return null
|
||||
return "$type|$match"
|
||||
}
|
||||
|
||||
/** Case, punctuation and a leading article don't make a different title. The year keeps remakes apart. */
|
||||
internal fun catalogueTitleKey(type: String, title: String, year: Int?): String {
|
||||
val letters = title.lowercase(Locale.ROOT).map { if (it.isLetterOrDigit()) it else ' ' }.joinToString("").trim().replace(Regex(" +"), " ")
|
||||
return "$type|${letters.removePrefix("the ")}|${year ?: "?"}"
|
||||
}
|
||||
|
||||
/** Holds counts and short keys only, so a pass over the whole catalogue stays small. */
|
||||
internal class CatalogueTally {
|
||||
private var titles = 0L
|
||||
private var withoutId = 0L
|
||||
private val byId = HashMap<String, Int>()
|
||||
private val byTitle = HashSet<String>()
|
||||
private val decades = HashMap<Int, Long>()
|
||||
private val genres = HashMap<String, Long>()
|
||||
private val tags = HashMap<String, Long>()
|
||||
fun add(type: String, title: String, year: Int?, genreList: String, tagList: String, providerIds: String) {
|
||||
titles++
|
||||
val key = catalogueIdKey(type, providerIds)
|
||||
if (key == null) withoutId++ else byId.merge(key, 1, Int::plus)
|
||||
byTitle += catalogueTitleKey(type, title, year)
|
||||
year?.let { decades.merge(it / 10 * 10, 1L, Long::plus) }
|
||||
genreList.split('|').filter { it.isNotBlank() }.forEach { genres.merge(it, 1L, Long::plus) }
|
||||
tagList.split('|').filter { it.isNotBlank() }.forEach { tags.merge(it.lowercase(Locale.ROOT), 1L, Long::plus) }
|
||||
}
|
||||
private fun top(map: Map<String, Long>, limit: Int) = map.entries.sortedByDescending { it.value }.take(limit).map { it.key to it.value }
|
||||
fun result(scanMs: Long, timings: List<CatalogueTiming>, bytes: Long) = CatalogueHealth(titles, byId.size + withoutId, byTitle.size.toLong(),
|
||||
byId.values.sumOf { (it - 1).toLong() }, withoutId, decades.entries.sortedBy { it.key }.map { "${it.key}s" to it.value },
|
||||
top(genres, 8), top(tags, 8), scanMs, timings, bytes)
|
||||
}
|
||||
@@ -64,6 +64,33 @@ internal class CatalogueStore private constructor(context: Context) : CatalogueS
|
||||
total, meta(db, "finished") == "true" && total != null, count("genres != ''"), count("provider_ids != '{}'"),
|
||||
count("year IS NOT NULL"), count("rating != ''"), count("premiere != ''"), count("tags != ''"), file.length())
|
||||
}
|
||||
/** Read-only: one pass over the saved rows and a few timed filters. It writes nothing and changes no table. */
|
||||
@Synchronized fun health(checkActive: () -> Unit = {}): CatalogueHealth {
|
||||
val began = System.currentTimeMillis()
|
||||
val db = readableDatabase
|
||||
val tally = CatalogueTally()
|
||||
db.rawQuery("SELECT type, title, year, genres, tags, provider_ids FROM items", null).use { row ->
|
||||
var seen = 0
|
||||
while (row.moveToNext()) {
|
||||
if (++seen % 2000 == 0) checkActive()
|
||||
tally.add(row.getString(0), row.getString(1), if (row.isNull(2)) null else row.getInt(2), row.getString(3), row.getString(4), row.getString(5))
|
||||
}
|
||||
}
|
||||
val scanMs = System.currentTimeMillis() - began
|
||||
fun timed(label: String, sql: String, vararg args: String): CatalogueTiming {
|
||||
checkActive()
|
||||
val start = System.currentTimeMillis()
|
||||
val count = db.rawQuery(sql, args).use { it.moveToFirst(); it.getLong(0) }
|
||||
return CatalogueTiming(label, count, System.currentTimeMillis() - start)
|
||||
}
|
||||
val timings = listOf(
|
||||
timed("Horror films, 1980s", "SELECT COUNT(*) FROM items WHERE type = 'Movie' AND year BETWEEN 1980 AND 1989 AND ('|' || genres || '|') LIKE '%|Horror|%'"),
|
||||
timed("Animated series", "SELECT COUNT(*) FROM items WHERE type = 'Series' AND ('|' || genres || '|') LIKE '%|Animation|%'"),
|
||||
timed("Documentaries", "SELECT COUNT(*) FROM items WHERE ('|' || genres || '|') LIKE '%|Documentary|%'"),
|
||||
timed("Title search", "SELECT COUNT(*) FROM items WHERE title LIKE ?", "%Star%"),
|
||||
timed("Lookup by title", "SELECT COUNT(*) FROM items WHERE type = 'Series' AND title = ?", "Fixture"))
|
||||
return tally.result(scanMs, timings, file.length())
|
||||
}
|
||||
@Synchronized fun clear() {
|
||||
close()
|
||||
if (file.exists() && !SQLiteDatabase.deleteDatabase(file)) throw IllegalStateException("The catalogue could not be removed.")
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
package tv.orbit.classic
|
||||
|
||||
internal data class ChannelBranding(val presetId: String, val logo: Int, val background: Int)
|
||||
internal data class ChannelBranding(val presetId: String, val displayName: String, val logo: Int, val background: Int, val bug: Int)
|
||||
|
||||
internal object ChannelBrands {
|
||||
private val brands = mapOf(102 to ChannelBranding("orbit-after-dark", R.drawable.orbit_after_dark_epg, R.drawable.orbit_after_dark_background))
|
||||
private val brands = mapOf(
|
||||
101 to ChannelBranding("orbit-1", "Orbit 1", R.drawable.orbit_one_epg, R.drawable.orbit_one_background, R.drawable.orbit_one_bug),
|
||||
102 to ChannelBranding("orbit-after-dark", "Orbit After Dark", R.drawable.orbit_after_dark_epg, R.drawable.orbit_after_dark_background, R.drawable.orbit_after_dark_bug),
|
||||
103 to ChannelBranding("vortex", "Vortex", R.drawable.vortex_epg, R.drawable.vortex_background, R.drawable.vortex_bug))
|
||||
fun forNumber(number: Int): ChannelBranding? = brands[number]
|
||||
/** Saved channels keep their original names; what the viewer sees comes from the brand. */
|
||||
fun displayName(channel: SampleChannel) = brands[channel.number]?.displayName ?: channel.name
|
||||
}
|
||||
|
||||
internal fun availableSampleChannels(vararg channels: SampleChannel?): List<SampleChannel> =
|
||||
channels.filterNotNull().distinctBy { it.number }.sortedBy { it.number }
|
||||
|
||||
/** Up and down wrap round the lineup, like a TV. A channel missing from the lineup stays put. */
|
||||
internal fun channelAfter(numbers: List<Int>, current: Int, step: Int): Int {
|
||||
val index = numbers.indexOf(current)
|
||||
return if (index < 0 || numbers.isEmpty()) current else numbers[Math.floorMod(index + step, numbers.size)]
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ internal fun ClassicProgrammeStrip(item: SampleItem, playing: Boolean, modifier:
|
||||
val paper = Color(0xFFE8EDF5)
|
||||
Column(modifier.border(1.dp, paper)) {
|
||||
Row(Modifier.fillMaxWidth().background(paper).padding(horizontal = 12.dp, vertical = 3.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(scheduled?.let { "${it.channel.number} ${it.channel.name}${if (it.clockOffsetMs != 0L) " / Transition check" else ""}" } ?: "Orbit Preview", color = Navy, fontWeight = FontWeight.Bold, fontSize = 14.sp)
|
||||
Text(scheduled?.let { "${it.channel.number} ${ChannelBrands.displayName(it.channel)}${if (it.clockOffsetMs != 0L) " / Transition check" else ""}" } ?: "Orbit Preview", color = Navy, fontWeight = FontWeight.Bold, fontSize = 14.sp)
|
||||
Spacer(Modifier.weight(1f))
|
||||
Text(SimpleDateFormat("HH:mm EEE d MMM", Locale.UK).format(Date(now)), color = Navy, fontSize = 13.sp)
|
||||
}
|
||||
@@ -44,7 +44,7 @@ internal fun ClassicProgrammeStrip(item: SampleItem, playing: Boolean, modifier:
|
||||
horizontalArrangement = Arrangement.spacedBy(30.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Legend("▲ ▼", "Channel")
|
||||
Legend("↶", "TV guide")
|
||||
Legend("CC", "Subtitles")
|
||||
if (scheduled != null) Legend("◄", "Last channel")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +41,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.21.0\"")
|
||||
connection.setRequestProperty("X-Emby-Authorization", "Emby Client=\"Orbit TV\", Device=\"Orbit TV\", DeviceId=\"$deviceId\", Version=\"0.22.0\"")
|
||||
request.token?.let { connection.setRequestProperty("X-Emby-Token", it) }
|
||||
if (request.method == "POST") {
|
||||
val bytes = request.body.orEmpty().toByteArray(Charsets.UTF_8)
|
||||
|
||||
@@ -80,6 +80,9 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
|
||||
var catalogueSummary by remember { mutableStateOf<CatalogueSummary?>(null) }
|
||||
var catalogueRun by remember { mutableStateOf<String?>(null) }
|
||||
var reviewingCatalogue by remember { mutableStateOf(false) }
|
||||
var reviewingHealth by remember { mutableStateOf(false) }
|
||||
var libraryHealth by remember { mutableStateOf<CatalogueHealth?>(null) }
|
||||
var lastNumber by remember { mutableStateOf<Int?>(null) }
|
||||
var afterDarkChannel by remember { mutableStateOf<SampleChannel?>(null) }
|
||||
var toonamiChannel by remember { mutableStateOf<SampleChannel?>(null) }
|
||||
var selectedNumber by remember { mutableIntStateOf(101) }
|
||||
@@ -121,7 +124,7 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
|
||||
}
|
||||
seriesMatches = null; episodeSamples = null; reviewingSeries = false; approvedSeries = emptySet()
|
||||
darkMovies = emptyList(); approvedDark = emptySet(); reviewingDark = false; afterDarkChannel = null
|
||||
metadataCoverage = null; reviewingCoverage = false; reviewingCatalogue = false; catalogueSummary = null; catalogueRun = null
|
||||
metadataCoverage = null; reviewingCoverage = false; reviewingCatalogue = false; catalogueSummary = null; catalogueRun = null; reviewingHealth = false; libraryHealth = null; lastNumber = null
|
||||
playback = null; session = null; sample = null; channel = null; toonamiChannel = null; selectedNumber = 101; watchingChannel = false
|
||||
onChannelAvailable(false)
|
||||
sessionSlot.clear()?.let { active -> EmbyCleanup.run { active.close() } }
|
||||
@@ -218,14 +221,40 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
|
||||
val liveChannel = availableChannels.firstOrNull { it.number == selectedNumber }
|
||||
if (mainGuide && liveSession != null && liveChannel != null) {
|
||||
if (watchingChannel) ScheduledChannelPlayer(liveSession, liveChannel, channelClockOffset,
|
||||
onClose = { watchingChannel = false; channelClockOffset = 0 })
|
||||
onClose = { watchingChannel = false; channelClockOffset = 0 },
|
||||
onStep = { step ->
|
||||
val next = channelAfter(availableChannels.map { it.number }, selectedNumber, step)
|
||||
if (next != selectedNumber) { lastNumber = selectedNumber; selectedNumber = next; channelClockOffset = 0 }
|
||||
},
|
||||
onLast = {
|
||||
val previous = lastNumber
|
||||
if (previous != null && availableChannels.any { it.number == previous }) { lastNumber = selectedNumber; selectedNumber = previous; channelClockOffset = 0 }
|
||||
})
|
||||
SampleChannelGuide(liveChannel, modifier, onTune = { offset ->
|
||||
channelClockOffset = offset; watchingChannel = true
|
||||
}, onBack = onBack, otherChannels = availableChannels.filter { it.number != selectedNumber },
|
||||
onSwitch = { selectedNumber = it; watchingChannel = false; channelClockOffset = 0 })
|
||||
onSwitch = { lastNumber = selectedNumber; selectedNumber = it; watchingChannel = false; channelClockOffset = 0 })
|
||||
return
|
||||
}
|
||||
if (!visible) return
|
||||
if (reviewingHealth) {
|
||||
LibraryHealthScreen(libraryHealth, busy, message, modifier, onRun = {
|
||||
if (!busy) {
|
||||
busy = true; message = null
|
||||
job = scope.launch {
|
||||
try {
|
||||
libraryHealth = withContext(Dispatchers.IO) {
|
||||
val context = coroutineContext
|
||||
catalogue.health { context.ensureActive() }
|
||||
}
|
||||
} catch (cancelled: CancellationException) { throw cancelled }
|
||||
catch (_: Exception) { message = "The check could not finish. Your library data hasn't changed." }
|
||||
finally { busy = false }
|
||||
}
|
||||
}
|
||||
}, onBack = { job?.cancel(); busy = false; reviewingHealth = false })
|
||||
return
|
||||
}
|
||||
if (reviewingCatalogue) {
|
||||
LaunchedEffect(Unit) {
|
||||
try { catalogueSummary = withContext(Dispatchers.IO) { catalogue.summary() } }
|
||||
@@ -262,7 +291,7 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
|
||||
}
|
||||
}
|
||||
}
|
||||
}, onBack = { job?.cancel(); busy = false; reviewingCatalogue = false })
|
||||
}, onBack = { job?.cancel(); busy = false; reviewingCatalogue = false }, onHealth = { message = null; reviewingHealth = true })
|
||||
return
|
||||
}
|
||||
if (reviewingCoverage) {
|
||||
@@ -502,7 +531,7 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
|
||||
})
|
||||
OrbitButton("102 Orbit After Dark — choose films", Modifier.fillMaxWidth(),
|
||||
enabled = !busy && session != null, onClick = { message = null; reviewingDark = true })
|
||||
OrbitButton(if (seriesMatches == null) "103 Toonami — check shortlist" else "103 Toonami — review matches",
|
||||
OrbitButton(if (seriesMatches == null) "103 Vortex — check shortlist" else "103 Vortex — review matches",
|
||||
Modifier.fillMaxWidth(), enabled = !busy && session != null, onClick = {
|
||||
if (seriesMatches != null) reviewingSeries = true else checkMoreSeries()
|
||||
})
|
||||
|
||||
@@ -25,6 +25,8 @@ import androidx.media3.datasource.DataSource
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||
import androidx.media3.ui.PlayerView
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.tv.material3.Text
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
@@ -32,7 +34,8 @@ import kotlinx.coroutines.withContext
|
||||
|
||||
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
|
||||
@Composable
|
||||
internal fun EmbyPlaybackDialog(session: EmbySampleSession, plan: EmbyPlaybackPlan, onClose: () -> Unit, scheduled: ScheduledPlayback? = null) {
|
||||
internal fun EmbyPlaybackDialog(session: EmbySampleSession, plan: EmbyPlaybackPlan, onClose: () -> Unit, scheduled: ScheduledPlayback? = null,
|
||||
onStep: (Int) -> Unit = {}, onLast: () -> Unit = {}) {
|
||||
val context = LocalContext.current
|
||||
var failure by remember { mutableStateOf<String?>(null) }
|
||||
var tuningNumberVisible by remember { mutableStateOf(true) }
|
||||
@@ -130,7 +133,10 @@ internal fun EmbyPlaybackDialog(session: EmbySampleSession, plan: EmbyPlaybackPl
|
||||
KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_ENTER -> {
|
||||
if (scheduled == null && infoVisible) { if (player.isPlaying) player.pause() else player.play() }
|
||||
}
|
||||
KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_MEDIA_REWIND -> if (scheduled == null) player.seekBack()
|
||||
KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_CHANNEL_UP -> if (scheduled != null) onStep(1)
|
||||
KeyEvent.KEYCODE_DPAD_DOWN, KeyEvent.KEYCODE_CHANNEL_DOWN -> if (scheduled != null) onStep(-1)
|
||||
KeyEvent.KEYCODE_DPAD_LEFT -> if (scheduled != null) onLast() else player.seekBack()
|
||||
KeyEvent.KEYCODE_MEDIA_REWIND -> if (scheduled == null) player.seekBack()
|
||||
KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> if (scheduled == null) player.seekForward()
|
||||
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> if (scheduled == null) { if (player.isPlaying) player.pause() else player.play() }
|
||||
KeyEvent.KEYCODE_MEDIA_PLAY -> player.play()
|
||||
@@ -152,6 +158,10 @@ internal fun EmbyPlaybackDialog(session: EmbySampleSession, plan: EmbyPlaybackPl
|
||||
if (scheduled != null && ready && tuningNumberVisible && failure == null)
|
||||
ClassicTuningNumber(scheduled.channel.number,
|
||||
Modifier.align(Alignment.TopEnd).padding(top = 32.dp, end = 44.dp))
|
||||
// The channel's mark, off while the programme bar is up. It's drawn over the picture, never into it.
|
||||
val bug = scheduled?.let { ChannelBrands.forNumber(it.channel.number)?.bug }
|
||||
if (bug != null && ready && !infoVisible && failure == null)
|
||||
Image(painterResource(bug), null, Modifier.align(Alignment.BottomStart).padding(start = 40.dp, bottom = 28.dp).width(108.dp).height(36.dp), alpha = .8f)
|
||||
if (infoVisible && failure == null && ready) ClassicProgrammeStrip(plan.item, playing,
|
||||
Modifier.align(Alignment.BottomCenter).fillMaxWidth(.86f).padding(bottom = 26.dp), scheduled = scheduled)
|
||||
if (failure != null) Column(Modifier.align(Alignment.Center).widthIn(max = 620.dp).background(Navy).padding(24.dp)) {
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package tv.orbit.classic
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.tv.material3.Text
|
||||
import java.util.Locale
|
||||
|
||||
private fun whole(value: Long) = String.format(Locale.UK, "%,d", value)
|
||||
private fun pairs(items: List<Pair<String, Long>>) = items.joinToString(" • ") { "${it.first} ${whole(it.second)}" }
|
||||
|
||||
@Composable
|
||||
internal fun LibraryHealthScreen(health: CatalogueHealth?, busy: Boolean, message: String?, modifier: Modifier, onRun: () -> Unit, onBack: () -> Unit) {
|
||||
BackHandler(onBack = onBack)
|
||||
val backFocus = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) { backFocus.requestFocus() }
|
||||
Column(modifier.fillMaxWidth().background(Navy).padding(18.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text("Library health check", color = Yellow, fontSize = 26.sp)
|
||||
Text("Read-only, using the details saved on this box. Nothing is changed and nothing goes over the network.", color = Color.White, fontSize = 15.sp)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OrbitButton(if (busy) "Checking…" else if (health == null) "Run check" else "Run again", enabled = !busy, onClick = onRun)
|
||||
OrbitButton("Back", Modifier.focusRequester(backFocus), onClick = onBack)
|
||||
}
|
||||
message?.let { Text(it, color = Yellow, fontSize = 15.sp, maxLines = 2) }
|
||||
health?.let { h ->
|
||||
Column(Modifier.fillMaxWidth().background(Panel).padding(10.dp), verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||
Text("${whole(h.titles)} titles: ${whole(h.distinctById)} different by external ID, ${whole(h.distinctByTitle)} by title and year", color = Color.White, fontSize = 17.sp)
|
||||
Text("Likely extra copies by ID: ${whole(h.repeatedById)} • no ID: ${whole(h.withoutId)}", color = Color.White, fontSize = 15.sp)
|
||||
Text(pairs(h.decades), color = Muted, fontSize = 13.sp, maxLines = 2)
|
||||
Text("Genres: ${pairs(h.genres)}", color = Muted, fontSize = 13.sp, maxLines = 2)
|
||||
Text("Tags: ${pairs(h.tags)}", color = Muted, fontSize = 13.sp, maxLines = 2)
|
||||
}
|
||||
Column(Modifier.fillMaxWidth().background(Panel).padding(10.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text("Full pass ${whole(h.scanMs)} ms • database ${whole(h.bytes / 1024)} KB", color = Color.White, fontSize = 15.sp)
|
||||
h.timings.forEach { Text("${it.label}: ${whole(it.count)} found, ${whole(it.ms)} ms", color = Muted, fontSize = 14.sp) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -47,13 +47,13 @@ internal fun SampleChannelGuide(channel: SampleChannel, modifier: Modifier = Mod
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("TV Guide", color = Yellow, fontSize = 26.sp, fontWeight = FontWeight.Bold)
|
||||
Spacer(Modifier.weight(1f))
|
||||
otherChannels.forEach { other -> OrbitButton("${other.number} ${other.name}", onClick = { onSwitch(other.number) }) }
|
||||
otherChannels.forEach { other -> OrbitButton("${other.number} ${ChannelBrands.displayName(other)}", onClick = { onSwitch(other.number) }) }
|
||||
Text(clock(now), color = Color.White, fontSize = 20.sp)
|
||||
}
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
Column(Modifier.width(125.dp)) {
|
||||
Text(channel.number.toString(), color = Yellow, fontSize = 36.sp, fontWeight = FontWeight.Bold)
|
||||
if (branding != null) Image(painterResource(branding.logo), channel.name, Modifier.width(125.dp).height(40.dp))
|
||||
if (branding != null) Image(painterResource(branding.logo), branding.displayName, Modifier.width(125.dp).height(40.dp))
|
||||
else Text(channel.name, color = Color.White, fontSize = 19.sp)
|
||||
}
|
||||
Column(Modifier.weight(1f)) {
|
||||
@@ -87,7 +87,7 @@ internal fun SampleChannelGuide(channel: SampleChannel, modifier: Modifier = Mod
|
||||
|
||||
@Composable
|
||||
internal fun ScheduledChannelPlayer(session: EmbySampleSession, channel: SampleChannel, clockOffsetMs: Long,
|
||||
onClose: () -> Unit) {
|
||||
onClose: () -> Unit, onStep: (Int) -> Unit = {}, onLast: () -> Unit = {}) {
|
||||
var now by remember { mutableLongStateOf(System.currentTimeMillis() + clockOffsetMs) }
|
||||
LaunchedEffect(channel, clockOffsetMs) { while (true) { now = System.currentTimeMillis() + clockOffsetMs; delay(250) } }
|
||||
val shows = channel.guide(now, 2)
|
||||
@@ -118,7 +118,7 @@ internal fun ScheduledChannelPlayer(session: EmbySampleSession, channel: SampleC
|
||||
if (plan != null) {
|
||||
key(current.startMs) {
|
||||
EmbyPlaybackDialog(session, plan, onClose,
|
||||
scheduled = ScheduledPlayback(channel, current, shows[1], clockOffsetMs))
|
||||
scheduled = ScheduledPlayback(channel, current, shows[1], clockOffsetMs), onStep = onStep, onLast = onLast)
|
||||
}
|
||||
} else Dialog(onDismissRequest = onClose, properties = DialogProperties(usePlatformDefaultWidth = false,
|
||||
decorFitsSystemWindows = false, securePolicy = SecureFlagPolicy.SecureOn)) {
|
||||
|
||||
@@ -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.21.0")
|
||||
next.setRequestProperty("User-Agent", "OrbitTV/0.22.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()) {
|
||||
|
||||
@@ -21,7 +21,7 @@ internal fun SeriesReviewScreen(matches: List<SeriesMatches>, approved: Set<Stri
|
||||
LaunchedEffect(episodes) { if (episodes != null) listState.scrollToItem(0) }
|
||||
BackHandler(onBack = onBack)
|
||||
Column(modifier.fillMaxWidth().background(Panel).padding(18.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("103 Toonami / Series check", color = Yellow, fontSize = 26.sp)
|
||||
Text("103 Vortex / Series check", color = Yellow, fontSize = 26.sp)
|
||||
Text("Choose up to three versions for this sample. Nothing is scheduled yet.", color = Muted, fontSize = 15.sp)
|
||||
if (busy) Text("Preparing sample…", color = Yellow, fontSize = 15.sp)
|
||||
message?.let { Text(it, color = Yellow, fontSize = 15.sp) }
|
||||
|
||||
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 6.0 KiB |
@@ -0,0 +1,42 @@
|
||||
package tv.orbit.classic
|
||||
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
class CatalogueStatsTest {
|
||||
private fun health(vararg rows: Array<Any?>): CatalogueHealth {
|
||||
val tally = CatalogueTally()
|
||||
rows.forEach { tally.add(it[0] as String, it[1] as String, it[2] as Int?, it[3] as String, it[4] as String, it[5] as String) }
|
||||
return tally.result(0, emptyList(), 0)
|
||||
}
|
||||
private fun film(title: String, year: Int?, ids: String = "{}", genres: String = "", tags: String = "") = arrayOf<Any?>("Movie", title, year, genres, tags, ids)
|
||||
|
||||
@Test fun copiesWithTheSameExternalIdCountAsOneTitle() {
|
||||
val h = health(film("Alien", 1979, """{"Imdb":"tt0078748"}"""), film("Alien Remux", 1979, """{"Imdb":"TT0078748","Tmdb":"348"}"""), film("Aliens", 1986, """{"Imdb":"tt0090605"}"""))
|
||||
assertEquals(3L, h.titles); assertEquals(2L, h.distinctById); assertEquals(1L, h.repeatedById); assertEquals(0L, h.withoutId)
|
||||
}
|
||||
@Test fun titlesAndYearsKeepRemakesApartAndIgnoreSpelling() {
|
||||
assertEquals(catalogueTitleKey("Movie", "The Thing", 1982), catalogueTitleKey("Movie", "thing:", 1982))
|
||||
assertNotEquals(catalogueTitleKey("Movie", "The Thing", 1982), catalogueTitleKey("Movie", "The Thing", 2011))
|
||||
assertNotEquals(catalogueTitleKey("Movie", "Fargo", 1996), catalogueTitleKey("Series", "Fargo", 1996))
|
||||
assertNotEquals(catalogueTitleKey("Movie", "Fargo", null), catalogueTitleKey("Movie", "Fargo", 1996))
|
||||
}
|
||||
@Test fun missingOrBrokenIdsAreCountedNotMerged() {
|
||||
val h = health(film("One", 2000), film("Two", 2000, "not json"), film("Three", 2000, """{"Other":"1"}"""))
|
||||
assertEquals(3L, h.withoutId); assertEquals(3L, h.distinctById); assertEquals(0L, h.repeatedById)
|
||||
assertNull(catalogueIdKey("Movie", "not json"))
|
||||
}
|
||||
@Test fun aFilmAndASeriesCanShareANumber() {
|
||||
assertNotEquals(catalogueIdKey("Movie", """{"Tmdb":"5"}"""), catalogueIdKey("Series", """{"Tmdb":"5"}"""))
|
||||
}
|
||||
@Test fun countsDecadesGenresAndTagsAndSortsThem() {
|
||||
val h = health(film("A", 1985, genres = "Horror|Comedy", tags = "Slasher"), film("B", 1989, genres = "Horror", tags = "slasher|Cult"), film("C", 1991), film("D", null))
|
||||
assertEquals(listOf("1980s" to 2L, "1990s" to 1L), h.decades)
|
||||
assertEquals("Horror" to 2L, h.genres.first()); assertEquals("slasher" to 2L, h.tags.first())
|
||||
}
|
||||
@Test fun upAndDownWrapRoundAndAnUnknownChannelStaysPut() {
|
||||
val lineup = listOf(101, 102, 103)
|
||||
assertEquals(102, channelAfter(lineup, 101, 1)); assertEquals(101, channelAfter(lineup, 103, 1)); assertEquals(103, channelAfter(lineup, 101, -1))
|
||||
assertEquals(999, channelAfter(lineup, 999, 1)); assertEquals(101, channelAfter(listOf(101), 101, 1))
|
||||
}
|
||||
}
|
||||