diff --git a/VERIFICATION-0.24.0.md b/VERIFICATION-0.24.0.md new file mode 100644 index 0000000..19b10b9 --- /dev/null +++ b/VERIFICATION-0.24.0.md @@ -0,0 +1,11 @@ +# Orbit TV 0.24.0 — grid guide + +Paul confirmed on 0.23 that Vortex tunes, and up, down and left work while watching. + +TV Guide now shows the real channels down the side and a two-hour window of half-hour slots across, drawn from each channel's own clock, with a now line. Up and down move between channels, left and right between programmes, and the window moves with the cursor, up to a day ahead. It never goes earlier than now. OK watches when the highlighted programme is on now; future programmes only show their details. A highlighted channel's title, time and logo sit at the top. + +The picture slot shows the channel's artwork for now, not live video. Live picture-in-picture is a separate step. "Test transition" is kept, above the grid, as a developer tool. Programme descriptions aren't shown, because channels don't store them yet. The old single-channel guide function is unused and can go in a later tidy-up. + +Validation: offline assembleDebug, testDebugUnitTest and lintDebug passed; 126 unit tests, zero failures or errors, no lint errors. New tests cover cells filling the window, agreement with the clock, clipping, slots and cursor moves. Layout, focus and remote feel have only been built, not seen, and need the Xiaomi test. + +Rollback: reinstall outputs/revision-0.23.0, or revert this commit. Data formats are unchanged. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index ff476bf..891670a 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 = 23 - versionName = "0.23.0-tuning-retry" + versionCode = 24 + versionName = "0.24.0-grid-guide" } 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 635ab67..4a979e1 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyClient.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyClient.kt @@ -41,7 +41,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.23.0\"") + connection.setRequestProperty("X-Emby-Authorization", "Emby Client=\"Orbit TV\", Device=\"Orbit TV\", DeviceId=\"$deviceId\", Version=\"0.24.0\"") 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/EmbyConnectionScreen.kt b/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt index 46d181b..1cd9b3e 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt @@ -230,10 +230,10 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea val previous = lastNumber if (previous != null && availableChannels.any { it.number == previous }) { lastNumber = selectedNumber; selectedNumber = previous; channelClockOffset = 0 } }) - SampleChannelGuide(liveChannel, modifier, onTune = { offset -> + GuideGrid(availableChannels, selectedNumber, modifier, onWatch = { number, offset -> + if (number != selectedNumber) { lastNumber = selectedNumber; selectedNumber = number } channelClockOffset = offset; watchingChannel = true - }, onBack = onBack, otherChannels = availableChannels.filter { it.number != selectedNumber }, - onSwitch = { lastNumber = selectedNumber; selectedNumber = it; watchingChannel = false; channelClockOffset = 0 }) + }, onBack = onBack) return } if (!visible) return diff --git a/android/app/src/main/java/tv/orbit/classic/GuideGrid.kt b/android/app/src/main/java/tv/orbit/classic/GuideGrid.kt new file mode 100644 index 0000000..da57645 --- /dev/null +++ b/android/app/src/main/java/tv/orbit/classic/GuideGrid.kt @@ -0,0 +1,145 @@ +package tv.orbit.classic + +import android.view.KeyEvent +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.* +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.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.tv.material3.Text +import kotlinx.coroutines.delay + +internal const val GUIDE_SLOT_MS = 30 * 60_000L +internal const val GUIDE_SLOTS = 4 +private const val GUIDE_MAX_SLOTS_AHEAD = 48 + +internal data class GuideCell(val show: SampleBroadcast, val startFraction: Float, val widthFraction: Float) + +/** The programmes a channel airs inside a window, clipped to it. The channel's own clock decides everything. */ +internal fun guideCells(channel: SampleChannel, windowStart: Long, windowEnd: Long): List { + require(windowEnd > windowStart) + val span = (windowEnd - windowStart).toFloat() + val cells = mutableListOf() + var show = channel.at(windowStart) + while (show.startMs < windowEnd) { + val from = maxOf(show.startMs, windowStart) + val to = minOf(show.endMs, windowEnd) + cells += GuideCell(show, (from - windowStart) / span, (to - from) / span) + show = channel.at(show.endMs) + } + return cells +} + +internal fun guideWindowStart(nowMs: Long, slotsAhead: Int) = Math.floorDiv(nowMs, GUIDE_SLOT_MS) * GUIDE_SLOT_MS + slotsAhead * GUIDE_SLOT_MS + +/** Cursor time after moving one programme along a channel, never earlier than now. */ +internal fun guideStep(channel: SampleChannel, cursorMs: Long, nowMs: Long, forward: Boolean): Long { + val show = channel.at(maxOf(cursorMs, nowMs)) + return if (forward) show.endMs else maxOf(show.startMs - 1, nowMs) +} + +@Composable +internal fun GuideGrid(channels: List, selectedNumber: Int, modifier: Modifier = Modifier, onWatch: (Int, Long) -> Unit, onBack: () -> Unit) { + var now by remember { mutableLongStateOf(System.currentTimeMillis()) } + LaunchedEffect(Unit) { while (true) { delay(1000); now = System.currentTimeMillis() } } + var row by remember { mutableIntStateOf(channels.indexOfFirst { it.number == selectedNumber }.coerceAtLeast(0)) } + var cursor by remember { mutableLongStateOf(now) } + var slotsAhead by remember { mutableIntStateOf(0) } + val gridFocus = remember { FocusRequester() } + val toolsFocus = remember { FocusRequester() } + var gridFocused by remember { mutableStateOf(true) } + LaunchedEffect(Unit) { gridFocus.requestFocus() } + BackHandler(onBack = onBack) + val windowStart = guideWindowStart(now, slotsAhead) + val windowEnd = windowStart + GUIDE_SLOTS * GUIDE_SLOT_MS + val channel = channels[row.coerceIn(channels.indices)] + val focused = channel.at(maxOf(cursor, now)) + fun keepCursorInWindow(time: Long) { + cursor = time + val base = guideWindowStart(now, 0) + var ahead = slotsAhead + while (time >= base + (ahead + GUIDE_SLOTS) * GUIDE_SLOT_MS && ahead < GUIDE_MAX_SLOTS_AHEAD) ahead++ + while (time < base + ahead * GUIDE_SLOT_MS && ahead > 0) ahead-- + slotsAhead = ahead + } + Column(modifier.fillMaxWidth().background(Navy).padding(horizontal = 14.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) { + Row(Modifier.fillMaxWidth().height(126.dp), horizontalArrangement = Arrangement.spacedBy(12.dp)) { + val branding = ChannelBrands.forNumber(channel.number) + Box(Modifier.width(224.dp).fillMaxHeight().background(Panel)) { + if (branding != null) Image(painterResource(branding.background), null, Modifier.matchParentSize(), contentScale = ContentScale.Crop) + } + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { + if (branding != null) Image(painterResource(branding.logo), branding.displayName, Modifier.width(112.dp).height(35.dp)) + else Text(ChannelBrands.displayName(channel), color = Color.White, fontSize = 16.sp) + Text(focused.media.item.title, color = Color.White, fontSize = 21.sp, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text("${clock(focused.startMs)} to ${clock(focused.endMs)} • ${channel.number} ${ChannelBrands.displayName(channel)}", color = Muted, fontSize = 13.sp, maxLines = 1) + Text(if (focused.startMs <= now) "OK Watch" else "On later", color = Muted, fontSize = 12.sp) + } + Column(verticalArrangement = Arrangement.Top) { + OrbitButton("Test transition", Modifier.focusRequester(toolsFocus).onPreviewKeyEvent { event -> + if (event.nativeKeyEvent.keyCode == KeyEvent.KEYCODE_DPAD_DOWN && event.nativeKeyEvent.action == KeyEvent.ACTION_DOWN) { gridFocus.requestFocus(); true } else false + }, onClick = { onWatch(channel.number, channel.transitionCheckOffset(System.currentTimeMillis())) }) + } + } + Row(Modifier.fillMaxWidth().height(24.dp).background(Panel), verticalAlignment = Alignment.CenterVertically) { + Text("All Channels", Modifier.width(150.dp).padding(start = 8.dp), color = Color.White, fontSize = 13.sp, fontWeight = FontWeight.SemiBold) + Row(Modifier.weight(1f)) { + repeat(GUIDE_SLOTS) { Text(clock(windowStart + it * GUIDE_SLOT_MS), Modifier.weight(1f).padding(start = 4.dp), color = Color.White, fontSize = 13.sp, fontWeight = FontWeight.SemiBold) } + } + } + Column(Modifier.fillMaxWidth().weight(1f).focusRequester(gridFocus).onFocusChanged { gridFocused = it.isFocused }.focusable().onPreviewKeyEvent { event -> + val native = event.nativeKeyEvent + if (native.action != KeyEvent.ACTION_DOWN) return@onPreviewKeyEvent false + when (native.keyCode) { + KeyEvent.KEYCODE_DPAD_UP -> { if (row > 0) row-- else toolsFocus.requestFocus(); true } + KeyEvent.KEYCODE_DPAD_DOWN -> { if (row < channels.lastIndex) row++; true } + KeyEvent.KEYCODE_DPAD_RIGHT -> { + if (slotsAhead < GUIDE_MAX_SLOTS_AHEAD) keepCursorInWindow(guideStep(channel, cursor, now, true)); true + } + KeyEvent.KEYCODE_DPAD_LEFT -> { keepCursorInWindow(guideStep(channel, cursor, now, false)); true } + KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_ENTER -> { + if (native.repeatCount == 0 && focused.startMs <= now) onWatch(channel.number, 0); true + } + else -> false + } + }, verticalArrangement = Arrangement.spacedBy(4.dp)) { + channels.forEachIndexed { index, line -> + Row(Modifier.fillMaxWidth().weight(1f).widthIn(max = 2000.dp), horizontalArrangement = Arrangement.spacedBy(0.dp)) { + val mark = ChannelBrands.forNumber(line.number) + Row(Modifier.width(150.dp).fillMaxHeight().background(Panel).padding(start = 6.dp), verticalAlignment = Alignment.CenterVertically) { + Text(line.number.toString(), Modifier.width(34.dp), color = Muted, fontSize = 13.sp, fontWeight = FontWeight.SemiBold) + if (mark != null) Image(painterResource(mark.logo), mark.displayName, Modifier.width(104.dp).height(32.dp)) + else Text(ChannelBrands.displayName(line), color = Color.White, fontSize = 13.sp, maxLines = 1) + } + BoxWithConstraints(Modifier.weight(1f).fillMaxHeight()) { + guideCells(line, windowStart, windowEnd).forEach { cell -> + val isFocus = gridFocused && index == row && cell.show.startMs == focused.startMs + val onNow = cell.show.startMs <= now + Box(Modifier.offset(x = maxWidth * cell.startFraction).width((maxWidth * cell.widthFraction - 2.dp).coerceAtLeast(1.dp)).fillMaxHeight() + .background(if (isFocus) Yellow else if (onNow) Blue else Panel).padding(horizontal = 6.dp), contentAlignment = Alignment.CenterStart) { + Text(cell.show.media.item.title, color = if (isFocus) Navy else Color.White, fontSize = 14.sp, fontWeight = if (isFocus) FontWeight.SemiBold else FontWeight.Normal, maxLines = 2, overflow = TextOverflow.Ellipsis) + } + } + if (now in windowStart until windowEnd) + Box(Modifier.offset(x = maxWidth * ((now - windowStart).toFloat() / (windowEnd - windowStart))).width(2.dp).fillMaxHeight().background(Color.White)) + } + } + } + } + Text("▲▼ Channel ◄► Time OK Watch Back Menu", color = Muted, fontSize = 12.sp) + } +} 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 506bab6..e075b58 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.23.0") + next.setRequestProperty("User-Agent", "OrbitTV/0.24.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()) { diff --git a/android/app/src/test/java/tv/orbit/classic/GuideGridTest.kt b/android/app/src/test/java/tv/orbit/classic/GuideGridTest.kt new file mode 100644 index 0000000..dd26eaa --- /dev/null +++ b/android/app/src/test/java/tv/orbit/classic/GuideGridTest.kt @@ -0,0 +1,42 @@ +package tv.orbit.classic + +import org.junit.Assert.* +import org.junit.Test + +class GuideGridTest { + private val minute = 60_000L + private fun media(id: String, minutes: Long) = ScheduledMedia(SampleItem("Fixture $id", "Movie", minutes, "mkv", id, minutes * minute), "$id-source", minutes * minute) + private val channel = SampleChannel(listOf(media("a", 20), media("b", 50), media("c", 90)), "Fixture", 101) + private val start = guideWindowStart(1_800_000_000_000L, 0) + private val end = start + GUIDE_SLOTS * GUIDE_SLOT_MS + + @Test fun cellsFillTheWindowWithNoGapsAndAddUpToIt() { + val cells = guideCells(channel, start, end) + assertEquals(1f, cells.sumOf { it.widthFraction.toDouble() }.toFloat(), .001f) + assertEquals(0f, cells.first().startFraction, .001f) + cells.zipWithNext().forEach { (a, b) -> assertEquals(a.startFraction + a.widthFraction, b.startFraction, .001f) } + } + @Test fun cellsAgreeWithTheChannelClock() { + guideCells(channel, start, end).forEach { cell -> + assertEquals(channel.at(cell.show.startMs).media.item.id, cell.show.media.item.id) + assertTrue(cell.show.startMs < end && cell.show.endMs > start) + } + } + @Test fun aLongFilmIsClippedToTheWindow() { + val longOne = SampleChannel(listOf(media("x", 600), media("y", 600)), "Fixture", 102) + val cells = guideCells(longOne, start, start + GUIDE_SLOT_MS) + assertEquals(1, cells.size); assertEquals(1f, cells[0].widthFraction, .001f) + } + @Test fun theWindowStartsOnTheHalfHourAndMovesBySlots() { + assertEquals(0L, start % GUIDE_SLOT_MS) + assertEquals(start + 2 * GUIDE_SLOT_MS, guideWindowStart(start + minute, 2)) + } + @Test fun movingRightGoesToTheNextProgrammeAndLeftNeverPassesNow() { + val now = start + minute + val current = channel.at(now) + assertEquals(current.endMs, guideStep(channel, now, now, true)) + assertEquals(now, guideStep(channel, now, now, false)) + val later = current.endMs + minute + assertEquals(current.endMs - 1, guideStep(channel, later, now, false)) + } +} diff --git a/docs/roadmap.md b/docs/roadmap.md index c6ae32a..f5c7c1b 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -69,6 +69,10 @@ Profiles and PINs, more providers, authorised IPTV and external guide data, them Audit and phased plan are in review, from `docs/briefs/indexing-programming-engine-2026-11-01.md`: https://claude.ai/artifact/YYGHnyn7fhxfM1NctEtexw. Nothing from it is approved or built yet. +## Channel numbers + +Paul's direction, 1 November 2026: 101, 102 and 103 should become Orbit 1, Orbit 2 and Orbit Three. Today 102 is After Dark and 103 is Vortex, so they'll move when the generic channel list arrives, with a tested migration. Other numbers aren't mapped yet. + ## Interface direction Paul's idea, 1 November 2026, for after the 25 channels work. Proposed, not built.