Add Detour, BritX, Orbit Junior and Orbit Wild, and a subtitles and audio panel (0.63.0)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@@ -0,0 +1,18 @@
|
||||
# Orbit TV 0.62.0 and 0.63.0 verification
|
||||
|
||||
English sound, four more channels and a subtitles and audio panel.
|
||||
|
||||
## Changed
|
||||
- 0.62: asks for English sound, and leaves out episodes with only other languages, except on Vortex.
|
||||
- 0.63: Detour (114) and BritX (115) film channels from Paul's lists, each film resting a week. Orbit Junior (125) in the Kids category and Orbit Wild (143). New vector logos in design/channels/build-more-brands.py, with an amber X on BritX so it can't be mistaken for Orbit X.
|
||||
- Right while watching opens a panel of subtitles and audio. Choices carry across programmes until you leave full screen. Up to 8 subtitles and 5 audio tracks are listed.
|
||||
|
||||
## Checked
|
||||
- assembleDebug, testDebugUnitTest and lintDebug pass; 250 tests, 0 failures.
|
||||
- Logos checked on the PC at the size they appear, not on the TV.
|
||||
- APK signature verified with apksigner.
|
||||
|
||||
## Not checked
|
||||
- Not yet run on the box. Which titles Paul's library has, and the panel on a real remote, can only be seen there.
|
||||
- Orbit Junior is hand-picked but isn't age-locked, as profiles don't exist yet.
|
||||
- I filled out Junior's and Wild's lists myself beyond Paul's named shows.
|
||||
@@ -9,8 +9,8 @@ android {
|
||||
applicationId = "tv.orbit.classic"
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
versionCode = 63
|
||||
versionName = "0.62.0-english-audio"
|
||||
versionCode = 64
|
||||
versionName = "0.63.0-four-more-channels"
|
||||
}
|
||||
buildFeatures { compose = true }
|
||||
compileOptions { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 }
|
||||
|
||||
@@ -11,6 +11,10 @@ internal object ChannelBrands {
|
||||
112 to ChannelBranding("orbit-three", "Orbit Three", R.drawable.orbit_three_epg, R.drawable.orbit_three_background, R.drawable.orbit_three_bug, ChannelCategory.Comedy),
|
||||
113 to ChannelBranding("orbit-riot", "Orbit Riot", R.drawable.orbit_riot_epg, R.drawable.orbit_riot_background, R.drawable.orbit_riot_bug, ChannelCategory.Comedy),
|
||||
142 to ChannelBranding("orbit-dark-files", "Orbit Dark Files", R.drawable.orbit_dark_files_epg, R.drawable.orbit_dark_files_background, R.drawable.orbit_dark_files_bug, ChannelCategory.Documentaries),
|
||||
114 to ChannelBranding("orbit-detour", "Orbit Detour", R.drawable.orbit_detour_epg, R.drawable.orbit_one_background, R.drawable.orbit_detour_bug, ChannelCategory.Movies),
|
||||
115 to ChannelBranding("orbit-britx", "Orbit BritX", R.drawable.orbit_britx_epg, R.drawable.orbit_one_background, R.drawable.orbit_britx_bug, ChannelCategory.Movies),
|
||||
125 to ChannelBranding("orbit-junior", "Orbit Junior", R.drawable.orbit_junior_epg, R.drawable.orbit_one_background, R.drawable.orbit_junior_bug, ChannelCategory.Kids),
|
||||
143 to ChannelBranding("orbit-wild", "Orbit Wild", R.drawable.orbit_wild_epg, R.drawable.orbit_one_background, R.drawable.orbit_wild_bug, ChannelCategory.Documentaries),
|
||||
121 to ChannelBranding("heroes", "Orbit Heroes", R.drawable.heroes_epg, R.drawable.heroes_background, R.drawable.heroes_bug, ChannelCategory.Cartoons),
|
||||
122 to ChannelBranding("toonz", "Orbit ToonZ", R.drawable.toonz_epg, R.drawable.toonz_background, R.drawable.toonz_bug, ChannelCategory.Cartoons),
|
||||
123 to ChannelBranding("rewind", "Orbit Rewind", R.drawable.rewind_epg, R.drawable.rewind_background, R.drawable.rewind_bug, ChannelCategory.Cartoons),
|
||||
|
||||
@@ -3,7 +3,9 @@ package tv.orbit.classic
|
||||
/** A channel the app can build from named shows. Numbers follow the design and may change with the generic channel list. */
|
||||
internal data class ChannelPreset(val number: Int, val name: String, val searches: List<String>,
|
||||
/** Started from its smart schedule alone, with no separate loop to build first. */
|
||||
val smartOnly: Boolean = false)
|
||||
val smartOnly: Boolean = false,
|
||||
/** A film channel: curated titles, each resting a week between showings. */
|
||||
val films: Boolean = false)
|
||||
|
||||
internal val DarkFilesCrime = listOf("Forensic Files", "Cold Case Files", "The First 48", "Making a Murderer", "The Staircase", "The Jinx", "I Am a Killer", "Evil Lives Here", "Conversations with a Killer: The Ted Bundy Tapes", "Crime Scene: The Vanishing at the Cecil Hotel",
|
||||
"Snapped", "American Justice", "City Confidential", "Homicide Hunter", "The Keepers", "Don't F**k with Cats: Hunting an Internet Killer", "The Confession Tapes", "Night Stalker: The Hunt for a Serial Killer",
|
||||
@@ -39,12 +41,25 @@ internal val ChannelPresets = listOf(
|
||||
ChannelPreset(141, "Evidence", listOf("CSI: Crime Scene Investigation", "NCIS", "CSI: Miami", "CSI: NY", "Law & Order", "Law & Order: Special Victims Unit", "Criminal Minds", "Bones",
|
||||
"Castle", "The Mentalist", "Monk", "Columbo", "Midsomer Murders", "Vera", "Luther", "Line of Duty", "Taggart", "Silent Witness", "Waking the Dead", "A Touch of Frost", "Dexter", "Sherlock", "Broadchurch", "Death in Paradise", "Lewis", "Inspector Morse",
|
||||
"Dalziel and Pascoe", "Happy Valley", "The Wire", "Hawaii Five-0", "Blue Bloods"), smartOnly = true),
|
||||
ChannelPreset(142, "Dark Files", DarkFilesCrime + DarkFilesMysteries + DarkFilesParanormal, smartOnly = true))
|
||||
ChannelPreset(142, "Dark Files", DarkFilesCrime + DarkFilesMysteries + DarkFilesParanormal, smartOnly = true),
|
||||
ChannelPreset(114, "Detour", listOf("American Pie", "American Pie 2", "American Wedding", "American Reunion", "Road Trip", "EuroTrip", "Harold & Kumar Go to White Castle", "Harold & Kumar Escape from Guantanamo Bay",
|
||||
"A Very Harold & Kumar Christmas", "The Hangover", "The Hangover Part II", "The Hangover Part III", "Van Wilder", "Van Wilder: The Rise of Taj", "Old School", "Sex Drive", "Accepted", "Superbad", "Pineapple Express",
|
||||
"Dude, Where's My Car?", "Without a Paddle", "The Girl Next Door", "Not Another Teen Movie", "Project X", "21 & Over", "Beerfest", "Grandma's Boy", "Waiting..."), smartOnly = true, films = true),
|
||||
ChannelPreset(115, "BritX", listOf("Shaun of the Dead", "Hot Fuzz", "The World's End", "Lock, Stock and Two Smoking Barrels", "Snatch", "RocknRolla", "Layer Cake", "Trainspotting", "T2 Trainspotting", "Green Street",
|
||||
"The Football Factory", "Dog Soldiers", "Dead Man's Shoes", "28 Days Later", "28 Weeks Later", "This Is England", "The Gentlemen", "The Business", "The Firm", "Rise of the Footsoldier", "The 51st State", "Sexy Beast",
|
||||
"The Bank Job", "The Long Good Friday", "Human Traffic", "Twin Town", "Shallow Grave", "Four Lions", "Severance", "Attack the Block", "The Descent", "In Bruges", "Filth", "Calibre", "Sightseers", "Sweet Sixteen"), smartOnly = true, films = true),
|
||||
ChannelPreset(125, "Orbit Junior", listOf("Thomas the Tank Engine & Friends", "Thomas & Friends", "Fireman Sam", "Postman Pat", "Brum", "Rosie and Jim", "Bob the Builder", "Pingu", "Noddy", "Teletubbies", "Tweenies", "Tots TV",
|
||||
"Pingu in the City", "Peppa Pig", "Bluey", "Bagpuss", "The Clangers", "Camberwick Green", "Trumpton", "Button Moon", "Rainbow", "Balamory", "Fimbles"), smartOnly = true),
|
||||
ChannelPreset(143, "Orbit Wild", listOf("The Crocodile Hunter", "Crocodile Hunter Diaries", "Deadliest Catch", "River Monsters", "Man vs. Wild", "Running Wild with Bear Grylls", "Survivorman", "Dual Survival", "Naked and Afraid", "Ice Road Truckers",
|
||||
"Swamp People", "Wild Things with Dominic Monaghan", "Lone Star Law", "Alone", "Ax Men", "Monster Fish", "Dirty Jobs", "Shark Week", "Whale Wars", "Dangerous Encounters with Brady Barr"), smartOnly = true))
|
||||
|
||||
/** A fallback for a channel with no old loop: its first episodes, in turn. Used only outside its plan. */
|
||||
internal fun loopFromPool(pool: List<PoolSeries>, programming: ChannelProgramming): SampleChannel? {
|
||||
val turns = (0 until (pool.maxOfOrNull { it.episodes.size } ?: 0)).asSequence().flatMap { round -> pool.asSequence().mapNotNull { show -> show.episodes.getOrNull(round)?.let { show to it } } }
|
||||
val media = turns.take(15).map { (show, episode) -> ScheduledMedia(scheduledItem(show.title, episode), episode.sourceId, episode.durationMs) }.toList()
|
||||
val media = turns.take(15).map { (show, episode) ->
|
||||
val item = if (show.isFilm) SampleItem(show.title, "Movie", episode.durationMs / 60_000, "", episode.id, episode.durationMs) else scheduledItem(show.title, episode)
|
||||
ScheduledMedia(item, episode.sourceId, episode.durationMs)
|
||||
}.toList()
|
||||
return if (media.size < 2) null else SampleChannel(media, programming.name, programming.number)
|
||||
}
|
||||
|
||||
|
||||
@@ -59,7 +59,8 @@ internal val SmartChannels = listOf(
|
||||
"Hostile Planet", "Africa", "Wild China", "South Pacific", "Great Barrier Reef", "The Hunt", "The Life of Mammals", "The Life of Birds", "Nature's Great Events", "Green Planet", "Prehistoric Planet"),
|
||||
seriesCount = 20, finishTogether = true, exactTitles = true),
|
||||
ChannelProgramming(152, "Paradox", ChannelPresets.first { it.number == 152 }.searches)) +
|
||||
ChannelPresets.filter { it.smartOnly }.map { ChannelProgramming(it.number, it.name, it.searches, seriesCount = 60, finishTogether = true, exactTitles = true,
|
||||
ChannelPresets.filter { it.smartOnly }.map { ChannelProgramming(it.number, it.name, it.searches, seriesCount = 60, finishTogether = !it.films, exactTitles = true,
|
||||
filmRestMs = if (it.films) 7 * 24 * 60 * 60_000L else null,
|
||||
suggestionRules = SmartChannelRules[it.number].orEmpty(), dayParts = SmartChannelParts[it.number].orEmpty()) }
|
||||
|
||||
/** Dark Files, from the programming bible: crime by day, mysteries early evening, the paranormal late. */
|
||||
|
||||
@@ -46,7 +46,7 @@ internal fun ClassicProgrammeStrip(item: SampleItem, playing: Boolean, modifier:
|
||||
Legend("↶", "TV guide")
|
||||
if (scheduled != null) {
|
||||
Legend("◄", "Last channel")
|
||||
Legend("►", "Subtitles")
|
||||
Legend("►", "Subtitles and audio")
|
||||
Legend("Hold OK", "Number")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,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.62.0\"")
|
||||
connection.setRequestProperty("X-Emby-Authorization", "Emby Client=\"Orbit TV\", Device=\"Orbit TV\", DeviceId=\"$deviceId\", Version=\"0.63.0\"")
|
||||
request.token?.let { connection.setRequestProperty("X-Emby-Token", it) }
|
||||
if (request.method == "POST") {
|
||||
val bytes = request.body.orEmpty().toByteArray(Charsets.UTF_8)
|
||||
|
||||
@@ -41,7 +41,7 @@ private const val MAX_PLAYER_RETRIES = 1
|
||||
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
|
||||
@Composable
|
||||
internal fun EmbyPlaybackDialog(session: EmbySampleSession, plan: EmbyPlaybackPlan, onClose: () -> Unit, scheduled: ScheduledPlayback? = null,
|
||||
onStep: (Int) -> Unit = {}, onLast: () -> Unit = {}, onNumber: (Int) -> Boolean = { false }, subtitlePreference: MutableState<String?>? = null, runningPlayer: ExoPlayer? = null, announce: Boolean = true, onHandBack: ((ExoPlayer) -> Boolean)? = null, onUnplayable: ((String) -> Unit)? = null) {
|
||||
onStep: (Int) -> Unit = {}, onLast: () -> Unit = {}, onNumber: (Int) -> Boolean = { false }, subtitlePreference: MutableState<String?>? = null, runningPlayer: ExoPlayer? = null, announce: Boolean = true, onHandBack: ((ExoPlayer) -> Boolean)? = null, onUnplayable: ((String) -> Unit)? = null, audioPreference: MutableState<String?>? = null) {
|
||||
val context = LocalContext.current
|
||||
var failure by remember { mutableStateOf<String?>(null) }
|
||||
var tuningNumberVisible by remember { mutableStateOf(announce) }
|
||||
@@ -57,7 +57,8 @@ internal fun EmbyPlaybackDialog(session: EmbySampleSession, plan: EmbyPlaybackPl
|
||||
var numberProblem by remember { mutableStateOf<String?>(null) }
|
||||
var okHeld by remember { mutableStateOf(false) }
|
||||
var subtitleIndex by remember { mutableStateOf<Int?>(null) }
|
||||
var subtitleNote by remember { mutableStateOf<String?>(null) }
|
||||
var trackMenu by remember { mutableStateOf<List<TrackRow>?>(null) }
|
||||
var trackCursor by remember { mutableIntStateOf(0) }
|
||||
val player = remember(session, plan) {
|
||||
if (runningPlayer != null) return@remember runningPlayer
|
||||
val factory = DataSource.Factory { SecureMediaDataSource(session) }
|
||||
@@ -65,7 +66,7 @@ internal fun EmbyPlaybackDialog(session: EmbySampleSession, plan: EmbyPlaybackPl
|
||||
.setSeekBackIncrementMs(10_000).setSeekForwardIncrementMs(10_000).build().apply {
|
||||
setMediaItem(channelMediaItem(plan))
|
||||
if (scheduled != null) seekTo(scheduled.programme.offsetAt(scheduled.nowMs()))
|
||||
trackSelectionParameters = trackSelectionParameters.buildUpon().setPreferredAudioLanguage("en").build()
|
||||
trackSelectionParameters = trackSelectionParameters.buildUpon().setPreferredAudioLanguage(audioPreference?.value ?: "en").build()
|
||||
prepare(); playWhenReady = true
|
||||
}
|
||||
}
|
||||
@@ -76,17 +77,25 @@ internal fun EmbyPlaybackDialog(session: EmbySampleSession, plan: EmbyPlaybackPl
|
||||
if (index != null) builder.addOverride(TrackSelectionOverride(tracks[index].mediaTrackGroup, 0))
|
||||
player.trackSelectionParameters = builder.build()
|
||||
}
|
||||
fun cycleSubtitles() {
|
||||
val tracks = textTracks()
|
||||
val choice = nextSubtitle(subtitleIndex, tracks.size)
|
||||
chooseSubtitle(choice, tracks)
|
||||
val format = choice?.let { tracks[it].getTrackFormat(0) }
|
||||
subtitlePreference?.value = format?.let { it.language.orEmpty() }
|
||||
subtitleNote = when {
|
||||
tracks.isEmpty() -> "No subtitles on this programme"
|
||||
format == null -> "Subtitles off"
|
||||
else -> "Subtitles: ${format.label ?: format.language ?: "Track ${choice + 1}"}"
|
||||
fun chooseAudio(group: Tracks.Group) {
|
||||
player.trackSelectionParameters = player.trackSelectionParameters.buildUpon().clearOverridesOfType(C.TRACK_TYPE_AUDIO)
|
||||
.addOverride(TrackSelectionOverride(group.mediaTrackGroup, 0)).build()
|
||||
audioPreference?.value = group.getTrackFormat(0).language
|
||||
}
|
||||
fun trackRows(): List<TrackRow> {
|
||||
val subtitles = textTracks()
|
||||
val audio = player.currentTracks.groups.filter { it.type == C.TRACK_TYPE_AUDIO && it.isSupported }
|
||||
val subtitleRows = listOf(TrackRow("Subtitles", "Off", subtitles.none { it.isSelected }) { chooseSubtitle(null, subtitles); subtitlePreference?.value = null }) +
|
||||
// Capped so the panel always fits the screen, even on a file with dozens of subtitles.
|
||||
subtitles.take(8).mapIndexed { index, group ->
|
||||
val format = group.getTrackFormat(0)
|
||||
TrackRow("Subtitles", trackLabel(format.label, format.language, index), group.isSelected) { chooseSubtitle(index, subtitles); subtitlePreference?.value = format.language.orEmpty() }
|
||||
}
|
||||
val audioRows = audio.take(5).mapIndexed { index, group ->
|
||||
val format = group.getTrackFormat(0)
|
||||
TrackRow("Audio", trackLabel(format.label, format.language, index), group.isSelected) { chooseAudio(group) }
|
||||
}
|
||||
return subtitleRows + audioRows
|
||||
}
|
||||
fun submitNumber(entry: ChannelEntry) {
|
||||
if (onNumber(entry.number)) { numberEntry = null; numberProblem = null }
|
||||
@@ -100,7 +109,8 @@ internal fun EmbyPlaybackDialog(session: EmbySampleSession, plan: EmbyPlaybackPl
|
||||
}
|
||||
}
|
||||
LaunchedEffect(numberEntry) { if (numberEntry != null) { delay(10_000); numberEntry = null; numberProblem = null } }
|
||||
LaunchedEffect(subtitleNote) { if (subtitleNote != null) { delay(3_000); subtitleNote = null } }
|
||||
// Closes by itself, so a stray press doesn't leave it over the programme.
|
||||
LaunchedEffect(trackMenu, trackCursor) { if (trackMenu != null) { delay(10_000); trackMenu = null } }
|
||||
val lifecycle = LocalLifecycleOwner.current.lifecycle
|
||||
var foreground by remember { mutableStateOf(lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) }
|
||||
DisposableEffect(player, lifecycle) {
|
||||
@@ -195,7 +205,7 @@ internal fun EmbyPlaybackDialog(session: EmbySampleSession, plan: EmbyPlaybackPl
|
||||
LaunchedEffect(infoVisible, lastInteraction, playing, ready) {
|
||||
if (infoVisible && playing && ready) { delay(5_000); infoVisible = false }
|
||||
}
|
||||
Dialog(onDismissRequest = { if (numberEntry != null) { numberEntry = null; numberProblem = null } else {
|
||||
Dialog(onDismissRequest = { if (trackMenu != null) trackMenu = null else if (numberEntry != null) { numberEntry = null; numberProblem = null } else {
|
||||
// Backing out keeps the stream running in the picture, without subtitles at that size.
|
||||
if (ready && failure == null && foreground && onHandBack?.invoke(player) == true) {
|
||||
handedBack = true
|
||||
@@ -223,6 +233,17 @@ internal fun EmbyPlaybackDialog(session: EmbySampleSession, plan: EmbyPlaybackPl
|
||||
if (event.action == KeyEvent.ACTION_UP) okHeld = false
|
||||
return@setOnKeyListener true
|
||||
}
|
||||
trackMenu?.let { rows ->
|
||||
if (event.action == KeyEvent.ACTION_DOWN) when (code) {
|
||||
KeyEvent.KEYCODE_DPAD_UP -> trackCursor = (trackCursor - 1).coerceAtLeast(0)
|
||||
KeyEvent.KEYCODE_DPAD_DOWN -> trackCursor = (trackCursor + 1).coerceAtMost(rows.lastIndex)
|
||||
}
|
||||
if (event.action == KeyEvent.ACTION_UP) {
|
||||
if (isOk) { rows[trackCursor].choose(); trackMenu = null }
|
||||
else if (code == KeyEvent.KEYCODE_DPAD_LEFT || code == KeyEvent.KEYCODE_DPAD_RIGHT) trackMenu = null
|
||||
}
|
||||
return@setOnKeyListener isOk || code in listOf(KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_DPAD_DOWN, KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_DPAD_RIGHT)
|
||||
}
|
||||
numberEntry?.let { entry ->
|
||||
// On release, so the key that closes the box can't also act on the channel behind it.
|
||||
val advance = (isOk || code == KeyEvent.KEYCODE_DPAD_RIGHT) && event.action == KeyEvent.ACTION_UP
|
||||
@@ -254,7 +275,11 @@ internal fun EmbyPlaybackDialog(session: EmbySampleSession, plan: EmbyPlaybackPl
|
||||
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 -> if (scheduled != null) cycleSubtitles() else player.seekForward()
|
||||
KeyEvent.KEYCODE_DPAD_RIGHT -> if (scheduled != null) {
|
||||
val rows = trackRows()
|
||||
trackCursor = rows.indexOfFirst { it.selected }.coerceAtLeast(0)
|
||||
trackMenu = rows
|
||||
} else player.seekForward()
|
||||
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()
|
||||
@@ -282,7 +307,7 @@ internal fun EmbyPlaybackDialog(session: EmbySampleSession, plan: EmbyPlaybackPl
|
||||
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)
|
||||
subtitleNote?.let { Text(it, Modifier.align(Alignment.TopCenter).padding(top = 28.dp).background(Navy).padding(horizontal = 14.dp, vertical = 6.dp), color = Color.White, fontSize = 15.sp) }
|
||||
trackMenu?.let { TrackPanel(it, trackCursor, Modifier.align(Alignment.CenterEnd).padding(end = 28.dp)) }
|
||||
numberEntry?.let { ChannelNumberBox(it, numberProblem, Modifier.align(Alignment.TopCenter).padding(top = 44.dp)) }
|
||||
if (failure != null) Column(Modifier.align(Alignment.Center).widthIn(max = 620.dp).background(Navy).padding(24.dp)) {
|
||||
Text(failure.orEmpty(), color = Color.White, fontSize = 20.sp)
|
||||
|
||||
@@ -93,6 +93,7 @@ internal fun ScheduledChannelPlayer(session: EmbySampleSession, channel: SampleC
|
||||
onClose: () -> Unit, onStep: (Int) -> Unit = {}, onLast: () -> Unit = {}, onNumber: (Int) -> Boolean = { false }, handoff: LiveHandoff? = null, onUnplayable: ((String) -> Unit)? = null) {
|
||||
// Outlives each programme and channel, and ends when the viewer leaves full screen.
|
||||
val subtitlePreference = remember { mutableStateOf<String?>(null) }
|
||||
val audioPreference = remember { mutableStateOf<String?>(null) }
|
||||
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)
|
||||
@@ -136,7 +137,7 @@ internal fun ScheduledChannelPlayer(session: EmbySampleSession, channel: SampleC
|
||||
val announce = remember { announcedChannel[0] != channel.number }
|
||||
SideEffect { announcedChannel[0] = channel.number }
|
||||
EmbyPlaybackDialog(session, plan, onClose,
|
||||
scheduled = ScheduledPlayback(channel, current, shows[1], clockOffsetMs), onStep = onStep, onLast = onLast, onNumber = onNumber, subtitlePreference = subtitlePreference, runningPlayer = handed?.takeIf { it.plan === plan }?.claim(), announce = announce, onUnplayable = onUnplayable,
|
||||
scheduled = ScheduledPlayback(channel, current, shows[1], clockOffsetMs), onStep = onStep, onLast = onLast, onNumber = onNumber, subtitlePreference = subtitlePreference, runningPlayer = handed?.takeIf { it.plan === plan }?.claim(), announce = announce, onUnplayable = onUnplayable, audioPreference = audioPreference,
|
||||
onHandBack = { running ->
|
||||
if (handoff != null && clockOffsetMs == 0L) { handoff.returned = LiveStream(channel.number, current.startMs, plan, running); true } else false
|
||||
})
|
||||
|
||||
@@ -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.62.0")
|
||||
next.setRequestProperty("User-Agent", "OrbitTV/0.63.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()) {
|
||||
|
||||
@@ -27,14 +27,6 @@ internal data class ChannelEntry(val digits: List<Int>, val slot: Int = 0) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Right steps through the tracks and then off. */
|
||||
internal fun nextSubtitle(current: Int?, count: Int): Int? = when {
|
||||
count == 0 -> null
|
||||
current == null -> 0
|
||||
current + 1 < count -> current + 1
|
||||
else -> null
|
||||
}
|
||||
|
||||
/** Subtitles stay on across programmes: the same language if it's there, otherwise the first track. */
|
||||
internal fun subtitleFor(wanted: String?, languages: List<String>): Int? {
|
||||
if (wanted == null || languages.isEmpty()) return null
|
||||
@@ -57,3 +49,32 @@ internal fun ChannelNumberBox(entry: ChannelEntry, problem: String?, modifier: M
|
||||
Text(problem ?: "▲▼ Change OK Next Back Cancel", color = if (problem != null) Yellow else Muted, fontSize = 11.sp)
|
||||
}
|
||||
}
|
||||
|
||||
/** One choice in the subtitles and audio panel. */
|
||||
internal class TrackRow(val section: String, val label: String, val selected: Boolean, val choose: () -> Unit)
|
||||
|
||||
/** A plain name for a track: its own label, else its language, else its position. */
|
||||
internal fun trackLabel(label: String?, language: String?, position: Int): String {
|
||||
label?.trim()?.takeIf { it.isNotEmpty() }?.let { return it }
|
||||
val code = language?.trim().orEmpty()
|
||||
if (code.isNotEmpty() && code != "und") {
|
||||
val name = java.util.Locale(code).getDisplayLanguage(java.util.Locale.UK)
|
||||
return if (name.isNullOrBlank() || name == code) code else name
|
||||
}
|
||||
return "Track ${position + 1}"
|
||||
}
|
||||
|
||||
/** Sits to one side, so the programme keeps playing while you choose. */
|
||||
@Composable
|
||||
internal fun TrackPanel(rows: List<TrackRow>, cursor: Int, modifier: Modifier = Modifier) {
|
||||
val corner = RoundedCornerShape(6.dp)
|
||||
Column(modifier.width(280.dp).background(Navy.copy(alpha = .94f), corner).border(1.dp, Panel, corner).padding(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
rows.forEachIndexed { index, row ->
|
||||
if (index == 0 || rows[index - 1].section != row.section) Text(row.section, color = Muted, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(top = if (index == 0) 0.dp else 6.dp))
|
||||
val on = index == cursor
|
||||
Text((if (row.selected) "✓ " else " ") + row.label, Modifier.fillMaxWidth().background(if (on) Yellow else Color.Transparent, RoundedCornerShape(4.dp)).padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
color = if (on) Navy else Color.White, fontSize = 15.sp, fontWeight = if (on || row.selected) FontWeight.SemiBold else FontWeight.Normal, maxLines = 1)
|
||||
}
|
||||
Text("▲▼ Move OK Choose Back Close", color = Muted, fontSize = 11.sp, modifier = Modifier.padding(top = 4.dp))
|
||||
}
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 7.9 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
@@ -137,4 +137,22 @@ class ChannelPresetsTest {
|
||||
assertEquals(all.size, all.distinct().size)
|
||||
assertTrue(SmartChannels.first { it.number == 142 }.seriesCount >= all.size)
|
||||
}
|
||||
|
||||
@Test fun detourAndBritXAreFilmChannelsWithTheirOwnRest() {
|
||||
listOf(114, 115).forEach { number ->
|
||||
val channel = SmartChannels.first { it.number == number }
|
||||
assertNotNull(channel.filmRestMs)
|
||||
assertFalse(channel.finishTogether)
|
||||
assertEquals(ChannelCategory.Movies, ChannelBrands.forNumber(number)!!.category)
|
||||
}
|
||||
assertEquals(ChannelCategory.Kids, ChannelBrands.forNumber(125)!!.category)
|
||||
assertEquals(ChannelCategory.Documentaries, ChannelBrands.forNumber(143)!!.category)
|
||||
}
|
||||
|
||||
@Test fun aFilmChannelsFallbackShowsFilmsNotEpisodes() {
|
||||
val film = { id: String -> PoolSeries(id, "Film $id", listOf(PoolEpisode(id, "s$id", EpisodeMark(1, 1), "Film $id", 60_000))) }
|
||||
val loop = loopFromPool(listOf(film("a"), film("b")), ChannelProgramming(114, "Detour", listOf("x")))!!
|
||||
assertEquals(listOf("Movie", "Movie"), loop.lineup.map { it.item.type })
|
||||
assertEquals("Film a", loop.lineup.first().item.title)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,14 +36,6 @@ class WatchingControlsTest {
|
||||
assertEquals(2, typed.slot)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun subtitlesStepThroughTracksThenOff() {
|
||||
assertEquals(0, nextSubtitle(null, 2))
|
||||
assertEquals(1, nextSubtitle(0, 2))
|
||||
assertNull(nextSubtitle(1, 2))
|
||||
assertNull(nextSubtitle(null, 0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun subtitlesKeepTheirLanguageAcrossProgrammes() {
|
||||
assertEquals(1, subtitleFor("eng", listOf("fre", "eng")))
|
||||
@@ -51,4 +43,13 @@ class WatchingControlsTest {
|
||||
assertNull(subtitleFor(null, listOf("eng")))
|
||||
assertNull(subtitleFor("eng", emptyList()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tracksGetPlainNames() {
|
||||
assertEquals("Director's commentary", trackLabel("Director's commentary", "eng", 0))
|
||||
assertEquals("English", trackLabel(null, "eng", 0))
|
||||
assertEquals("Japanese", trackLabel("", "ja", 1))
|
||||
assertEquals("Track 2", trackLabel(null, "und", 1))
|
||||
assertEquals("Track 1", trackLabel(null, null, 0))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Vector logos for Detour, BritX, Orbit Junior and Orbit Wild, in the family style of the other channels.
|
||||
Detour and BritX follow Paul's concept boards in docs/briefs/concepts. BritX's X is amber so it can't be mistaken for Orbit X."""
|
||||
import html, os, subprocess, sys
|
||||
from pathlib import Path
|
||||
from PIL import Image
|
||||
HERE = Path(__file__).parent
|
||||
ROOT = HERE.parents[1]
|
||||
RES = ROOT / 'android/app/src/main/res/drawable-nodpi'
|
||||
ENV = dict(os.environ)
|
||||
if os.environ.get('ORBIT_FONT_HOME'): ENV['XDG_DATA_HOME'] = os.environ['ORBIT_FONT_HOME']
|
||||
BARLOW, ANTON, ROUND = 'Barlow', 'Anton', 'Comfortaa'
|
||||
SILVER = '<linearGradient id="silver" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#FFFFFF"/><stop offset=".44" stop-color="#E8EDF5"/><stop offset=".5" stop-color="#B7CBE8"/><stop offset="1" stop-color="#F5F7FF"/></linearGradient>'
|
||||
CRESCENT = 'M335 132C141 114 66 384 257 475C143 363 185 207 335 132Z'
|
||||
def text(x, y, value, size, fill, family=BARLOW, weight='400', extra=''):
|
||||
return f'<text x="{x}" y="{y}" font-family="{family}" font-size="{size}" font-weight="{weight}" fill="{fill}" {extra}>{html.escape(value)}</text>'
|
||||
def mark(colour, sx=.26, dx=-5, dy=1):
|
||||
return (f'<g transform="translate({dx},{dy}) scale({sx})"><path d="{CRESCENT}" fill="url(#silver)"/>'
|
||||
f'<path d="M129 369C102 399 111 425 161 428C267 432 431 340 445 289C455 250 405 250 369 261" fill="none" stroke="{colour}" stroke-width="11"/><circle cx="430" cy="282" r="9" fill="{colour}"/></g>')
|
||||
def svg(w, h, body, defs=''):
|
||||
return f'<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="{h}" viewBox="0 0 {w} {h}"><defs>{SILVER}{defs}</defs>{body}</svg>'
|
||||
ORBIT = text(134, 50, 'ORBIT', 18, '#C9D3E3', BARLOW, '600', 'letter-spacing="5"')
|
||||
def write(name, kind, content, size):
|
||||
folder = HERE / name.replace('_', '-'); folder.mkdir(exist_ok=True)
|
||||
src = folder / f'{name}_{kind}.svg'; src.write_text(content)
|
||||
png = folder / f'{name}_{kind}.png'
|
||||
subprocess.run(['vectorcraft-cli', 'convert', str(src), str(png)], env=ENV, check=True, stdout=subprocess.DEVNULL)
|
||||
Image.open(png).convert('RGBA').resize(size, Image.LANCZOS).save(RES / f'{name}_{kind}.webp', quality=92)
|
||||
png.unlink()
|
||||
def both(name, epg, bug, defs=''):
|
||||
write(name, 'epg', svg(512, 160, epg, defs), (256, 80))
|
||||
write(name, 'bug', svg(384, 128, bug, defs), (144, 48))
|
||||
|
||||
arrow = lambda x, y, w, c: f'<path d="M{x} {y} q{w*.5} 14 {w} -6" fill="none" stroke="{c}" stroke-width="5" stroke-dasharray="22 12"/><polygon points="{x+w-6},{y-20} {x+w+30},{y-14} {x+w+6},{y+14}" fill="{c}"/>'
|
||||
both('orbit_detour',
|
||||
mark('#FF2D7E') + ORBIT + '<g transform="skewX(-10)">' + text(150, 118, 'DETOU', 72, '#F5F1E8', ANTON) + text(312, 118, 'R', 72, '#FF2D7E', ANTON) + '</g>' + arrow(136, 142, 196, '#FFD23F'),
|
||||
mark('#FF2D7E', .2, -6, -3) + '<g transform="skewX(-10)">' + text(118, 90, 'DETOU', 62, '#F5F1E8', ANTON) + text(257, 90, 'R', 62, '#FF2D7E', ANTON) + '</g>' + arrow(104, 112, 170, '#FFD23F'))
|
||||
both('orbit_britx',
|
||||
mark('#E8A33D') + ORBIT + '<g transform="skewX(-8)">' + text(150, 122, 'BRIT', 78, 'url(#slate)', ANTON) + text(274, 122, 'X', 78, '#E8A33D', ANTON) + '</g>',
|
||||
mark('#E8A33D', .2, -6, -3) + '<g transform="skewX(-8)">' + text(118, 94, 'BRIT', 68, 'url(#slate)', ANTON) + text(226, 94, 'X', 68, '#E8A33D', ANTON) + '</g>',
|
||||
'<linearGradient id="slate" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#F2F4F8"/><stop offset=".55" stop-color="#C4CAD4"/><stop offset="1" stop-color="#8E97A6"/></linearGradient>')
|
||||
letters = [('J', '#FF5A4F'), ('U', '#FFC933'), ('N', '#3FC1FF'), ('I', '#5BD46A'), ('O', '#FF8A3D'), ('R', '#C77DFF')]
|
||||
def junior(x, y, size, step):
|
||||
out, cx = '', x
|
||||
for (c, f), w in zip(letters, (.72, .92, .95, .42, 1.0, .85)):
|
||||
out += text(f'{cx:.0f}', y, c, size, f, ROUND, '700', f'stroke="{f}" stroke-width="3"')
|
||||
cx += step * w
|
||||
return out
|
||||
both('orbit_junior',
|
||||
mark('#FFC933') + ORBIT + junior(134, 112, 60, 50),
|
||||
mark('#FFC933', .2, -6, -3) + junior(104, 86, 52, 43))
|
||||
def claws(x, y, h, c):
|
||||
return ''.join(f'<path d="M{x+i*13} {y} l{-10} {h} l{7} 0 Z" fill="{c}"/>' for i in range(3))
|
||||
both('orbit_wild',
|
||||
mark('#F28C28') + ORBIT + text(136, 122, 'WILD', 70, 'url(#earth)', ANTON) + claws(268, 66, 56, '#F28C28'),
|
||||
mark('#F28C28', .2, -6, -3) + text(106, 94, 'WILD', 68, 'url(#earth)', ANTON) + claws(238, 42, 54, '#F28C28'),
|
||||
'<linearGradient id="earth" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#FFF4DC"/><stop offset=".6" stop-color="#E9D3A5"/><stop offset="1" stop-color="#B9975B"/></linearGradient>')
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="384" height="128" viewBox="0 0 384 128"><defs><linearGradient id="silver" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#FFFFFF"/><stop offset=".44" stop-color="#E8EDF5"/><stop offset=".5" stop-color="#B7CBE8"/><stop offset="1" stop-color="#F5F7FF"/></linearGradient><linearGradient id="slate" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#F2F4F8"/><stop offset=".55" stop-color="#C4CAD4"/><stop offset="1" stop-color="#8E97A6"/></linearGradient></defs><g transform="translate(-6,-3) scale(0.2)"><path d="M335 132C141 114 66 384 257 475C143 363 185 207 335 132Z" fill="url(#silver)"/><path d="M129 369C102 399 111 425 161 428C267 432 431 340 445 289C455 250 405 250 369 261" fill="none" stroke="#E8A33D" stroke-width="11"/><circle cx="430" cy="282" r="9" fill="#E8A33D"/></g><g transform="skewX(-8)"><text x="118" y="94" font-family="Anton" font-size="68" font-weight="400" fill="url(#slate)" >BRIT</text><text x="226" y="94" font-family="Anton" font-size="68" font-weight="400" fill="#E8A33D" >X</text></g></svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="160" viewBox="0 0 512 160"><defs><linearGradient id="silver" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#FFFFFF"/><stop offset=".44" stop-color="#E8EDF5"/><stop offset=".5" stop-color="#B7CBE8"/><stop offset="1" stop-color="#F5F7FF"/></linearGradient><linearGradient id="slate" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#F2F4F8"/><stop offset=".55" stop-color="#C4CAD4"/><stop offset="1" stop-color="#8E97A6"/></linearGradient></defs><g transform="translate(-5,1) scale(0.26)"><path d="M335 132C141 114 66 384 257 475C143 363 185 207 335 132Z" fill="url(#silver)"/><path d="M129 369C102 399 111 425 161 428C267 432 431 340 445 289C455 250 405 250 369 261" fill="none" stroke="#E8A33D" stroke-width="11"/><circle cx="430" cy="282" r="9" fill="#E8A33D"/></g><text x="134" y="50" font-family="Barlow" font-size="18" font-weight="600" fill="#C9D3E3" letter-spacing="5">ORBIT</text><g transform="skewX(-8)"><text x="150" y="122" font-family="Anton" font-size="78" font-weight="400" fill="url(#slate)" >BRIT</text><text x="274" y="122" font-family="Anton" font-size="78" font-weight="400" fill="#E8A33D" >X</text></g></svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="384" height="128" viewBox="0 0 384 128"><defs><linearGradient id="silver" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#FFFFFF"/><stop offset=".44" stop-color="#E8EDF5"/><stop offset=".5" stop-color="#B7CBE8"/><stop offset="1" stop-color="#F5F7FF"/></linearGradient></defs><g transform="translate(-6,-3) scale(0.2)"><path d="M335 132C141 114 66 384 257 475C143 363 185 207 335 132Z" fill="url(#silver)"/><path d="M129 369C102 399 111 425 161 428C267 432 431 340 445 289C455 250 405 250 369 261" fill="none" stroke="#FF2D7E" stroke-width="11"/><circle cx="430" cy="282" r="9" fill="#FF2D7E"/></g><g transform="skewX(-10)"><text x="118" y="90" font-family="Anton" font-size="62" font-weight="400" fill="#F5F1E8" >DETOU</text><text x="257" y="90" font-family="Anton" font-size="62" font-weight="400" fill="#FF2D7E" >R</text></g><path d="M104 112 q85.0 14 170 -6" fill="none" stroke="#FFD23F" stroke-width="5" stroke-dasharray="22 12"/><polygon points="268,92 304,98 280,126" fill="#FFD23F"/></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="160" viewBox="0 0 512 160"><defs><linearGradient id="silver" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#FFFFFF"/><stop offset=".44" stop-color="#E8EDF5"/><stop offset=".5" stop-color="#B7CBE8"/><stop offset="1" stop-color="#F5F7FF"/></linearGradient></defs><g transform="translate(-5,1) scale(0.26)"><path d="M335 132C141 114 66 384 257 475C143 363 185 207 335 132Z" fill="url(#silver)"/><path d="M129 369C102 399 111 425 161 428C267 432 431 340 445 289C455 250 405 250 369 261" fill="none" stroke="#FF2D7E" stroke-width="11"/><circle cx="430" cy="282" r="9" fill="#FF2D7E"/></g><text x="134" y="50" font-family="Barlow" font-size="18" font-weight="600" fill="#C9D3E3" letter-spacing="5">ORBIT</text><g transform="skewX(-10)"><text x="150" y="118" font-family="Anton" font-size="72" font-weight="400" fill="#F5F1E8" >DETOU</text><text x="312" y="118" font-family="Anton" font-size="72" font-weight="400" fill="#FF2D7E" >R</text></g><path d="M136 142 q98.0 14 196 -6" fill="none" stroke="#FFD23F" stroke-width="5" stroke-dasharray="22 12"/><polygon points="326,122 362,128 338,156" fill="#FFD23F"/></svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="384" height="128" viewBox="0 0 384 128"><defs><linearGradient id="silver" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#FFFFFF"/><stop offset=".44" stop-color="#E8EDF5"/><stop offset=".5" stop-color="#B7CBE8"/><stop offset="1" stop-color="#F5F7FF"/></linearGradient></defs><g transform="translate(-6,-3) scale(0.2)"><path d="M335 132C141 114 66 384 257 475C143 363 185 207 335 132Z" fill="url(#silver)"/><path d="M129 369C102 399 111 425 161 428C267 432 431 340 445 289C455 250 405 250 369 261" fill="none" stroke="#FFC933" stroke-width="11"/><circle cx="430" cy="282" r="9" fill="#FFC933"/></g><text x="104" y="86" font-family="Comfortaa" font-size="52" font-weight="700" fill="#FF5A4F" stroke="#FF5A4F" stroke-width="3">J</text><text x="135" y="86" font-family="Comfortaa" font-size="52" font-weight="700" fill="#FFC933" stroke="#FFC933" stroke-width="3">U</text><text x="175" y="86" font-family="Comfortaa" font-size="52" font-weight="700" fill="#3FC1FF" stroke="#3FC1FF" stroke-width="3">N</text><text x="215" y="86" font-family="Comfortaa" font-size="52" font-weight="700" fill="#5BD46A" stroke="#5BD46A" stroke-width="3">I</text><text x="233" y="86" font-family="Comfortaa" font-size="52" font-weight="700" fill="#FF8A3D" stroke="#FF8A3D" stroke-width="3">O</text><text x="276" y="86" font-family="Comfortaa" font-size="52" font-weight="700" fill="#C77DFF" stroke="#C77DFF" stroke-width="3">R</text></svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="160" viewBox="0 0 512 160"><defs><linearGradient id="silver" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#FFFFFF"/><stop offset=".44" stop-color="#E8EDF5"/><stop offset=".5" stop-color="#B7CBE8"/><stop offset="1" stop-color="#F5F7FF"/></linearGradient></defs><g transform="translate(-5,1) scale(0.26)"><path d="M335 132C141 114 66 384 257 475C143 363 185 207 335 132Z" fill="url(#silver)"/><path d="M129 369C102 399 111 425 161 428C267 432 431 340 445 289C455 250 405 250 369 261" fill="none" stroke="#FFC933" stroke-width="11"/><circle cx="430" cy="282" r="9" fill="#FFC933"/></g><text x="134" y="50" font-family="Barlow" font-size="18" font-weight="600" fill="#C9D3E3" letter-spacing="5">ORBIT</text><text x="134" y="112" font-family="Comfortaa" font-size="60" font-weight="700" fill="#FF5A4F" stroke="#FF5A4F" stroke-width="3">J</text><text x="170" y="112" font-family="Comfortaa" font-size="60" font-weight="700" fill="#FFC933" stroke="#FFC933" stroke-width="3">U</text><text x="216" y="112" font-family="Comfortaa" font-size="60" font-weight="700" fill="#3FC1FF" stroke="#3FC1FF" stroke-width="3">N</text><text x="264" y="112" font-family="Comfortaa" font-size="60" font-weight="700" fill="#5BD46A" stroke="#5BD46A" stroke-width="3">I</text><text x="284" y="112" font-family="Comfortaa" font-size="60" font-weight="700" fill="#FF8A3D" stroke="#FF8A3D" stroke-width="3">O</text><text x="334" y="112" font-family="Comfortaa" font-size="60" font-weight="700" fill="#C77DFF" stroke="#C77DFF" stroke-width="3">R</text></svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="384" height="128" viewBox="0 0 384 128"><defs><linearGradient id="silver" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#FFFFFF"/><stop offset=".44" stop-color="#E8EDF5"/><stop offset=".5" stop-color="#B7CBE8"/><stop offset="1" stop-color="#F5F7FF"/></linearGradient><linearGradient id="earth" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#FFF4DC"/><stop offset=".6" stop-color="#E9D3A5"/><stop offset="1" stop-color="#B9975B"/></linearGradient></defs><g transform="translate(-6,-3) scale(0.2)"><path d="M335 132C141 114 66 384 257 475C143 363 185 207 335 132Z" fill="url(#silver)"/><path d="M129 369C102 399 111 425 161 428C267 432 431 340 445 289C455 250 405 250 369 261" fill="none" stroke="#F28C28" stroke-width="11"/><circle cx="430" cy="282" r="9" fill="#F28C28"/></g><text x="106" y="94" font-family="Anton" font-size="68" font-weight="400" fill="url(#earth)" >WILD</text><path d="M238 42 l-10 54 l7 0 Z" fill="#F28C28"/><path d="M251 42 l-10 54 l7 0 Z" fill="#F28C28"/><path d="M264 42 l-10 54 l7 0 Z" fill="#F28C28"/></svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="160" viewBox="0 0 512 160"><defs><linearGradient id="silver" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#FFFFFF"/><stop offset=".44" stop-color="#E8EDF5"/><stop offset=".5" stop-color="#B7CBE8"/><stop offset="1" stop-color="#F5F7FF"/></linearGradient><linearGradient id="earth" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#FFF4DC"/><stop offset=".6" stop-color="#E9D3A5"/><stop offset="1" stop-color="#B9975B"/></linearGradient></defs><g transform="translate(-5,1) scale(0.26)"><path d="M335 132C141 114 66 384 257 475C143 363 185 207 335 132Z" fill="url(#silver)"/><path d="M129 369C102 399 111 425 161 428C267 432 431 340 445 289C455 250 405 250 369 261" fill="none" stroke="#F28C28" stroke-width="11"/><circle cx="430" cy="282" r="9" fill="#F28C28"/></g><text x="134" y="50" font-family="Barlow" font-size="18" font-weight="600" fill="#C9D3E3" letter-spacing="5">ORBIT</text><text x="136" y="122" font-family="Anton" font-size="70" font-weight="400" fill="url(#earth)" >WILD</text><path d="M268 66 l-10 56 l7 0 Z" fill="#F28C28"/><path d="M281 66 l-10 56 l7 0 Z" fill="#F28C28"/><path d="M294 66 l-10 56 l7 0 Z" fill="#F28C28"/></svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -71,7 +71,7 @@ Audit and phased plan are in review, from `docs/briefs/indexing-programming-engi
|
||||
|
||||
## Smart channels status
|
||||
|
||||
Phase A of [the Smart Channels Foundation brief](briefs/smart-channels-foundation-2026-11-01.md) passed Paul's review on 2 November 2026, with Paradox on the box (VERIFICATION-0.39.0.md). Phase B (Terra and After Dark, VERIFICATION-0.40.0.md) and Phase C (Vortex, plus fewer repeats on Terra, VERIFICATION-0.41.0.md) are on the box as 0.41, tested and awaiting Paul's review. Day parts are on the box on Vortex and After Dark (VERIFICATION-0.42.0.md). Phase D (the remaining channels) needs separate approval. Reviewed suggestions are built and in use on After Dark and Vortex (VERIFICATION-0.47.0.md), the first piece of the programming director. Vortex Movie Night, the first weekday event, is on the box. Phase D has started with Orbit Gold (111), Heroes (121) and Evidence (141), on the box as 0.49, then ToonZ (122), Rewind (123) and Orbit X (124), all on the box as 0.51 with ten smart channels running (VERIFICATION-0.51.0.md). Orbit Three (112), Riot (113) and Dark Files (142, with day parts from the bible) are on the box as 0.59, making 13 smart channels (VERIFICATION-0.59.0.md). Still to add: Orbit 2, Wonder, PixelZ, Orbit VHS, Orbit Sci-Fi, Orbit Horror, Orbit Action, Orbit Romance, Orbit Premiere, Odyssey and Ring X. Numbers are placeholders until Paul maps the lineup. A show may run on several channels but never at the same moment. Animated idents are design files only and aren't in the app yet.
|
||||
Phase A of [the Smart Channels Foundation brief](briefs/smart-channels-foundation-2026-11-01.md) passed Paul's review on 2 November 2026, with Paradox on the box (VERIFICATION-0.39.0.md). Phase B (Terra and After Dark, VERIFICATION-0.40.0.md) and Phase C (Vortex, plus fewer repeats on Terra, VERIFICATION-0.41.0.md) are on the box as 0.41, tested and awaiting Paul's review. Day parts are on the box on Vortex and After Dark (VERIFICATION-0.42.0.md). Phase D (the remaining channels) needs separate approval. Reviewed suggestions are built and in use on After Dark and Vortex (VERIFICATION-0.47.0.md), the first piece of the programming director. Vortex Movie Night, the first weekday event, is on the box. Phase D has started with Orbit Gold (111), Heroes (121) and Evidence (141), on the box as 0.49, then ToonZ (122), Rewind (123) and Orbit X (124), all on the box as 0.51 with ten smart channels running (VERIFICATION-0.51.0.md). Orbit Three (112), Riot (113) and Dark Files (142, with day parts from the bible) are on the box as 0.59, making 13 smart channels (VERIFICATION-0.59.0.md). Paul's own channels, Detour (114), BritX (115), Orbit Junior (125) and Orbit Wild (143), are built as 0.63 from docs/briefs/new-channel-ideas-2026-11-02.md; Orbit Unsolved is held, as Dark Files covers it. Still to add: Orbit 2, Wonder, PixelZ, Orbit VHS, Orbit Sci-Fi, Orbit Horror, Orbit Action, Orbit Romance, Orbit Premiere, Odyssey and Ring X. Numbers are placeholders until Paul maps the lineup. A show may run on several channels but never at the same moment. Animated idents are design files only and aren't in the app yet.
|
||||
|
||||
## Programming director
|
||||
|
||||
|
||||