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''
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]))