0.81.1: Tots morning blink and the roll-in ident
pauljd/davidson-ci: Davidson CI / quality (push) Failing after 1m56s
pauljd/davidson-ci: Davidson CI / ai-review (push) Failing after 1m31s

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-11-03 00:35:25 +00:00
co-authored by Claude Opus 5.5
parent a5ea55e1e2
commit 78c889b004
12 changed files with 115 additions and 22 deletions
+34
View File
@@ -0,0 +1,34 @@
# Orbit TV 0.81.1: Orbit Tots
Built, tested and linted. Not installed yet, so nothing below marked "on the box" has been checked.
## What's in it
- Orbit Tots on 614, with hand-approved shows: Morning 06:00 to 09:00, Playtime 09:00 to 17:00, Wind Down 17:00 to 21:00, Night 21:00 to 06:00.
- Shuffled episodes, per channel. Only Tots uses it; every other channel stays in season order.
- Hello, TOTS!, its roll-in variation and Sleepy TOTS, chosen by the next programme's start time. Jingle at most every 30 minutes, in Morning and Wind Down only.
- A TOTS-only bug, a nightcap planet on the evening tuning screen, and still frames.
- Channel logo, Idents and Ident sound switches under Watching.
- Wind Down Now under Watching, with a confirmation. Its plan is saved apart from Tots' own and ends by the next 06:00.
- Drive-In reminders held back while watching Tots from 17:00 to 06:00.
## Measured
- Unit tests: 306 passed, 0 failed (12 new in TotsTest). Lint passed.
- APK 27,530,032 bytes, up about 1.07 MB. Tots assets 1,056,301 bytes against the 1.5 MB target.
- Morning film 331,543 bytes, 5.0 s, -23.3 LUFS, peak -8.8 dBFS, average brightness 180 to 189 of 255, largest frame-to-frame change 0.51. Roll-in film 304,476 bytes, same jingle, brightness 180 to 190, largest change 0.81.
- Evening film 339,386 bytes, 6.0 s, -30.0 LUFS, peak -17.7 dBFS, average brightness 44 to 94 of 255, largest frame-to-frame change 4.48, during its slow fades.
## Still to prove on the box
- The title list finds each show, and each has English episodes that play.
- Morning and evening changeovers, with the jingle balanced against real programme sound.
- Logo, ident and sound switches.
- Wind Down Now on and off, including across a restart.
## Known limits
- No child profiles, PIN or lock yet, so a child can change channel from Tots. Paul accepted this for now; that work is next.
- Wind Down Now's stop takes effect at once, after a confirmation. Its start waits for the current programme.
- The jingle's 30 minute gap resets if the app restarts.
- Fimbles isn't in the library. Rollback: reinstall 0.80.0; turning Tots off removes it from the guide.
+2 -2
View File
@@ -9,8 +9,8 @@ android {
applicationId = "tv.orbit.classic"
minSdk = 26
targetSdk = 36
versionCode = 91
versionName = "0.81.0-orbit-tots"
versionCode = 92
versionName = "0.81.1-orbit-tots"
}
buildFeatures { compose = true }
compileOptions { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 }
@@ -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.81.0\"")
connection.setRequestProperty("X-Emby-Authorization", "Emby Client=\"Orbit TV\", Device=\"Orbit TV\", DeviceId=\"$deviceId\", Version=\"0.81.1\"")
request.token?.let { connection.setRequestProperty("X-Emby-Token", it) }
if (request.method == "POST") {
val bytes = request.body.orEmpty().toByteArray(Charsets.UTF_8)
@@ -38,7 +38,7 @@ private val IdentVideos = mapOf(101 to R.raw.ident_101, 102 to R.raw.ident_102,
611 to R.raw.ident_611, 612 to R.raw.ident_612, 613 to R.raw.ident_613)
/** [atMs] is when the next programme starts, for a channel whose ident follows the time of day. */
internal fun identVideo(number: Int, atMs: Long = 0) = if (number == TOTS_NUMBER) totsMood(atMs).video else IdentVideos[number]
internal fun identVideo(number: Int, atMs: Long = 0) = if (number == TOTS_NUMBER) totsVideo(atMs) else IdentVideos[number]
internal fun identLength(number: Int, atMs: Long = 0) = when (number) {
TOTS_NUMBER -> totsMood(atMs).lengthMs
in IdentVideos -> IDENT_VIDEO_MS
@@ -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.81.0")
next.setRequestProperty("User-Agent", "OrbitTV/0.81.1")
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()) {
@@ -24,6 +24,12 @@ internal enum class TotsMood(val video: Int, val still: Int, val lengthMs: Long,
val evening get() = this == WindDown || this == Night
}
/** Mornings take turns between the two films, by the programme's start, so the guide and player always agree. */
internal fun totsVideo(atMs: Long, zone: java.time.ZoneId = java.time.ZoneId.systemDefault()): Int {
val mood = totsMood(atMs, zone)
return if (!mood.evening && (atMs / 60_000) % 2 == 1L) R.raw.ident_614_morning_roll else mood.video
}
internal fun totsMood(atMs: Long, zone: java.time.ZoneId = java.time.ZoneId.systemDefault()) = when (java.time.Instant.ofEpochMilli(atMs).atZone(zone).hour) {
in 6 until 9 -> TotsMood.Morning
in 9 until 17 -> TotsMood.Playtime
Binary file not shown.
@@ -37,7 +37,9 @@ class TotsTest {
}
@Test fun identFollowsTheNextProgramme() {
assertEquals(TotsMood.Morning.video, identVideo(TOTS_NUMBER, at(7)))
assertEquals(R.raw.ident_614_morning, totsVideo(at(7), zone))
assertEquals(R.raw.ident_614_morning_roll, totsVideo(at(7) + 60_000L, zone))
assertEquals(R.raw.ident_614_evening, totsVideo(at(18) + 60_000L, zone))
assertEquals(5_000L, identLength(TOTS_NUMBER, at(7)))
assertEquals(6_000L, identLength(TOTS_NUMBER, at(18)))
}
+1
View File
@@ -36,4 +36,5 @@ done <<'LIST'
LIST
# Tots keeps its jingles, and has a film for morning and evening. Built by orbit-tots/build-idents.py.
cp "$D/tots/tots_morning.mp4" "$OUT/ident_614_morning.mp4"
cp "$D/tots/tots_morning_roll.mp4" "$OUT/ident_614_morning_roll.mp4"
cp "$D/tots/tots_evening.mp4" "$OUT/ident_614_evening.mp4"
+13 -10
View File
@@ -30,20 +30,23 @@ def nightcap(x, y, r, sway=0.0, cloth='#A99BE8', brim='#EEE9FF', tip='#FFE08A'):
+ stroke(f'M{p(-.76, -.62)} Q{p(0, -.92)} {p(.76, -.62)}', OUTLINE, r * .34) + stroke(f'M{p(-.76, -.62)} Q{p(0, -.92)} {p(.76, -.62)}', brim, r * .2)
+ star(x + tx * r, y + ty * r, r * .2, tip))
def planet(x, y, r, colour, ring_colour=RING, eyes=1.0):
ring = lambda arc: f'<path d="{arc}" fill="none" stroke="{OUTLINE}" stroke-width="12" stroke-linecap="round"/><path d="{arc}" fill="none" stroke="{ring_colour}" stroke-width="6" stroke-linecap="round"/>'
# The ring sits low, under the smile.
def ring(x, y, r, front, colour=RING, tilt=-12):
# The ring sits low, under the smile. Drawn in two halves so the planet can sit inside it.
rx, ry, low = r * 1.42, r * .22, y + r * .38
back = f'M{x - rx} {low} A{rx} {ry} 0 0 1 {x + rx} {low}'
front = f'M{x - rx} {low} A{rx} {ry} 0 0 0 {x + rx} {low}'
arc = f'M{x - rx} {low} A{rx} {ry} 0 0 {0 if front else 1} {x + rx} {low}'
return (f'<g transform="rotate({tilt} {x} {low})"><path d="{arc}" fill="none" stroke="{OUTLINE}" stroke-width="12" stroke-linecap="round"/>'
f'<path d="{arc}" fill="none" stroke="{colour}" stroke-width="6" stroke-linecap="round"/></g>')
def ball(x, y, r, colour, eyes=1.0, turn=0.0):
# Eyes close to a soft line for the sleepy blink.
lid = max(.025, .17 * eyes)
face = (f'<ellipse cx="{x - r * .34}" cy="{y - r * .42}" rx="{r * .12}" ry="{r * lid}" fill="{OUTLINE}"/>'
return (f'<g transform="rotate({turn:.2f} {x} {y})"><circle cx="{x}" cy="{y}" r="{r}" fill="{colour}" stroke="{OUTLINE}" stroke-width="5"/>'
f'<ellipse cx="{x - r * .34}" cy="{y - r * .42}" rx="{r * .12}" ry="{r * lid}" fill="{OUTLINE}"/>'
f'<ellipse cx="{x + r * .34}" cy="{y - r * .42}" rx="{r * .12}" ry="{r * lid}" fill="{OUTLINE}"/>'
f'<path d="M{x - r * .32} {y - r * .08} Q{x} {y + r * .32} {x + r * .32} {y - r * .08}" fill="none" stroke="{OUTLINE}" stroke-width="{r * .12}" stroke-linecap="round"/>')
return (f'<g transform="rotate(-12 {x} {low})">{ring(back)}</g>'
f'<circle cx="{x}" cy="{y}" r="{r}" fill="{colour}" stroke="{OUTLINE}" stroke-width="5"/>{face}'
f'<g transform="rotate(-12 {x} {low})">{ring(front)}</g>')
f'<path d="M{x - r * .32} {y - r * .08} Q{x} {y + r * .32} {x + r * .32} {y - r * .08}" fill="none" stroke="{OUTLINE}" stroke-width="{r * .12}" stroke-linecap="round"/></g>')
def planet(x, y, r, colour, ring_colour=RING, eyes=1.0):
return ring(x, y, r, False, ring_colour) + ball(x, y, r, colour, eyes) + ring(x, y, r, True, ring_colour)
def ess(x, y, colour):
return letter([f'M{x + 24} {y + 5} C{x + 10} {y - 8} {x - 24} {y - 6} {x - 22} {y + 13} C{x - 20} {y + 31} {x + 24} {y + 25} {x + 24} {y + 44} C{x + 24} {y + 63} {x - 14} {y + 64} {x - 26} {y + 48}'], colour)
+53 -6
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env python3
"""The two Orbit Tots films: Hello, TOTS! and Sleepy TOTS. Soft flat illustration drawn from the logo's own shapes, one SVG a frame.
build-idents.py <morning|evening> <out folder> [frame numbers for stills]
"""The Orbit Tots films: Hello, TOTS!, its roll-in variation, and Sleepy TOTS. Soft flat illustration drawn from the logo's own shapes, one SVG a frame.
build-idents.py <morning|roll|evening> <out folder> [frame numbers for stills]
"""
import concurrent.futures, importlib.util, math, os, random, subprocess, sys
from pathlib import Path
@@ -71,8 +71,11 @@ def page(body, defs):
return f'<svg xmlns="http://www.w3.org/2000/svg" width="{W}" height="{H}" viewBox="0 0 {W} {H}"><defs>{brands["SILVER"]}{defs}</defs>{body}</svg>'
MORNING_BITS = Scatter(4, 16, ('#FFF4C2', '#FFFFFF', '#FFC2D1', '#CDBBF5', '#FFE08A'))
def morning(frame):
t = (frame - 1) / FPS
def slow_blink(t, start):
return (1 - ease(between(t, start, start + .28))) + ease(between(t, start + .4, start + .75))
def morning_sky(t):
# The same morning for both films.
defs = '<linearGradient id="sky" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#5DBDF5"/><stop offset=".7" stop-color="#A9E2FB"/><stop offset="1" stop-color="#D6F3FB"/></linearGradient>'
body = f'<rect width="{W}" height="{H}" fill="url(#sky)"/>'
# The sunburst, turning slowly behind everything.
@@ -85,6 +88,11 @@ def morning(frame):
# Soft hills along the foot.
body += f'<path d="M0 640 Q320 560 640 636 T1280 610 V720 H0 Z" fill="#8FE07E"/><path d="M0 680 Q300 626 720 682 T1280 660 V720 H0 Z" fill="#6FCF6A"/>'
body += MORNING_BITS.draw(t)
return body, defs
def morning(frame):
t = (frame - 1) / FPS
body, defs = morning_sky(t)
# Mark first, so the letters sit over its ring.
body += mark_layer(tots.SUN, between(t, 2.6, 3.4), ease(between(t, 2.5, 3.1)))
for name, colour_body, start in (('t1', tots.tee(*FINAL['t1'], tots.CORAL, -5), 1.05), ('t2', tots.tee(*FINAL['t2'], tots.SKY, 4), 1.4), ('s', tots.ess(*FINAL['s'], tots.LEAF), 1.75)):
@@ -101,7 +109,46 @@ def morning(frame):
u = ease(between(t, 1.5, 2.6))
x, y = bezier([(330, 320), (470, 90), (820, 110), (fx, fy)], u); size = 1.5 - .5 * u
tilt = 10 * math.sin(t * 2.4) * (1 - ease(between(t, 2.2, 3.0)))
body += f'<g transform="translate({x:.2f},{y:.2f}) rotate({tilt:.2f}) scale({ZOOM * size:.4f}) translate({-FINAL["o"][0]},{-FINAL["o"][1]})">' + tots.planet(*FINAL['o'], 36, tots.SUN) + '</g>'
body += f'<g transform="translate({x:.2f},{y:.2f}) rotate({tilt:.2f}) scale({ZOOM * size:.4f}) translate({-FINAL["o"][0]},{-FINAL["o"][1]})">' + tots.planet(*FINAL['o'], 36, tots.SUN, eyes=slow_blink(t, 3.75)) + '</g>'
return page(body, defs)
def roll_in(frame):
# The letters are waiting, with the ring held open between the two Ts. The planet rolls along the ground and jumps in.
t = (frame - 1) / FPS
body, defs = morning_sky(t)
body += mark_layer(tots.SUN, between(t, 2.7, 3.5), ease(between(t, 2.6, 3.2)))
for name, colour_body, start in (('t1', tots.tee(*FINAL['t1'], tots.CORAL, -5), .05), ('t2', tots.tee(*FINAL['t2'], tots.SKY, 4), .25), ('s', tots.ess(*FINAL['s'], tots.LEAF), .45)):
u = between(t, start, start + .6)
if u > 0:
body += letter(colour_body, name, 110 * (1 - settle(u)), .7 + .3 * settle(u), ease(u * 3.5))
fx, fy = screen(*FINAL['o'])
ground, land, leap = 582, 2.5, 1.9
# A nudge as it lands, settling back, so the ring feels caught.
shake = 11 * math.sin(between(t, land, land + .7) * math.tau * 1.5) * (1 - clamp(between(t, land, land + .7))) if t > land else 0
open_ring = ease(between(t, .55, 1.0))
ring_at = f'translate({fx:.2f},{fy:.2f}) scale({ZOOM * (.6 + .4 * settle(between(t, .55, 1.1))):.4f}) translate({-FINAL["o"][0]},{-FINAL["o"][1]})'
behind = f'<g opacity="{open_ring:.3f}" transform="{ring_at}">' + tots.ring(*FINAL['o'], 36, False, tilt=-12 + shake) + '</g>'
front = f'<g opacity="{open_ring:.3f}" transform="{ring_at}">' + tots.ring(*FINAL['o'], 36, True, tilt=-12 + shake) + '</g>'
squash, stretch = 0, 0
if t < leap - .22:
u = between(t, .55, leap - .22)
eased = 1 - (1 - clamp(u)) ** 1.6
x, y, turn = -110 + (fx - 110 + 110) * eased, ground, 360 * eased
elif t < leap:
# Crouches ready to jump.
x, y, turn, squash = fx - 110 * 0 - 0, ground, 360, math.sin(math.pi * between(t, leap - .22, leap)) * .16
elif t < land:
u = between(t, leap, land)
x, y, turn = fx, ground + (fy - ground) * u - 250 * 4 * u * (1 - u) * .62, 360
stretch = .07 * math.sin(math.pi * u)
else:
x, y, turn = fx, fy, 360
squash = .09 * math.sin(math.pi * clamp(between(t, land, land + .3)))
sx, sy = 1 + squash - stretch, 1 - squash + stretch
drawn = (f'<g transform="translate({x:.2f},{y:.2f}) scale({ZOOM * sx:.4f},{ZOOM * sy:.4f}) translate({-FINAL["o"][0]},{-FINAL["o"][1]})">'
+ tots.ball(*FINAL['o'], 36, tots.SUN, eyes=slow_blink(t, 3.75), turn=turn) + '</g>')
# The ring is only in front once the planet has come down through it.
body += behind + drawn + front if t >= land - .12 else behind + front + drawn
return page(body, defs)
EVENING_STARS = Scatter(9, 34, ('#FFE9A8', '#EDE6FF', '#FFD9E4'), (20, 500), speed=3)
@@ -149,7 +196,7 @@ def render(job):
if __name__ == '__main__':
which, folder = sys.argv[1], Path(sys.argv[2]); folder.mkdir(parents=True, exist_ok=True)
draw, count = (morning, 150) if which == 'morning' else (evening, 180)
draw, count = {'morning': (morning, 150), 'roll': (roll_in, 150), 'evening': (evening, 180)}[which]
frames = [int(f) for f in sys.argv[3:]] or list(range(1, count + 1))
with concurrent.futures.ThreadPoolExecutor(6) as pool:
list(pool.map(render, [(draw, frame, folder) for frame in frames]))