Build Orbit Drive-In with real events and three scenes (0.67.0)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@@ -0,0 +1,16 @@
|
||||
# Orbit TV 0.67.0: Orbit Drive-In
|
||||
|
||||
The Drive-In now has real events from Paul's films, on a full-screen destination with three scenes that take turns, one per visit: his theatre, his drive-in (feature artwork on the big screen) and the CRT.
|
||||
|
||||
- Sunday to Thursday: Orbit Marquee at 20:00, then The Late Show at 23:00, or 15 to 30 minutes after a long film.
|
||||
- Friday: Orbit Double Bill at 20:00, then The Late Show. Saturday: Orbit Triple Bill at 19:00.
|
||||
- Double and triple bills are hand-picked and rotate by week. One with a film missing, recently shown or on elsewhere is passed over; Friday or Saturday falls back to a Marquee if none fits.
|
||||
- Booked a week ahead, once, in schedule-160.json, using film pools already on the box, so it makes no extra provider requests. Other channels now avoid the Drive-In's films when they top up, and bills avoid what they've already committed.
|
||||
- OK on a showing bill tunes it live. OK on a later bill shows its ticket. Between shows, the player closes back to the lot.
|
||||
- The home card shows the real next bill.
|
||||
|
||||
Checked: build, lint and 266 unit tests pass, including 12 new ones. The tests caught a late show being merged into the bill before it, fixed by always leaving a break.
|
||||
|
||||
Not checked: how it looks on the TV. Screenshots are blocked on the box, so text positions on the marquee boards and the artwork on the big screen and CRT were placed by measurement and need Paul's eye. Tuning a bill live and returning to the lot between shows also haven't been run on the box.
|
||||
|
||||
Not built: reminders. A ticket doesn't yet alert you when the show starts. The app is 19.6 MB, up from 19.1 MB. Catalogue unchanged.
|
||||
@@ -9,8 +9,8 @@ android {
|
||||
applicationId = "tv.orbit.classic"
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
versionCode = 67
|
||||
versionName = "0.66.0-full-lineup"
|
||||
versionCode = 68
|
||||
versionName = "0.67.0-drive-in"
|
||||
}
|
||||
buildFeatures { compose = true }
|
||||
compileOptions { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 }
|
||||
|
||||
@@ -26,6 +26,7 @@ internal object ChannelBrands {
|
||||
136 to ChannelBranding("orbit-premiere", "Orbit Premiere", R.drawable.orbit_premiere_epg, R.drawable.orbit_one_background, R.drawable.orbit_premiere_bug, ChannelCategory.Movies),
|
||||
144 to ChannelBranding("orbit-odyssey", "Orbit Odyssey", R.drawable.orbit_odyssey_epg, R.drawable.orbit_one_background, R.drawable.orbit_odyssey_bug, ChannelCategory.Documentaries),
|
||||
150 to ChannelBranding("orbit-ring-x", "Orbit Ring X", R.drawable.orbit_ring_x_epg, R.drawable.orbit_one_background, R.drawable.orbit_ring_x_bug, ChannelCategory.Sport),
|
||||
DRIVE_IN_NUMBER to ChannelBranding("orbit-drive-in", "Orbit Drive-In", R.drawable.orbit_drive_in_epg, R.drawable.orbit_one_background, R.drawable.orbit_drive_in_bug, ChannelCategory.Movies),
|
||||
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),
|
||||
|
||||
@@ -26,6 +26,20 @@ internal class ChannelScheduler(private val store: ScheduleStore, private val cl
|
||||
store.saveProgress(userId, number, ScheduleStore.SavedProgress(enabled, progress?.aired ?: ScheduleCursor()))
|
||||
}
|
||||
|
||||
/** Books the Drive-In's evenings from the film pools already on the box, so it asks the provider for nothing. */
|
||||
fun topUpDriveIn(userId: String): ChannelPlan? {
|
||||
val now = clock()
|
||||
val existing = store.loadPlan(userId, DRIVE_IN_NUMBER)
|
||||
if (driveInBookedAhead(existing, now)) return existing
|
||||
val failed = store.loadFailed(userId)
|
||||
fun films(numbers: List<Int>) = numbers.flatMap { number -> withoutFailed(store.loadPool(userId, number)?.series.orEmpty().filter { it.isFilm }, failed) }.distinctBy { seriesKey(it.title) }
|
||||
val avoid = clashesWith(SmartChannels.mapNotNull { store.loadPlan(userId, it.number) })
|
||||
val plan = planDriveIn(existing, films(DriveInMarqueeChannels), films(DriveInLateChannels), films(DriveInBillChannels), now, avoid = avoid) ?: return existing
|
||||
if (plan !== existing) store.savePlan(userId, DRIVE_IN_NUMBER, plan)
|
||||
Log.i("OrbitSchedule", "channel=$DRIVE_IN_NUMBER programmes=${plan.entries.size}")
|
||||
return plan
|
||||
}
|
||||
|
||||
/** A file that wouldn't play. It stays in what's already planned, and is left out from here on. */
|
||||
fun markFailed(userId: String, itemId: String) {
|
||||
store.saveFailed(userId, store.loadFailed(userId) + itemId)
|
||||
@@ -73,7 +87,7 @@ internal class ChannelScheduler(private val store: ScheduleStore, private val cl
|
||||
val progress = store.loadProgress(userId, number)
|
||||
val base = progress?.aired ?: ScheduleCursor()
|
||||
// Other channels' plans are only read if this one is actually about to grow.
|
||||
val others by lazy { clashesWith(SmartChannels.filter { it.number != number }.mapNotNull { store.loadPlan(userId, it.number) }) }
|
||||
val others by lazy { clashesWith((SmartChannels.map { it.number }.filter { it != number } + DRIVE_IN_NUMBER).mapNotNull { store.loadPlan(userId, it) }) }
|
||||
val plan = topUpPlan(existing, base, pool?.series?.let { withoutFailed(it, store.loadFailed(userId)) }, now, programming.filmRestMs, programming.finishTogether, programming.dayParts,
|
||||
avoid = { show, startMs, endMs -> others(show, startMs, endMs) }) ?: throw EmbyCheckException("${programming.name} has no episodes to schedule.")
|
||||
if (plan !== existing) store.savePlan(userId, number, plan)
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
package tv.orbit.classic
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.Matrix
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Path
|
||||
import android.graphics.RectF
|
||||
import android.view.KeyEvent
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
|
||||
import androidx.compose.ui.graphics.nativeCanvas
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.tv.material3.Text
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.time.DayOfWeek
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
import java.time.format.TextStyle as DateTextStyle
|
||||
|
||||
/** Not a channel in the guide: one linear evening's bill, with nothing on between shows. */
|
||||
internal const val DRIVE_IN_NUMBER = 160
|
||||
internal const val DRIVE_IN_SCENES = 3
|
||||
private const val DRIVE_IN_REST_MS = 14 * 24 * 60 * 60_000L
|
||||
|
||||
internal val DriveInMarqueeChannels = listOf(136, 132, 134, 131)
|
||||
internal val DriveInLateChannels = listOf(133, 102)
|
||||
internal val DriveInBillChannels = listOf(136, 131, 132, 133, 134, 135, 126, 127, 114, 115, 102)
|
||||
|
||||
/** Hand-picked, so a bill always belongs together. One whose films aren't all in the library is skipped. */
|
||||
private val DoubleBills = listOf(listOf("Alien", "Aliens"), listOf("The Terminator", "Terminator 2: Judgment Day"), listOf("Predator", "RoboCop"), listOf("Evil Dead II", "Army of Darkness"),
|
||||
listOf("Bill & Ted's Excellent Adventure", "Bill & Ted's Bogus Journey"), listOf("Ghostbusters", "Ghostbusters II"), listOf("Gremlins", "Gremlins 2: The New Batch"), listOf("The Karate Kid", "The Karate Kid Part II"),
|
||||
listOf("John Wick", "John Wick: Chapter 2"), listOf("Kill Bill: Vol. 1", "Kill Bill: Vol. 2"), listOf("Spider-Man", "Spider-Man 2"), listOf("Home Alone", "Home Alone 2: Lost in New York"),
|
||||
listOf("Lethal Weapon", "Lethal Weapon 2"), listOf("Trainspotting", "T2 Trainspotting"), listOf("28 Days Later", "28 Weeks Later"), listOf("The Incredibles", "Incredibles 2"))
|
||||
private val TripleBills = listOf(listOf("Back to the Future", "Back to the Future Part II", "Back to the Future Part III"), listOf("Star Wars", "The Empire Strikes Back", "Return of the Jedi"),
|
||||
listOf("Raiders of the Lost Ark", "Indiana Jones and the Temple of Doom", "Indiana Jones and the Last Crusade"), listOf("Shaun of the Dead", "Hot Fuzz", "The World's End"),
|
||||
listOf("The Bourne Identity", "The Bourne Supremacy", "The Bourne Ultimatum"), listOf("Die Hard", "Die Hard 2", "Die Hard: With a Vengeance"), listOf("Batman Begins", "The Dark Knight", "The Dark Knight Rises"),
|
||||
listOf("Toy Story", "Toy Story 2", "Toy Story 3"), listOf("Mad Max", "Mad Max 2", "Mad Max: Fury Road"), listOf("The Hangover", "The Hangover Part II", "The Hangover Part III"),
|
||||
listOf("The Lord of the Rings: The Fellowship of the Ring", "The Lord of the Rings: The Two Towers", "The Lord of the Rings: The Return of the King"))
|
||||
|
||||
internal enum class DriveInKind(val label: String) { Marquee("Orbit Marquee"), Double("Orbit Double Bill"), Triple("Orbit Triple Bill"), LateShow("The Late Show") }
|
||||
|
||||
/** One night out: a film, or films running straight into each other. */
|
||||
internal class DriveInBill(val kind: DriveInKind, val entries: List<ScheduleEntry>) {
|
||||
val startMs get() = entries.first().startMs
|
||||
val endMs get() = entries.last().endMs
|
||||
val films get() = entries.map { it.media.item.title }
|
||||
val title get() = films.joinToString(" + ")
|
||||
}
|
||||
|
||||
/** An evening runs past midnight, so the small hours belong to the night before. */
|
||||
private fun eveningOf(timeMs: Long, zone: ZoneId): LocalDate {
|
||||
val local = Instant.ofEpochMilli(timeMs).atZone(zone)
|
||||
return if (local.hour < 6) local.toLocalDate().minusDays(1) else local.toLocalDate()
|
||||
}
|
||||
|
||||
internal fun driveInBookedAhead(plan: ChannelPlan?, nowMs: Long, zone: ZoneId = ZoneId.systemDefault()): Boolean {
|
||||
val last = plan?.entries?.lastOrNull() ?: return false
|
||||
return !eveningOf(last.startMs, zone).isBefore(eveningOf(nowMs + SCHEDULE_AHEAD_MS, zone))
|
||||
}
|
||||
|
||||
private fun filmEntry(film: PoolSeries, startMs: Long): ScheduleEntry {
|
||||
val copy = film.episodes.first()
|
||||
return ScheduleEntry(film.id, copy.mark, ScheduledMedia(SampleItem(film.title, "Movie", copy.durationMs / 60_000, "", copy.id, copy.durationMs), copy.sourceId, copy.durationMs), startMs)
|
||||
}
|
||||
|
||||
/**
|
||||
* Books each evening out to a week ahead, once, so a bill never changes after it's shown.
|
||||
* Friday is a double bill, Saturday a triple, and other nights a big film. A late show follows, except after a triple.
|
||||
*/
|
||||
internal fun planDriveIn(existing: ChannelPlan?, marquee: List<PoolSeries>, late: List<PoolSeries>, bills: List<PoolSeries>, nowMs: Long,
|
||||
zone: ZoneId = ZoneId.systemDefault(), avoid: ProgrammeClash = { _, _, _ -> false }): ChannelPlan? {
|
||||
val kept = existing?.let { plan -> plan.copy(entries = plan.entries.dropWhile { it.endMs < nowMs - SCHEDULE_KEEP_PAST_MS }) }
|
||||
if (driveInBookedAhead(kept, nowMs, zone)) return kept
|
||||
if (marquee.isEmpty()) return kept
|
||||
val byTitle = bills.associateBy { seriesKey(it.title) }
|
||||
val shown = kept?.tail?.filmShown.orEmpty().toMutableMap()
|
||||
val entries = kept?.entries.orEmpty().toMutableList()
|
||||
var date = kept?.entries?.lastOrNull()?.let { eveningOf(it.startMs, zone).plusDays(1) } ?: eveningOf(nowMs, zone)
|
||||
val lastDate = eveningOf(nowMs + SCHEDULE_AHEAD_MS, zone)
|
||||
fun book(film: PoolSeries, startMs: Long): Long = filmEntry(film, startMs).let { entry -> entries += entry; shown[film.id] = startMs; entry.endMs }
|
||||
/** A hand-picked bill, turn and turn about by week, whose films are all here, rested and not on elsewhere. */
|
||||
fun billFor(choices: List<List<String>>, startMs: Long, week: Long): List<PoolSeries>? = choices.indices.asSequence().map { choices[Math.floorMod(week + it, choices.size.toLong()).toInt()] }.mapNotNull { names ->
|
||||
val films = names.map { byTitle[seriesKey(it)] ?: return@mapNotNull null }
|
||||
var at = startMs
|
||||
films.takeIf { it.all { film ->
|
||||
val end = at + film.episodes.first().durationMs
|
||||
(shown[film.id]?.let { seen -> startMs - seen >= DRIVE_IN_REST_MS } ?: true) && !avoid(film, at, end).also { at = end }
|
||||
} }
|
||||
}.firstOrNull()
|
||||
while (!date.isAfter(lastDate)) {
|
||||
val week = Math.floorDiv(date.toEpochDay(), 7L)
|
||||
fun at(hour: Int, minute: Int = 0) = date.atTime(hour, minute).atZone(zone).toInstant().toEpochMilli()
|
||||
val triple = if (date.dayOfWeek == DayOfWeek.SATURDAY) billFor(TripleBills, at(19), week) else null
|
||||
val double = if (date.dayOfWeek == DayOfWeek.FRIDAY) billFor(DoubleBills, at(20), week) else null
|
||||
var end = when {
|
||||
triple != null -> triple.fold(at(19)) { start, film -> book(film, start) }
|
||||
double != null -> double.fold(at(20)) { start, film -> book(film, start) }
|
||||
else -> book(nextFilm(marquee, shown, at(20), DRIVE_IN_REST_MS, avoid), at(20))
|
||||
}
|
||||
if (triple == null && late.isNotEmpty()) {
|
||||
val quarter = 15 * 60_000L
|
||||
// Always a break first, which is also how a late show is told apart from the bill before it.
|
||||
val start = maxOf(at(23), (end + 2 * quarter - 1) / quarter * quarter)
|
||||
// Too late to be a late show by then: the night ends with the main feature.
|
||||
if (start < at(23) + 4 * 60 * 60_000L) end = book(nextFilm(late, shown, start, DRIVE_IN_REST_MS, avoid), start)
|
||||
}
|
||||
date = date.plusDays(1)
|
||||
}
|
||||
return ChannelPlan(entries, ScheduleCursor(null, emptyMap(), shown))
|
||||
}
|
||||
|
||||
internal fun driveInBills(plan: ChannelPlan, zone: ZoneId = ZoneId.systemDefault()): List<DriveInBill> {
|
||||
val runs = mutableListOf<MutableList<ScheduleEntry>>()
|
||||
for (entry in plan.entries) {
|
||||
val run = runs.lastOrNull()
|
||||
if (run != null && run.last().endMs == entry.startMs) run += entry else runs += mutableListOf(entry)
|
||||
}
|
||||
return runs.map { run ->
|
||||
val hour = Instant.ofEpochMilli(run.first().startMs).atZone(zone).hour
|
||||
DriveInBill(when {
|
||||
run.size >= 3 -> DriveInKind.Triple
|
||||
run.size == 2 -> DriveInKind.Double
|
||||
hour in 6..21 -> DriveInKind.Marquee
|
||||
else -> DriveInKind.LateShow
|
||||
}, run)
|
||||
}
|
||||
}
|
||||
|
||||
/** The next of each kind, for the list on screen. */
|
||||
internal fun driveInLineup(bills: List<DriveInBill>, nowMs: Long): List<DriveInBill> =
|
||||
DriveInKind.values().mapNotNull { kind -> bills.firstOrNull { it.kind == kind && it.endMs > nowMs } }
|
||||
|
||||
internal fun driveInAiring(plan: ChannelPlan, nowMs: Long) = plan.entries.any { it.startMs <= nowMs && nowMs < it.endMs }
|
||||
|
||||
internal fun driveInDay(bill: DriveInBill, nowMs: Long, zone: ZoneId = ZoneId.systemDefault()): String {
|
||||
if (bill.startMs <= nowMs) return "Now showing"
|
||||
val date = eveningOf(bill.startMs, zone)
|
||||
val today = eveningOf(nowMs, zone)
|
||||
return when (date) {
|
||||
today -> "Tonight"
|
||||
today.plusDays(1) -> "Tomorrow"
|
||||
else -> date.dayOfWeek.getDisplayName(DateTextStyle.FULL, java.util.Locale.UK)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun driveInWhen(bill: DriveInBill, nowMs: Long): String =
|
||||
if (bill.startMs <= nowMs) "now showing" else "${driveInDay(bill, nowMs).lowercase(java.util.Locale.UK)} at ${clock(bill.startMs)}"
|
||||
|
||||
/** The bill as something the player can tune to. Outside the bill's times the screen closes the player. */
|
||||
internal fun driveInChannel(plan: ChannelPlan): ScheduledChannel? {
|
||||
val films = plan.entries.map { it.media }.distinctBy { it.item.id }.take(15)
|
||||
if (films.size < 2) return null
|
||||
return ScheduledChannel(SampleChannel(films, "Orbit Drive-In", DRIVE_IN_NUMBER), plan)
|
||||
}
|
||||
|
||||
private val Marquee = FontFamily(Font(R.font.anton_regular))
|
||||
private val BoardInk = Color(0xFF2A1238)
|
||||
private val BoardPink = Color(0xFFC2358F)
|
||||
private val Gold = Color(0xFFF2C14E)
|
||||
|
||||
@Composable
|
||||
private fun rememberFilmPicture(session: EmbySampleSession, itemId: String?): Bitmap? {
|
||||
val context = LocalContext.current
|
||||
val picture by produceState<Bitmap?>(null, itemId) {
|
||||
value = null
|
||||
if (itemId != null) value = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
loadArtwork(ArtworkCache(File(context.cacheDir, "artwork")), itemId, null, session::fetchArtwork)?.let { BitmapFactory.decodeByteArray(it, 0, it.size) }
|
||||
}.getOrNull()
|
||||
}
|
||||
}
|
||||
return picture
|
||||
}
|
||||
|
||||
/** Tonight's bill in the board's own letters. The board stays the same size, so long titles get smaller. */
|
||||
@Composable
|
||||
private fun MarqueeBoard(bill: DriveInBill?, nowMs: Long, x: Dp, y: Dp, width: Dp, height: Dp, big: Float) {
|
||||
Box(Modifier.offset(x, y).size(width, height), contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy((big * .1f).dp)) {
|
||||
val head = bill?.let { driveInDay(it, nowMs).uppercase(java.util.Locale.UK) } ?: "COMING SOON"
|
||||
Text(if (bill != null) "$head AT THE DRIVE-IN" else head, color = BoardInk, fontFamily = Marquee, fontSize = big.sp, maxLines = 1, softWrap = false)
|
||||
if (bill != null) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Box(Modifier.size((big * 1.4f).dp, 2.dp).background(BoardPink))
|
||||
Text(bill.kind.label.uppercase(java.util.Locale.UK), color = BoardInk, fontFamily = Barlow, fontWeight = FontWeight.SemiBold, fontSize = (big * .62f).sp, letterSpacing = 1.sp, maxLines = 1, softWrap = false)
|
||||
Box(Modifier.size((big * 1.4f).dp, 2.dp).background(BoardPink))
|
||||
}
|
||||
val films = bill.films.joinToString(" • ").uppercase(java.util.Locale.UK)
|
||||
val fit = when { films.length > 60 -> .36f; films.length > 42 -> .46f; else -> .58f }
|
||||
Text(films, Modifier.fillMaxWidth(), color = BoardInk, fontFamily = Barlow, fontWeight = FontWeight.SemiBold, fontSize = (big * fit).sp, textAlign = TextAlign.Center, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EventTile(bill: DriveInBill, nowMs: Long, selected: Boolean, modifier: Modifier) {
|
||||
val corner = RoundedCornerShape(5.dp)
|
||||
val ink = if (selected) Navy else Color.White
|
||||
Row((if (selected) modifier.background(Yellow, corner) else modifier.background(Color(0xFF0E2147), corner).border(1.dp, Color(0xFFC98A1E), corner)).padding(horizontal = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(driveInDay(bill, nowMs).uppercase(java.util.Locale.UK), color = if (selected) Navy else Gold, fontFamily = Barlow, fontWeight = FontWeight.SemiBold, fontSize = 10.sp, letterSpacing = 1.sp, maxLines = 1)
|
||||
Text(bill.kind.label, color = ink, fontFamily = Barlow, fontWeight = FontWeight.SemiBold, fontSize = 15.sp, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
Text(clock(bill.startMs), color = ink, fontFamily = Barlow, fontSize = 16.sp, maxLines = 1)
|
||||
}
|
||||
}
|
||||
|
||||
/** Over the lot's big screen, which leans away from the viewer. Corners are in the 1920-wide scene. */
|
||||
@Composable
|
||||
private fun PictureOnBigScreen(picture: Bitmap?) {
|
||||
if (picture == null) return
|
||||
Canvas(Modifier.fillMaxSize()) {
|
||||
val s = size.width / 1920f
|
||||
val to = floatArrayOf(1321f, 340f, 1860f, 262f, 1866f, 591f, 1321f, 597f).map { it * s }.toFloatArray()
|
||||
val from = floatArrayOf(0f, 0f, picture.width.toFloat(), 0f, picture.width.toFloat(), picture.height.toFloat(), 0f, picture.height.toFloat())
|
||||
val matrix = Matrix().apply { setPolyToPoly(from, 0, to, 0, 4) }
|
||||
drawIntoCanvas { it.nativeCanvas.drawBitmap(picture, matrix, Paint(Paint.FILTER_BITMAP_FLAG)) }
|
||||
}
|
||||
}
|
||||
|
||||
/** On the CRT's curved glass, cropped to its old shape, with faint scan lines. */
|
||||
@Composable
|
||||
private fun PictureOnTube(picture: Bitmap?) {
|
||||
Canvas(Modifier.fillMaxSize()) {
|
||||
val s = size.width / 1920f
|
||||
val glass = Path().apply {
|
||||
moveTo(160 * s, 152 * s); cubicTo(340 * s, 132 * s, 680 * s, 132 * s, 860 * s, 152 * s); cubicTo(884 * s, 300 * s, 884 * s, 640 * s, 860 * s, 788 * s)
|
||||
cubicTo(680 * s, 808 * s, 340 * s, 808 * s, 160 * s, 788 * s); cubicTo(136 * s, 640 * s, 136 * s, 300 * s, 160 * s, 152 * s); close()
|
||||
}
|
||||
val area = RectF(136 * s, 136 * s, 884 * s, 804 * s)
|
||||
drawIntoCanvas { canvas ->
|
||||
val native = canvas.nativeCanvas
|
||||
native.save(); native.clipPath(glass)
|
||||
if (picture != null) {
|
||||
val scale = maxOf(area.width() / picture.width, area.height() / picture.height)
|
||||
val matrix = Matrix().apply { postTranslate(-picture.width / 2f, -picture.height / 2f); postScale(scale, scale); postTranslate(area.centerX(), area.centerY()) }
|
||||
native.drawBitmap(picture, matrix, Paint(Paint.FILTER_BITMAP_FLAG))
|
||||
}
|
||||
val line = Paint().apply { color = android.graphics.Color.argb(40, 0, 0, 0); strokeWidth = 2 * s }
|
||||
var y = area.top
|
||||
while (y < area.bottom) { native.drawLine(area.left, y, area.right, y, line); y += 6 * s }
|
||||
native.restore()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DriveInTicket(bill: DriveInBill, nowMs: Long, onClose: () -> Unit) {
|
||||
BackHandler(onBack = onClose)
|
||||
Box(Modifier.fillMaxSize().background(Color.Black.copy(alpha = .6f)), contentAlignment = Alignment.Center) {
|
||||
Row(Modifier.width(620.dp).height(250.dp).background(Color(0xFF0E2147)).border(3.dp, Gold).padding(20.dp)) {
|
||||
Column(Modifier.weight(1f).fillMaxHeight()) {
|
||||
Text("ORBIT DRIVE-IN", color = Gold, fontFamily = Marquee, fontSize = 26.sp, maxLines = 1)
|
||||
Text(bill.kind.label, color = Color.White, fontFamily = Barlow, fontSize = 15.sp)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Text(bill.title, color = Color.White, fontFamily = Barlow, fontWeight = FontWeight.SemiBold, fontSize = 22.sp, lineHeight = 26.sp, maxLines = 3, overflow = TextOverflow.Ellipsis)
|
||||
Spacer(Modifier.weight(1f))
|
||||
Text("Doors open ${driveInWhen(bill, nowMs)}", color = Gold, fontFamily = Barlow, fontWeight = FontWeight.SemiBold, fontSize = 17.sp, maxLines = 1)
|
||||
Text("Back Return to the lot", color = Muted, fontFamily = Barlow, fontSize = 12.sp)
|
||||
}
|
||||
Box(Modifier.padding(horizontal = 16.dp).width(1.dp).fillMaxHeight().background(Gold.copy(alpha = .6f)))
|
||||
Column(Modifier.width(84.dp).fillMaxHeight(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) {
|
||||
Text("ADMIT", color = Gold, fontFamily = Barlow, fontSize = 18.sp)
|
||||
Text("01", color = Color.White, fontFamily = Barlow, fontSize = 36.sp)
|
||||
Text("FRONT ROW", color = Gold, fontFamily = Barlow, fontSize = 10.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Three scenes take turns, a new one each visit: the theatre, the lot and the old set. */
|
||||
@Composable
|
||||
internal fun DriveInScreen(session: EmbySampleSession, plan: ChannelPlan?, visit: Int, modifier: Modifier, active: Boolean, onWatch: () -> Unit) {
|
||||
var now by remember { mutableLongStateOf(System.currentTimeMillis()) }
|
||||
LaunchedEffect(Unit) { while (true) { delay(1000); now = System.currentTimeMillis() } }
|
||||
val minute = now / 60_000
|
||||
val lineup = remember(plan, minute) { plan?.let { driveInLineup(driveInBills(it), now) }.orEmpty() }
|
||||
var chosenKind by remember { mutableStateOf<DriveInKind?>(null) }
|
||||
val soonest = lineup.minByOrNull { it.startMs }
|
||||
val selected = lineup.firstOrNull { it.kind == chosenKind } ?: soonest
|
||||
val index = lineup.indexOf(selected)
|
||||
var ticket by remember { mutableStateOf<DriveInBill?>(null) }
|
||||
val focus = remember { FocusRequester() }
|
||||
LaunchedEffect(active, ticket) { if (active && ticket == null) focus.requestFocus() }
|
||||
val scene = Math.floorMod(visit, DRIVE_IN_SCENES)
|
||||
Box(modifier.fillMaxSize().background(Navy).focusRequester(focus).focusable().onPreviewKeyEvent { event ->
|
||||
val native = event.nativeKeyEvent
|
||||
if (native.action != KeyEvent.ACTION_DOWN || lineup.isEmpty()) return@onPreviewKeyEvent false
|
||||
when (native.keyCode) {
|
||||
KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_DPAD_UP -> { chosenKind = lineup[Math.floorMod(index - 1, lineup.size)].kind; true }
|
||||
KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_DPAD_DOWN -> { chosenKind = lineup[Math.floorMod(index + 1, lineup.size)].kind; true }
|
||||
KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_ENTER -> {
|
||||
if (native.repeatCount == 0) selected?.let { if (it.startMs <= now) onWatch() else ticket = it }
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
}) {
|
||||
val backdrop = when (scene) { 0 -> R.drawable.drive_in_theatre; 1 -> R.drawable.drive_in_lot; else -> R.drawable.drive_in_crt }
|
||||
Image(painterResource(backdrop), null, Modifier.fillMaxSize(), contentScale = ContentScale.FillBounds)
|
||||
// Only the two scenes with a screen need the film's picture.
|
||||
val picture = rememberFilmPicture(session, selected?.entries?.first()?.media?.item?.id.takeIf { scene != 0 })
|
||||
when (scene) {
|
||||
0 -> MarqueeBoard(selected, now, 270.dp, 192.dp, 430.dp, 92.dp, 31f)
|
||||
1 -> {
|
||||
PictureOnBigScreen(picture)
|
||||
MarqueeBoard(selected, now, 225.dp, 186.dp, 300.dp, 76.dp, 25f)
|
||||
}
|
||||
else -> {
|
||||
PictureOnTube(picture)
|
||||
if (selected != null) Column(Modifier.offset(68.dp, 318.dp).size(374.dp, 84.dp).background(Color(0xCC03081A)).padding(horizontal = 14.dp, vertical = 8.dp)) {
|
||||
Text("${driveInDay(selected, now).uppercase(java.util.Locale.UK)} • ${selected.kind.label.uppercase(java.util.Locale.UK)}", color = Gold, fontFamily = Barlow, fontWeight = FontWeight.SemiBold, fontSize = 12.sp, letterSpacing = 1.sp, maxLines = 1)
|
||||
Text(selected.title.uppercase(java.util.Locale.UK), color = Color.White, fontFamily = Marquee, fontSize = if (selected.title.length > 40) 15.sp else 24.sp, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (scene == 2) {
|
||||
Column(Modifier.offset(607.dp, 200.dp).width(330.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
lineup.forEach { EventTile(it, now, it === selected, Modifier.fillMaxWidth().height(50.dp)) }
|
||||
if (lineup.isEmpty()) Text("Tonight's bill is on its way.", color = Color.White, fontFamily = Barlow, fontSize = 16.sp)
|
||||
}
|
||||
} else {
|
||||
Box(Modifier.align(Alignment.BottomCenter).fillMaxWidth().height(100.dp).background(Brush.verticalGradient(listOf(Color.Transparent, Color(0xEB03081A)))))
|
||||
Row(Modifier.align(Alignment.BottomCenter).padding(start = 20.dp, end = 20.dp, bottom = 26.dp).fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
lineup.forEach { EventTile(it, now, it === selected, Modifier.weight(1f).height(42.dp)) }
|
||||
if (lineup.isEmpty()) Text("Tonight's bill is on its way.", color = Color.White, fontFamily = Barlow, fontSize = 16.sp)
|
||||
}
|
||||
}
|
||||
val hint = if (selected != null && selected.startMs <= now) "OK Watch" else "OK Get your ticket"
|
||||
Text("$hint ◄► Choose an event Back Leave", Modifier.align(Alignment.BottomStart).padding(start = 20.dp, bottom = 6.dp), color = Color(0xFFD5DCEA), fontFamily = Barlow, fontSize = 11.sp)
|
||||
ticket?.let { DriveInTicket(it, now, onClose = { ticket = null }) }
|
||||
}
|
||||
}
|
||||
@@ -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.66.0\"")
|
||||
connection.setRequestProperty("X-Emby-Authorization", "Emby Client=\"Orbit TV\", Device=\"Orbit TV\", DeviceId=\"$deviceId\", Version=\"0.67.0\"")
|
||||
request.token?.let { connection.setRequestProperty("X-Emby-Token", it) }
|
||||
if (request.method == "POST") {
|
||||
val bytes = request.body.orEmpty().toByteArray(Charsets.UTF_8)
|
||||
|
||||
@@ -52,7 +52,7 @@ private const val RESTORE_QUIET_TRIES = 4
|
||||
|
||||
@Composable
|
||||
internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolean,
|
||||
mainGuide: Boolean, mainHome: Boolean = false, onChannelAvailable: (Boolean) -> Unit, onRestoreChecked: () -> Unit = {}, onGuide: () -> Unit,
|
||||
mainGuide: Boolean, mainHome: Boolean = false, mainDriveIn: Boolean = false, onChannelAvailable: (Boolean) -> Unit, onRestoreChecked: () -> Unit = {}, onGuide: () -> Unit,
|
||||
onHome: () -> Unit = {}, onDriveIn: () -> Unit = {}, onBack: () -> Unit) {
|
||||
val appContext = androidx.compose.ui.platform.LocalContext.current.applicationContext
|
||||
val savedStore = remember { RememberedConnectionStore.get(appContext) }
|
||||
@@ -92,6 +92,10 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
|
||||
var selectedNumber by remember { mutableIntStateOf(101) }
|
||||
var channel by remember { mutableStateOf<SampleChannel?>(null) }
|
||||
var watchingChannel by remember { mutableStateOf(false) }
|
||||
var watchingDriveIn by remember { mutableStateOf(false) }
|
||||
var driveInPlan by remember { mutableStateOf<ChannelPlan?>(null) }
|
||||
// Each visit opens on the next of the three scenes.
|
||||
var driveInVisits by remember { mutableIntStateOf((System.currentTimeMillis() / 60_000 % DRIVE_IN_SCENES).toInt()) }
|
||||
var guideCategory by remember { mutableStateOf(ChannelCategory.All) }
|
||||
val liveHandoff = remember { LiveHandoff() }
|
||||
val scheduleStore = remember { ScheduleStore(java.io.File(appContext.noBackupFilesDir, "schedule")) }
|
||||
@@ -258,6 +262,9 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
|
||||
} catch (cancelled: CancellationException) { throw cancelled }
|
||||
catch (_: Exception) { /* Tried again next hour. */ }
|
||||
}
|
||||
try { driveInPlan = withContext(Dispatchers.IO) { scheduler.topUpDriveIn(active.accountId) } }
|
||||
catch (cancelled: CancellationException) { throw cancelled }
|
||||
catch (_: Exception) { /* Tried again next hour. */ }
|
||||
delay(60 * 60_000L)
|
||||
}
|
||||
}
|
||||
@@ -275,6 +282,7 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
|
||||
LaunchedEffect(mainGuide, mainHome) {
|
||||
if (!mainGuide && !mainHome) { watchingChannel = false; channelClockOffset = 0 }
|
||||
}
|
||||
LaunchedEffect(mainDriveIn) { if (mainDriveIn) driveInVisits++ else watchingDriveIn = false }
|
||||
val ready = !busy && !restoring && now >= nextAttemptAt && username.isNotBlank() && password.text.isNotEmpty()
|
||||
playback?.let { plan -> session?.let { active -> EmbyPlaybackDialog(active, plan, onClose = { playback = null }) } }
|
||||
val liveSession = session
|
||||
@@ -284,6 +292,17 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
|
||||
}
|
||||
val availableChannels = availableSampleChannels(channel, afterDarkChannel?.let(::onAir), toonamiChannel?.let(::onAir), *extraChannels.map(::onAir).toTypedArray())
|
||||
val liveChannel = availableChannels.firstOrNull { it.number == selectedNumber }
|
||||
if (mainDriveIn && liveSession != null) {
|
||||
val booked = driveInPlan
|
||||
val onScreen = remember(booked) { booked?.let(::driveInChannel) }
|
||||
if (watchingDriveIn && onScreen != null) {
|
||||
ScheduledChannelPlayer(liveSession, onScreen, 0, onClose = { watchingDriveIn = false }, onUnplayable = ::rememberUnplayable)
|
||||
// Between shows there's nothing on, so the viewer comes back out to the lot.
|
||||
LaunchedEffect(onScreen) { while (driveInAiring(onScreen.plan, System.currentTimeMillis())) delay(1000); watchingDriveIn = false }
|
||||
}
|
||||
DriveInScreen(liveSession, booked, driveInVisits, modifier, active = !watchingDriveIn, onWatch = { watchingDriveIn = true })
|
||||
return
|
||||
}
|
||||
if ((mainGuide || mainHome) && liveSession != null && liveChannel != null) {
|
||||
if (watchingChannel) ScheduledChannelPlayer(liveSession, liveChannel, channelClockOffset,
|
||||
onClose = { watchingChannel = false; channelClockOffset = 0 },
|
||||
@@ -307,7 +326,7 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
|
||||
}
|
||||
val picture = @Composable { GuidePicture(liveSession, liveChannel, live = !watchingChannel, handoff = liveHandoff, onUnplayable = ::rememberUnplayable) }
|
||||
if (mainHome) HomeScreen(availableChannels, liveChannel, modifier, picture,
|
||||
onCategory = { guideCategory = it; onGuide() }, onWatch = { tune(it, 0) }, onDriveIn = onDriveIn)
|
||||
onCategory = { guideCategory = it; onGuide() }, onWatch = { tune(it, 0) }, onDriveIn = onDriveIn, driveInPlan = driveInPlan)
|
||||
else GuideGrid(channelsIn(guideCategory, availableChannels).ifEmpty { availableChannels }, selectedNumber, modifier, guideCategory.label, picture,
|
||||
onWatch = ::tune, onBack = { guideCategory = ChannelCategory.All; onHome() })
|
||||
return
|
||||
|
||||
@@ -67,7 +67,7 @@ private fun ProgressBar(fraction: Float, modifier: Modifier = Modifier, focused:
|
||||
/** Home opens onto the tuned channel playing, one press from anything else. */
|
||||
@Composable
|
||||
internal fun HomeScreen(channels: List<SampleChannel>, tuned: SampleChannel, modifier: Modifier = Modifier,
|
||||
picture: @Composable () -> Unit, onCategory: (ChannelCategory) -> Unit, onWatch: (Int) -> Unit, onDriveIn: () -> Unit) {
|
||||
picture: @Composable () -> Unit, onCategory: (ChannelCategory) -> Unit, onWatch: (Int) -> Unit, onDriveIn: () -> Unit, driveInPlan: ChannelPlan? = null) {
|
||||
var now by remember { mutableLongStateOf(System.currentTimeMillis()) }
|
||||
LaunchedEffect(Unit) { while (true) { delay(1000); now = System.currentTimeMillis() } }
|
||||
val firstFocus = remember { FocusRequester() }
|
||||
@@ -119,16 +119,17 @@ internal fun HomeScreen(channels: List<SampleChannel>, tuned: SampleChannel, mod
|
||||
}
|
||||
}
|
||||
}
|
||||
val preview = driveInEvents.first()
|
||||
val nowMs = System.currentTimeMillis()
|
||||
val preview = driveInPlan?.let { plan -> driveInBills(plan).firstOrNull { it.endMs > nowMs } }
|
||||
HomeCard(Modifier.fillMaxWidth().weight(1f), onClick = onDriveIn) { focused ->
|
||||
Column {
|
||||
Row(Modifier.fillMaxWidth().background(if (focused) Yellow else Color(0xFF7A5A12)).padding(horizontal = 10.dp, vertical = 5.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Image(painterResource(R.drawable.orbit_icon_office), null, Modifier.size(20.dp), colorFilter = if (focused) ColorFilter.tint(Navy) else null)
|
||||
Text("Orbit Drive-In preview", color = if (focused) Navy else Color.White, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, maxLines = 1)
|
||||
Text("Orbit Drive-In", color = if (focused) Navy else Color.White, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, maxLines = 1)
|
||||
}
|
||||
Column(Modifier.padding(horizontal = 10.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(preview.title, color = if (focused) Navy else Color.White, fontSize = 18.sp, fontWeight = FontWeight.SemiBold, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
Text("Fictional event, for now", color = if (focused) Navy else Muted, fontSize = 12.sp, maxLines = 1)
|
||||
Text(preview?.title ?: "Tonight's bill is on its way", color = if (focused) Navy else Color.White, fontSize = 18.sp, fontWeight = FontWeight.SemiBold, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
if (preview != null) Text("${preview.kind.label}, ${driveInWhen(preview, nowMs)}", color = if (focused) Navy else Muted, fontSize = 12.sp, maxLines = 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,7 +143,7 @@ private fun OrbitApp() {
|
||||
if (!realChannelAvailable) {
|
||||
val tab = when (section) { "TV Guide" -> 1; "Drive-In" -> 2; else -> 0 }
|
||||
tabFocus[tab].requestFocus()
|
||||
} else if (section != "Home" && section != "TV Guide") gearFocus.requestFocus()
|
||||
} else if (section != "Home" && section != "TV Guide" && section != "Drive-In") gearFocus.requestFocus()
|
||||
}
|
||||
}
|
||||
Box(Modifier.fillMaxSize().background(Navy)) {
|
||||
@@ -154,8 +154,9 @@ private fun OrbitApp() {
|
||||
Text(programme.title, color = Color.White, fontSize = 26.sp, fontWeight = FontWeight.Bold)
|
||||
Text(playbackError ?: "${clock(programme.start)}–${clock(programme.end)} • Demo signal ${elapsed(player.currentPosition)} / 30:00 • Back for guide", color = Muted, fontSize = 15.sp)
|
||||
}
|
||||
} else Column(Modifier.fillMaxSize().padding(horizontal = 36.dp, vertical = 16.dp)) {
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
} else Column(if (section == "Drive-In" && realChannelAvailable) Modifier.fillMaxSize() else Modifier.fillMaxSize().padding(horizontal = 36.dp, vertical = 16.dp)) {
|
||||
// The Drive-In takes the whole screen once there are real films to show.
|
||||
if (section != "Drive-In" || !realChannelAvailable) Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
androidx.compose.foundation.Image(androidx.compose.ui.res.painterResource(R.drawable.orbit_logo), "Orbit", Modifier.width(144.dp).height(36.dp))
|
||||
Spacer(Modifier.weight(1f))
|
||||
Text(SimpleDateFormat("EEE d MMM HH:mm", Locale.UK).format(Date(now)), color = Color.White, fontSize = 17.sp)
|
||||
@@ -170,7 +171,7 @@ private fun OrbitApp() {
|
||||
androidx.compose.foundation.Image(androidx.compose.ui.res.painterResource(R.drawable.orbit_icon_profile), "Profile", Modifier.size(38.dp, 32.dp).background(Panel).padding(5.dp))
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
if (section != "Drive-In" || !realChannelAvailable) Spacer(Modifier.height(8.dp))
|
||||
// The numbered tabs only serve the demo guide. With real channels, home and the gear replace them.
|
||||
if (!realChannelAvailable && restoreChecked) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
@@ -232,7 +233,7 @@ private fun OrbitApp() {
|
||||
} else if (section == "Connect Emby") {
|
||||
// Connection content is kept composed below to retain the temporary session.
|
||||
} else if (section == "Drive-In") {
|
||||
DriveIn(Modifier.weight(1f), onGuide = { section = "TV Guide" })
|
||||
if (!realChannelAvailable) DriveIn(Modifier.weight(1f), onGuide = { section = "TV Guide" })
|
||||
} else if (section != "TV Guide" && section != "Home") {
|
||||
Column(Modifier.fillMaxWidth().weight(1f).background(Panel).padding(28.dp)) {
|
||||
Text("Welcome to Orbit Interactive", color = Yellow, fontSize = 27.sp, fontWeight = FontWeight.Bold)
|
||||
@@ -247,6 +248,7 @@ private fun OrbitApp() {
|
||||
EmbyConnectionScreen(Modifier.weight(1f), visible = section == "Connect Emby",
|
||||
mainGuide = section == "TV Guide" && realChannelAvailable,
|
||||
mainHome = section == "Home" && realChannelAvailable,
|
||||
mainDriveIn = section == "Drive-In" && realChannelAvailable,
|
||||
onChannelAvailable = { realChannelAvailable = it }, onRestoreChecked = { restoreChecked = true },
|
||||
onGuide = { section = "TV Guide" }, onHome = { section = "Home" }, onDriveIn = { section = "Drive-In" }, onBack = { section = "Menu" })
|
||||
}
|
||||
|
||||
@@ -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.66.0")
|
||||
next.setRequestProperty("User-Agent", "OrbitTV/0.67.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()) {
|
||||
|
||||
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 183 KiB |
|
After Width: | Height: | Size: 145 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
@@ -0,0 +1,102 @@
|
||||
package tv.orbit.classic
|
||||
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
import java.time.DayOfWeek
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.ZonedDateTime
|
||||
|
||||
class DriveInTest {
|
||||
private val london = ZoneId.of("Europe/London")
|
||||
private val hour = 60 * 60_000L
|
||||
private fun film(title: String, hours: Long = 2) = title.replace(Regex("[^A-Za-z0-9]"), "").let { id -> PoolSeries(id, title, listOf(PoolEpisode(id, "s$id", EpisodeMark(1, 1), title, hours * hour))) }
|
||||
private fun at(day: Int, h: Int, m: Int = 0) = ZonedDateTime.of(2026, 11, day, h, m, 0, 0, london).toInstant().toEpochMilli()
|
||||
private val big = (1..30).map { film("Big $it") }
|
||||
private val late = (1..30).map { film("Late $it") }
|
||||
private val bills = listOf(film("Alien"), film("Aliens"), film("Back to the Future"), film("Back to the Future Part II"), film("Back to the Future Part III"))
|
||||
private fun local(ms: Long) = Instant.ofEpochMilli(ms).atZone(london)
|
||||
|
||||
@Test fun fridayIsADoubleBillAndSaturdayATriple() {
|
||||
val plan = planDriveIn(null, big, late, bills, at(2, 10), london)!!
|
||||
val all = driveInBills(plan, london)
|
||||
val double = all.first { it.kind == DriveInKind.Double }
|
||||
assertEquals(DayOfWeek.FRIDAY, local(double.startMs).dayOfWeek)
|
||||
assertEquals(20, local(double.startMs).hour)
|
||||
assertEquals(listOf("Alien", "Aliens"), double.films)
|
||||
val triple = all.first { it.kind == DriveInKind.Triple }
|
||||
assertEquals(DayOfWeek.SATURDAY, local(triple.startMs).dayOfWeek)
|
||||
assertEquals(19, local(triple.startMs).hour)
|
||||
assertEquals(3, triple.films.size)
|
||||
}
|
||||
|
||||
@Test fun otherNightsHaveAMarqueeThenALateShowWithNoOverlap() {
|
||||
val plan = planDriveIn(null, big, late, bills, at(2, 10), london)!!
|
||||
val monday = driveInBills(plan, london).filter { it.startMs in at(2, 6)..at(3, 5) }
|
||||
assertEquals(listOf(DriveInKind.Marquee, DriveInKind.LateShow), monday.map { it.kind })
|
||||
assertEquals(20, local(monday[0].startMs).hour)
|
||||
assertEquals(23, local(monday[1].startMs).hour)
|
||||
plan.entries.zipWithNext { a, b -> assertTrue(a.endMs <= b.startMs) }
|
||||
}
|
||||
|
||||
@Test fun aLongFeaturePushesTheLateShowBack() {
|
||||
val plan = planDriveIn(null, listOf(film("Epic", 4), film("Epic Two", 4)), late, bills, at(2, 10), london)!!
|
||||
val monday = driveInBills(plan, london).filter { it.startMs in at(2, 6)..at(3, 5) }
|
||||
assertEquals(at(3, 0, 15), monday[1].startMs)
|
||||
}
|
||||
|
||||
@Test fun whatIsBookedStaysBooked() {
|
||||
val first = planDriveIn(null, big, late, bills, at(2, 10), london)!!
|
||||
val later = planDriveIn(first, big, late, bills, at(3, 10), london)!!
|
||||
val kept = first.entries.filter { it.endMs >= at(3, 10) - SCHEDULE_KEEP_PAST_MS }
|
||||
assertEquals(kept.map { it.media.item.id to it.startMs }, later.entries.take(kept.size).map { it.media.item.id to it.startMs })
|
||||
assertTrue(later.endMs > first.endMs)
|
||||
}
|
||||
|
||||
@Test fun nothingNewIsNeededOnceAWeekIsBooked() {
|
||||
val first = planDriveIn(null, big, late, bills, at(2, 10), london)!!
|
||||
assertTrue(driveInBookedAhead(first, at(2, 11), london))
|
||||
assertFalse(driveInBookedAhead(first, at(3, 11), london))
|
||||
assertFalse(driveInBookedAhead(null, at(2, 11), london))
|
||||
}
|
||||
|
||||
@Test fun aBillWithAFilmMissingGivesWayToAMarquee() {
|
||||
val plan = planDriveIn(null, big, late, listOf(film("Alien")), at(2, 10), london)!!
|
||||
assertTrue(driveInBills(plan, london).none { it.kind == DriveInKind.Double || it.kind == DriveInKind.Triple })
|
||||
}
|
||||
|
||||
@Test fun aBillAvoidsAFilmOnAnotherChannelAtTheSameTime() {
|
||||
val clash: ProgrammeClash = { show, _, _ -> show.title == "Aliens" }
|
||||
val plan = planDriveIn(null, big, late, bills, at(2, 10), london, clash)!!
|
||||
assertTrue(plan.entries.none { it.media.item.title == "Aliens" })
|
||||
}
|
||||
|
||||
@Test fun anEmptyLibraryBooksNothing() {
|
||||
assertNull(planDriveIn(null, emptyList(), late, bills, at(2, 10), london))
|
||||
}
|
||||
|
||||
@Test fun theLineupIsTheNextOfEachKind() {
|
||||
val plan = planDriveIn(null, big, late, bills, at(2, 10), london)!!
|
||||
val lineup = driveInLineup(driveInBills(plan, london), at(2, 10))
|
||||
assertEquals(DriveInKind.values().toList(), lineup.map { it.kind })
|
||||
}
|
||||
|
||||
@Test fun dayNamesCountTheSmallHoursAsTheNightBefore() {
|
||||
val plan = planDriveIn(null, big, late, bills, at(2, 10), london)!!
|
||||
val tonight = driveInBills(plan, london).first { it.kind == DriveInKind.Marquee }
|
||||
assertEquals("Tonight", driveInDay(tonight, at(2, 10), london))
|
||||
assertEquals("Now showing", driveInDay(tonight, tonight.startMs, london))
|
||||
assertEquals("Tonight", driveInDay(tonight, at(2, 19), london))
|
||||
}
|
||||
|
||||
@Test fun airingIsFalseBetweenShows() {
|
||||
val plan = planDriveIn(null, big, late, bills, at(2, 10), london)!!
|
||||
assertTrue(driveInAiring(plan, at(2, 21)))
|
||||
assertFalse(driveInAiring(plan, at(2, 15)))
|
||||
assertNotNull(driveInChannel(plan))
|
||||
}
|
||||
|
||||
@Test fun theDriveInIsBranded() {
|
||||
assertEquals("Orbit Drive-In", ChannelBrands.forNumber(DRIVE_IN_NUMBER)!!.displayName)
|
||||
}
|
||||
}
|
||||
@@ -74,7 +74,8 @@ for i, (day, name, at, on) in enumerate(rows):
|
||||
ink, sub = ('#071632', '#071632') if on else ('#F5F7FF', '#F2C14E')
|
||||
bill += text(1242, y + 40, day, 25, sub, 'Barlow', 'font-weight="600" letter-spacing="3"') + text(1242, y + 84, name, 38, ink, 'Barlow', 'font-weight="600"') + text(1760, y + 72, at, 40, ink, 'Barlow Condensed')
|
||||
stars = ''.join(f'<circle cx="{(i * 373) % 1920}" cy="{(i * 197) % 360}" r="{1 + i % 2}" fill="#FFFFFF" fill-opacity="{.25 + (i % 5) * .12:.2f}"/>' for i in range(90))
|
||||
SIDE = ('<rect x="1192" y="70" width="704" height="300" rx="14" fill="#0B1F47" stroke="url(#gold)" stroke-width="3"/>' + bulbs(1210, 88, 668, 264, 30, 4.5) + mark(1214, 92, .32) + orbit(1394, 156, 28, 11) + word(1392, 300, 112) + bill)
|
||||
SIGN = ('<rect x="1192" y="70" width="704" height="300" rx="14" fill="#0B1F47" stroke="url(#gold)" stroke-width="3"/>' + bulbs(1210, 88, 668, 264, 30, 4.5) + mark(1214, 92, .32) + orbit(1394, 156, 28, 11) + word(1392, 300, 112))
|
||||
SIDE = SIGN + bill
|
||||
HINTS = text(40, 1048, 'OK Get your ticket', 26, '#AEBBD2', 'Barlow') + text(320, 1048, '▲▼ Choose an event', 26, '#AEBBD2', 'Barlow') + text(630, 1048, 'Back Leave the lot', 26, '#AEBBD2', 'Barlow')
|
||||
EXTRA = ('<defs><linearGradient id="wood" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#7A4C2A"/><stop offset=".5" stop-color="#5A3419"/><stop offset="1" stop-color="#3A2010"/></linearGradient>'
|
||||
'<linearGradient id="foyer" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#FFE9A8"/><stop offset="1" stop-color="#E89A3C"/></linearGradient>'
|
||||
@@ -82,14 +83,18 @@ EXTRA = ('<defs><linearGradient id="wood" x1="0" y1="0" x2="1" y2="1"><stop offs
|
||||
'<radialGradient id="lamp"><stop offset="0" stop-color="#FFB866" stop-opacity=".3"/><stop offset="1" stop-color="#FFB866" stop-opacity="0"/></radialGradient>'
|
||||
'<radialGradient id="tube" cx=".5" cy=".5" r=".72"><stop offset=".62" stop-color="#000" stop-opacity="0"/><stop offset="1" stop-color="#000" stop-opacity=".62"/></radialGradient>'
|
||||
'<clipPath id="crt"><path d="M160 152C340 132 680 132 860 152C884 300 884 640 860 788C680 808 340 808 160 788C136 640 136 300 160 152Z"/></clipPath></defs>')
|
||||
def crt():
|
||||
def crt(on=True):
|
||||
grille = ''.join(f'<path d="M944 {y}H1094" stroke="#1A0F08" stroke-width="5"/>' for y in range(520, 770, 18))
|
||||
lines = ''.join(f'<path d="M136 {y}H884" stroke="#000" stroke-opacity=".16" stroke-width="2"/>' for y in range(136, 806, 6))
|
||||
grain = ''.join(f'<path d="M64 {y}C340 {y + 10} 800 {y - 10} 1136 {y + 4}" stroke="#2E1A0C" stroke-opacity=".4" stroke-width="2" fill="none"/>' for y in range(112, 868, 42))
|
||||
dial = lambda y: f'<circle cx="1018" cy="{y}" r="50" fill="#C9CDD4"/><circle cx="1018" cy="{y}" r="36" fill="#23262C"/><path d="M1018 {y}L1042 {y - 24}" stroke="#F5F1E0" stroke-width="6" stroke-linecap="round"/>'
|
||||
return ('<rect width="1920" height="1080" fill="#0A1228"/><circle cx="600" cy="480" r="760" fill="url(#lamp)"/>' +
|
||||
'<rect x="60" y="90" width="1080" height="780" rx="38" fill="url(#wood)"/>' + grain + '<rect x="108" y="124" width="804" height="692" rx="52" fill="#0A0806"/>' +
|
||||
picture(136, 136, 748, 668, 42, 'crt') + f'<g clip-path="url(#crt)">{lines}<rect x="136" y="136" width="748" height="668" fill="url(#tube)"/><path d="M190 180C330 164 480 164 610 176C470 200 310 240 190 300Z" fill="#FFFFFF" fill-opacity=".07"/></g>' +
|
||||
(picture(136, 136, 748, 668, 42, 'crt') + f'<g clip-path="url(#crt)">{lines}<rect x="136" y="136" width="748" height="668" fill="url(#tube)"/><path d="M190 180C330 164 480 164 610 176C470 200 310 240 190 300Z" fill="#FFFFFF" fill-opacity=".07"/></g>' if on else '') +
|
||||
'<rect x="928" y="124" width="182" height="692" rx="10" fill="#3A2010"/>' + dial(218) + dial(366) + grille + text(1019, 796, 'ORBIT', 26, '#F5F1E0', 'Barlow', 'font-weight="600" text-anchor="middle" letter-spacing="5"') +
|
||||
'<path d="M160 870V920M1040 870V920" stroke="#1A0F08" stroke-width="22"/><rect y="920" width="1920" height="160" fill="#1A0F08"/><path d="M0 920H1920" stroke="#3A2010" stroke-width="5"/>' + SIDE + HINTS)
|
||||
write('drive_in_screen_crt', 1920, 1080, EXTRA + crt())
|
||||
# What the app draws on: the set and the sign, with the screen left empty and the bill to come.
|
||||
SIDE, HINTS = SIGN, ''
|
||||
blank = crt(False)
|
||||
write('drive_in_crt_blank', 1920, 1080, EXTRA + blank)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
"""The theatre and drive-in screens, from Paul's two concept pictures in references/. The old name comes off the sign, the marquee board
|
||||
and the big screen, leaving blank scenes the app can write on. With an output folder and a picture it also makes mock-ups with sample text."""
|
||||
import subprocess, sys
|
||||
import subprocess, sys, tempfile
|
||||
from pathlib import Path
|
||||
from PIL import Image, ImageChops, ImageDraw, ImageFilter
|
||||
HERE = Path(__file__).parent
|
||||
@@ -116,3 +116,17 @@ if picture:
|
||||
else:
|
||||
theatre().save(HERE / 'theatre_blank.webp', quality=88)
|
||||
drive_in().save(HERE / 'drive_in_blank.webp', quality=88)
|
||||
# The app's own copies, with the sign lit and room left for it to write on.
|
||||
RES = HERE.parents[1] / 'android/app/src/main/res/drawable-nodpi'
|
||||
OUT = Path(tempfile.mkdtemp())
|
||||
for scene, sign, name in ((theatre(), logo(600, 92, .42, 824, 168, 34, 302, 136), 'drive_in_theatre'), (drive_in(), logo(494, 158, .3, 642, 216, 25, 322, 96), 'drive_in_lot')):
|
||||
lit(scene, overlay(name, sign), 14 if name.endswith('theatre') else 9)
|
||||
scene.resize((1280, 720), Image.LANCZOS).save(RES / f'{name}.webp', quality=84)
|
||||
Image.open(HERE / 'drive_in_crt_blank.png').convert('RGB').resize((1280, 720), Image.LANCZOS).save(RES / 'drive_in_crt.webp', quality=84)
|
||||
def small(w, h, body, size, name):
|
||||
src = OUT / f'{name}.svg'
|
||||
src.write_text(f'<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="{h}" viewBox="0 0 {w} {h}">{DEFS}{body}</svg>')
|
||||
subprocess.run(['vectorcraft-cli', 'convert', str(src), str(src.with_suffix('.png'))], check=True, stdout=subprocess.DEVNULL)
|
||||
Image.open(src.with_suffix('.png')).convert('RGBA').resize(size, Image.LANCZOS).save(RES / f'{name}.webp', quality=92)
|
||||
small(512, 160, logo(-6, 1, .26, 134, 50, 18, 136, 78), (256, 80), 'orbit_drive_in_epg')
|
||||
small(384, 128, logo(-6, -3, .2, 106, 38, 15, 108, 66), (144, 48), 'orbit_drive_in_bug')
|
||||
|
||||
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 949 KiB After Width: | Height: | Size: 184 KiB |
|
Before Width: | Height: | Size: 293 KiB After Width: | Height: | Size: 24 KiB |
@@ -95,6 +95,7 @@ Paul's ideas, 1 November 2026. Not fixes and not scheduled.
|
||||
- **Subtitles default (Paul, 1 November 2026):** a setting behind the gear for subtitles on or off by default, in the viewer's language where a programme has it.
|
||||
- **Orbit Drive-In name (Paul, 1 November 2026):** decided, replacing OrbitOffice. Event types inside it are Orbit Marquee, Orbit Double Bill, Orbit Triple Bill and The Late Show. The briefs and design/interface still show the old name.
|
||||
|
||||
- **Orbit Drive-In (Paul, 2 November 2026):** built in 0.67 with three scenes that take turns: his theatre and drive-in pictures, and the CRT. Marquee nights, Friday double bills, Saturday triple bills and late shows from his films, booked a week ahead. Still to come: reminders from a ticket.
|
||||
- **Orbit Arcade (Paul, 2 November 2026):** the look in design/arcade is approved: logo, honeycomb menu and Bedlam screen. Games are OrbitHive Bedlam, Snake, Block Drop, Brick Break, Pairs and Patience. Small games only, so the app stays small: drawn in code with no game engine, and measured for size when built. Not scheduled.
|
||||
|
||||
## Channel numbers
|
||||
|
||||