diff --git a/VERIFICATION-0.65.md b/VERIFICATION-0.65.md new file mode 100644 index 0000000..018218c --- /dev/null +++ b/VERIFICATION-0.65.md @@ -0,0 +1,12 @@ +# Orbit TV 0.65.0: now marker and new icons + +- Guide: a thin bar along the foot of what's on now, ending at the current time on every row, and a small tick in the time header. It doesn't cross any title. Both go away when the guide moves to later times. +- Icons: Cartoons has its own (comic burst), Documentaries is a film camera on a tripod, All Channels has the Orbit crescent and ring inside the TV. +- App icon and TV banner now use the real Orbit mark. The old placeholder vector is removed. +- Sources: `design/interface/build-new-icons.py`; the first icon set is left as it was. + +Checked: build, lint and 252 unit tests pass (new test for the now position). Icons, app icon and banner checked by eye on a contact sheet at full and small size. + +Not checked on the TV: the marker on screen, and how the launcher shows the new icon and banner. The launcher may cache the old art until it refreshes. + +Catalogue and schedule code unchanged. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 3a25ab0..2db47cd 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 = 65 - versionName = "0.64.0-film-clash-fix" + versionCode = 66 + versionName = "0.65.0-now-marker-new-icons" } buildFeatures { compose = true } compileOptions { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 581f284..7cb1a2b 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -2,7 +2,7 @@ - + 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 7378d8e..7b84b1f 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.64.0\"") + connection.setRequestProperty("X-Emby-Authorization", "Emby Client=\"Orbit TV\", Device=\"Orbit TV\", DeviceId=\"$deviceId\", Version=\"0.65.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/GuideGrid.kt b/android/app/src/main/java/tv/orbit/classic/GuideGrid.kt index 54f134f..56a110e 100644 --- a/android/app/src/main/java/tv/orbit/classic/GuideGrid.kt +++ b/android/app/src/main/java/tv/orbit/classic/GuideGrid.kt @@ -71,6 +71,10 @@ internal fun guideWatchableCells(cells: List, nowMs: Long): List= windowEnd) null else (nowMs - windowStart).toFloat() / (windowEnd - windowStart) + /** Below this a title can't fit, so the block shows it's cut short rather than sitting empty. */ private val GuideTitleMinWidth = 34.dp @@ -108,6 +112,7 @@ internal fun GuideGrid(channels: List, selectedNumber: Int, modif BackHandler(onBack = onBack) val windowStart = guideWindowStart(now, slotsAhead) val windowEnd = windowStart + GUIDE_SLOTS * GUIDE_SLOT_MS + val nowFraction = guideNowFraction(now, windowStart, windowEnd) val channel = channels[row.coerceIn(channels.indices)] val focused = channel.at(maxOf(cursor, now)) fun keepCursorInWindow(time: Long) { @@ -136,8 +141,11 @@ internal fun GuideGrid(channels: List, selectedNumber: Int, modif } Row(Modifier.fillMaxWidth().height(20.dp).background(Panel), verticalAlignment = Alignment.CenterVertically) { Text(guideDayPrefix(windowStart, now).trim().ifEmpty { "$title, today" }, Modifier.width(136.dp).padding(start = 8.dp), color = Color.White, fontSize = 12.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 = 12.sp, fontWeight = FontWeight.SemiBold) } + BoxWithConstraints(Modifier.weight(1f).fillMaxHeight()) { + Row(Modifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically) { + repeat(GUIDE_SLOTS) { Text(clock(windowStart + it * GUIDE_SLOT_MS), Modifier.weight(1f).padding(start = 4.dp), color = Color.White, fontSize = 12.sp, fontWeight = FontWeight.SemiBold) } + } + if (nowFraction != null) Box(Modifier.align(Alignment.BottomStart).offset(x = maxWidth * nowFraction - 1.dp).size(2.dp, 5.dp).background(Color.White)) } } BoxWithConstraints(Modifier.fillMaxWidth().weight(1f)) { @@ -180,11 +188,14 @@ internal fun GuideGrid(channels: List, selectedNumber: Int, modif val isFocus = gridFocused && index == row && cell.show.startMs == focused.startMs val onNow = cell.show.startMs <= now val cellWidth = maxWidth * cell.widthFraction - 3.dp + // Runs along the foot of what's on now, so the time reads down the rows without crossing a title. + val elapsed = if (onNow && nowFraction != null) (maxWidth * (nowFraction - cell.startFraction)).coerceIn(0.dp, cellWidth.coerceAtLeast(0.dp)) else 0.dp Box(Modifier.offset(x = maxWidth * cell.startFraction).width(cellWidth.coerceAtLeast(1.dp)).fillMaxHeight() - .background(if (isFocus) Yellow else if (onNow) Blue else Panel).padding(horizontal = 6.dp), contentAlignment = Alignment.CenterStart) { + .background(if (isFocus) Yellow else if (onNow) Blue else Panel), contentAlignment = Alignment.CenterStart) { val runsOn = cell.show.endMs > windowEnd val ink = if (isFocus) Navy else Color.White - Row(verticalAlignment = Alignment.CenterVertically) { + if (elapsed > 0.dp) Box(Modifier.align(Alignment.BottomStart).width(elapsed).height(3.dp).background(ink)) + Row(Modifier.padding(horizontal = 6.dp), verticalAlignment = Alignment.CenterVertically) { Text(if (cellWidth >= GuideTitleMinWidth) guideTitleParts(cell.show.media.item.title).first else "…", Modifier.weight(1f, fill = false), color = ink, fontSize = 13.sp, fontWeight = if (isFocus || onNow) FontWeight.SemiBold else FontWeight.Normal, maxLines = 1, overflow = TextOverflow.Ellipsis) if (runsOn && cellWidth >= GuideTitleMinWidth) Text(" ►", color = ink, fontSize = 9.sp) diff --git a/android/app/src/main/java/tv/orbit/classic/HomeScreen.kt b/android/app/src/main/java/tv/orbit/classic/HomeScreen.kt index f599b8b..1c13846 100644 --- a/android/app/src/main/java/tv/orbit/classic/HomeScreen.kt +++ b/android/app/src/main/java/tv/orbit/classic/HomeScreen.kt @@ -27,7 +27,7 @@ internal enum class ChannelCategory(val label: String, val icon: Int) { Entertainment("Entertainment", R.drawable.orbit_icon_entertainment), Movies("Movies", R.drawable.orbit_icon_movies), Comedy("Comedy", R.drawable.orbit_icon_comedy), - Cartoons("Cartoons", R.drawable.orbit_icon_kids), + Cartoons("Cartoons", R.drawable.orbit_icon_cartoons), Kids("Kids", R.drawable.orbit_icon_kids), Documentaries("Documentaries", R.drawable.orbit_icon_documentaries), Sport("Sport", R.drawable.orbit_icon_sport) 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 62f2bad..30d83dc 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.64.0") + next.setRequestProperty("User-Agent", "OrbitTV/0.65.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/main/res/drawable-nodpi/orbit_app_icon.webp b/android/app/src/main/res/drawable-nodpi/orbit_app_icon.webp new file mode 100644 index 0000000..ed7eac0 Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/orbit_app_icon.webp differ diff --git a/android/app/src/main/res/drawable-nodpi/orbit_banner.png b/android/app/src/main/res/drawable-nodpi/orbit_banner.png index 7fd1f0f..e28bebd 100644 Binary files a/android/app/src/main/res/drawable-nodpi/orbit_banner.png and b/android/app/src/main/res/drawable-nodpi/orbit_banner.png differ diff --git a/android/app/src/main/res/drawable-nodpi/orbit_icon_all.webp b/android/app/src/main/res/drawable-nodpi/orbit_icon_all.webp index 7039062..9584bf1 100644 Binary files a/android/app/src/main/res/drawable-nodpi/orbit_icon_all.webp and b/android/app/src/main/res/drawable-nodpi/orbit_icon_all.webp differ diff --git a/android/app/src/main/res/drawable-nodpi/orbit_icon_cartoons.webp b/android/app/src/main/res/drawable-nodpi/orbit_icon_cartoons.webp new file mode 100644 index 0000000..303ff23 Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/orbit_icon_cartoons.webp differ diff --git a/android/app/src/main/res/drawable-nodpi/orbit_icon_documentaries.webp b/android/app/src/main/res/drawable-nodpi/orbit_icon_documentaries.webp index 04d3b5d..d007843 100644 Binary files a/android/app/src/main/res/drawable-nodpi/orbit_icon_documentaries.webp and b/android/app/src/main/res/drawable-nodpi/orbit_icon_documentaries.webp differ diff --git a/android/app/src/main/res/drawable/orbit_icon.xml b/android/app/src/main/res/drawable/orbit_icon.xml deleted file mode 100644 index d060114..0000000 --- a/android/app/src/main/res/drawable/orbit_icon.xml +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/android/app/src/test/java/tv/orbit/classic/GuideGridTest.kt b/android/app/src/test/java/tv/orbit/classic/GuideGridTest.kt index d2b51fe..0cbf854 100644 --- a/android/app/src/test/java/tv/orbit/classic/GuideGridTest.kt +++ b/android/app/src/test/java/tv/orbit/classic/GuideGridTest.kt @@ -96,4 +96,12 @@ class GuideGridTest { assertEquals(1, guideFirstRow(5, 0, 6, 11)) assertEquals(5, guideFirstRow(10, 0, 6, 11)) } + + @Test + fun nowOnlyShowsWhileItIsInTheWindow() { + assertEquals(0.25f, guideNowFraction(1_250, 1_000, 2_000)!!, 0.001f) + assertEquals(0f, guideNowFraction(1_000, 1_000, 2_000)!!, 0.001f) + assertNull(guideNowFraction(999, 1_000, 2_000)) + assertNull(guideNowFraction(2_000, 1_000, 2_000)) + } } diff --git a/design/interface/build-new-icons.py b/design/interface/build-new-icons.py new file mode 100644 index 0000000..57924e5 --- /dev/null +++ b/design/interface/build-new-icons.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +"""New category icons and the app's launcher art. The first icon set in icons/ stays as it was.""" +import os, subprocess +from pathlib import Path +from PIL import Image +HERE = Path(__file__).parent +ICONS = HERE / 'icons' +RES = HERE.parents[1] / 'android/app/src/main/res/drawable-nodpi' +C, A = '#E8EDF5', '#7FB2FF' +S = f'fill="none" stroke="{C}" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"' +T = f'fill="none" stroke="{A}" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"' +CRESCENT = 'M335 132C141 114 66 384 257 475C143 363 185 207 335 132Z' +ICON = { + # The TV now carries the Orbit crescent and ring. + 'all': f'' + f'', + # A camera on a tripod: someone out filming the real world. + 'documentaries': f'', + # A comic burst, so Cartoons can't be confused with the Kids rocket. + 'cartoons': f'', +} +def render(svg, out): + path = ICONS / out + path.write_text(svg) + png = path.with_suffix('.png') + subprocess.run(['vectorcraft-cli', 'convert', str(path), str(png)], check=True, stdout=subprocess.DEVNULL) + return png +for name, body in ICON.items(): + png = render(f'{body}', f'orbit_icon_{name}_v2.svg') + Image.open(png).convert('RGBA').resize((96, 96), Image.LANCZOS).save(RES / f'orbit_icon_{name}.webp', lossless=True) +SILVER = '' +BG = '' +MARK = f'' +png = render(f'{SILVER}{BG}{MARK}', 'orbit_app_icon.svg') +Image.open(png).convert('RGB').resize((512, 512), Image.LANCZOS).save(RES / 'orbit_app_icon.webp', quality=92) +png = render(f'{SILVER}{BG}{MARK}ORBIT', 'orbit_banner.svg') +Image.open(png).convert('RGB').resize((640, 360), Image.LANCZOS).save(RES / 'orbit_banner.png') +for f in ICONS.glob('*_v2.png'): f.unlink() +for n in ('orbit_app_icon', 'orbit_banner'): (ICONS / f'{n}.png').unlink(missing_ok=True) diff --git a/design/interface/icons/orbit_app_icon.svg b/design/interface/icons/orbit_app_icon.svg new file mode 100644 index 0000000..8d7773a --- /dev/null +++ b/design/interface/icons/orbit_app_icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/design/interface/icons/orbit_banner.svg b/design/interface/icons/orbit_banner.svg new file mode 100644 index 0000000..27762a7 --- /dev/null +++ b/design/interface/icons/orbit_banner.svg @@ -0,0 +1 @@ +ORBIT \ No newline at end of file diff --git a/design/interface/icons/orbit_icon_all_v2.svg b/design/interface/icons/orbit_icon_all_v2.svg new file mode 100644 index 0000000..aed020c --- /dev/null +++ b/design/interface/icons/orbit_icon_all_v2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/design/interface/icons/orbit_icon_cartoons_v2.svg b/design/interface/icons/orbit_icon_cartoons_v2.svg new file mode 100644 index 0000000..68ce1b1 --- /dev/null +++ b/design/interface/icons/orbit_icon_cartoons_v2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/design/interface/icons/orbit_icon_documentaries_v2.svg b/design/interface/icons/orbit_icon_documentaries_v2.svg new file mode 100644 index 0000000..7f9bcf2 --- /dev/null +++ b/design/interface/icons/orbit_icon_documentaries_v2.svg @@ -0,0 +1 @@ + \ No newline at end of file