Bring back the now marker without crossing titles, new icons and app icon (0.65.0)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@@ -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.
|
||||
@@ -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 }
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-feature android:name="android.software.leanback" android:required="true" />
|
||||
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
|
||||
<application android:label="Orbit TV" android:theme="@style/OrbitTheme" android:icon="@drawable/orbit_icon" android:banner="@drawable/orbit_banner" android:allowBackup="false" android:usesCleartextTraffic="false" android:supportsRtl="true">
|
||||
<application android:label="Orbit TV" android:theme="@style/OrbitTheme" android:icon="@drawable/orbit_app_icon" android:banner="@drawable/orbit_banner" android:allowBackup="false" android:usesCleartextTraffic="false" android:supportsRtl="true">
|
||||
<activity android:name=".MainActivity" android:exported="true" android:screenOrientation="landscape">
|
||||
<intent-filter><action android:name="android.intent.action.MAIN" /><category android:name="android.intent.category.LEANBACK_LAUNCHER" /></intent-filter>
|
||||
</activity>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -71,6 +71,10 @@ internal fun guideWatchableCells(cells: List<GuideCell>, nowMs: Long): List<Guid
|
||||
return listOf(first.copy(startFraction = 0f, widthFraction = first.startFraction + first.widthFraction)) + left.drop(1)
|
||||
}
|
||||
|
||||
/** Where now sits across the window, or null when the guide has moved on to later times. */
|
||||
internal fun guideNowFraction(nowMs: Long, windowStart: Long, windowEnd: Long): Float? =
|
||||
if (nowMs < windowStart || nowMs >= 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<SampleChannel>, 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<SampleChannel>, 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<SampleChannel>, 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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
|
After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 2.1 KiB |
@@ -1 +0,0 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="108dp" android:height="108dp" android:viewportWidth="108" android:viewportHeight="108"><path android:fillColor="#071632" android:pathData="M0,0h108v108h-108z"/><path android:strokeColor="#FFDE59" android:strokeWidth="7" android:fillColor="#00000000" android:pathData="M54,26a28,28 0,1 0,0 56a28,28 0,1 0,0 -56M15,72L93,36"/></vector>
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'<rect x="10" y="26" width="76" height="56" rx="9" {S}/><path d="M34 26L24 10M62 26L72 10" {S}/><g transform="translate(20,25) scale(.095)"><path d="{CRESCENT}" fill="{C}"/></g>'
|
||||
f'<path d="M32 60C30 63 31 65 36 65.5C46 66 62 57 63.5 52.5C64.5 49 60 49 56.5 50" fill="none" stroke="{A}" stroke-width="3" stroke-linecap="round"/>',
|
||||
# A camera on a tripod: someone out filming the real world.
|
||||
'documentaries': f'<rect x="14" y="36" width="46" height="26" rx="4" {S}/><path d="M60 44L82 34V64L60 54" {S}/><circle cx="26" cy="23" r="9" {T}/><circle cx="48" cy="23" r="9" {T}/><path d="M37 62V72M37 72L22 90M37 72L52 90M37 72V90" {S}/>',
|
||||
# A comic burst, so Cartoons can't be confused with the Kids rocket.
|
||||
'cartoons': f'<path d="M48 6L56 26L74 12L70 34L92 32L76 48L92 64L70 62L74 84L56 70L48 90L40 70L22 84L26 62L4 64L20 48L4 32L26 34L22 12L40 26Z" {S}/><path d="M48 32V52" {T}/><circle cx="48" cy="62" r="3.5" fill="{A}"/>',
|
||||
}
|
||||
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'<svg xmlns="http://www.w3.org/2000/svg" width="192" height="192" viewBox="0 0 96 96">{body}</svg>', 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 = '<linearGradient id="s" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#FFFFFF"/><stop offset=".44" stop-color="#E8EDF5"/><stop offset=".5" stop-color="#B7CBE8"/><stop offset="1" stop-color="#F5F7FF"/></linearGradient>'
|
||||
BG = '<linearGradient id="n" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#12356B"/><stop offset="1" stop-color="#071632"/></linearGradient>'
|
||||
MARK = f'<path d="{CRESCENT}" fill="url(#s)"/><path d="M129 369C102 399 111 425 161 428C267 432 431 340 445 289C455 250 405 250 369 261" fill="none" stroke="#36BFFF" stroke-width="13"/><circle cx="430" cy="282" r="10" fill="#36BFFF"/>'
|
||||
png = render(f'<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"><defs>{SILVER}{BG}</defs><rect width="512" height="512" fill="url(#n)"/><g transform="translate(-34,-32) scale(1.1)">{MARK}</g></svg>', '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'<svg xmlns="http://www.w3.org/2000/svg" width="640" height="360" viewBox="0 0 640 360"><defs>{SILVER}{BG}</defs><rect width="640" height="360" fill="url(#n)"/><g transform="translate(40,70) scale(.46)">{MARK}</g><text x="262" y="222" font-family="Barlow" font-weight="600" font-size="86" letter-spacing="22" fill="#F5F7FF">ORBIT</text></svg>', '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)
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"><defs><linearGradient id="s" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#FFFFFF"/><stop offset=".44" stop-color="#E8EDF5"/><stop offset=".5" stop-color="#B7CBE8"/><stop offset="1" stop-color="#F5F7FF"/></linearGradient><linearGradient id="n" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#12356B"/><stop offset="1" stop-color="#071632"/></linearGradient></defs><rect width="512" height="512" fill="url(#n)"/><g transform="translate(-34,-32) scale(1.1)"><path d="M335 132C141 114 66 384 257 475C143 363 185 207 335 132Z" fill="url(#s)"/><path d="M129 369C102 399 111 425 161 428C267 432 431 340 445 289C455 250 405 250 369 261" fill="none" stroke="#36BFFF" stroke-width="13"/><circle cx="430" cy="282" r="10" fill="#36BFFF"/></g></svg>
|
||||
|
After Width: | Height: | Size: 846 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="640" height="360" viewBox="0 0 640 360"><defs><linearGradient id="s" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#FFFFFF"/><stop offset=".44" stop-color="#E8EDF5"/><stop offset=".5" stop-color="#B7CBE8"/><stop offset="1" stop-color="#F5F7FF"/></linearGradient><linearGradient id="n" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#12356B"/><stop offset="1" stop-color="#071632"/></linearGradient></defs><rect width="640" height="360" fill="url(#n)"/><g transform="translate(40,70) scale(.46)"><path d="M335 132C141 114 66 384 257 475C143 363 185 207 335 132Z" fill="url(#s)"/><path d="M129 369C102 399 111 425 161 428C267 432 431 340 445 289C455 250 405 250 369 261" fill="none" stroke="#36BFFF" stroke-width="13"/><circle cx="430" cy="282" r="10" fill="#36BFFF"/></g><text x="262" y="222" font-family="Barlow" font-weight="600" font-size="86" letter-spacing="22" fill="#F5F7FF">ORBIT</text></svg>
|
||||
|
After Width: | Height: | Size: 967 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="192" height="192" viewBox="0 0 96 96"><rect x="10" y="26" width="76" height="56" rx="9" fill="none" stroke="#E8EDF5" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/><path d="M34 26L24 10M62 26L72 10" fill="none" stroke="#E8EDF5" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/><g transform="translate(20,25) scale(.095)"><path d="M335 132C141 114 66 384 257 475C143 363 185 207 335 132Z" fill="#E8EDF5"/></g><path d="M32 60C30 63 31 65 36 65.5C46 66 62 57 63.5 52.5C64.5 49 60 49 56.5 50" fill="none" stroke="#7FB2FF" stroke-width="3" stroke-linecap="round"/></svg>
|
||||
|
After Width: | Height: | Size: 646 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="192" height="192" viewBox="0 0 96 96"><path d="M48 6L56 26L74 12L70 34L92 32L76 48L92 64L70 62L74 84L56 70L48 90L40 70L22 84L26 62L4 64L20 48L4 32L26 34L22 12L40 26Z" fill="none" stroke="#E8EDF5" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/><path d="M48 32V52" fill="none" stroke="#7FB2FF" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/><circle cx="48" cy="62" r="3.5" fill="#7FB2FF"/></svg>
|
||||
|
After Width: | Height: | Size: 476 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="192" height="192" viewBox="0 0 96 96"><rect x="14" y="36" width="46" height="26" rx="4" fill="none" stroke="#E8EDF5" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/><path d="M60 44L82 34V64L60 54" fill="none" stroke="#E8EDF5" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/><circle cx="26" cy="23" r="9" fill="none" stroke="#7FB2FF" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/><circle cx="48" cy="23" r="9" fill="none" stroke="#7FB2FF" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/><path d="M37 62V72M37 72L22 90M37 72L52 90M37 72V90" fill="none" stroke="#E8EDF5" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
|
After Width: | Height: | Size: 756 B |