diff --git a/VERIFICATION-0.81.1.md b/VERIFICATION-0.81.1.md new file mode 100644 index 0000000..6bef471 --- /dev/null +++ b/VERIFICATION-0.81.1.md @@ -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. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index edc4b03..4222fc6 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -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 } diff --git a/android/app/src/main/java/tv/orbit/classic/EmbyClient.kt b/android/app/src/main/java/tv/orbit/classic/EmbyClient.kt index de3fb50..144bcab 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyClient.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyClient.kt @@ -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) diff --git a/android/app/src/main/java/tv/orbit/classic/Idents.kt b/android/app/src/main/java/tv/orbit/classic/Idents.kt index 687a3ad..5b411c6 100644 --- a/android/app/src/main/java/tv/orbit/classic/Idents.kt +++ b/android/app/src/main/java/tv/orbit/classic/Idents.kt @@ -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 diff --git a/android/app/src/main/java/tv/orbit/classic/SecureMediaDataSource.kt b/android/app/src/main/java/tv/orbit/classic/SecureMediaDataSource.kt index 1fe6413..dc4e9d3 100644 --- a/android/app/src/main/java/tv/orbit/classic/SecureMediaDataSource.kt +++ b/android/app/src/main/java/tv/orbit/classic/SecureMediaDataSource.kt @@ -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()) { diff --git a/android/app/src/main/java/tv/orbit/classic/Tots.kt b/android/app/src/main/java/tv/orbit/classic/Tots.kt index 2b39120..1739906 100644 --- a/android/app/src/main/java/tv/orbit/classic/Tots.kt +++ b/android/app/src/main/java/tv/orbit/classic/Tots.kt @@ -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 diff --git a/android/app/src/main/res/raw/ident_614_morning.mp4 b/android/app/src/main/res/raw/ident_614_morning.mp4 index b68539e..2a76f50 100644 Binary files a/android/app/src/main/res/raw/ident_614_morning.mp4 and b/android/app/src/main/res/raw/ident_614_morning.mp4 differ diff --git a/android/app/src/main/res/raw/ident_614_morning_roll.mp4 b/android/app/src/main/res/raw/ident_614_morning_roll.mp4 new file mode 100644 index 0000000..38be5ba Binary files /dev/null and b/android/app/src/main/res/raw/ident_614_morning_roll.mp4 differ diff --git a/android/app/src/test/java/tv/orbit/classic/TotsTest.kt b/android/app/src/test/java/tv/orbit/classic/TotsTest.kt index 9276b73..fdac493 100644 --- a/android/app/src/test/java/tv/orbit/classic/TotsTest.kt +++ b/android/app/src/test/java/tv/orbit/classic/TotsTest.kt @@ -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))) } diff --git a/design/channels/build-idents.sh b/design/channels/build-idents.sh index 1a34436..de6a3e2 100755 --- a/design/channels/build-idents.sh +++ b/design/channels/build-idents.sh @@ -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" diff --git a/design/channels/build-tots.py b/design/channels/build-tots.py index 814ff42..cab8dc4 100644 --- a/design/channels/build-tots.py +++ b/design/channels/build-tots.py @@ -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'' - # 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'' + f'') + +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'' + return (f'' + f'' f'' - f'') - return (f'{ring(back)}' - f'{face}' - f'{ring(front)}') + f'') + +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) diff --git a/design/channels/orbit-tots/build-idents.py b/design/channels/orbit-tots/build-idents.py index efd1653..418ac70 100644 --- a/design/channels/orbit-tots/build-idents.py +++ b/design/channels/orbit-tots/build-idents.py @@ -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 [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 [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'{brands["SILVER"]}{defs}{body}' 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 = '' body = f'' # The sunburst, turning slowly behind everything. @@ -85,6 +88,11 @@ def morning(frame): # Soft hills along the foot. body += f'' 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'' + tots.planet(*FINAL['o'], 36, tots.SUN) + '' + body += f'' + tots.planet(*FINAL['o'], 36, tots.SUN, eyes=slow_blink(t, 3.75)) + '' + 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'' + tots.ring(*FINAL['o'], 36, False, tilt=-12 + shake) + '' + front = f'' + tots.ring(*FINAL['o'], 36, True, tilt=-12 + shake) + '' + 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'' + + tots.ball(*FINAL['o'], 36, tots.SUN, eyes=slow_blink(t, 3.75), turn=turn) + '') + # 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]))