Orbit TV at 0.16.0-tuning-screen
pauljd/davidson-ci: Davidson CI / quality (push) Failing after 1m48s
pauljd/davidson-ci: Davidson CI / ai-review (push) Successful in 49s

First commit, imported from the working folder: Android TV app, loopback
guide server, design sources and per-release verification notes.

Includes the 0.16 tuning screen, built and tested but not yet confirmed
on the box.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-11-01 17:25:30 +00:00
co-authored by Claude Opus 5.5
commit 934f82d805
315 changed files with 59399 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
.tooling/
android/.gradle/
android/local.properties
android/**/build/
server/node_modules/
server/dist/
+139
View File
@@ -0,0 +1,139 @@
# Orbit TV — local prototype
A native Android TV prototype for review on Paul's Xiaomi TV Box S 3rd Gen.
## Revision 0.6.0 — one scheduled Emby channel
Paul confirmed the 0.5.0 strip hides and displays correctly. The next increment adds a
sample guide for one real channel: Menu → Connect Emby → Check 20 items → Create sample channel.
It checks at most five media-source metadata responses and keeps up to three eligible titles.
A precise UTC timeline repeats the lineup, joins in progress and advances automatically.
Watch channel uses the ordinary clock; Check transition jumps near an end for a quick handover
check and is labelled as a test. It may affect normal provider viewing history.
Now/Next in the strip and sample guide comes from that timeline. Pause/seek remain on-demand
features; the scheduled channel follows the clock. Back returns to the sample guide.
No channel, credentials or tokens are persisted yet. See docs/sample-scheduled-channel.md.
This version needs approval before TV installation and real channel/rollover acceptance testing.
## Revision 0.5.0 — Classic programme strip
Paul confirmed 0.4.0 played real provider media. This visual revision replaces its persistent
top banner/stock controller with a bottom classic programme strip that hides after five seconds
of active playback. OK reveals it; while visible OK toggles pause/play, and left/right seek.
The channel/guide/subtitle legend is visual only for now, as requested. Back still returns
to the sample. The stock subtitle/audio menu is not shown in this presentation pass.
See docs/classic-programme-strip.md. Logo artwork also loses the CRT's white cabinet outline.
The new presentation needs its own TV acceptance check and approval before installation.
## Revision 0.4.0 — small-sample playback test
The 20-item connection check succeeded on Paul's Xiaomi box. The next revision makes those
sample items playable, retaining the temporary login in memory until the sample screen exits.
Select a title, use fullscreen pause/seek/audio/subtitle controls, and Back to return to the sample.
Direct streaming is tested first: no automatic video transcoding, full scan or saved credentials.
Playback may update normal provider watch history. See docs/emby-playback-test.md for boundaries.
The 0.4.0 APK was installed, and Paul confirmed real media playback. An original gold/charcoal
OrbOffice drive-in ticket logo concept is included in design/, separately from the app interface.
Brand-name clearance is not established. The Android CLI skill was assessed, not installed.
## Revision 0.3.0 — Connect Emby
Menu → Connect Emby opens an on-TV username/password form for the selected provider.
Check 20 items performs one sign-in, one metadata-page request and a temporary-session logout.
The sample shows titles, media types, durations and available container/codec metadata.
No password, token or sample is persisted. Credentials stay out of chat and the APK.
The connection screen blocks screenshots and clears transient data when backgrounded.
The fixed provider URL is displayed before submission; no real account has been used in tests.
This does not yet import a library or play Emby video. See docs/emby-connection-check.md.
The prior 0.2.0 APK was installed and verified on Paul's Xiaomi box. The 0.3.0 build was subsequently installed; Paul confirmed the 20-record sample succeeded.
Later profile requirements are recorded in docs/profiles-roadmap.md.
## Revision 0.2.0
- Top navigation is now 1 Menu / 2 TV Guide / 3 Box Office.
- Menu links to the guide, OrbOffice and Services (including the OrbHive Bedlam roadmap).
- OrbOffice has two selectable fictional event ticket previews with an original perforated stub design.
- Barlow and Barlow Condensed are bundled for the new sections; their OFL licences are in licenses/.
- Existing guide and fullscreen playback-bar styling is preserved.
- Proper logo work is deferred; Paul wants a centred mark with safe padding in the TV app tile.
- Reproduce the design previews with design/render-designs.sh (requires FigCraft CLI and Pillow).
Fonts are loaded from a project-local XDG data directory, without a system font installation.
The first APK was installed on Paul's Xiaomi box. Paul confirmed navigation, tuning,
fullscreen playback and the bottom programme bar worked. Revision 0.2.0 was subsequently installed and verified on the box.
## What works in source
- Offline TV guide with three fictional channels and three half-hour columns.
- D-pad focus, programme descriptions, OK to tune the current show, Back to the guide.
- Current programme is determined from fixed UTC slots, not app launch time.
- Media3 joins the original 30-minute local demo video at the scheduled offset.
- One player moves between guide preview and fullscreen; playback pauses in background.
- Box Office is a ticket preview; Services keeps OrbHive Bedlam as an explicit roadmap item.
- Independent loopback-only TypeScript guide API, with no runtime npm dependencies.
- Editable FigCraft mockup and VectorCraft wordmark.
The Android app uses its bundled Kotlin schedule. It does not call the TypeScript service yet.
The same fictional titles and slot algorithm are duplicated for this proof of concept;
replace these with an API contract before adding real channel editing.
All channels use the same silent original test signal. This is not an Emby integration.
Titles and synopses are fictional. No third-party programme footage is included.
## Build
Prerequisites: Java 21; Android platform 36, build-tools 36.0.0 and platform-tools.
The inspected SDK is /opt/android-sdk. android/local.properties records that machine-local path
and is excluded from version control. Set ANDROID_HOME on another machine and create an
appropriate local.properties if needed.
Run `./build-android.sh` from the project root. The checked-in wrapper downloads the
SHA-256-pinned Gradle 9.3.1 distribution. Google/Maven Central access is needed initially.
Build dependencies, debug signing key and Gradle caches stay under .tooling/ when using the script.
The build uses AGP 9.1.1 and its built-in Kotlin support, with Compose compiler 2.2.10.
APK: android/app/build/outputs/apk/debug/app-debug.apk
Tests: android/app/build/test-results/testDebugUnitTest/
Lint: android/app/build/reports/lint-results-debug.html
## Backend
Requires Node 24 and npm. This PikaOS Node package lacks native TypeScript support,
so compilation uses the pinned TypeScript dev dependency.
```
cd server
npm ci --ignore-scripts
npm test
npm start
```
The development server binds only to 127.0.0.1:4317. Stop with Ctrl+C.
GET /health; GET /guide; optional ?at=<epoch-milliseconds>&count=1..48.
`node smoke-test.mjs` starts the service, checks responses and stops it automatically.
## Xiaomi hardware testing
The PC was paired with Paul's box; 0.1.0 and 0.2.0 were installed successfully. Ask before installing a new revision.
After approving a connection method, use Google's platform-tools to pair/connect as
supported by the box, then install the debug APK with `adb install -r <apk-path>`.
The launcher entry is Orbit TV. Pairing prompts and IP/port details come from the device;
do not guess them or expose ADB outside your trusted network.
The demo runs entirely on-device and needs no PC backend connection.
Acceptance checks on hardware:
1. Navigate every guide cell using only arrows and OK; check readable focus and text at sofa distance.
2. Tune each current programme, return with Back, open future programme information.
3. Open at 18 minutes past a half-hour and confirm Media3 joins around 18 minutes.
4. Cross a half-hour boundary in fullscreen and guide; ensure guide and video switch together.
5. Background/resume across a slot boundary; verify the current schedule is rejoined.
6. Check Box Office and Services return paths. Record Android version and output resolution.
## Remaining work
Paul confirmed playback, focus and remote navigation on 0.1.0 and praised the installed 0.2.0 revision. New connection form/keyboard behaviour needs hardware testing. No emulator was installed.
No Emby credentials or authenticated library have been accessed by development tools.
The provider's public identification API was checked read-only. No VPS administration was performed. No services or firewall were changed.
Emby indexing, incremental reconciliation, parental rules, calendar programming blocks,
channel editing, theme switching, seasonal events and OrbHive Bedlam are not implemented.
Open-source licensing and final branding remain decisions for Paul.
+15
View File
@@ -0,0 +1,15 @@
# Orbit TV 0.10.0 — shortlist and episode metadata
The Toonami working shortlist now includes 23 terms: the previous five plus Dragon Ball, Sailor Moon, Voltron, ReBoot, Beast Wars, Jonny Quest, Robotech, Ronin Warriors, Gundam Wing, Tenchi Muyo, Outlaw Star, The Big O, Cardcaptors, Zoids, Dragon Ball GT, Naruto, Samurai Jack, Justice League. Some terms intentionally omit punctuation/subtitles to expose provider naming variants; Paul approves actual titles/years. This is his programming direction, not a verified historical broadcast list.
Searches run in explicit batches of at most five, ten candidates per term, accumulating in the temporary session. Check next five series advances only after a successful batch. Existing matches/approvals survive subsequent batches. Batch failure stops without automatic retry. Cancellation is checked between network requests. Individual requests retain the existing timeouts and response size limit.
For the sample, approve up to three distinct series. Check sample episodes performs at most three GET /Shows/{id}/Episodes requests, one per selected series, Limit=5 and StartIndex=0 with user ID and header token, images/user data disabled. Results are capped locally even if the provider ignores the limit, IDs/types/returned SeriesId validated, duplicates removed. Returned sample episodes are sorted locally by known season/episode numbers; this does not prove the returned page contains the earliest five episodes. Unknown order/runtime is labelled; runtime must be 1 second to 7 days to be shown as usable metadata. Missing/invalid metadata is not invented. Episode results appear above matches and scroll into view. Changing approval clears previous episode results.
No new playback eligibility is asserted or tested: these are metadata pages only, no PlaybackInfo or video request, no watch-history updates from the check. Channel 103 is not scheduled yet. Channel 102 and optional advert/ident modules remain future work. The existing working 101 playback integration is unchanged apart from the client version string.
Validation: cached/offline assembleDebug, testDebugUnitTest and lintDebug passed; 61 unit tests, zero failures/errors. Added tests for later/final search batches, cancellation between searches, bounded episode pages, ordering of returned sample, source identity validation, unknown order/runtime, invalid/oversized approvals and stopping on rate limits. APK signature verified. Reviewed request bounds, session-only state and error handling. No real credentials/catalogue/media/logs accessed by development tools. Provider endpoint compatibility, remote navigation and layout still need the Xiaomi test after installation. No new dependency/device permission, VPS, firewall or service changes.
Official references checked:
https://dev.emby.media/reference/RestAPI/ItemsService/getItems.html
https://dev.emby.media/reference/RestAPI/TvShowsService/getShowsByIdEpisodes.html
+15
View File
@@ -0,0 +1,15 @@
# Orbit TV 0.11.0 — playable channel 103
Approved scope: create a repeating clock-based channel from the checked sample of one to three approved series. Flow: sign in → check shortlist → approve versions → check sample episodes → Create playable 103 → Watch channel. Existing channel 101 can also be built in the session; the guide header offers switching between 101 and 103 when both exist. This is a guide switch, not yet channel-up/down remote tuning. Channel 102 and adverts remain future work.
A session retains the latest checked episode metadata (maximum three series × five episodes). The builder interleaves each show's returned sample in ascending season/episode order, e.g. A1,B1,C1,A2,B2,C2, continuing remaining shows when sample lengths differ. Missing season/episode, specials (season zero), duplicate positions or duplicate IDs reject the build rather than inventing order. Only the returned sample is ordered; earlier library pages/episode gaps are not discovered or repaired. The complete sample repeats, returning to each show's first sampled episode on the next cycle. No persisted viewing progression or complete-library episode rotation is claimed.
At most 15 PlaybackInfo metadata probes, IsPlayback=false, no live opening/transcoding. Every sampled episode must offer an eligible direct source and valid runtime; an unsupported episode stops the build rather than skipping ahead. Source runtimes and IDs are locked into the timeline. The existing UTC clock, join-in-progress seek, boundary handover, Now/Next, transition check, manual joining Retry and green number overlay are reused; overlay reads 103 for this channel. Decoder compatibility is still a hardware question.
Playback authorization now additionally accepts canonical episode records from the current checked page or retained built-103 lineup. Arbitrary IDs still fail before any request. These allowlists are bounded (at most 15 checked + 15 built episode records), in memory only, cleared at sign-out. No private catalogue or video was accessed by development tools. Actual watching sends normal Emby playback reports and may update watch history; transition check remains explicitly labelled.
Validation: offline assembleDebug, testDebugUnitTest and lintDebug passed. 66 tests passed, zero failures/errors. New fictional fixtures cover interleaving uneven series, rejecting unknown/special/duplicate order, 15-item schedule cap and clock boundaries, episode allowlisting, IsPlayback=false probes, source runtime preference/locking, and stopping at an unsupported episode. Source reviewed for boundedness, temporary ownership, guide navigation and API scope. Signature verified. Provider eligibility, 103 playback/rollover and 101↔103 navigation still require the Xiaomi remote test after installation.
No dependency/device permission, VPS/firewall/service, full importer or persistent credential changes. Previous Emby playback API references remain applicable:
https://dev.emby.media/reference/RestAPI/MediaInfoService/postItemsByIdPlaybackinfo.html
https://dev.emby.media/doc/restapi/Playback-Guidelines.html
+11
View File
@@ -0,0 +1,11 @@
# Orbit TV 0.12.0 — compatible source selection and diagnostics
Report: Create playable 103 failed with the app's unsupported-container message, from selections among the original five search terms. The precise selected shows, source container and availability of alternate sources are not yet known. No real metadata/account/device logs were collected. The real failure cannot yet be attributed to a particular format.
Inspection found an independent selection defect: prepare chose the first direct source, then rejected its container, ignoring later compatible direct sources. It now filters eligible direct sources by the existing supported-container set before choosing. A previously locked scheduled source is still mandatory and cannot silently change to another version. Container tokens are trimmed and case-normalized; supported set is unchanged (mp4,mkv,mov,m4v,webm,ts,mpegts). No unsupported extractor/decoder capability is invented.
If no eligible direct source has a supported container, the error includes at most three short sanitized format labels. Arbitrary paths, URLs and long strings become unknown. A failed Toonami builder identifies the series/episode using its already displayed local title and retains HTTP status when wrapping errors. It still stops instead of skipping episodes. No transcoding, remuxing, fallback server process or full importer added. If this provider only offers an unsupported format, this build will explain it rather than necessarily make the episode playable.
Validation: cached/offline assembleDebug, testDebugUnitTest and lintDebug passed; 68 tests, zero failures/errors. Added regression coverage with a first unsupported AVI candidate followed by supported MKV: MKV is selected; a lock to the AVI source still fails. Tests cover container whitespace/case normalization, unsupported diagnostics and raw-URL/oversized-label rejection. Signature verified. Real provider behavior and specific blocker still require the Xiaomi test after installation. No dependency, device permission, VPS/firewall/service or private-data persistence changes.
Official format capabilities checked: https://developer.android.com/media/media3/exoplayer/supported-formats . No claim that the fixture AVI value is the user's actual container.
+13
View File
@@ -0,0 +1,13 @@
# Orbit TV 0.13.0 — 3GP and duplicate-entry details
Report: Powerpuff Girls S1 E2 failed direct container eligibility with 3gp. Batman Beyond has two search entries. We have not read actual private account metadata or media from development tooling, and do not know what distinguishes those two entries.
The pinned Media3 1.8.0 MP4 Sniffer accepts file brands beginning 3gp. The app's direct-container set now includes 3gp, shared between the advertised Emby DirectPlayProfile and local validation. URLs retain stream.3gp; files are not renamed, transcoded or remuxed. This removes the app-level block; actual codecs, provider delivery, seeking and hardware decoding still require the box test. No claim that every 3GP file is playable.
Same-title/same-year series records with distinct IDs remain separate and are labelled Entry 1 of N, Entry 2 of N within that search. Different years remain distinct without duplicate labels. No automatic merging or guessed 1080p/4K/remux designation. Episode samples request Fields=MediaStreams and show returned video dimensions, container and video/audio codec names where available; missing fields remain absent. These describe the returned sample episode, not a guarantee that every episode of a series shares its format. The episode section repeats the entry label to aid comparison before choosing a version. No extra per-series network probes are added.
Validation: offline assembleDebug, testDebugUnitTest and lintDebug passed; 71 tests, zero failures/errors. New tests cover 3GP profile/URL consistency, neutral duplicate labels with distinct IDs and years, returned resolution/container/codecs, and absence of guessed quality when metadata is missing. APK signature verified. Source reviewed for bounded parsing, original-ID preservation, in-memory session handling and no changes to source locks. Provider compatibility, 3GP playback and duplicate comparison layout still need the Xiaomi remote test after installation. No new dependencies/device permissions, VPS/firewall/services or persistent private-data changes.
Primary evidence:
https://github.com/androidx/media/blob/1.8.0/libraries/extractor/src/main/java/androidx/media3/extractor/mp4/Sniffer.java (isCompatibleBrand accepts 3gp prefix)
https://dev.emby.media/reference/RestAPI/ItemsService/getItems.html (Fields=MediaStreams and stream Width/Height/Codec)
+19
View File
@@ -0,0 +1,19 @@
# Orbit TV 0.14.0 — remembered connection and setup
Scope approved: remember sign-in and saved channel choices, before building out the standard 101 Orbit One / 102 Orbit After Dark / 103 classic-action-animation lineup. Custom user channel presets, recommendation/availability ticks, full indexing/refresh, original multi-channel grid, seasonal rules and adverts remain future increments. Channel 102 is not added here.
The app now stores an access token, provider-scoped user ID and consistent generated device ID, the existing bounded sample metadata, shortlist results/progress, approved versions, episode pages, built 101/103 lineups/source IDs/runtimes and selected guide channel. Password and username text fields are never persisted. A new sign-in is needed once after installing this first persistence build because earlier versions saved nothing.
Storage is AES-256/GCM with fresh provider-generated 96-bit IV per write, 128-bit authentication tag, fixed schema-specific AAD and an AndroidKeyStore key. Only ciphertext is written with AtomicFile in app-private noBackupFilesDir; allowBackup=false remains. No plaintext fallback, additional dependency/device permission or external storage. Schema/origin/IDs/counts/text lengths and channel bounds are validated. Saved plaintext is capped at 512 KiB; decrypted state stays in memory. No hardware-backed-key guarantee is made: actual KeyStore support and reopen behavior require the Xiaomi test.
Writes serialize through a process-wide store, with generation tickets invalidated before Disconnect/new-connection cleanup to prevent an older in-flight save rewriting forgotten state. Disconnect removes the file and key, clears visible choices and attempts normal provider logout. Clear failures produce a Settings/app-data instruction instead of claiming success. Connected UI shows Setup saved on this device after a successful write. Saves are briefly debounced; wait for confirmation before force-stopping or cutting power.
On a fresh launch the app decrypts the saved record and validates its token with GET /Users/{savedId} using the same saved generated DeviceId. It checks the returned user identity before exposing/restoring private setup. No password authentication, full scan, fresh sample import or schedule rebuild occurs during successful restore. Restoring saved source runtimes/lineup order preserves the existing UTC schedule. Source availability/runtime is still rechecked through normal tuning. Network errors retain the saved record and offer Retry saved connection. 401/403 removes the active saved token and asks for sign-in; matching-account sign-in can restore choices. Different-account sign-in cannot apply prior choices. Corrupt ciphertext/unavailable key fails closed, never reads it as plaintext, and requests sign-in.
Home/background stops playback and sends a normal stop report while retaining login/setup. The player progress loop checks foreground state. Owner disposal detaches local session state without revoking the saved provider token. This is remembered-token login, not a promise that provider tokens never expire. A token expiring during a live session still produces the existing sign-in-again error; return to Connect Emby to authenticate. Refreshing catalogue changes is not added in this increment.
Validation: final cached/offline assembleDebug, testDebugUnitTest and lintDebug passed; 79 tests, zero failures/errors. New fake-fixture tests cover encryption round trip and preserved timeline, distinct nonces, tampering/wrong keys, schema/origin/identity/approval bounds, oversized records, token removal with retained choices, validation-only resume with restored episode allowlisting, expired login with no password/logout request, cross-account rejection and stop-without-logout detach. Signature verified. Reviewed at-rest handling, fixed-origin/user binding, generation/forget races, background playback and request limits. No actual credentials/private catalogue/video or device app data/logs were read by tools. Platform Keystore/AtomicFile operations and TV lifecycle/UI are not instrumented tests; actual restoration, Disconnect and process-restart need the hardware test after installation. No VPS/firewall/service changes.
Primary references:
https://developer.android.com/privacy-and-security/keystore
https://dev.emby.media/reference/RestAPI/UserService/getUsersById.html
+15
View File
@@ -0,0 +1,15 @@
# Orbit TV 0.15.0 — Orbit After Dark 102
Approved scope: a small explicit film-selection flow and playable channel 102 using the existing engine, with the approved silver/magenta branding and lit-window background. The user's later motion request is delivered as a separate optional-ident proof, not injected into playback.
Flow: restored connection (or sign in) → Menu → Connect Emby → 102 Orbit After Dark — choose films → Check Horror / Check Thriller / Check Science Fiction → select two or three films → Create / rebuild 102 → Watch channel. One GET /Items request per explicit category action, Movie only, Genres filter, StartIndex=0, Limit=20, Fields=Genres,MediaStreams, images/userdata disabled. At most 20 returned records parsed; requires returned genre evidence, valid Movie IDs, bounded strings/genre lists. Genre labels differing from configured names may return no confirmed matches. No returned matches is not proof the whole library lacks suitable films. Candidate accumulation deduplicates IDs and caps at 60. Initial selection is empty; no automatically approved replacements. This is a sample, not a full importer or refresh engine. Horror/Thriller/Science Fiction are initial candidate pools for broader late-night/cult programming; no explicit-content or age-control feature is claimed.
Two or three approved film IDs must belong to the checked cache. Each gets PlaybackInfo with IsPlayback=false, no live opening/transcoding. Every selected film needs an eligible direct container/source/runtime; errors identify the film and stop the build without publishing a partial replacement. Source IDs and real runtimes are locked. Existing UTC join/rollover, Now/Next, transition check, Retry and green tuning number are reused. Existing 102 lineup remains until a new build succeeds. Normal watching reports to Emby as before.
Saved state adds optional afterDark/darkMovies/approvedDark fields under the existing encrypted schema/key/AAD. Missing fields default empty for 0.14 records. Selected channel has a new optional selectedChannel field; legacy selected stays 101 when new selection is 102, preserving readable legacy navigation fields. Tests prove loading pre-102 records and preserving 101/103/102 timelines after a round trip. All three built channels appear as guide switch choices. Login/token lifecycle unchanged. Downgrading to older APKs can drop unrecognised 102 fields on a subsequent save; downgrade is not a 102-preserving rollback strategy.
Approved static compact logo and lit-window WebP background are compiled resources. A small ChannelBrands registry maps the existing 102 number to the orbit-after-dark asset identity. After Dark review and guide use the background and logo; network navy/yellow focus remains. Other channels retain their existing presentation. No persistent bottom-left bug, ident playback or new settings are added; those remain optional integration increments. Original multi-channel time grid remains a future step.
Validation: final offline assembleDebug, testDebugUnitTest and lintDebug passed; 87 unit tests, zero failures/errors. Eight new tests cover genre query/token scope, returned genre evidence, ID/type rejection, server ignoring limits, cache caps/deduplication, explicit approval bounds, metadata-only source probes/runtime/source locking, rate-limit stop, legacy records and all three saved timelines/guide ordering. APK signature verified. No private account/catalogue/media, device app data or logs accessed by tools. Provider genre behavior, remote layout/focus, 102 playback/rollover and persistent three-channel navigation require the Xiaomi test after installation. No dependencies, device permissions, VPS/firewall/services or full-library queries added.
Motion proof 03: 4.2-second/720p/25fps silent H.264. Twelve separately animated windows, editable vector Trim Paths trail, then logo reveal. Fully decoded and inspected via contact sheet and final native frame; collected EffectCraft project includes relative footage and source assets. It is not an APK asset and cannot delay channel playback.
+33
View File
@@ -0,0 +1,33 @@
# Orbit TV 0.2.0 — review notes
## Changes
Top navigation: 1 Menu / 2 TV Guide / 3 Box Office. Menu includes Services.
OrbOffice has an original perforated admission-ticket design and two selectable fictional events.
Barlow and Barlow Condensed are bundled for new sections; OFL notices are included in the APK and source.
Proper logo work is deferred. Paul wants it centred with safe padding in the TV's app tile.
## Validation
- Final Android debug build, unit tests and lint: passed.
- Four Kotlin scheduler tests: passed; five backend scheduler tests: passed.
- Android lint: zero errors, nine warnings (seven available-version notices, orientation and backup advisories).
- APK signature verified and signing certificate matches the installed 0.1.0 build.
- Fullscreen video and bottom programme-bar source compared with the original source ZIP: unchanged.
- Fonts inspected with fc-scan; FigCraft font report confirms Barlow and Barlow Condensed available with the project-local font loader.
- Design previews regenerated and visually inspected. VectorCraft conversion reported no warnings.
- New code reviewed for focus return, navigation/back paths, local asset loading and playback preservation.
## Hardware evidence and limits
Paul confirmed 0.1.0 loads on his Xiaomi TV Box S 3rd Gen: remote navigation, channel selection,
fullscreen playback and the bottom programme bar work. The 0.2.0 APK has not been installed on the box yet.
Preview PNGs are design mockups, not captures of 0.2.0 running on the TV.
No Emby integration or event playback is implemented. Tickets represent fictional events.
No device, VPS, service or firewall changes were made in this increment.
## Next TV check
After approval, update the existing app with the 0.2.0 APK. Check all three tabs, Menu → Services,
Back navigation, switching event ticket previews, and returning to the guide. Recheck fullscreen playback.
Use design/open-design.sh to open editable FigCraft files with the included fonts available.
+42
View File
@@ -0,0 +1,42 @@
# Orbit TV 0.3.0 — Connect Emby
## Reviewable result
Menu → Connect Emby. On-TV username and always-hidden password fields, explicit provider address,
and Check 20 items. One sign-in, one metadata page, then temporary-session logout.
Sample contains titles, types, runtimes and any returned container/codec details.
No full import or Emby video playback. The existing demo guide and fullscreen bar remain.
## Validation
- Debug APK build, all 19 Android unit tests and Android lint passed.
- 15 Emby protocol/boundary tests and four scheduler tests; no failures or skipped tests.
- Tests use scripted fake replies and fake credentials. No live provider login was attempted.
- Intentional removal of the 20-item cap failed its regression test; source was restored,
then the complete build/tests/lint passed again.
- Tested: one-page limit even if ignored by server; password preservation; scoped token/header use;
incorrect credentials; denial of library access; malformed JSON/identity/token; rate limit and redirect
responses; logout after errors; failed logout; response-size bound; UTF-8; bounded provider text.
- APK signature verified; certificate matches the installed 0.2.0 build.
- Fullscreen playback/programme-bar source matches the TV-tested previous revision.
- Security review: fixed HTTPS origin, default certificate checks, no redirects, bounded reads and
I/O timeouts, no retries, no persistent credentials/tokens/sample, no request/response logging,
secure password field, FLAG_SECURE while connection screen is displayed, lifecycle clearing.
- Lint: zero errors; ten warnings (eight version-update notices and two existing platform advisories).
- The org.json dependency is test-only; the app uses Android's JSONObject implementation.
## Limits and next check
The new APK has not been installed on the box. Remote keyboard entry and real account permissions
must be tested by Paul on the Xiaomi. No password, token or authenticated library data was received
by development tools. Do not send credentials or screenshots of library results back into chat.
Only the provider's public identification endpoint was previously checked, returning version 4.9.1.23.
After approval to install: Menu → Connect Emby → enter credentials on TV → Check 20 items.
Report only success/count, or the fixed error message. Demo channels do not change.
This version intentionally requires signing in again for another check; persistent connection and
large-library indexing are separate increments. A later initial-setup flow and named/icon profiles,
Kids restrictions and a four-digit adult PIN are recorded in the roadmap, not implemented here.
No device update, VPS, service or firewall changes were made during this build increment.
See docs/emby-connection-check.md for the request contract and retention details.
+64
View File
@@ -0,0 +1,64 @@
# Orbit TV 0.4.0 — small-sample playback test
Prepared 1 November 2026, Europe/London.
## Result
The current 20-item sample becomes selectable for on-demand fullscreen playback. The temporary
login stays in memory during testing and attempts logout when leaving/backgrounding the screen.
The player provides pause/resume, ten-second seeks, audio settings and available subtitle tracks.
No full-library import, scheduled real channels, stored login or automatic video transcoding is added.
The established demo guide and its fullscreen programme bar remain independent and unchanged.
## Evidence
- Android debug build and lint: passed, zero lint errors.
- 31 Android unit tests: 15 authentication/metadata, 12 playback/session/media-address, four scheduler.
- Coverage includes sample membership, direct-source negotiation, refusal of transcoding/live-opening
sources, source-ID validation, origin-scoped tokens, API-key removal from provider media URLs,
HTTPS/URL validation, subtitle URL validation, byte-range start validation, local-address rejection,
normal playback reporting, logout, stale-report positions and invalidation of late login results.
- An intentional change forwarding credentials to every media host failed its regression test.
The safe implementation was restored and all checks rerun successfully.
- Existing Emby metadata tests remain fake-transport checks; new playback tests also use only
fake tokens and URLs. No authenticated provider/media access was performed by development tools.
- Media3's custom data-source/controller APIs have explicit UnstableApi opt-ins.
- The background lifecycle handler stops the player directly before asynchronous session cleanup.
- Source review checked main-thread player access, preserving sample list/focus during loading,
safe error messages, redirect/header handling, request bounds and cleanup ownership.
- The APK signing certificate matches the installed previous version, allowing a normal update.
- Lint warnings are dependency-update/platform advisories and two optional KTX suggestions.
## Hardware limits
Paul confirmed 0.3.0 received 20 records from his provider on the Xiaomi TV Box S 3rd Gen.
Real video, audio, remote controller navigation, seeking and subtitles in 0.4.0 remain unverified
until the approved APK is installed and Paul selects a sample title on the TV.
Direct-stream container support does not guarantee every codec/profile decodes on this box.
External subtitle support is initially SRT/WebVTT only when the provider supplies a delivery URL.
No HLS/transcoding fallback, live-source opening or special CDN request headers are implemented.
HTTPS redirects are supported without forwarding the Emby token to another origin. DNS results
are checked for private addresses, but Java's second DNS resolution is not pinned against rebinding.
Playback reports may update normal provider watch history, as regular viewing does.
## Logo direction
An original OrbOffice drive-in ticket concept is supplied as SVG, FigCraft and VectorCraft artwork,
with a PNG proof. It uses a ringed cinema-screen emblem, gold/charcoal, original one-word lettering
and a detachable stub. It is separate from the installed application's interface.
The supplied historic logo was a reference, not traced or redistributed with this project.
OrbOffice name availability and trade-mark clearance have not been established.
## Linked skill
The exact MCP Market page could not be read. An official Android CLI Specialist reference was
found and assessed. It is relevant to SDK/device diagnostics and repeatable UI journey testing.
The android CLI is absent from PATH. No new skill/CLI was installed; existing Gradle/ADB remains in use.
https://developer.android.com/agents/skills/devtools/android-cli/skill
## Next step
Ask Paul before updating the TV box. After installation: Menu → Connect Emby → sign in on TV →
Check 20 items → select a title. Check picture/sound, pause/resume, forward/backward seeking,
audio/subtitles when available, Back to the same sample, then a second title.
Report only pass/fail and fixed error messages; do not collect credentials or media URLs.
+40
View File
@@ -0,0 +1,40 @@
# Orbit TV 0.5.0 — classic bottom programme strip
## Changes
Paul confirmed 0.4.0 played provider media on the Xiaomi. He requested a classic bottom
programme strip, removal of the persistent top banner, an icon legend without coloured
remote-key boxes, and removal of the CRT logo's white outline.
The sample player now shows a pale channel/time header, blue NOW/PAUSED and INFO rows,
and a plain Channel ▲▼ / back-to-guide icon / CC legend. It is positioned at the bottom,
hides after five seconds of active playback and is revealed by OK. Paused/buffering states
keep it visible. The old permanent top banner and stock controller are removed.
The legend is visual only in this pass, as Paul requested. It does not switch channels,
open the guide or change subtitles. Physical Back still returns to the sample. OK toggles
play/pause when the strip is already visible; left/right seek ten seconds. The standard
subtitle/audio selector from 0.4.0 is not shown in this visual revision.
The separate logo artwork retains the dark CRT, curved gold screen and white Office
lettering but removes the cream/white outer cabinet border and pale feet.
## Validation
Final debug APK build, 31 existing Android tests and lint passed. Zero lint errors.
APK signature verified and certificate matches the installed previous revision.
Code review covered remote-key event handling, five-second timeout reset, playback-state
visibility and retaining the existing error/background/session-cleanup paths.
FigCraft OSD mockup and logo renders were visually inspected; VectorCraft conversion
reported no warnings. No redundant implementation-mirroring tests were added for this UI pass.
## Limits and TV check
The new layout and timer need a hardware check after approval to install 0.5.0.
The preview PNG is a design mockup using an original abstract background, not a capture
of provider video. No credentials, library titles, media URLs or provider images were read
by development tools. No real provider login was performed during this increment.
Check: select a sample title, wait five seconds, press OK to reveal the strip, pause/resume,
try left/right seeking and Back to sample. Confirm readability and absence of the old top banner.
The existing fictional guide remains unchanged. No full import or new channel engine is added.
No TV installation, VPS, firewall or service change was performed during this build increment.
+62
View File
@@ -0,0 +1,62 @@
# Orbit TV 0.6.0 — one real scheduled sample channel
Prepared 1 November 2026, Europe/London.
## Result
Menu → Connect Emby → Check 20 items → Create sample channel.
Builds 101 Orbit One from up to three distinct direct-stream-eligible titles, after at most
five media-source metadata probes within the existing 20-item sample. At least two are required.
The new sample guide displays real Now/Next and upcoming start/end times. Watch channel joins
the current programme in progress and automatically follows subsequent wall-clock slots.
Back returns to that guide. The main three-channel fictional guide remains separate.
Actual source runtimes retain millisecond precision, and the selected source ID is locked for
tuning. The repeating UTC timeline is independent of launching or watching the app. The same
lineup and durations recreate the same schedule; no channel, token or password is persisted.
Scheduled mode follows the clock, with pause/seeking reserved for the existing on-demand mode.
The previously accepted auto-hiding programme strip is preserved and gains genuine Now/Next.
Check transition temporarily joins near a boundary, using real durations and the same rollover
path. It is labelled Transition check and returns to the ordinary clock when closed. The guide
notes that joining near the end can affect normal provider viewing history.
## Validation
- Final Android debug build, all 47 unit tests and lint passed; zero lint errors.
- 16 auth/metadata tests, 15 playback/source tests, 12 channel/timeline tests, four demo-schedule tests.
- Tested precise source/catalogue runtimes, exact boundaries, lineup repetition, UTC continuity
across midnight/DST dates, negative epochs, deterministic recreation, immutable snapshots,
duration/count validation, transition checking, a three-title/five-probe limit, early stop on
expired login/rate limit, selected-source locking and null playback identity handling.
- An intentionally incorrect inclusive end boundary failed its regression test. The correct
strict boundary was restored, then the complete build/tests/lint passed again.
- Review checked wall-clock effect keys, old-plan suppression at rollover, cancellation,
serialization of slow metadata preparation, post-buffer join adjustment, real guide focus,
source-runtime drift handling, Back navigation and the existing background/session cleanup.
- The debug signing certificate matches installed 0.5.0, allowing a standard update.
- No new runtime dependency, device permission, server administration or full scan was added.
## Evidence limits
Paul confirmed 0.4.0 played provider media and 0.5.0's programme strip hides correctly.
Real 0.6.0 channel tuning and automatic rollover remain unverified until TV installation.
All new tests use fictional items, fake credentials and scripted API responses; development
tools did not sign in, read private library metadata, retrieve video or capture provider images.
Metadata eligibility is not a guarantee every codec/profile decodes on the Xiaomi.
Buffering can occur between titles; this is not a gapless server broadcast. Current access is
session-scoped. A changed provider sample/runtime can change the rebuilt lineup.
A source runtime change beyond two seconds triggers a rebuild message instead of timeline drift.
The detailed request/lifecycle limits remain in docs/sample-scheduled-channel.md.
## TV acceptance check after approval
1. Sign in on the TV and load the existing sample.
2. Create sample channel, then Watch channel; check join-in-progress and the true Next title/time.
3. Back should return to the real sample guide without another login.
4. Check transition should join near an end and automatically move to the next programme.
5. Close the test and confirm the guide returns to the ordinary wall clock.
6. Back to sample should retain on-demand playback; leaving the connection flow ends the session.
Report pass/fail and any fixed error text only. Keep credentials, private titles and URLs on the TV.
No device update was performed during this build increment; ask Paul before installation.
+9
View File
@@ -0,0 +1,9 @@
# Orbit TV 0.7.0 — main guide integration
Orbit One now appears under the main 2 TV Guide tab after Menu → Connect Emby → Check 20 items → Create sample channel. The connected single-channel guide replaces the fictional demo guide for that temporary session. Watch channel joins in progress; Back returns to this main guide. Menu and Box Office remain accessible without signing in again.
The connection component remains composed across main navigation. A Disconnect button clears the temporary connection and channel. Backgrounding the app or disposing its owner still cancels pending work, clears credentials and attempts logout. Password and username fields clear when leaving connection setup; unfinished operations are cancelled. Returning to the guide does not automatically restart a previously closed channel player.
Private connection and connected catalogue screens retain screenshot protection. Credentials, tokens and catalogue stay in memory. No persistence, dependency changes, full-library imports, VPS, firewall or service changes. Green 101 in the top-right is recorded as the direction for a later tuning animation; it is not included in this increment.
Validation: final offline Gradle assembleDebug, testDebugUnitTest and lintDebug passed. All 47 unit tests passed (16 client, 15 playback, 12 scheduled-channel, 4 demo schedule). APK signature verification passed. Navigation/session lifecycle changes were reviewed in source; there is no on-device UI automation or new UI-test coverage. Hardware navigation, background sign-out and playback return need Paul's remote test after installation. No private account/media was accessed by development tools.
+7
View File
@@ -0,0 +1,7 @@
# Orbit TV 0.8.0 — Retry and classic tuning number
The scheduled channel joining-error screen now offers Retry, focused for the remote. Each click runs a fresh preparation against the current wall-clock programme using the same temporary session and locked media source. The existing mutex serializes preparation. Retry is manual, disappears while preparation runs and does not reauthenticate or create an automatic retry loop. Back still returns to the main TV Guide. This does not establish or fix the cause of the previously reported intermittent provider connection failure.
A green monospace channel number appears at the top-right during preparation and for two seconds after player readiness. Current channel is 101. No artificial loading delay, new dependency or EffectCraft asset is needed for this native text overlay. No separate retry is added to full-screen decoder/media errors in this increment.
Validation: offline assembleDebug, testDebugUnitTest and lintDebug passed. All 48 unit tests passed (16 client, 16 playback, 12 channel, 4 demo schedule). Added a fake-transport check that one failed preparation can be explicitly retried using the same login and source without reauthentication; this is protocol coverage, not automated UI coverage. APK signature verification passed. Green number positioning/timing and Retry focus/click still require the Xiaomi remote test. No private account, catalogue, video or device logs were read. No VPS/firewall/services/dependency changes. APK not installed as part of preparation.
+11
View File
@@ -0,0 +1,11 @@
# Orbit TV 0.9.0 — channel 103 series check
Approved direction: 101 Orbit One (mixed entertainment), 102 Orbit After Dark (horror/thriller/cult), 103 Toonami (working name, classic action animation). Only 101 has scheduled playback today. This increment adds candidate discovery and temporary approval for 103, not a playable 103 or 102.
After sign-in, Menu → Connect Emby → 103 Toonami — check five series. Uses the existing temporary session to make five serial authenticated GET /Items queries with UserId, Recursive, IncludeItemTypes=Series, SearchTerm, StartIndex=0, Limit=10, images/userdata disabled. Terms: Dragon Ball Z, Batman Beyond, Superman, Powerpuff Girls, ThunderCats. At most ten records processed per response even if the provider ignores the limit. Valid series IDs only; titles/rating bounded, year validated, IDs deduplicated within each search. Authentication/rate-limit/network failures stop the check. No automatic retry, no episode queries, no playback, no account edits. Returned names are possible matches, never automatically accepted; lack of search results is not proof of absence from the entire library.
The review lists titles, returned years and ratings for choosing versions. Approve toggles an in-memory set of series IDs. Navigation retains it during this session; disconnect/background clears it. It is not persisted or scheduled. Episode availability, ordering, duration and playback eligibility are future checks. Existing 101, classic green tuning number and manual joining Retry remain.
Validation: offline assembleDebug, testDebugUnitTest, lintDebug successful; 53 tests passed with zero failures/errors. Five new fake-transport tests cover the five-query bound, encoding/header credentials, provider ignoring limits, invalid/non-series/duplicate results, empty results, and immediate stop on 401/429. Signature verification passed. Source reviewed for scope, request bounds and secret handling. No real account/catalogue/media accessed by development tools. Actual provider series-search compatibility and remote focus/layout require Paul's Xiaomi test after installation. No new dependency, device permission, VPS/firewall/service or persisted private data.
Official API reference checked: https://dev.emby.media/reference/RestAPI/ItemsService/getItems.html (SearchTerm, IncludeItemTypes, Limit, UserId, EnableImages, EnableUserData).
+38
View File
@@ -0,0 +1,38 @@
# Orbit TV prototype verification — 31 October 2026
Project: /home/pauljd/Documents/Codex/orbit-tv
Target for manual testing: Xiaomi TV Box S 3rd Gen (not connected).
- Final Android debug APK build: passed.
- Android schedule tests: 4 passed, 0 failed, 0 skipped.
- TypeScript compilation and scheduler tests: 5 passed, 0 failed.
- HTTP smoke test: health and guide 200; invalid count 400; missing route 404; POST 405. Temporary process stopped.
- Boundary mutation: intentionally rounding slots up made the backend tests fail; original logic restored and tests passed.
- APK signing verified with apksigner (debug certificate, APK signature v2).
- Media probe: original silent local test signal, 1800 seconds.
- FigCraft mockup: rendered and visually inspected; editable native file has 63 nodes.
- VectorCraft wordmark conversion: no import/export warnings.
- Final code review: corrected stale programme detail on half-hour rollover, paused playback outside guide,
added playback error/retry handling and actual player elapsed-time overlay, moved banner to drawable-nodpi.
## Limits
APK has not been launched on hardware or an emulator. Remote focus, playback decoder behaviour,
preview/fullscreen switching and resume timing require Xiaomi acceptance testing in README.md.
The design PNG is a mockup, not a screenshot of the running Android app.
No Emby performance or compatibility claim has been tested. No VPS or media library was accessed.
No project commit, deployment, TV pairing, firewall change or persistent service was created.
## Android lint
No errors. Remaining warnings:
- AndroidGradlePluginVersion: A newer version of Gradle than 9.3.1 is available: 9.8.2
- GradleDependency: A newer version of androidx.compose:compose-bom than 2025.08.01 is available: 2026.09.00
- GradleDependency: A newer version of androidx.activity:activity-compose than 1.10.1 is available: 1.13.0
- GradleDependency: A newer version of androidx.lifecycle:lifecycle-runtime-compose than 2.9.2 is available: 2.11.0
- GradleDependency: A newer version of androidx.tv:tv-material than 1.0.0 is available: 1.1.0
- GradleDependency: A newer version of androidx.media3:media3-exoplayer than 1.8.0 is available: 1.11.1
- GradleDependency: A newer version of androidx.media3:media3-ui than 1.8.0 is available: 1.11.1
- DiscouragedApi: Fixed screen orientations will be ignored in most cases, starting from Android 16. Android is moving toward a model where apps are expected to adapt to various orientations, display sizes, and aspect ratios.
- DataExtractionRules: The attribute `android:allowBackup` is deprecated from Android 12 and higher and may be removed in future versions. Consider adding the attribute `android:dataExtractionRules` specifying an `@xml` resource which configures cloud backups and device transfers on Android 12 and higher.
+30
View File
@@ -0,0 +1,30 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.plugin.compose")
}
android {
namespace = "tv.orbit.classic"
compileSdk = 36
defaultConfig {
applicationId = "tv.orbit.classic"
minSdk = 26
targetSdk = 36
versionCode = 16
versionName = "0.16.0-tuning-screen"
}
buildFeatures { compose = true }
compileOptions { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 }
}
dependencies {
implementation(platform("androidx.compose:compose-bom:2025.08.01"))
implementation("androidx.activity:activity-compose:1.10.1")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.9.2")
implementation("androidx.compose.foundation:foundation")
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.tv:tv-material:1.0.0")
implementation("androidx.media3:media3-exoplayer:1.8.0")
implementation("androidx.media3:media3-ui:1.8.0")
testImplementation("junit:junit:4.13.2")
testImplementation("org.json:json:20250107")
}
+10
View File
@@ -0,0 +1,10 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<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">
<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>
</application>
</manifest>
@@ -0,0 +1,93 @@
Copyright 2017 The Barlow Project Authors (https://github.com/jpt/barlow)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -0,0 +1,93 @@
Copyright 2017 The Barlow Project Authors (https://github.com/jpt/barlow)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
@@ -0,0 +1,39 @@
package tv.orbit.classic
import java.net.URLEncoder
internal val AfterDarkGenres = listOf("Horror", "Thriller", "Science Fiction")
internal data class AfterDarkMovie(val item: SampleItem, val year: Int?, val rating: String, val genres: List<String>)
/** One explicit genre query, at most twenty films. Genre evidence must be returned. */
internal fun discoverAfterDarkMovies(transport: EmbyTransport, userId: String, token: () -> String,
genre: String): List<AfterDarkMovie> {
require(EmbyClient.validId(userId) && genre in AfterDarkGenres)
val filter = URLEncoder.encode(genre, "UTF-8")
val reply = transport.execute(EmbyRequest("GET", "/Items?UserId=$userId&Recursive=true&IncludeItemTypes=Movie&Genres=$filter&StartIndex=0&Limit=20&SortBy=SortName&SortOrder=Ascending&Fields=Genres,MediaStreams&EnableImages=false&EnableUserData=false", token = token()))
EmbyClient.expectSuccess(reply.status, false)
val rows = EmbyClient.parse(reply.body).optJSONArray("Items") ?: throw EmbyCheckException("The provider did not return a film sample.")
return (0 until minOf(rows.length(), 20)).mapNotNull { index ->
val row = rows.optJSONObject(index) ?: return@mapNotNull null
val id = row.optString("Id")
if (!EmbyClient.validId(id) || row.optString("Type") != "Movie") return@mapNotNull null
val tags = row.optJSONArray("Genres")
val genres = (0 until minOf(tags?.length() ?: 0, 8)).mapNotNull { i ->
(tags?.opt(i) as? String)?.let { EmbyClient.clean(it, 40) }?.takeIf { it.isNotBlank() }
}.distinct()
if (genres.none { it.equals(genre, ignoreCase = true) }) return@mapNotNull null
val title = EmbyClient.clean(row.optString("Name"), 160).takeIf { it.isNotBlank() } ?: return@mapNotNull null
val duration = (row.optLong("RunTimeTicks", 0) / 10_000).takeIf { it in 1_000..SampleChannel.MAX_DURATION_MS }
AfterDarkMovie(SampleItem(title, "Movie", duration?.div(60_000), episodeMediaLabel(row).take(160), id, duration),
row.optInt("ProductionYear").takeIf { it in 1800..2200 }, EmbyClient.clean(row.optString("OfficialRating"), 40), genres)
}.distinctBy { it.item.id }
}
internal fun mergeAfterDarkMovies(current: List<AfterDarkMovie>, incoming: List<AfterDarkMovie>): List<AfterDarkMovie> =
(current + incoming).distinctBy { it.item.id }.take(60)
internal fun approvedAfterDarkItems(candidates: List<AfterDarkMovie>, ids: Set<String>): List<SampleItem> {
if (ids.size !in 2..3 || !candidates.map { it.item.id }.containsAll(ids))
throw EmbyCheckException("Approve two or three films from the checked After Dark sample first.")
return candidates.filter { it.item.id in ids }.distinctBy { it.item.id }.map { it.item }
}
@@ -0,0 +1,54 @@
package tv.orbit.classic
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.Text
@Composable
internal fun AfterDarkReviewScreen(movies: List<AfterDarkMovie>, approved: Set<String>, busy: Boolean,
message: String?, modifier: Modifier, onGenre: (String) -> Unit, onToggle: (String) -> Unit,
onBuild: () -> Unit, onBack: () -> Unit) {
BackHandler(onBack = onBack)
Box(modifier.fillMaxWidth().background(Navy)) {
Image(painterResource(R.drawable.orbit_after_dark_background), null, Modifier.matchParentSize(), contentScale = ContentScale.Crop, alpha = .45f)
Column(Modifier.fillMaxSize().padding(18.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Image(painterResource(R.drawable.orbit_after_dark_epg), "102 Orbit After Dark", Modifier.width(230.dp).height(65.dp))
Text("Late-night and cult cinema. Check a category, then choose two or three films.", color = Color.White, fontSize = 17.sp)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
AfterDarkGenres.forEach { genre -> OrbitButton("Check $genre", enabled = !busy, onClick = { onGenre(genre) }) }
}
if (busy) Text("Checking your film sample…", color = Yellow, fontSize = 15.sp)
message?.let { Text(it, color = Yellow, fontSize = 15.sp, maxLines = 3) }
if (movies.isEmpty() && !busy) Text("No checked films yet. Each category checks up to 20 records; this is a small sample of your library.", color = Muted, fontSize = 16.sp)
LazyColumn(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(8.dp)) {
items(movies, key = { it.item.id }) { movie ->
Column(Modifier.fillMaxWidth().background(Panel.copy(alpha = .94f)).padding(10.dp)) {
Text(movie.item.title + (movie.year?.let { " ($it)" } ?: ""), color = Color.White, fontSize = 20.sp, maxLines = 2)
val details = listOfNotNull(movie.rating.takeIf { it.isNotBlank() }, movie.item.minutes?.let { "$it min" }, movie.item.format.takeIf { it.isNotBlank() })
if (details.isNotEmpty()) Text(details.joinToString(" • "), color = Muted, fontSize = 14.sp)
Text("Categories: ${movie.genres.joinToString(", ")}", color = Muted, fontSize = 14.sp, maxLines = 2)
OrbitButton(if (movie.item.id in approved) "✓ Remove from selection" else "+ Add to channel",
selected = movie.item.id in approved, enabled = !busy && (movie.item.id in approved || approved.size < 3),
onClick = { onToggle(movie.item.id) })
}
}
}
Text("${approved.size}/3 selected • Existing schedules change only when you create or rebuild this channel.", color = Muted, fontSize = 13.sp)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OrbitButton("Create / rebuild 102", enabled = !busy && approved.size in 2..3, onClick = onBuild)
OrbitButton("Back", onClick = onBack)
}
}
}
}
@@ -0,0 +1,11 @@
package tv.orbit.classic
internal data class ChannelBranding(val presetId: String, val logo: Int, val background: Int)
internal object ChannelBrands {
private val brands = mapOf(102 to ChannelBranding("orbit-after-dark", R.drawable.orbit_after_dark_epg, R.drawable.orbit_after_dark_background))
fun forNumber(number: Int): ChannelBranding? = brands[number]
}
internal fun availableSampleChannels(vararg channels: SampleChannel?): List<SampleChannel> =
channels.filterNotNull().distinctBy { it.number }.sortedBy { it.number }
@@ -0,0 +1,31 @@
package tv.orbit.classic
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.Text
/** Presentation only: no timers, media requests or schedule changes. */
@Composable
internal fun ChannelTuningBackdrop(number: Int, modifier: Modifier = Modifier) {
val branding = ChannelBrands.forNumber(number)
Box(modifier.fillMaxSize().background(Navy)) {
if (branding != null) {
Image(painterResource(branding.background), null, Modifier.matchParentSize(), contentScale = ContentScale.Crop)
Image(painterResource(branding.logo), null,
Modifier.align(Alignment.TopStart).padding(start = 40.dp, top = 32.dp).width(200.dp).height(62.dp))
}
ClassicTuningNumber(number, Modifier.align(Alignment.TopEnd).padding(top = 32.dp, end = 44.dp))
Column(Modifier.align(Alignment.BottomStart).padding(start = 40.dp, bottom = 36.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text("Tuning…", color = Muted, fontSize = 17.sp)
Text("Back for TV guide", color = Muted, fontSize = 13.sp)
}
}
}
@@ -0,0 +1,58 @@
package tv.orbit.classic
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
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
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/** First visual pass: the icon row previews the future scheduled-channel controls. */
@Composable
internal fun ClassicProgrammeStrip(item: SampleItem, playing: Boolean, modifier: Modifier = Modifier, scheduled: ScheduledPlayback? = null) {
var now by remember { mutableLongStateOf(System.currentTimeMillis()) }
LaunchedEffect(Unit) { while (true) { delay(1000); now = System.currentTimeMillis() } }
val paper = Color(0xFFE8EDF5)
Column(modifier.border(1.dp, paper)) {
Row(Modifier.fillMaxWidth().background(paper).padding(horizontal = 12.dp, vertical = 3.dp), verticalAlignment = Alignment.CenterVertically) {
Text(scheduled?.let { "${it.channel.number} ${it.channel.name}${if (it.clockOffsetMs != 0L) " / Transition check" else ""}" } ?: "Orbit Preview", color = Navy, fontWeight = FontWeight.Bold, fontSize = 14.sp)
Spacer(Modifier.weight(1f))
Text(SimpleDateFormat("HH:mm EEE d MMM", Locale.UK).format(Date(now)), color = Navy, fontSize = 13.sp)
}
Column(Modifier.fillMaxWidth().background(Blue).padding(horizontal = 14.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(3.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(if (playing || scheduled != null) "NOW" else "PAUSED", Modifier.width(70.dp), color = Yellow, fontWeight = FontWeight.Bold, fontSize = 14.sp)
Text(item.title, color = Color.White, fontWeight = FontWeight.Bold, fontSize = 20.sp, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
Row(verticalAlignment = Alignment.CenterVertically) {
Text(if (scheduled == null) "INFO" else "NEXT", Modifier.width(70.dp), color = Muted, fontWeight = FontWeight.Bold, fontSize = 13.sp)
Text(scheduled?.let { "${clock(it.next.startMs)} ${it.next.media.item.title}" } ?: listOf(item.type, item.minutes?.let { "$it min" }.orEmpty()).filter { it.isNotBlank() }.joinToString(" • "),
color = Color.White, fontSize = 14.sp, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
Row(Modifier.fillMaxWidth().background(paper).padding(horizontal = 14.dp, vertical = 5.dp),
horizontalArrangement = Arrangement.spacedBy(30.dp), verticalAlignment = Alignment.CenterVertically) {
Legend("▲ ▼", "Channel")
Legend("↶", "TV guide")
Legend("CC", "Subtitles")
}
}
}
@Composable
private fun Legend(icon: String, label: String) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text(icon, color = Navy, fontSize = 16.sp, fontWeight = FontWeight.Bold)
Text(label, color = Navy, fontSize = 14.sp, fontWeight = FontWeight.Bold)
}
}
@@ -0,0 +1,11 @@
package tv.orbit.classic
internal const val DirectVideoContainers = "mp4,mkv,mov,m4v,webm,ts,mpegts,3gp"
internal fun supportedDirectContainer(value: String): String? = value.trim().lowercase(java.util.Locale.ROOT)
.takeIf { it in DirectVideoContainers.split(",") }
/** Only a short format label may reach diagnostics; never a raw URL/path from metadata. */
internal fun safeContainerLabel(value: String): String = value.trim().lowercase(java.util.Locale.ROOT)
.takeIf { it.length in 1..16 && it.all { ch -> ch in 'a'..'z' || ch in '0'..'9' || ch == '-' || ch == '_' } }
?: "unknown"
@@ -0,0 +1,137 @@
package tv.orbit.classic
import org.json.JSONObject
import java.io.ByteArrayOutputStream
import java.io.InputStream
import java.net.URL
import java.util.UUID
import javax.net.ssl.HttpsURLConnection
internal class EmbyCheckException(message: String, val httpStatus: Int? = null) : Exception(message)
internal data class SampleItem(val title: String, val type: String, val minutes: Long?, val format: String, val id: String = "", val durationMs: Long? = minutes?.takeIf { it in 1..(Long.MAX_VALUE / 60_000) }?.times(60_000))
internal data class EmbySample(val items: List<SampleItem>, val total: Long?)
internal class EmbyRequest(val method: String, val path: String, val body: String? = null, val token: String? = null)
internal class EmbyReply(val status: Int, val body: String)
internal fun interface EmbyTransport { fun execute(request: EmbyRequest): EmbyReply }
/** Network boundary: fixed HTTPS origin, standard TLS checks, no redirects or response logging. */
internal class HttpsEmbyTransport(internal val deviceId: String = UUID.randomUUID().toString()) : EmbyTransport {
companion object {
const val SERVER = "https://bk2.appboxmanager.xyz"
const val MAX_RESPONSE_BYTES = 2 * 1024 * 1024
internal fun readBounded(stream: InputStream): String {
val output = ByteArrayOutputStream()
val buffer = ByteArray(8192)
while (true) {
val count = stream.read(buffer)
if (count == -1) break
if (output.size() + count > MAX_RESPONSE_BYTES) throw EmbyCheckException("The provider response was too large for this sample check.")
output.write(buffer, 0, count)
}
return output.toString(Charsets.UTF_8.name())
}
}
override fun execute(request: EmbyRequest): EmbyReply {
// Paths come from the protocol below, never from a provider response or a text field.
require(request.path.startsWith("/") && !request.path.startsWith("//"))
val connection = URL("$SERVER/emby${request.path}").openConnection() as HttpsURLConnection
try {
connection.requestMethod = request.method
connection.instanceFollowRedirects = false
connection.connectTimeout = 10_000
connection.readTimeout = 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.16.0\"")
request.token?.let { connection.setRequestProperty("X-Emby-Token", it) }
if (request.method == "POST") {
val bytes = request.body.orEmpty().toByteArray(Charsets.UTF_8)
connection.doOutput = true
connection.setRequestProperty("Content-Type", "application/json; charset=utf-8")
connection.setFixedLengthStreamingMode(bytes.size)
connection.outputStream.use { it.write(bytes) }
}
val status = connection.responseCode
if (status !in 200..299 || request.path == "/Sessions/Logout") return EmbyReply(status, "")
if (connection.contentLengthLong > MAX_RESPONSE_BYTES) throw EmbyCheckException("The provider response was too large for this sample check.")
val body = connection.inputStream.use { readBounded(it) }
return EmbyReply(status, body)
} catch (e: EmbyCheckException) { throw e }
catch (_: javax.net.ssl.SSLException) { throw EmbyCheckException("The secure connection could not be verified. No insecure fallback was used.") }
catch (_: java.io.IOException) { throw EmbyCheckException("The provider could not be reached. Check your connection and try again.") }
finally { connection.disconnect() }
}
}
/** One explicit sign-in and one bounded metadata page; ownership passes to the sample screen. */
internal class EmbyClient(private val transport: EmbyTransport = HttpsEmbyTransport()) {
fun connect(username: String, password: String): EmbySampleSession {
if (username.isBlank() || username.length > 256 || password.isEmpty() || password.length > 1024)
throw EmbyCheckException("Enter your username and password to check the connection.")
val login = transport.execute(EmbyRequest("POST", "/Users/AuthenticateByName",
JSONObject().put("Username", username).put("Pw", password).toString()))
expectSuccess(login.status, true)
val authentication = parse(login.body)
val token = authentication.opt("AccessToken") as? String
?: throw EmbyCheckException("The provider returned an unusable login response.")
if (token.isBlank() || token.length > 4096 || token.any { it.code !in 33..126 })
throw EmbyCheckException("The provider returned an unusable login response.")
var success = false
try {
val userId = authentication.optJSONObject("User")?.optString("Id").orEmpty()
if (!validId(userId)) throw EmbyCheckException("The provider returned an unusable user identity.")
val reply = transport.execute(EmbyRequest("GET", "/Users/$userId/Items?Recursive=true&IncludeItemTypes=Movie,Episode&StartIndex=0&Limit=20&SortBy=SortName&SortOrder=Ascending&Fields=MediaStreams&EnableImages=false&EnableUserData=false", token = token))
expectSuccess(reply.status, false)
val page = parse(reply.body)
val records = page.optJSONArray("Items") ?: throw EmbyCheckException("The provider did not return a library sample.")
val items = (0 until minOf(records.length(), 20)).mapNotNull { index ->
val item = records.optJSONObject(index) ?: return@mapNotNull null
val id = item.optString("Id")
if (!validId(id)) return@mapNotNull null
val type = item.optString("Type")
if (type !in listOf("Movie", "Episode")) return@mapNotNull null
val title = clean(item.optString("Name"), 160).ifBlank { "Untitled programme" }
val ticks = item.optLong("RunTimeTicks", 0)
val streams = item.optJSONArray("MediaStreams")
val codecs = (0 until minOf(streams?.length() ?: 0, 32)).mapNotNull { streamIndex ->
val stream = streams?.optJSONObject(streamIndex) ?: return@mapNotNull null
if (stream.optString("Type") in listOf("Video", "Audio")) clean(stream.optString("Codec"), 24).ifBlank { null } else null
}.distinct().take(3)
SampleItem(title, type, if (ticks > 0) ticks / 600_000_000 else null,
(listOf(clean(item.optString("Container"), 24)) + codecs).filter { it.isNotBlank() }.joinToString(" / "), id, (ticks / 10_000).takeIf { it > 0 })
}
val total = if (page.has("TotalRecordCount")) page.optLong("TotalRecordCount", -1).takeIf { it >= 0 } else null
val session = EmbySampleSession(transport, token, userId, EmbySample(items, total))
success = true
return session
} finally {
if (!success) try { transport.execute(EmbyRequest("POST", "/Sessions/Logout", token = token)) }
catch (_: Exception) { }
}
}
fun resume(saved: RememberedState): EmbySampleSession {
val identity = saved.identity
val token = identity.token ?: throw EmbyCheckException("Sign in to restore your saved channel choices.")
require(validId(identity.userId) && validId(identity.deviceId))
val reply = transport.execute(EmbyRequest("GET", "/Users/${identity.userId}", token = token))
expectSuccess(reply.status, false)
if (parse(reply.body).optString("Id") != identity.userId)
throw EmbyCheckException("The saved connection could not be verified. Sign in again.")
return EmbySampleSession(transport, token, identity.userId, saved.sample).also { it.restoreRemembered(saved) }
}
companion object {
internal fun validId(value: String) = Regex("[A-Za-z0-9_-]{1,128}").matches(value)
internal fun parse(body: String): JSONObject = try { JSONObject(body) }
catch (_: Exception) { throw EmbyCheckException("The provider returned a response this version cannot read.") }
internal fun clean(value: String, limit: Int) = value.filter { !it.isISOControl() }.take(limit)
internal fun expectSuccess(status: Int, login: Boolean) {
when (status) {
in 200..299 -> return
401 -> throw EmbyCheckException(if (login) "Sign-in failed. Check your username and password." else "The temporary login expired. Try the connection check again.", status)
403 -> throw EmbyCheckException("The provider did not permit this connection or library request.", status)
429 -> throw EmbyCheckException("The provider asked us to slow down. Wait before trying again.", status)
in 300..399 -> throw EmbyCheckException("The provider redirected the request. The login was not forwarded to another address.")
else -> throw EmbyCheckException("The provider could not complete this check (HTTP $status).")
}
}
}
}
@@ -0,0 +1,468 @@
package tv.orbit.classic
import android.view.KeyEvent
import android.view.WindowManager
import androidx.activity.compose.LocalActivity
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.BasicSecureTextField
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.input.InputTransformation
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.TextObfuscationMode
import androidx.compose.foundation.text.input.maxLength
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.*
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.graphics.SolidColor
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.tv.material3.Text
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@Composable
internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolean,
mainGuide: Boolean, onChannelAvailable: (Boolean) -> Unit, onGuide: () -> Unit,
onBack: () -> Unit) {
val appContext = androidx.compose.ui.platform.LocalContext.current.applicationContext
val savedStore = remember { RememberedConnectionStore.get(appContext) }
var setupSaved by remember { mutableStateOf(false) }
var pendingSaved by remember { mutableStateOf<RememberedState?>(null) }
var restoring by remember { mutableStateOf(true) }
var restoreAttempt by remember { mutableIntStateOf(0) }
var saveTicket by remember { mutableLongStateOf(savedStore.ticket()) }
var username by remember { mutableStateOf("") }
// Deliberately not rememberSaveable: credentials never enter saved instance state.
val password = remember { TextFieldState() }
var busy by remember { mutableStateOf(false) }
var seriesMatches by remember { mutableStateOf<List<SeriesMatches>?>(null) }
var episodeSamples by remember { mutableStateOf<List<SeriesEpisodes>?>(null) }
var reviewingSeries by remember { mutableStateOf(false) }
var approvedSeries by remember { mutableStateOf<Set<String>>(emptySet()) }
var sample by remember { mutableStateOf<EmbySample?>(null) }
var session by remember { mutableStateOf<EmbySampleSession?>(null) }
var playback by remember { mutableStateOf<EmbyPlaybackPlan?>(null) }
var darkMovies by remember { mutableStateOf<List<AfterDarkMovie>>(emptyList()) }
var approvedDark by remember { mutableStateOf<Set<String>>(emptySet()) }
var reviewingDark by remember { mutableStateOf(false) }
var afterDarkChannel by remember { mutableStateOf<SampleChannel?>(null) }
var toonamiChannel by remember { mutableStateOf<SampleChannel?>(null) }
var selectedNumber by remember { mutableIntStateOf(101) }
var channel by remember { mutableStateOf<SampleChannel?>(null) }
var watchingChannel by remember { mutableStateOf(false) }
var channelClockOffset by remember { mutableLongStateOf(0) }
var busyLabel by remember { mutableStateOf("Connecting…") }
val sessionSlot = remember { EmbySessionSlot() }
var message by remember { mutableStateOf<String?>(null) }
var nextAttemptAt by remember { mutableLongStateOf(0) }
var now by remember { mutableLongStateOf(System.currentTimeMillis()) }
var job by remember { mutableStateOf<Job?>(null) }
val scope = rememberCoroutineScope()
val keyboard = LocalSoftwareKeyboardController.current
val focus = LocalFocusManager.current
val passwordFocus = remember { FocusRequester() }
val submitFocus = remember { FocusRequester() }
val activity = LocalActivity.current
val lifecycle = LocalLifecycleOwner.current.lifecycle
val imeVisible = WindowInsets.ime.getBottom(LocalDensity.current) > 0
fun clearPassword() { password.edit { replace(0, length, "") } }
fun applyChoices(saved: RememberedState) {
seriesMatches = saved.matches.takeIf { it.isNotEmpty() }; approvedSeries = saved.approved
episodeSamples = saved.episodes; channel = saved.orbitOne; toonamiChannel = saved.toonami
darkMovies = saved.darkMovies; approvedDark = saved.approvedDark; afterDarkChannel = saved.afterDark
val available = availableSampleChannels(channel, afterDarkChannel, toonamiChannel)
selectedNumber = available.firstOrNull { it.number == saved.selectedNumber }?.number ?: available.firstOrNull()?.number ?: 101
onChannelAvailable(available.isNotEmpty())
}
fun closeSession(forget: Boolean = false) {
setupSaved = false
savedStore.invalidate()
if (forget) {
pendingSaved = null
try { savedStore.clear() }
catch (_: Exception) { message = "The saved connection could not be removed. Clear Orbit TV app data in Android Settings before leaving this device." }
}
seriesMatches = null; episodeSamples = null; reviewingSeries = false; approvedSeries = emptySet()
darkMovies = emptyList(); approvedDark = emptySet(); reviewingDark = false; afterDarkChannel = null
playback = null; session = null; sample = null; channel = null; toonamiChannel = null; selectedNumber = 101; watchingChannel = false
onChannelAvailable(false)
sessionSlot.clear()?.let { active -> EmbyCleanup.run { active.close() } }
}
BackHandler(imeVisible) { keyboard?.hide(); focus.clearFocus() }
val protectScreen = visible || session != null || busy
DisposableEffect(activity, protectScreen) {
val alreadySecure = (activity?.window?.attributes?.flags ?: 0) and WindowManager.LayoutParams.FLAG_SECURE != 0
if (protectScreen) activity?.window?.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
onDispose {
if (protectScreen && !alreadySecure) activity?.window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
}
}
DisposableEffect(lifecycle) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_STOP) {
clearPassword(); job?.cancel(); playback = null; watchingChannel = false; busy = false
if (session == null && !restoring) sessionSlot.clear()?.let { active -> EmbyCleanup.run { active.close() } }
}
}
lifecycle.addObserver(observer)
onDispose {
clearPassword(); job?.cancel(); lifecycle.removeObserver(observer)
sessionSlot.clear()?.let { active -> EmbyCleanup.run { active.detach() } }
}
}
LaunchedEffect(restoreAttempt) {
restoring = true
try {
val saved = withContext(Dispatchers.IO) { savedStore.load() }
pendingSaved = saved
if (saved?.identity?.token != null && session == null) {
val slotTicket = sessionSlot.ticket()
val resumed = withContext(Dispatchers.IO) {
val active = EmbyClient(HttpsEmbyTransport(saved.identity.deviceId)).resume(saved)
if (!sessionSlot.attach(active, slotTicket)) { active.detach(); throw CancellationException() }
active
}
session = resumed; sample = resumed.sample; applyChoices(saved)
saveTicket = savedStore.ticket()
message = "Saved connection restored."
} else if (saved != null && session == null) message = "Sign in again to restore your saved channel choices."
} catch (cancelled: CancellationException) { throw cancelled }
catch (failure: EmbyCheckException) {
if (failure.httpStatus == 401 || failure.httpStatus == 403) {
pendingSaved = pendingSaved?.withoutToken()
try { pendingSaved?.let { value -> withContext(Dispatchers.IO) { savedStore.save(value, savedStore.ticket()) } } }
catch (cancelled: CancellationException) { throw cancelled }
catch (_: Exception) { /* Keep the expired token out of active use even if secure storage is unavailable. */ }
message = "Your saved login expired. Sign in again; choices restore for the same account."
} else message = "The saved connection could not be reached. Retry saved connection or sign in again."
} catch (_: Exception) {
// A corrupt file or unavailable key is never read as plaintext.
try { withContext(Dispatchers.IO) { savedStore.clear() } } catch (_: Exception) { }
pendingSaved = null
message = "The saved connection could not be opened. Sign in again."
} finally { restoring = false }
}
LaunchedEffect(session, seriesMatches, approvedSeries, episodeSamples, channel, toonamiChannel, selectedNumber, restoring, afterDarkChannel, darkMovies, approvedDark) {
val active = session
if (active != null && !restoring) {
setupSaved = false
delay(100)
try {
val saved = RememberedState(active.rememberedIdentity(), active.sample, seriesMatches.orEmpty(),
approvedSeries, episodeSamples, channel, toonamiChannel, selectedNumber, afterDarkChannel, darkMovies, approvedDark)
val ticket = saveTicket
if (withContext(Dispatchers.IO) { savedStore.save(saved, ticket) }) {
pendingSaved = saved; setupSaved = true
}
} catch (cancelled: CancellationException) { throw cancelled }
catch (_: Exception) { message = "Connected, but this setup could not be saved securely. It will need sign-in after restart." }
}
}
LaunchedEffect(Unit) { while (true) { now = System.currentTimeMillis(); delay(1000) } }
LaunchedEffect(visible) {
if (!visible) {
clearPassword(); username = ""; keyboard?.hide()
if (busy && !restoring) {
job?.cancel()
if (session == null) closeSession()
busy = false
}
playback = null
}
}
LaunchedEffect(mainGuide) {
if (!mainGuide) { watchingChannel = false; channelClockOffset = 0 }
}
val ready = !busy && !restoring && now >= nextAttemptAt && username.isNotBlank() && password.text.isNotEmpty()
playback?.let { plan -> session?.let { active -> EmbyPlaybackDialog(active, plan, onClose = { playback = null }) } }
val liveSession = session
val availableChannels = availableSampleChannels(channel, afterDarkChannel, toonamiChannel)
val liveChannel = availableChannels.firstOrNull { it.number == selectedNumber }
if (mainGuide && liveSession != null && liveChannel != null) {
if (watchingChannel) ScheduledChannelPlayer(liveSession, liveChannel, channelClockOffset,
onClose = { watchingChannel = false; channelClockOffset = 0 })
SampleChannelGuide(liveChannel, modifier, onTune = { offset ->
channelClockOffset = offset; watchingChannel = true
}, onBack = onBack, otherChannels = availableChannels.filter { it.number != selectedNumber },
onSwitch = { selectedNumber = it; watchingChannel = false; channelClockOffset = 0 })
return
}
if (!visible) return
if (reviewingDark) {
AfterDarkReviewScreen(darkMovies, approvedDark, busy, message, modifier,
onGenre = { genre ->
val active = session
if (!busy && active != null) {
busy = true; message = null
job = scope.launch {
try {
darkMovies = withContext(Dispatchers.IO) { active.checkAfterDarkMovies(genre) }
message = "${darkMovies.size} checked films in this sample. If a category has no matches, it may have no results or incomplete genre metadata in its first page."
} catch (cancelled: CancellationException) { throw cancelled }
catch (failure: EmbyCheckException) { message = failure.message }
catch (_: Exception) { message = "The film check could not finish. Try again later." }
finally { busy = false }
}
}
}, onToggle = { id ->
if (!busy) approvedDark = if (id in approvedDark) approvedDark - id else if (approvedDark.size < 3) approvedDark + id else approvedDark
}, onBuild = {
val active = session
if (!busy && active != null && approvedDark.size in 2..3) {
busy = true; message = null
job = scope.launch {
try {
val built = withContext(Dispatchers.IO) {
val context = coroutineContext
active.buildAfterDarkChannel(approvedDark) { context.ensureActive() }
}
afterDarkChannel = built; selectedNumber = 102; reviewingDark = false
onChannelAvailable(true); onGuide()
} catch (cancelled: CancellationException) { throw cancelled }
catch (failure: EmbyCheckException) { message = failure.message }
catch (_: Exception) { message = "Channel 102 could not be prepared. Try again later." }
finally { busy = false }
}
}
}, onBack = { job?.cancel(); busy = false; reviewingDark = false })
return
}
fun checkMoreSeries() {
val active = session ?: return
if (busy) return
val start = seriesMatches?.size ?: 0
if (start >= ToonamiSearches.size) return
busy = true; busyLabel = "Checking next five series…"; message = null
job = scope.launch {
try {
val batch = withContext(Dispatchers.IO) {
val context = coroutineContext
active.checkToonamiSeries(start) { context.ensureActive() }
}
seriesMatches = seriesMatches.orEmpty() + batch
reviewingSeries = true
} catch (cancelled: CancellationException) { throw cancelled }
catch (failure: EmbyCheckException) { message = failure.message }
catch (_: Exception) { message = "The series check could not finish. Try again later." }
finally { busy = false }
}
}
if (reviewingSeries) {
SeriesReviewScreen(seriesMatches.orEmpty(), approvedSeries, episodeSamples, busy, message, modifier,
onToggle = { id ->
if (!busy) {
approvedSeries = if (id in approvedSeries) approvedSeries - id else if (approvedSeries.size < 3) approvedSeries + id else approvedSeries
episodeSamples = null
}
}, onMore = { checkMoreSeries() }, onEpisodes = {
val active = session
val selected = seriesMatches.orEmpty().flatMap { it.candidates }.distinctBy { it.id }.filter { it.id in approvedSeries }
if (!busy && active != null && selected.size in 1..3) {
busy = true; message = null; episodeSamples = null
job = scope.launch {
try {
episodeSamples = withContext(Dispatchers.IO) {
val context = coroutineContext
active.checkSeriesEpisodes(selected) { context.ensureActive() }
}
} catch (cancelled: CancellationException) { throw cancelled }
catch (failure: EmbyCheckException) { message = failure.message }
catch (_: Exception) { message = "The episode check could not finish. Try again later." }
finally { busy = false }
}
}
}, onBuild = {
val active = session
if (!busy && active != null && episodeSamples != null) {
busy = true; message = null
job = scope.launch {
try {
val built = withContext(Dispatchers.IO) {
val context = coroutineContext
active.buildToonamiChannel { context.ensureActive() }
}
toonamiChannel = built; selectedNumber = 103
reviewingSeries = false; onChannelAvailable(true); onGuide()
} catch (cancelled: CancellationException) { throw cancelled }
catch (failure: EmbyCheckException) { message = failure.message }
catch (_: Exception) { message = "Channel 103 could not be prepared. Try again later." }
finally { busy = false }
}
}
}, onBack = { job?.cancel(); busy = false; reviewingSeries = false })
return
}
Row(modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(24.dp)) {
Column(Modifier.weight(1f).fillMaxHeight().verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Connect Emby", color = Yellow, fontSize = 27.sp)
Text(HttpsEmbyTransport.SERVER, color = Muted, fontSize = 15.sp)
Text("Sign in for a 20-item playback sample, then create your TV channel.", color = Color.White, fontSize = 16.sp)
Text("Username", color = Muted, fontSize = 14.sp)
var userFocused by remember { mutableStateOf(false) }
BasicTextField(value = username, onValueChange = { if (it.length <= 256) username = it.replace("\n", "") },
enabled = !busy && !restoring, singleLine = true, textStyle = TextStyle(color = Color.White, fontSize = 18.sp),
cursorBrush = SolidColor(Yellow),
keyboardOptions = KeyboardOptions(autoCorrectEnabled = false, imeAction = ImeAction.Next),
keyboardActions = KeyboardActions(onNext = { passwordFocus.requestFocus(); keyboard?.show() }),
modifier = Modifier.fillMaxWidth().onFocusChanged { userFocused = it.isFocused }
.background(Panel).border(2.dp, if (userFocused) Yellow else Blue)
.onPreviewKeyEvent { event ->
if (event.nativeKeyEvent.keyCode == KeyEvent.KEYCODE_DPAD_CENTER) {
if (event.nativeKeyEvent.action == KeyEvent.ACTION_UP) keyboard?.show()
true
} else false
}.padding(12.dp))
Text("Password", color = Muted, fontSize = 14.sp)
var passwordFocused by remember { mutableStateOf(false) }
BasicSecureTextField(state = password, enabled = !busy && !restoring,
textObfuscationMode = TextObfuscationMode.Hidden,
inputTransformation = InputTransformation.maxLength(1024),
textStyle = TextStyle(color = Color.White, fontSize = 18.sp), cursorBrush = SolidColor(Yellow),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done),
onKeyboardAction = { keyboard?.hide(); if (ready) submitFocus.requestFocus() else focus.clearFocus() },
modifier = Modifier.fillMaxWidth().focusRequester(passwordFocus)
.onFocusChanged { passwordFocused = it.isFocused }
.background(Panel).border(2.dp, if (passwordFocused) Yellow else Blue)
.onPreviewKeyEvent { event ->
if (event.nativeKeyEvent.keyCode == KeyEvent.KEYCODE_DPAD_CENTER) {
if (event.nativeKeyEvent.action == KeyEvent.ACTION_UP) keyboard?.show()
true
} else false
}.padding(12.dp))
OrbitButton(if (busy) "Checking connection…" else if (now < nextAttemptAt) "Wait before trying again" else "Check 20 items",
Modifier.fillMaxWidth().focusRequester(submitFocus), enabled = ready, onClick = {
if (!ready) return@OrbitButton
val loginName = username.trim()
val secret = password.text.toString()
clearPassword(); keyboard?.hide(); focus.clearFocus()
closeSession()
val ticket = sessionSlot.ticket()
busyLabel = "Connecting…"
busy = true; message = null; sample = null
nextAttemptAt = System.currentTimeMillis() + 15_000
job = scope.launch {
try {
val connected = withContext(Dispatchers.IO) {
val opened = EmbyClient().connect(loginName, secret)
if (!sessionSlot.attach(opened, ticket)) { opened.close(); throw CancellationException() }
opened
}
val saved = pendingSaved
if (saved != null && connected.matchesAccount(saved.identity)) {
connected.restoreRemembered(saved); applyChoices(saved)
}
session = connected; sample = connected.sample
saveTicket = savedStore.ticket()
}
catch (cancelled: CancellationException) { throw cancelled }
catch (failure: EmbyCheckException) { message = failure.message }
catch (_: Exception) { message = "The connection check could not finish. Try again later." }
finally { busy = false }
}
})
Text("This device remembers the session token and channel choices using encrypted storage. Your password is never saved.", color = Muted, fontSize = 13.sp)
if (setupSaved && session != null) Text("Setup saved on this device", color = Yellow, fontSize = 14.sp)
if (session != null) OrbitButton("Disconnect", Modifier.fillMaxWidth(), onClick = { job?.cancel(); closeSession(forget = true) })
if (session == null && pendingSaved?.identity?.token != null)
OrbitButton("Retry saved connection", Modifier.fillMaxWidth(), enabled = !restoring && !busy,
onClick = { restoreAttempt++ })
if (session == null && pendingSaved != null)
OrbitButton("Forget saved connection", Modifier.fillMaxWidth(), enabled = !restoring && !busy,
onClick = { closeSession(forget = true) })
if (restoring) Text("Restoring saved connection…", color = Yellow, fontSize = 14.sp)
OrbitButton("Return to Menu", Modifier.fillMaxWidth(), onClick = onBack)
}
Column(Modifier.weight(1.15f).fillMaxHeight().background(Panel).padding(16.dp)) {
Text(if (sample != null) "Sample received" else "Connection check", color = Yellow, fontSize = 24.sp)
Spacer(Modifier.height(10.dp))
val result = sample
when {
busy && result == null -> Text(busyLabel, color = Color.White, fontSize = 18.sp)
message != null && result == null -> Text(message.orEmpty(), color = Color.White, fontSize = 18.sp)
result != null -> {
if (busy) Text(busyLabel, color = Yellow, fontSize = 14.sp)
message?.let { Text(it, color = Yellow, fontSize = 14.sp) }
Text("${result.items.size} records received${result.total?.let { " • $it available to this account" }.orEmpty()}", color = Color.White, fontSize = 16.sp)
Text("Select a title, or create one channel from up to three playable titles.", color = Muted, fontSize = 13.sp)
OrbitButton(if (channel == null) "Create sample channel" else "Open TV Guide",
Modifier.fillMaxWidth(), enabled = !busy && session != null, onClick = {
if (channel != null) { selectedNumber = 101; onGuide() } else {
val active = session ?: return@OrbitButton
busyLabel = "Preparing your channel…"; busy = true; message = null
job = scope.launch {
try {
channel = withContext(Dispatchers.IO) { active.buildSampleChannel() }
selectedNumber = 101
onChannelAvailable(true)
onGuide()
} catch (cancelled: CancellationException) { throw cancelled }
catch (failure: EmbyCheckException) { message = failure.message }
catch (_: Exception) { message = "The sample channel could not be created. Try again later." }
finally { busy = false }
}
}
})
OrbitButton("102 Orbit After Dark — choose films", Modifier.fillMaxWidth(),
enabled = !busy && session != null, onClick = { message = null; reviewingDark = true })
OrbitButton(if (seriesMatches == null) "103 Toonami — check shortlist" else "103 Toonami — review matches",
Modifier.fillMaxWidth(), enabled = !busy && session != null, onClick = {
if (seriesMatches != null) reviewingSeries = true else checkMoreSeries()
})
Spacer(Modifier.height(8.dp))
if (result.items.isEmpty()) Text("No movies or episodes were returned for this account.", color = Color.White, fontSize = 17.sp)
LazyColumn(verticalArrangement = Arrangement.spacedBy(6.dp)) {
itemsIndexed(result.items) { index, item ->
var focused by remember { mutableStateOf(false) }
Column(Modifier.fillMaxWidth().onFocusChanged { focused = it.isFocused }
.border(1.dp, if (focused) Yellow else Blue).clickable(enabled = session != null) {
if (busy) return@clickable
val active = session ?: return@clickable
busyLabel = "Preparing selected title…"; busy = true; message = null
job = scope.launch {
try { playback = withContext(Dispatchers.IO) { active.prepare(item) } }
catch (cancelled: CancellationException) { throw cancelled }
catch (failure: EmbyCheckException) { message = failure.message }
catch (_: Exception) { message = "This sample could not be opened. Try another title." }
finally { busy = false }
}
}.padding(10.dp)) {
Text("${index + 1}. ${item.title}", color = Color.White, fontSize = 17.sp, maxLines = 2)
Text(listOf(item.type, item.minutes?.let { "$it min" }.orEmpty(), item.format)
.filter { it.isNotBlank() }.joinToString(" • "), color = Muted, fontSize = 13.sp)
}
}
}
}
else -> Text("Enter your provider login on the TV. Read a small sample, then create Orbit One in the main TV Guide.", color = Muted, fontSize = 18.sp)
}
}
}
}
@@ -0,0 +1,164 @@
package tv.orbit.classic
import android.view.KeyEvent
import android.os.SystemClock
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.window.SecureFlagPolicy
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.media3.common.MediaItem
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.datasource.DataSource
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.ui.PlayerView
import androidx.tv.material3.Text
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
@Composable
internal fun EmbyPlaybackDialog(session: EmbySampleSession, plan: EmbyPlaybackPlan, onClose: () -> Unit, scheduled: ScheduledPlayback? = null) {
val context = LocalContext.current
var failure by remember { mutableStateOf<String?>(null) }
var tuningNumberVisible by remember { mutableStateOf(true) }
var ready by remember { mutableStateOf(false) }
var playing by remember { mutableStateOf(false) }
var infoVisible by remember { mutableStateOf(true) }
var lastInteraction by remember { mutableLongStateOf(SystemClock.elapsedRealtime()) }
fun showInfo() { infoVisible = true; lastInteraction = SystemClock.elapsedRealtime() }
val player = remember(session, plan) {
val factory = DataSource.Factory { SecureMediaDataSource(session) }
ExoPlayer.Builder(context).setMediaSourceFactory(DefaultMediaSourceFactory(factory))
.setSeekBackIncrementMs(10_000).setSeekForwardIncrementMs(10_000).build().apply {
setMediaItem(MediaItem.Builder().setUri(plan.url).setSubtitleConfigurations(plan.subtitles.map { subtitle ->
MediaItem.SubtitleConfiguration.Builder(android.net.Uri.parse(subtitle.url)).setMimeType(subtitle.mime)
.setLanguage(subtitle.language).setLabel(subtitle.label).build()
}).build())
if (scheduled != null) seekTo(scheduled.programme.offsetAt(scheduled.nowMs()))
prepare(); playWhenReady = true
}
}
val lifecycle = LocalLifecycleOwner.current.lifecycle
var foreground by remember { mutableStateOf(lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) }
DisposableEffect(player, lifecycle) {
var stopReported = false
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_START) foreground = true
if (event == Lifecycle.Event.ON_STOP) {
foreground = false
val position = player.currentPosition
player.stop(); ready = false
stopReported = true
EmbyCleanup.run { session.report(plan, "Playing/Stopped", position, true) }
}
}
lifecycle.addObserver(observer)
var joinedSchedule = false
val listener = object : Player.Listener {
override fun onPlayerError(error: PlaybackException) {
val safeReason = generateSequence(error.cause) { it.cause }.filterIsInstance<MediaDeliveryException>().firstOrNull()?.message
failure = safeReason ?: "Playback could not continue (${error.errorCodeName}). Try another sample title, or return and report this code."
ready = false
}
override fun onIsPlayingChanged(isPlaying: Boolean) { playing = isPlaying }
override fun onPlaybackStateChanged(state: Int) {
if (state == Player.STATE_READY) {
if (scheduled != null && !joinedSchedule) {
joinedSchedule = true
val target = scheduled.programme.offsetAt(scheduled.nowMs())
if (kotlin.math.abs(player.currentPosition - target) > 2_000) player.seekTo(target)
}
ready = true
}
}
}
player.addListener(listener)
onDispose {
val position = player.currentPosition
lifecycle.removeObserver(observer)
player.removeListener(listener); player.release()
if (!stopReported) EmbyCleanup.run { session.report(plan, "Playing/Stopped", position, true) }
}
}
LaunchedEffect(player, ready) {
if (ready) { delay(2_000); tuningNumberVisible = false }
}
LaunchedEffect(player, ready) {
if (ready && foreground) {
val startPosition = player.currentPosition; val startPaused = !player.isPlaying
withContext(Dispatchers.IO) { if (foreground) session.report(plan, "Playing", startPosition, startPaused) }
while (foreground) {
delay(10_000)
if (!foreground) break
val position = player.currentPosition; val paused = !player.isPlaying
withContext(Dispatchers.IO) { if (foreground) session.report(plan, "Playing/Progress", position, paused) }
}
}
}
LaunchedEffect(infoVisible, lastInteraction, playing, ready) {
if (infoVisible && playing && ready) { delay(5_000); infoVisible = false }
}
Dialog(onDismissRequest = onClose, properties = DialogProperties(usePlatformDefaultWidth = false,
decorFitsSystemWindows = false, securePolicy = SecureFlagPolicy.SecureOn)) {
Box(Modifier.fillMaxSize().background(Color.Black)) {
AndroidView(modifier = Modifier.fillMaxSize(), factory = { nativeContext ->
PlayerView(nativeContext).apply {
useController = false
setOnKeyListener { _, code, event ->
val supported = code in listOf(KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_ENTER,
KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_DPAD_DOWN,
KeyEvent.KEYCODE_CHANNEL_UP, KeyEvent.KEYCODE_CHANNEL_DOWN, KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE,
KeyEvent.KEYCODE_MEDIA_PLAY, KeyEvent.KEYCODE_MEDIA_PAUSE, KeyEvent.KEYCODE_MEDIA_REWIND, KeyEvent.KEYCODE_MEDIA_FAST_FORWARD)
if (!supported) false else {
if (event.action == KeyEvent.ACTION_UP) {
when (code) {
KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_ENTER -> {
if (scheduled == null && infoVisible) { if (player.isPlaying) player.pause() else player.play() }
}
KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_MEDIA_REWIND -> if (scheduled == null) player.seekBack()
KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> if (scheduled == null) player.seekForward()
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> if (scheduled == null) { if (player.isPlaying) player.pause() else player.play() }
KeyEvent.KEYCODE_MEDIA_PLAY -> player.play()
KeyEvent.KEYCODE_MEDIA_PAUSE -> if (scheduled == null) player.pause()
}
showInfo()
}
true
}
}
setShowBuffering(PlayerView.SHOW_BUFFERING_WHEN_PLAYING)
this.player = player
isFocusable = true; isFocusableInTouchMode = true
post { requestFocus() }
}
}, update = { it.player = player }, onRelease = { it.player = null })
if (scheduled != null && !ready && failure == null && foreground)
ChannelTuningBackdrop(scheduled.channel.number)
if (scheduled != null && ready && tuningNumberVisible && failure == null)
ClassicTuningNumber(scheduled.channel.number,
Modifier.align(Alignment.TopEnd).padding(top = 32.dp, end = 44.dp))
if (infoVisible && failure == null && ready) ClassicProgrammeStrip(plan.item, playing,
Modifier.align(Alignment.BottomCenter).fillMaxWidth(.86f).padding(bottom = 26.dp), scheduled = scheduled)
if (failure != null) Column(Modifier.align(Alignment.Center).widthIn(max = 620.dp).background(Navy).padding(24.dp)) {
Text(failure.orEmpty(), color = Color.White, fontSize = 20.sp)
Spacer(Modifier.height(16.dp))
OrbitButton("Return to sample", onClick = onClose)
}
}
}
}
@@ -0,0 +1,225 @@
package tv.orbit.classic
import org.json.JSONArray
import org.json.JSONObject
import java.net.URI
import java.net.URLDecoder
import java.net.URLEncoder
internal class PlaybackSubtitle(val url: String, val mime: String, val language: String?, val label: String)
internal class EmbyPlaybackPlan(val item: SampleItem, val url: String, val sourceId: String,
val playSessionId: String, val subtitles: List<PlaybackSubtitle>, val durationMs: Long? = item.durationMs)
internal object PlaybackUrls {
private val origin = URI(HttpsEmbyTransport.SERVER)
fun isProvider(uri: URI) = uri.scheme.equals("https", true) && uri.host.equals(origin.host, true) && (uri.port == -1 || uri.port == 443)
fun resolve(value: String, base: String = HttpsEmbyTransport.SERVER + "/emby/", token: String = ""): String {
try {
val uri = URI(base).resolve(value)
if (!uri.scheme.equals("https", true) || uri.host.isNullOrBlank() || uri.userInfo != null || uri.fragment != null)
throw EmbyCheckException("This stream does not provide a supported secure media address.")
if (!isProvider(uri) && token.isNotEmpty() && (URLDecoder.decode(uri.rawPath, "UTF-8").contains(token) || uri.rawAuthority.contains(token)))
throw EmbyCheckException("The provider login cannot be sent to another media host.")
val query = uri.rawQuery?.split("&")?.filter { pair ->
val key = URLDecoder.decode(pair.substringBefore('='), "UTF-8")
val item = URLDecoder.decode(pair.substringAfter('=', ""), "UTF-8")
if (!isProvider(uri) && token.isNotEmpty() && (item.contains(token) || uri.rawPath.contains(token)))
throw EmbyCheckException("The provider login cannot be sent to another media host.")
!(isProvider(uri) && (key.equals("api_key", true) || key.equals("X-Emby-Token", true)))
}?.joinToString("&")
val suffix = if (query.isNullOrEmpty()) "" else "?$query"
return "${uri.scheme}://${uri.rawAuthority}${uri.rawPath}$suffix"
} catch (e: EmbyCheckException) { throw e }
catch (_: Exception) { throw EmbyCheckException("This stream does not provide a supported secure media address.") }
}
}
/** A temporary session is owned by the sample screen, never by persistent storage. */
internal class EmbySampleSession(private val transport: EmbyTransport, private var token: String?,
private val userId: String, sample: EmbySample) {
var sample: EmbySample = sample
private set
private val reportLock = Any()
@Volatile private var checkedEpisodes: List<SeriesEpisodes> = emptyList()
@Volatile private var channelEpisodes: Map<String, SampleItem> = emptyMap()
@Volatile private var candidateEpisodes: Map<String, SampleItem> = emptyMap()
@Volatile private var darkCandidates: List<AfterDarkMovie> = emptyList()
@Volatile private var darkLineup: Map<String, SampleItem> = emptyMap()
@Volatile private var active: EmbyPlaybackPlan? = null
@Volatile private var lastPositionMs = 0L
fun rememberedIdentity() = RememberedIdentity(userId, currentToken(),
(transport as? HttpsEmbyTransport)?.deviceId ?: "fixture-device")
fun matchesAccount(identity: RememberedIdentity) = userId == identity.userId
fun restoreRemembered(saved: RememberedState) {
require(matchesAccount(saved.identity))
sample = saved.sample
checkedEpisodes = saved.episodes.orEmpty()
candidateEpisodes = checkedEpisodes.flatMap { group -> group.episodes.map { episodeSampleItem(group.series, it) } }.associateBy { it.id }
channelEpisodes = saved.toonami?.lineup.orEmpty().associate { it.item.id to it.item }
darkCandidates = saved.darkMovies
darkLineup = saved.afterDark?.lineup.orEmpty().associate { it.item.id to it.item }
}
/** End local use without invalidating the token saved for the next launch. */
fun detach() = synchronized(reportLock) {
active?.let { report(it, "Playing/Stopped", lastPositionMs, true) }
synchronized(this) { token = null }
checkedEpisodes = emptyList(); candidateEpisodes = emptyMap(); channelEpisodes = emptyMap(); darkCandidates = emptyList(); darkLineup = emptyMap()
}
private fun currentToken() = synchronized(this) { token ?: throw EmbyCheckException("This sample session has ended. Sign in again.") }
fun mediaUrl(value: String, base: String = HttpsEmbyTransport.SERVER + "/emby/") = PlaybackUrls.resolve(value, base, currentToken())
fun mediaHeaders(url: String): Map<String, String> {
val auth = currentToken()
return if (PlaybackUrls.isProvider(URI(url))) mapOf("X-Emby-Token" to auth) else emptyMap()
}
fun prepare(item: SampleItem, isPlayback: Boolean = true, preferredSourceId: String? = null): EmbyPlaybackPlan {
if (preferredSourceId != null && !EmbyClient.validId(preferredSourceId)) throw EmbyCheckException("The channel source is no longer usable.")
if (item.id.isBlank() || sample.items.none { it.id == item.id } && candidateEpisodes[item.id] != item && channelEpisodes[item.id] != item && darkCandidates.none { it.item == item } && darkLineup[item.id] != item) throw EmbyCheckException("Choose a title from the current sample.")
val request = JSONObject().put("UserId", userId).put("IsPlayback", isPlayback)
.put("EnableDirectPlay", true).put("EnableDirectStream", true).put("EnableTranscoding", false)
.put("AutoOpenLiveStream", false)
.put("DeviceProfile", JSONObject().put("Name", "Orbit TV playback test")
.put("DirectPlayProfiles", JSONArray().put(JSONObject().put("Type", "Video").put("Container", DirectVideoContainers)))
.put("TranscodingProfiles", JSONArray())
.put("SubtitleProfiles", JSONArray().put(JSONObject().put("Format", "srt").put("Method", "External"))
.put(JSONObject().put("Format", "vtt").put("Method", "External"))))
if (preferredSourceId != null) request.put("MediaSourceId", preferredSourceId)
val response = transport.execute(EmbyRequest("POST", "/Items/${item.id}/PlaybackInfo", request.toString(), currentToken()))
EmbyClient.expectSuccess(response.status, false)
val info = EmbyClient.parse(response.body)
if (!info.isNull("ErrorCode") && !info.optString("ErrorCode").let { it.isBlank() || it == "0" || it == "None" }) throw EmbyCheckException("The provider did not offer a playable source for this account.")
val sources = info.optJSONArray("MediaSources") ?: throw EmbyCheckException("The provider did not return a media source.")
val directSources = (0 until minOf(sources.length(), 20)).mapNotNull { sources.optJSONObject(it) }.filter {
(preferredSourceId == null || it.optString("Id") == preferredSourceId) &&
(it.optBoolean("SupportsDirectStream") || it.optBoolean("SupportsDirectPlay")) && !it.optBoolean("RequiresOpening") && !it.optBoolean("RequiresClosing")
}
if (directSources.isEmpty()) throw EmbyCheckException("This title needs a playback method outside the current direct-stream test. Try another sample title.")
val source = directSources.firstOrNull { supportedDirectContainer(it.optString("Container")) != null }
?: throw EmbyCheckException("Unsupported direct container: ${directSources.map { safeContainerLabel(it.optString("Container")) }.distinct().take(3).joinToString(", ")}. Choose another series version for this sample.")
val sourceId = source.optString("Id")
if (!EmbyClient.validId(sourceId)) throw EmbyCheckException("The provider returned an unusable media source.")
val playId = (if (info.isNull("PlaySessionId")) "" else info.optString("PlaySessionId")).ifBlank { java.util.UUID.randomUUID().toString() }
if (!EmbyClient.validId(playId)) throw EmbyCheckException("The provider returned an unusable playback identity.")
val container = requireNotNull(supportedDirectContainer(source.optString("Container")))
val url = "${HttpsEmbyTransport.SERVER}/emby/Videos/${item.id}/stream.$container?Static=true&MediaSourceId=${encode(sourceId)}&PlaySessionId=${encode(playId)}"
val streams = source.optJSONArray("MediaStreams")
val subtitles = (0 until minOf(streams?.length() ?: 0, 32)).mapNotNull { index ->
val stream = streams?.optJSONObject(index) ?: return@mapNotNull null
if (stream.optString("Type") != "Subtitle" || !stream.optBoolean("IsExternal")) return@mapNotNull null
val mime = when (stream.optString("DeliveryFormat").ifBlank { stream.optString("Codec") }.lowercase()) {
"srt", "subrip" -> "application/x-subrip"
"vtt", "webvtt" -> "text/vtt"
else -> return@mapNotNull null
}
val delivery = stream.optString("DeliveryUrl").takeIf { it.isNotBlank() } ?: return@mapNotNull null
val safe = try { mediaUrl(delivery) } catch (_: EmbyCheckException) { return@mapNotNull null }
PlaybackSubtitle(safe, mime, stream.optString("Language").take(16).ifBlank { null },
EmbyClient.clean(stream.optString("DisplayTitle").ifBlank { "Subtitles ${index + 1}" }, 80))
}
val runtime = (source.optLong("RunTimeTicks", 0) / 10_000).takeIf { it > 0 } ?: item.durationMs
return EmbyPlaybackPlan(item, url, sourceId, playId, subtitles, runtime)
}
fun checkToonamiSeries(startIndex: Int = 0, checkActive: () -> Unit = {}): List<SeriesMatches> =
discoverToonamiSeries(transport, userId, ::currentToken, startIndex, checkActive)
fun checkSeriesEpisodes(series: List<SeriesCandidate>, checkActive: () -> Unit = {}): List<SeriesEpisodes> {
val checked = discoverSeriesEpisodes(transport, userId, ::currentToken, series, checkActive)
checkActive()
currentToken()
checkedEpisodes = checked
candidateEpisodes = checked.flatMap { group -> group.episodes.map { episode -> episodeSampleItem(group.series, episode) } }.associateBy { it.id }
return checked
}
fun buildToonamiChannel(checkActive: () -> Unit = {}): SampleChannel {
val checked = checkedEpisodes
val ordered = toonamiEpisodeOrder(checked)
val media = ordered.map { item ->
checkActive()
val plan = try { prepare(item, isPlayback = false) }
catch (failure: EmbyCheckException) {
throw EmbyCheckException("${item.title}: ${failure.message}", failure.httpStatus)
}
val duration = plan.durationMs?.takeIf { it in 1_000..SampleChannel.MAX_DURATION_MS }
?: throw EmbyCheckException("An episode has no usable runtime. Check the sample again.")
ScheduledMedia(item, plan.sourceId, duration)
}
checkActive(); currentToken()
val channel = SampleChannel(media, "Toonami", 103)
channelEpisodes = ordered.associateBy { it.id }
return channel
}
fun checkAfterDarkMovies(genre: String): List<AfterDarkMovie> {
val found = discoverAfterDarkMovies(transport, userId, ::currentToken, genre)
currentToken()
darkCandidates = mergeAfterDarkMovies(darkCandidates, found)
return darkCandidates
}
fun buildAfterDarkChannel(approved: Set<String>, checkActive: () -> Unit = {}): SampleChannel {
val items = approvedAfterDarkItems(darkCandidates, approved)
val entries = items.map { item ->
checkActive()
val plan = try { prepare(item, isPlayback = false) }
catch (failure: EmbyCheckException) { throw EmbyCheckException("${item.title}: ${failure.message}", failure.httpStatus) }
val duration = plan.durationMs?.takeIf { it in 1_000..SampleChannel.MAX_DURATION_MS }
?: throw EmbyCheckException("${item.title}: no usable source runtime.")
ScheduledMedia(item, plan.sourceId, duration)
}
checkActive(); currentToken()
return SampleChannel(entries, "Orbit After Dark", 102).also { darkLineup = items.associateBy { it.id } }
}
fun buildSampleChannel(): SampleChannel {
val entries = mutableListOf<ScheduledMedia>()
for (item in sample.items.take(5)) {
if (entries.any { it.item.id == item.id }) continue
try {
val source = prepare(item, isPlayback = false)
val duration = source.durationMs ?: continue
if (duration !in 1_000..SampleChannel.MAX_DURATION_MS) continue
entries += ScheduledMedia(item.copy(minutes = duration / 60_000, durationMs = duration), source.sourceId, duration)
if (entries.size == 3) break
} catch (failure: EmbyCheckException) {
if (failure.httpStatus == 401 || failure.httpStatus == 429) throw failure
}
}
if (entries.size < 2) throw EmbyCheckException("This sample needs at least two direct-stream titles with usable runtimes to create a channel.")
return SampleChannel(entries.toList())
}
fun report(plan: EmbyPlaybackPlan, event: String, positionMs: Long, paused: Boolean) = synchronized(reportLock) {
require(event in listOf("Playing", "Playing/Progress", "Playing/Stopped"))
if (synchronized(this) { token } == null) return@synchronized
if (event == "Playing") active = plan
val position = positionMs.coerceAtLeast(0)
if (active === plan) lastPositionMs = position
val payload = JSONObject().put("ItemId", plan.item.id).put("MediaSourceId", plan.sourceId)
.put("PlaySessionId", plan.playSessionId).put("PositionTicks", position.coerceAtMost(Long.MAX_VALUE / 10_000) * 10_000)
.put("IsPaused", paused).put("CanSeek", true).put("PlayMethod", "DirectStream")
try { transport.execute(EmbyRequest("POST", "/Sessions/$event", payload.toString(), currentToken())) }
catch (_: Exception) { /* Playback reports are best effort; never log response bodies or URLs. */ }
if (event == "Playing/Stopped" && active === plan) active = null
}
fun close(): Boolean = synchronized(reportLock) {
val auth = synchronized(this) { token } ?: return@synchronized true
active?.let { report(it, "Playing/Stopped", lastPositionMs, true) }
synchronized(this) { token = null }
checkedEpisodes = emptyList(); candidateEpisodes = emptyMap(); channelEpisodes = emptyMap(); darkCandidates = emptyList(); darkLineup = emptyMap()
try { transport.execute(EmbyRequest("POST", "/Sessions/Logout", token = auth)).status in 200..299 }
catch (_: Exception) { false }
}
private fun encode(value: String) = URLEncoder.encode(value, "UTF-8")
}
/** Invalidates an in-flight sign-in when its screen leaves or is backgrounded. */
internal class EmbySessionSlot {
private var generation = 0
private var session: EmbySampleSession? = null
@Synchronized fun ticket(): Int = generation
@Synchronized fun attach(value: EmbySampleSession, ticket: Int): Boolean {
if (ticket != generation || session != null) return false
session = value; return true
}
@Synchronized fun clear(): EmbySampleSession? { generation++; return session.also { session = null } }
}
internal object EmbyCleanup {
private val executor = java.util.concurrent.Executors.newSingleThreadExecutor { task -> Thread(task, "Orbit-session-cleanup").apply { isDaemon = true } }
fun run(task: () -> Unit) { executor.execute { try { task() } catch (_: Exception) { } } }
}
@@ -0,0 +1,45 @@
package tv.orbit.classic
internal data class EpisodeCandidate(val id: String, val title: String, val season: Int?, val episode: Int?, val durationMs: Long?, val mediaLabel: String = "")
internal data class SeriesEpisodes(val series: SeriesCandidate, val episodes: List<EpisodeCandidate>, val moreAvailable: Boolean)
/** Metadata only: three approved series, one page of five episodes per series. */
internal fun discoverSeriesEpisodes(transport: EmbyTransport, userId: String, token: () -> String,
approved: List<SeriesCandidate>, checkActive: () -> Unit = {}): List<SeriesEpisodes> {
require(EmbyClient.validId(userId))
require(approved.size in 1..3 && approved.map { it.id }.distinct().size == approved.size)
require(approved.all { EmbyClient.validId(it.id) })
return approved.map { series ->
checkActive()
val response = transport.execute(EmbyRequest("GET", "/Shows/${series.id}/Episodes?UserId=$userId&StartIndex=0&Limit=5&Fields=MediaStreams&EnableImages=false&EnableUserData=false", token = token()))
EmbyClient.expectSuccess(response.status, false)
val body = EmbyClient.parse(response.body)
val records = body.optJSONArray("Items") ?: throw EmbyCheckException("The provider did not return an episode sample.")
val episodes = (0 until minOf(records.length(), 5)).mapNotNull { index ->
val item = records.optJSONObject(index) ?: return@mapNotNull null
val id = item.optString("Id")
if (!EmbyClient.validId(id) || item.optString("Type") != "Episode") return@mapNotNull null
val seriesId = item.optString("SeriesId")
if (seriesId.isNotEmpty() && seriesId != series.id) return@mapNotNull null
EpisodeCandidate(id, EmbyClient.clean(item.optString("Name"), 160).ifBlank { "Untitled episode" },
item.optInt("ParentIndexNumber", -1).takeIf { it >= 0 },
item.optInt("IndexNumber", -1).takeIf { it >= 0 },
(item.optLong("RunTimeTicks", 0) / 10_000).takeIf { it in 1_000..SampleChannel.MAX_DURATION_MS }, episodeMediaLabel(item))
}.distinctBy { it.id }.sortedWith(compareBy<EpisodeCandidate> { it.season ?: Int.MAX_VALUE }.thenBy { it.episode ?: Int.MAX_VALUE }.thenBy { it.id })
SeriesEpisodes(series, episodes, body.optLong("TotalRecordCount", records.length().toLong()) > 5)
}
}
internal fun episodeMediaLabel(item: org.json.JSONObject): String {
val streams = item.optJSONArray("MediaStreams")
val media = (0 until minOf(streams?.length() ?: 0, 32)).mapNotNull { streams?.optJSONObject(it) }
val video = media.firstOrNull { it.optString("Type") == "Video" }
val width = (video?.optInt("Width", 0) ?: item.optInt("Width", 0)).takeIf { it in 1..16384 }
val height = (video?.optInt("Height", 0) ?: item.optInt("Height", 0)).takeIf { it in 1..16384 }
val resolution = if (width != null && height != null) "${width}×${height}" else null
val container = item.optString("Container").takeIf { it.isNotBlank() }?.let(::safeContainerLabel)
val codecs = media.filter { it.optString("Type") in listOf("Video", "Audio") }
.mapNotNull { EmbyClient.clean(it.optString("Codec"), 24).takeIf { value -> value.isNotBlank() } }.distinct().take(3)
.joinToString(" / ").takeIf { it.isNotEmpty() }
return listOfNotNull(resolution, container, codecs).joinToString(" • ")
}
@@ -0,0 +1,230 @@
package tv.orbit.classic
import android.os.Bundle
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
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.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.common.PlaybackException
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.PlayerView
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import kotlinx.coroutines.delay
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
internal val Navy = Color(0xFF071632)
internal val Panel = Color(0xFF12356B)
internal val Blue = Color(0xFF194C91)
internal val Yellow = Color(0xFFFFDE59)
internal val Muted = Color(0xFFB7CBE8)
private fun elapsed(time: Long): String = "%02d:%02d".format(Locale.UK, time / 60000, (time / 1000) % 60)
internal fun clock(time: Long) = SimpleDateFormat("HH:mm", Locale.UK).format(Date(time))
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
setContent { MaterialTheme { OrbitApp() } }
}
}
@Composable
internal fun OrbitButton(label: String, modifier: Modifier = Modifier, selected: Boolean = false,
enabled: Boolean = true, onFocus: () -> Unit = {}, onClick: () -> Unit) {
var focused by remember { mutableStateOf(false) }
Box(modifier.onFocusChanged { focused = it.isFocused; if (it.isFocused) onFocus() }
.background(if (focused) Yellow else if (selected) Blue else Panel)
.border(if (focused) 2.dp else 1.dp, if (focused) Color.White else Color(0xFF38629A))
.clickable(enabled = enabled, onClick = onClick).padding(horizontal = 14.dp, vertical = 8.dp)) {
Text(label, color = if (focused) Navy else Color.White, fontSize = 14.sp, maxLines = 1, overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
fontWeight = if (focused || selected) FontWeight.Bold else FontWeight.Normal)
}
}
@Composable
private fun OrbitApp() {
var now by remember { mutableLongStateOf(System.currentTimeMillis()) }
LaunchedEffect(Unit) { while (true) { now = System.currentTimeMillis(); delay(1000) } }
var realChannelAvailable by remember { mutableStateOf(false) }
var section by remember { mutableStateOf("TV Guide") }
var tuned by remember { mutableIntStateOf(0) }
var fullscreen by remember { mutableStateOf(false) }
var selectedChannel by remember { mutableIntStateOf(0) }
var selectedColumn by remember { mutableIntStateOf(0) }
val selected = Schedule.guide(Schedule.channels[selectedChannel], now)[selectedColumn]
var pinned by remember { mutableStateOf(false) }
val tabFocus = remember { List(3) { FocusRequester() } }
val programme = Schedule.programme(Schedule.channels[tuned], now)
val context = androidx.compose.ui.platform.LocalContext.current
val player = remember { ExoPlayer.Builder(context).build().apply {
setMediaItem(MediaItem.fromUri("asset:///orbit-demo.mp4")); prepare(); playWhenReady = true
} }
DisposableEffect(player) { onDispose { player.release() } }
var playbackError by remember { mutableStateOf<String?>(null) }
DisposableEffect(player) {
val listener = object : Player.Listener {
override fun onPlayerError(error: PlaybackException) { playbackError = "Demo video could not play. Return to the guide and try again." }
override fun onPlaybackStateChanged(state: Int) { if (state == Player.STATE_READY) playbackError = null }
}
player.addListener(listener)
onDispose { player.removeListener(listener) }
}
val lifecycle = LocalLifecycleOwner.current.lifecycle
DisposableEffect(player, lifecycle, section, fullscreen, realChannelAvailable) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_STOP) player.pause()
if (event == Lifecycle.Event.ON_START && ((section == "TV Guide" && !realChannelAvailable) || fullscreen)) player.play()
}
lifecycle.addObserver(observer)
onDispose { lifecycle.removeObserver(observer) }
}
LaunchedEffect(section, fullscreen, realChannelAvailable) {
if ((section == "TV Guide" && !realChannelAvailable) || fullscreen) player.play() else player.pause()
}
LaunchedEffect(tuned, programme.start) {
player.seekTo(programme.offsetAt(System.currentTimeMillis())); if ((section == "TV Guide" && !realChannelAvailable) || fullscreen) player.play()
}
// Recheck on resume: a stopped app must rejoin the wall-clock schedule.
DisposableEffect(lifecycle, tuned) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
val time = System.currentTimeMillis()
player.seekTo(Schedule.programme(Schedule.channels[tuned], time).offsetAt(time))
}
}
lifecycle.addObserver(observer)
onDispose { lifecycle.removeObserver(observer) }
}
BackHandler(fullscreen || pinned || section != "TV Guide") {
when { fullscreen -> fullscreen = false; pinned -> pinned = false; section == "Services" || section == "Connect Emby" -> section = "Menu"; else -> section = "TV Guide" }
}
LaunchedEffect(section, fullscreen) {
if (!fullscreen) {
val tab = when (section) { "TV Guide" -> 1; "Box Office" -> 2; else -> 0 }
tabFocus[tab].requestFocus()
}
}
Box(Modifier.fillMaxSize().background(Navy)) {
if (fullscreen) {
Video(player, Modifier.fillMaxSize())
Column(Modifier.align(Alignment.BottomStart).fillMaxWidth().background(Navy.copy(alpha = .92f)).padding(24.dp)) {
Text("${Schedule.channels[tuned].number} ${Schedule.channels[tuned].name}", color = Yellow, fontSize = 20.sp)
Text(programme.title, color = Color.White, fontSize = 26.sp, fontWeight = FontWeight.Bold)
Text(playbackError ?: "${clock(programme.start)}–${clock(programme.end)} • Demo signal ${elapsed(player.currentPosition)} / 30:00 • Back for guide", color = Muted, fontSize = 15.sp)
}
} else Column(Modifier.fillMaxSize().padding(horizontal = 36.dp, vertical = 16.dp)) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text("◉ orbit", color = Color.White, fontSize = 30.sp, fontWeight = FontWeight.Bold)
Text(" / Classic", color = Muted, fontSize = 19.sp)
Spacer(Modifier.weight(1f))
Text(SimpleDateFormat("EEE d MMM HH:mm", Locale.UK).format(Date(now)), color = Color.White, fontSize = 17.sp)
}
Spacer(Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
listOf("Menu", "TV Guide", "Box Office").forEachIndexed { index, title ->
OrbitButton("${index + 1} $title", Modifier.focusRequester(tabFocus[index]),
section == title, onClick = { section = title; pinned = false })
}
}
Spacer(Modifier.height(8.dp))
if (section == "TV Guide" && !realChannelAvailable) {
Row(Modifier.fillMaxWidth().height(120.dp), horizontalArrangement = Arrangement.spacedBy(20.dp)) {
Box(Modifier.width(256.dp).fillMaxHeight().border(1.dp, Muted)) {
Video(player, Modifier.fillMaxSize())
Text(if (playbackError == null) "Demo transmission" else "Video unavailable", Modifier.align(Alignment.BottomStart).background(Navy).padding(5.dp), color = Yellow, fontSize = 12.sp)
}
Column(Modifier.weight(1f)) {
Text(if (pinned) "Programme information" else "${Schedule.channels[selectedChannel].name} / ${clock(selected.start)}–${clock(selected.end)}", color = Yellow, fontSize = 15.sp)
Spacer(Modifier.height(6.dp))
Text(selected.title, color = Color.White, fontSize = 21.sp, fontWeight = FontWeight.Bold)
Spacer(Modifier.height(6.dp))
Text(selected.synopsis, color = Muted, fontSize = 16.sp)
Spacer(Modifier.weight(1f))
Text(if (pinned) "Future programme • Back to browse" else "All programmes and video in this prototype are fictional.", color = Muted, fontSize = 12.sp)
}
}
Spacer(Modifier.height(8.dp))
Row(Modifier.fillMaxWidth().background(Blue).padding(vertical = 6.dp)) {
Text("Channel", Modifier.width(170.dp).padding(start = 12.dp), color = Color.White, fontSize = 14.sp)
repeat(3) { Text(clock(Math.floorDiv(now, Schedule.SLOT) * Schedule.SLOT + it * Schedule.SLOT), Modifier.weight(1f).padding(start = 12.dp), color = Color.White, fontSize = 14.sp) }
}
Column(Modifier.weight(1f).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(5.dp)) {
Schedule.channels.forEachIndexed { channelIndex, channel ->
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(5.dp), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.width(165.dp).padding(start = 12.dp)) {
Text(channel.number.toString(), color = Yellow, fontSize = 15.sp)
Text(channel.name, color = Color.White, fontSize = 16.sp)
}
Schedule.guide(channel, now).forEachIndexed { columnIndex, show ->
OrbitButton(show.title, Modifier.weight(1f), onFocus = {
selectedColumn = columnIndex; selectedChannel = channelIndex; pinned = false
}, onClick = {
selectedColumn = columnIndex; selectedChannel = channelIndex
if (now >= show.start && now < show.end) {
tuned = channelIndex; fullscreen = true
if (playbackError != null) player.prepare()
player.seekTo(show.offsetAt(now)); player.play()
} else pinned = true
})
}
}
}
}
Text("Arrows Browse OK Watch now / programme info Back Return", color = Muted, fontSize = 13.sp)
} else if (section == "Menu") {
OrbitMenu(Modifier.weight(1f), onNavigate = { section = it; pinned = false })
} else if (section == "Connect Emby") {
// Connection content is kept composed below to retain the temporary session.
} else if (section == "Box Office") {
OrbOffice(Modifier.weight(1f), onGuide = { section = "TV Guide" })
} else if (section != "TV Guide") {
Column(Modifier.fillMaxWidth().weight(1f).background(Panel).padding(28.dp)) {
Text("Welcome to Orbit Interactive", color = Yellow, fontSize = 27.sp, fontWeight = FontWeight.Bold)
Spacer(Modifier.height(20.dp))
Text("OrbHive Bedlam is on the roadmap. Games, channel setup and theme selection will live here.", color = Color.White, fontSize = 21.sp)
Spacer(Modifier.height(24.dp))
Text("Coming later", color = Muted, fontSize = 16.sp)
Spacer(Modifier.height(24.dp))
OrbitButton("Return to TV Guide", onClick = { section = "TV Guide" })
}
}
EmbyConnectionScreen(Modifier.weight(1f), visible = section == "Connect Emby",
mainGuide = section == "TV Guide" && realChannelAvailable,
onChannelAvailable = { realChannelAvailable = it },
onGuide = { section = "TV Guide" }, onBack = { section = "Menu" })
}
}
}
@Composable
private fun Video(player: ExoPlayer, modifier: Modifier) {
AndroidView(modifier = modifier, factory = { context -> PlayerView(context).apply {
useController = false; isFocusable = false; descendantFocusability = android.view.ViewGroup.FOCUS_BLOCK_DESCENDANTS
this.player = player
} }, update = { it.player = player }, onRelease = { it.player = null })
}
@@ -0,0 +1,120 @@
package tv.orbit.classic
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.Text
private val Barlow = FontFamily(Font(R.font.barlow_regular), Font(R.font.barlow_semibold, FontWeight.SemiBold))
private val TicketType = FontFamily(Font(R.font.barlow_condensed_medium))
private val Paper = Color(0xFFF7EAC8)
private data class OfficeEvent(val title: String, val collection: String, val time: String,
val synopsis: String, val reference: String)
private val officeEvents = listOf(
OfficeEvent("The Last Shuttle Home", "Orbit premieres", "Tonight at 21:30",
"One final journey brings an unlikely crew together. A fictional premiere for your own front-row seat.", "ORB-001"),
OfficeEvent("Visitors at Dawn", "After dark", "Tonight at 23:00",
"First contact arrives in the quietest corner of Earth. A fictional late-night science-fiction special.", "ORB-002")
)
@Composable
internal fun OrbitMenu(modifier: Modifier = Modifier, onNavigate: (String) -> Unit) {
Row(modifier.fillMaxWidth().background(Panel).padding(24.dp), horizontalArrangement = Arrangement.spacedBy(30.dp)) {
Column(Modifier.weight(1f)) {
Text("Your evening starts here.", color = Yellow, fontFamily = Barlow, fontWeight = FontWeight.SemiBold, fontSize = 30.sp)
Spacer(Modifier.height(10.dp))
Text("Your collection. Your channels.\nYour television.", color = Color.White, fontFamily = Barlow, fontSize = 24.sp, lineHeight = 29.sp)
Spacer(Modifier.weight(1f))
Text("Orbit Classic", color = Muted, fontFamily = TicketType, fontSize = 25.sp)
Text("The satellite spirit, in your universe.", color = Muted, fontFamily = Barlow, fontSize = 16.sp)
}
Column(Modifier.weight(1f).align(Alignment.CenterVertically), verticalArrangement = Arrangement.spacedBy(10.dp)) {
OrbitButton("Connect Emby / Check a small sample", Modifier.fillMaxWidth(), onClick = { onNavigate("Connect Emby") })
OrbitButton("TV Guide / What's on now", Modifier.fillMaxWidth(), onClick = { onNavigate("TV Guide") })
OrbitButton("OrbOffice / Special transmissions", Modifier.fillMaxWidth(), onClick = { onNavigate("Box Office") })
OrbitButton("Services / Orbit Interactive", Modifier.fillMaxWidth(), onClick = { onNavigate("Services") })
}
}
}
@Composable
internal fun OrbOffice(modifier: Modifier = Modifier, onGuide: () -> Unit) {
var selectedEvent by remember { mutableIntStateOf(0) }
val event = officeEvents[selectedEvent]
Row(modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(24.dp)) {
Column(Modifier.weight(1.55f).fillMaxHeight()) {
Row(verticalAlignment = Alignment.Bottom) {
Text("OrbOffice", color = Yellow, fontFamily = TicketType, fontSize = 36.sp)
Text(" Your front-row seat.", color = Muted, fontFamily = Barlow, fontSize = 17.sp, modifier = Modifier.padding(bottom = 5.dp))
}
Spacer(Modifier.height(10.dp))
AdmissionTicket(event, Modifier.fillMaxWidth().weight(1f))
Spacer(Modifier.height(8.dp))
Text("Preview ticket • Fictional event • Included in your collection", color = Muted, fontFamily = Barlow, fontSize = 13.sp)
}
Column(Modifier.weight(1f).fillMaxHeight(), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text("Special transmissions", color = Color.White, fontFamily = Barlow, fontWeight = FontWeight.SemiBold, fontSize = 23.sp)
officeEvents.forEachIndexed { index, item ->
OrbitButton(item.collection, Modifier.fillMaxWidth(), selected = selectedEvent == index,
onFocus = { selectedEvent = index }, onClick = { selectedEvent = index })
}
Text(event.synopsis, color = Muted, fontFamily = Barlow, fontSize = 18.sp, lineHeight = 23.sp)
Spacer(Modifier.weight(1f))
Text("Event playback comes with the Emby connection. For now, explore the ticket and the guide.", color = Muted, fontFamily = Barlow, fontSize = 14.sp)
OrbitButton("Return to TV Guide", Modifier.fillMaxWidth(), onClick = onGuide)
}
}
}
@Composable
private fun AdmissionTicket(event: OfficeEvent, modifier: Modifier = Modifier) {
Box(modifier.background(Paper)) {
Canvas(Modifier.matchParentSize()) {
val seam = size.width * .76f
drawLine(Navy.copy(alpha = .45f), Offset(seam, 0f), Offset(seam, size.height),
strokeWidth = 1.dp.toPx(), pathEffect = PathEffect.dashPathEffect(floatArrayOf(5.dp.toPx(), 5.dp.toPx())))
for (i in 0..11) {
val y = (i + .5f) * size.height / 12
drawCircle(Navy, 5.dp.toPx(), Offset(0f, y))
drawCircle(Navy, 5.dp.toPx(), Offset(size.width, y))
}
drawCircle(Navy, 9.dp.toPx(), Offset(seam, 0f))
drawCircle(Navy, 9.dp.toPx(), Offset(seam, size.height))
}
Row(Modifier.fillMaxSize()) {
Column(Modifier.weight(.76f).fillMaxHeight().padding(horizontal = 22.dp, vertical = 18.dp)) {
Text("OrbOffice", color = Navy, fontFamily = TicketType, fontSize = 27.sp)
Text(event.collection, color = Navy, fontFamily = Barlow, fontSize = 14.sp)
Spacer(Modifier.height(14.dp))
Text(event.title, color = Navy, fontFamily = TicketType, fontSize = 33.sp, lineHeight = 35.sp, maxLines = 3)
Spacer(Modifier.weight(1f))
Text(event.time, color = Navy, fontFamily = Barlow, fontWeight = FontWeight.SemiBold, fontSize = 18.sp)
Text("One universe. Best seat in the house.", color = Navy.copy(alpha = .75f), fontFamily = Barlow, fontSize = 14.sp)
Spacer(Modifier.height(12.dp))
Text("Admit one", Modifier.background(Yellow).padding(horizontal = 14.dp, vertical = 5.dp), color = Navy, fontFamily = TicketType, fontSize = 23.sp)
}
Column(Modifier.weight(.24f).fillMaxHeight().padding(horizontal = 10.dp, vertical = 22.dp), horizontalAlignment = Alignment.CenterHorizontally) {
Text("orbit", color = Navy, fontFamily = Barlow, fontWeight = FontWeight.SemiBold, fontSize = 21.sp)
Text("Front row", color = Navy, fontFamily = TicketType, fontSize = 19.sp)
Spacer(Modifier.weight(1f))
Text("01", color = Navy, fontFamily = TicketType, fontSize = 52.sp)
Text("Seat", color = Navy, fontFamily = Barlow, fontSize = 14.sp)
Spacer(Modifier.weight(1f))
Text(event.reference, color = Navy, fontFamily = TicketType, fontSize = 17.sp)
Text("Preview", color = Navy.copy(alpha = .65f), fontFamily = Barlow, fontSize = 12.sp)
}
}
}
}
@@ -0,0 +1,88 @@
package tv.orbit.classic
import android.content.Context
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.AtomicFile
import java.io.File
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
internal object ConnectionCipher {
private val aad = "tv.orbit.classic/remembered-connection/v1".toByteArray(Charsets.UTF_8)
fun seal(key: SecretKey, plain: ByteArray): ByteArray {
require(plain.size <= RememberedStateCodec.MAX_BYTES)
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.ENCRYPT_MODE, key); cipher.updateAAD(aad)
require(cipher.iv.size == 12)
return byteArrayOf(1) + cipher.iv + cipher.doFinal(plain)
}
fun open(key: SecretKey, envelope: ByteArray): ByteArray {
require(envelope.size in 29..(RememberedStateCodec.MAX_BYTES + 29) && envelope[0] == 1.toByte())
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(128, envelope.copyOfRange(1, 13)))
cipher.updateAAD(aad)
return cipher.doFinal(envelope.copyOfRange(13, envelope.size))
}
}
/** App-private, backup-excluded, authenticated ciphertext only. All operations serialize. */
internal class RememberedConnectionStore private constructor(context: Context) {
companion object {
private var instance: RememberedConnectionStore? = null
@Synchronized fun get(context: Context): RememberedConnectionStore = instance
?: RememberedConnectionStore(context.applicationContext).also { instance = it }
}
@Synchronized fun invalidate() { generation++ }
private val file = AtomicFile(File(context.noBackupFilesDir, "orbit-connection.v1"))
private val alias = "orbit-connection-key-v1"
private var generation = 0L
@Synchronized fun ticket() = generation
private fun key(create: Boolean): SecretKey {
val store = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
(store.getKey(alias, null) as? SecretKey)?.let { return it }
check(create)
return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore").apply {
init(KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM).setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setKeySize(256).setRandomizedEncryptionRequired(true).build())
}.generateKey()
}
@Synchronized fun load(): RememberedState? {
if (!file.baseFile.exists() && !File(file.baseFile.path + ".bak").exists()) return null
return file.openRead().use { input ->
val bytes = input.readBytesLimited(RememberedStateCodec.MAX_BYTES + 29)
RememberedStateCodec.decode(ConnectionCipher.open(key(false), bytes))
}
}
@Synchronized fun save(state: RememberedState, ticket: Long): Boolean {
if (ticket != generation) return false
val plain = RememberedStateCodec.encode(state)
// Validate the same bounded schema before committing it.
RememberedStateCodec.decode(plain)
val encrypted = ConnectionCipher.seal(key(true), plain)
val output = file.startWrite()
try { output.write(encrypted); file.finishWrite(output) }
catch (failure: Exception) { file.failWrite(output); throw failure }
return true
}
@Synchronized fun clear() {
generation++
file.delete()
KeyStore.getInstance("AndroidKeyStore").apply { load(null); if (containsAlias(alias)) deleteEntry(alias) }
}
}
private fun java.io.InputStream.readBytesLimited(max: Int): ByteArray {
val output = java.io.ByteArrayOutputStream()
val buffer = ByteArray(8192)
while (true) {
val count = read(buffer); if (count < 0) break
require(output.size() + count <= max)
output.write(buffer, 0, count)
}
return output.toByteArray()
}
@@ -0,0 +1,97 @@
package tv.orbit.classic
import org.json.JSONArray
import org.json.JSONObject
// Intentionally not data classes: default toString must never expose an access token.
internal class RememberedIdentity(val userId: String, val token: String?, val deviceId: String)
internal class RememberedState(val identity: RememberedIdentity, val sample: EmbySample,
val matches: List<SeriesMatches>, val approved: Set<String>, val episodes: List<SeriesEpisodes>?,
val orbitOne: SampleChannel?, val toonami: SampleChannel?, val selectedNumber: Int,
val afterDark: SampleChannel? = null, val darkMovies: List<AfterDarkMovie> = emptyList(), val approvedDark: Set<String> = emptySet()) {
fun withoutToken() = RememberedState(RememberedIdentity(identity.userId, null, identity.deviceId), sample,
matches, approved, episodes, orbitOne, toonami, selectedNumber, afterDark, darkMovies, approvedDark)
}
internal object RememberedStateCodec {
const val MAX_BYTES = 512 * 1024
private fun arr(items: Iterable<JSONObject>) = JSONArray().also { a -> items.forEach { a.put(it) } }
private fun candidate(s: SeriesCandidate) = JSONObject().put("id", s.id).put("title", s.title)
.put("year", s.year ?: JSONObject.NULL).put("rating", s.rating).put("variant", s.variantLabel)
private fun item(s: SampleItem) = JSONObject().put("id", s.id).put("title", s.title).put("type", s.type)
.put("format", s.format).put("duration", s.durationMs ?: JSONObject.NULL)
private fun channel(c: SampleChannel?) = c?.let { JSONObject().put("number", it.number).put("name", it.name)
.put("entries", arr(it.lineup.map { m -> JSONObject().put("item", item(m.item)).put("source", m.sourceId).put("duration", m.durationMs) })) } ?: JSONObject.NULL
fun encode(state: RememberedState): ByteArray {
val i = state.identity
val root = JSONObject().put("schema", 1).put("origin", HttpsEmbyTransport.SERVER)
.put("user", i.userId).put("token", i.token ?: JSONObject.NULL).put("device", i.deviceId)
.put("sample", arr(state.sample.items.map(::item))).put("total", state.sample.total ?: JSONObject.NULL)
.put("matches", arr(state.matches.map { m -> JSONObject().put("requested", m.requested).put("more", m.moreAvailable).put("candidates", arr(m.candidates.map(::candidate))) }))
.put("approved", JSONArray(state.approved.toList())).put("selected", if (state.selectedNumber == 102) 101 else state.selectedNumber)
.put("selectedChannel", state.selectedNumber).put("afterDark", channel(state.afterDark))
.put("darkMovies", arr(state.darkMovies.map { movie -> JSONObject().put("item", item(movie.item)).put("year", movie.year ?: JSONObject.NULL)
.put("rating", movie.rating).put("genres", JSONArray(movie.genres)) }))
.put("approvedDark", JSONArray(state.approvedDark.toList()))
.put("one", channel(state.orbitOne)).put("toonami", channel(state.toonami))
.put("episodes", state.episodes?.let { groups -> arr(groups.map { group -> JSONObject().put("series", candidate(group.series)).put("more", group.moreAvailable)
.put("items", arr(group.episodes.map { e -> JSONObject().put("id", e.id).put("title", e.title)
.put("season", e.season ?: JSONObject.NULL).put("episode", e.episode ?: JSONObject.NULL)
.put("duration", e.durationMs ?: JSONObject.NULL).put("media", e.mediaLabel) })) }) } ?: JSONObject.NULL)
return root.toString().toByteArray(Charsets.UTF_8).also { require(it.size <= MAX_BYTES) }
}
private fun text(o: JSONObject, key: String, max: Int, blank: Boolean = true): String = o.getString(key).also {
require(it.length <= max && (blank || it.isNotBlank()) && it.none(Char::isISOControl))
}
private fun id(o: JSONObject, key: String) = text(o, key, 128, false).also { require(EmbyClient.validId(it)) }
private fun list(o: JSONObject, key: String, max: Int): List<JSONObject> = o.getJSONArray(key).let { a ->
require(a.length() <= max); (0 until a.length()).map { a.getJSONObject(it) }
}
private fun duration(o: JSONObject) = if (o.isNull("duration")) null else o.getLong("duration").also { require(it in 1..SampleChannel.MAX_DURATION_MS) }
private fun readItem(o: JSONObject): SampleItem {
val d = duration(o)
return SampleItem(text(o, "title", 600, false), text(o, "type", 16).also { require(it in listOf("Movie", "Episode")) }, d?.div(60_000), text(o, "format", 160), id(o, "id"), d)
}
private fun readCandidate(o: JSONObject) = SeriesCandidate(id(o, "id"), text(o, "title", 160, false),
if (o.isNull("year")) null else o.getInt("year").also { require(it in 1800..2200) }, text(o, "rating", 40), text(o, "variant", 40))
private fun readChannel(o: JSONObject, key: String, number: Int): SampleChannel? {
if (o.isNull(key)) return null
val c = o.getJSONObject(key); require(c.getInt("number") == number)
return SampleChannel(list(c, "entries", 15).map { m -> ScheduledMedia(readItem(m.getJSONObject("item")), id(m, "source"), requireNotNull(duration(m))) }, text(c, "name", 80, false), number)
}
fun decode(bytes: ByteArray): RememberedState {
require(bytes.size <= MAX_BYTES)
val o = JSONObject(bytes.toString(Charsets.UTF_8)); require(o.getInt("schema") == 1 && o.getString("origin") == HttpsEmbyTransport.SERVER)
val token = if (o.isNull("token")) null else o.getString("token").also { require(it.isNotEmpty() && it.length <= 4096 && it.all { c -> c.code in 33..126 }) }
val identity = RememberedIdentity(id(o, "user"), token, id(o, "device"))
val sample = EmbySample(list(o, "sample", 20).map(::readItem), if (o.isNull("total")) null else o.getLong("total").also { require(it >= 0) })
val matches = list(o, "matches", ToonamiSearches.size).map { m -> SeriesMatches(text(m, "requested", 80).also { require(it in ToonamiSearches) }, list(m, "candidates", 10).map(::readCandidate), m.getBoolean("more")) }
require(matches.map { it.requested } == ToonamiSearches.take(matches.size))
val approvedArray = o.getJSONArray("approved"); require(approvedArray.length() <= 3)
val approved = (0 until approvedArray.length()).map { approvedArray.getString(it) }.toSet()
require(approved.all { value -> matches.any { m -> m.candidates.any { it.id == value } } })
val episodes = if (o.isNull("episodes")) null else list(o, "episodes", 3).map { group ->
val series = readCandidate(group.getJSONObject("series")); require(series.id in approved)
SeriesEpisodes(series, list(group, "items", 5).map { e -> EpisodeCandidate(id(e, "id"), text(e, "title", 160, false),
if (e.isNull("season")) null else e.getInt("season").also { require(it >= 0) },
if (e.isNull("episode")) null else e.getInt("episode").also { require(it >= 0) }, duration(e), text(e, "media", 200)) }, group.getBoolean("more"))
}
val one = readChannel(o, "one", 101); val toonami = readChannel(o, "toonami", 103)
require(one == null || one.lineup.all { m -> sample.items.any { it.id == m.item.id } })
val dark = readChannel(o, "afterDark", 102)
require(dark == null || (dark.lineup.size in 2..3 && dark.lineup.all { it.item.type == "Movie" }))
val darkMovies = if (!o.has("darkMovies")) emptyList() else list(o, "darkMovies", 60).map { movie ->
val media = readItem(movie.getJSONObject("item")); require(media.type == "Movie")
val genres = movie.getJSONArray("genres"); require(genres.length() <= 8)
AfterDarkMovie(media, if (movie.isNull("year")) null else movie.getInt("year").also { require(it in 1800..2200) },
text(movie, "rating", 40), (0 until genres.length()).map { i -> genres.getString(i).also { require(it.length <= 40 && it.none(Char::isISOControl)) } })
}
require(darkMovies.map { it.item.id }.distinct().size == darkMovies.size)
val darkApproval = o.optJSONArray("approvedDark") ?: JSONArray()
require(darkApproval.length() <= 3)
val approvedDark = (0 until darkApproval.length()).map { darkApproval.getString(it) }.toSet()
require(approvedDark.all { value -> darkMovies.any { it.item.id == value } })
val selected = o.optInt("selectedChannel", o.getInt("selected")); require(selected in 101..103)
return RememberedState(identity, sample, matches, approved, episodes, one, toonami, selected, dark, darkMovies, approvedDark)
}
}
@@ -0,0 +1,44 @@
package tv.orbit.classic
internal data class ScheduledMedia(val item: SampleItem, val sourceId: String, val durationMs: Long)
internal data class SampleBroadcast(val media: ScheduledMedia, val startMs: Long) {
val endMs: Long get() = startMs + media.durationMs
fun offsetAt(timeMs: Long) = (timeMs - startMs).coerceIn(0, media.durationMs - 1)
}
/** A repeating UTC timeline: it advances even when no screen or player exists. */
internal class SampleChannel(entries: List<ScheduledMedia>, val name: String = "Orbit One", val number: Int = 101) {
val lineup = entries.toList()
companion object { const val MAX_DURATION_MS = 7 * 24 * 60 * 60 * 1000L }
init {
require(lineup.size in 2..15)
require(lineup.map { it.item.id }.distinct().size == lineup.size)
require(lineup.all { EmbyClient.validId(it.item.id) && EmbyClient.validId(it.sourceId) && it.durationMs in 1_000..MAX_DURATION_MS })
}
val cycleMs = lineup.sumOf { it.durationMs }
fun at(timeMs: Long): SampleBroadcast {
require(timeMs in -8_000_000_000_000_000L..8_000_000_000_000_000L)
val position = Math.floorMod(timeMs, cycleMs)
var cursor = 0L
for (media in lineup) {
if (position < cursor + media.durationMs) return SampleBroadcast(media, timeMs - position + cursor)
cursor += media.durationMs
}
error("Unreachable channel position")
}
fun guide(timeMs: Long, count: Int = 6): List<SampleBroadcast> {
require(count in 1..48)
var current = at(timeMs)
return List(count) { current.also { current = at(it.endMs) } }
}
fun transitionCheckOffset(timeMs: Long): Long {
val programme = at(timeMs)
val remaining = minOf(10_000L, programme.media.durationMs / 2).coerceAtLeast(1)
return programme.endMs - remaining - timeMs
}
}
internal data class ScheduledPlayback(val channel: SampleChannel, val programme: SampleBroadcast,
val next: SampleBroadcast, val clockOffsetMs: Long = 0) {
fun nowMs() = System.currentTimeMillis() + clockOffsetMs
}
@@ -0,0 +1,149 @@
package tv.orbit.classic
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.Image
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.layout.ContentScale
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
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.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.window.SecureFlagPolicy
import androidx.tv.material3.Text
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
@Composable
internal fun SampleChannelGuide(channel: SampleChannel, modifier: Modifier = Modifier,
onTune: (Long) -> Unit, onBack: () -> Unit, otherChannels: List<SampleChannel> = emptyList(), onSwitch: (Int) -> Unit = {}) {
var now by remember { mutableLongStateOf(System.currentTimeMillis()) }
LaunchedEffect(Unit) { while (true) { delay(1000); now = System.currentTimeMillis() } }
val shows = channel.guide(now)
val tuneFocus = remember { FocusRequester() }
LaunchedEffect(Unit) { tuneFocus.requestFocus() }
BackHandler(onBack = onBack)
val branding = ChannelBrands.forNumber(channel.number)
Box(modifier.fillMaxWidth().background(if (branding != null) Navy else Panel)) {
if (branding != null) Image(painterResource(branding.background), null, Modifier.matchParentSize(), contentScale = ContentScale.Crop, alpha = .65f)
Column(Modifier.fillMaxSize().padding(18.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text("TV Guide", color = Yellow, fontSize = 26.sp, fontWeight = FontWeight.Bold)
Spacer(Modifier.weight(1f))
otherChannels.forEach { other -> OrbitButton("${other.number} ${other.name}", onClick = { onSwitch(other.number) }) }
Text(clock(now), color = Color.White, fontSize = 20.sp)
}
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(16.dp)) {
Column(Modifier.width(125.dp)) {
Text(channel.number.toString(), color = Yellow, fontSize = 36.sp, fontWeight = FontWeight.Bold)
if (branding != null) Image(painterResource(branding.logo), channel.name, Modifier.width(125.dp).height(40.dp))
else Text(channel.name, color = Color.White, fontSize = 19.sp)
}
Column(Modifier.weight(1f)) {
Text("NOW ${clock(shows[0].startMs)}–${clock(shows[0].endMs)}", color = Yellow, fontSize = 15.sp)
Text(shows[0].media.item.title, color = Color.White, fontSize = 23.sp, maxLines = 2)
}
Column(Modifier.weight(1f)) {
Text("NEXT ${clock(shows[1].startMs)}", color = Muted, fontSize = 15.sp)
Text(shows[1].media.item.title, color = Color.White, fontSize = 21.sp, maxLines = 2)
}
}
LazyColumn(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp)) {
items(shows.drop(1), key = { it.startMs }) { show ->
var focused by remember { mutableStateOf(false) }
Row(Modifier.fillMaxWidth().onFocusChanged { focused = it.isFocused }.background(Blue)
.border(1.dp, if (focused) Yellow else Blue).focusable().padding(10.dp), horizontalArrangement = Arrangement.spacedBy(16.dp)) {
Text("${clock(show.startMs)}–${clock(show.endMs)}", color = Yellow, fontSize = 15.sp, modifier = Modifier.width(125.dp))
Text(show.media.item.title, color = Color.White, fontSize = 16.sp, maxLines = 1)
}
}
}
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
OrbitButton("Watch channel", Modifier.focusRequester(tuneFocus), onClick = { onTune(0) })
OrbitButton("Check transition", onClick = { onTune(channel.transitionCheckOffset(System.currentTimeMillis())) })
OrbitButton("Menu", onClick = onBack)
}
Text("${channel.lineup.size} titles • Transition check joins near the end and may update your provider’s viewing history.", color = Muted, fontSize = 12.sp)
}
}
}
@Composable
internal fun ScheduledChannelPlayer(session: EmbySampleSession, channel: SampleChannel, clockOffsetMs: Long,
onClose: () -> Unit) {
var now by remember { mutableLongStateOf(System.currentTimeMillis() + clockOffsetMs) }
LaunchedEffect(channel, clockOffsetMs) { while (true) { now = System.currentTimeMillis() + clockOffsetMs; delay(250) } }
val shows = channel.guide(now, 2)
val current = shows[0]
val prepareMutex = remember(channel) { Mutex() }
var prepared by remember(channel) { mutableStateOf<EmbyPlaybackPlan?>(null) }
var preparedSlot by remember(channel) { mutableStateOf<Long?>(null) }
var problem by remember(channel) { mutableStateOf<String?>(null) }
var retryAttempt by remember(channel) { mutableIntStateOf(0) }
// A new wall-clock slot disposes the old player before opening its successor.
LaunchedEffect(channel, current.startMs, retryAttempt) {
prepared = null; preparedSlot = null; problem = null
try {
val plan = prepareMutex.withLock {
withContext(Dispatchers.IO) {
session.prepare(current.media.item, preferredSourceId = current.media.sourceId)
}
}
if (plan.durationMs == null || kotlin.math.abs(plan.durationMs - current.media.durationMs) > 2_000)
throw EmbyCheckException("This programme's runtime changed. Return and rebuild the sample channel.")
preparedSlot = current.startMs; prepared = plan
} catch (cancelled: CancellationException) { throw cancelled }
catch (failure: EmbyCheckException) { problem = failure.message }
catch (_: Exception) { problem = "This scheduled programme could not be opened. Return to the guide and try again." }
}
// Suppress the preceding plan immediately at a boundary while the effect schedules its replacement.
val plan = prepared?.takeIf { it.item.id == current.media.item.id && preparedSlot == current.startMs && problem == null }
if (plan != null) {
key(current.startMs) {
EmbyPlaybackDialog(session, plan, onClose,
scheduled = ScheduledPlayback(channel, current, shows[1], clockOffsetMs))
}
} else Dialog(onDismissRequest = onClose, properties = DialogProperties(usePlatformDefaultWidth = false,
decorFitsSystemWindows = false, securePolicy = SecureFlagPolicy.SecureOn)) {
if (problem == null) ChannelTuningBackdrop(channel.number)
else Box(Modifier.fillMaxSize().background(Navy)) {
Column(Modifier.align(Alignment.Center).widthIn(max = 620.dp).padding(32.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(problem.orEmpty(), color = Color.White, fontSize = 20.sp)
val retryFocus = remember { FocusRequester() }
LaunchedEffect(problem) { retryFocus.requestFocus() }
OrbitButton("Retry", Modifier.focusRequester(retryFocus), onClick = {
problem = null
retryAttempt++
})
OrbitButton("Return to TV guide", onClick = onClose)
}
}
}
}
@Composable
internal fun ClassicTuningNumber(number: Int, modifier: Modifier = Modifier) {
Text(number.toString(), modifier = modifier, color = Color(0xFF47FF52), fontSize = 46.sp,
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
fontWeight = FontWeight.Bold,
style = androidx.compose.ui.text.TextStyle(shadow = androidx.compose.ui.graphics.Shadow(
Color.Black, androidx.compose.ui.geometry.Offset(2f, 2f), 2f)))
}
@@ -0,0 +1,35 @@
package tv.orbit.classic
/** Fixed UTC slots survive process restarts; only display formatting uses local time. */
data class Programme(val title: String, val synopsis: String, val start: Long, val duration: Long) {
val end: Long get() = start + duration
fun offsetAt(now: Long): Long = (now - start).coerceIn(0, duration - 1)
}
data class Channel(val number: Int, val name: String, val shows: List<Pair<String, String>>)
object Schedule {
const val SLOT = 1_800_000L
val channels = listOf(
Channel(101, "Orbit One", listOf(
"The Midnight Observatory" to "An astronomer follows a mysterious signal across the night sky.",
"Small Town, Big Orbit" to "An eccentric village prepares for its annual space festival.",
"Beyond the Blue" to "Explorers chart an ocean beneath an unfamiliar moon.",
"The Last Shuttle Home" to "One final journey brings an unlikely crew together.")),
Channel(202, "Orbit Sci-Fi", listOf(
"Signal from Europa" to "A research team discovers that the ice is listening.",
"Starbound Dispatch" to "Stories from the delivery route at the edge of the galaxy.",
"Visitors at Dawn" to "First contact arrives in the quietest corner of Earth.",
"Station Nine" to "Life, repairs and peculiar neighbours on a remote outpost.")),
Channel(401, "Orbit Kids", listOf(
"Moonbug Rangers" to "A brave little team protects a very big lunar garden.",
"The Comet Club" to "Saturday adventures with the galaxy's most curious friends.",
"Rocket Pocket" to "An inventor's tiny rocket causes enormous surprises.",
"Planet of Puzzles" to "Every new world has a puzzle waiting to be solved."))
)
fun programme(channel: Channel, time: Long): Programme {
val slot = Math.floorDiv(time, SLOT)
val show = channel.shows[Math.floorMod(slot, channel.shows.size.toLong()).toInt()]
return Programme(show.first, show.second, slot * SLOT, SLOT)
}
fun guide(channel: Channel, time: Long, count: Int = 3) =
List(count) { programme(channel, time + it * SLOT) }
}
@@ -0,0 +1,103 @@
package tv.orbit.classic
import android.net.Uri
import androidx.media3.common.C
import androidx.media3.datasource.BaseDataSource
import androidx.media3.datasource.DataSpec
import java.io.IOException
import java.io.InputStream
import java.net.InetAddress
import java.net.URI
import java.net.URL
import javax.net.ssl.HttpsURLConnection
/** Redirect handling is explicit so provider credentials never follow a stream to a CDN. */
internal class MediaDeliveryException(message: String) : IOException(message)
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
internal class SecureMediaDataSource(private val session: EmbySampleSession) : BaseDataSource(true) {
private var connection: HttpsURLConnection? = null
private var stream: InputStream? = null
private var currentUri: Uri? = null
private var remaining = C.LENGTH_UNSET.toLong()
private var opened = false
override fun open(dataSpec: DataSpec): Long {
transferInitializing(dataSpec)
try {
var address = session.mediaUrl(dataSpec.uri.toString())
for (redirect in 0..5) {
val host = URI(address).host
if (InetAddress.getAllByName(host).any { !isPublicMediaAddress(it) }) throw MediaDeliveryException("Media delivery resolved to a non-public address.")
val next = URL(address).openConnection() as HttpsURLConnection
connection = next
next.instanceFollowRedirects = false
next.connectTimeout = 10_000
next.readTimeout = 20_000
next.setRequestProperty("User-Agent", "OrbitTV/0.16.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()) {
val end = if (dataSpec.length == C.LENGTH_UNSET.toLong()) "" else (dataSpec.position + dataSpec.length - 1).toString()
next.setRequestProperty("Range", "bytes=${dataSpec.position}-$end")
}
val status = next.responseCode
if (status in listOf(301, 302, 303, 307, 308)) {
val location = next.getHeaderField("Location") ?: throw MediaDeliveryException("The media redirect had no destination.")
if (redirect == 5) throw MediaDeliveryException("The stream redirected too many times.")
address = session.mediaUrl(location, address)
next.disconnect(); connection = null
continue
}
if (status !in 200..299) throw MediaDeliveryException("The media host returned HTTP $status.")
if (status == 200 && dataSpec.position > 0) throw MediaDeliveryException("The media host did not support byte-range seeking.")
if (status == 206 && !mediaRangeStartsAt(next.getHeaderField("Content-Range"), dataSpec.position))
throw MediaDeliveryException("The media host returned an unexpected seek position.")
currentUri = Uri.parse(address)
val length = next.contentLengthLong
remaining = if (dataSpec.length != C.LENGTH_UNSET.toLong()) dataSpec.length else if (length >= 0) length else C.LENGTH_UNSET.toLong()
stream = next.inputStream
opened = true; transferStarted(dataSpec)
return remaining
}
throw MediaDeliveryException("The stream could not be opened.")
} catch (failure: IOException) { close(); throw if (failure is MediaDeliveryException) failure else IOException("The secure media connection could not be opened.") }
catch (_: Exception) { close(); throw IOException("The secure media connection could not be opened.") }
}
override fun read(buffer: ByteArray, offset: Int, length: Int): Int {
if (length == 0) return 0
if (remaining == 0L) return C.RESULT_END_OF_INPUT
val count = if (remaining == C.LENGTH_UNSET.toLong()) length else minOf(length.toLong(), remaining).toInt()
val read = try { stream?.read(buffer, offset, count) ?: C.RESULT_END_OF_INPUT }
catch (_: IOException) { throw IOException("The media connection was interrupted.") }
if (read == -1) {
if (remaining > 0) throw IOException("The media stream ended before the expected length.")
return C.RESULT_END_OF_INPUT
}
if (remaining != C.LENGTH_UNSET.toLong()) remaining -= read
bytesTransferred(read)
return read
}
override fun getUri(): Uri? = currentUri
override fun getResponseHeaders(): Map<String, List<String>> = connection?.getHeaderField("Content-Type")?.let { mapOf("Content-Type" to listOf(it)) } ?: emptyMap()
override fun close() {
try { stream?.close() } catch (_: IOException) { }
finally {
stream = null; connection?.disconnect(); connection = null; currentUri = null
if (opened) { opened = false; transferEnded() }
}
}
}
internal fun isPublicMediaAddress(address: InetAddress): Boolean {
if (address.isAnyLocalAddress || address.isLoopbackAddress || address.isLinkLocalAddress || address.isSiteLocalAddress || address.isMulticastAddress) return false
val bytes = address.address.map { it.toInt() and 255 }
if (bytes.size == 16 && bytes[0] and 254 == 252) return false
if (bytes.size == 4 && (bytes[0] == 0 || bytes[0] >= 224 || (bytes[0] == 100 && bytes[1] in 64..127) ||
(bytes[0] == 198 && bytes[1] in 18..19) || (bytes[0] == 192 && bytes[1] == 0 && bytes[2] == 0))) return false
return true
}
internal fun mediaRangeStartsAt(header: String?, position: Long): Boolean {
val match = Regex("bytes ([0-9]+)-([0-9]+)/(?:[0-9]+|\\*)", RegexOption.IGNORE_CASE).matchEntire(header.orEmpty()) ?: return false
return match.groupValues[1].toLongOrNull() == position
}
@@ -0,0 +1,50 @@
package tv.orbit.classic
import org.json.JSONObject
import java.net.URLEncoder
internal data class SeriesCandidate(val id: String, val title: String, val year: Int?, val rating: String, val variantLabel: String = "")
internal data class SeriesMatches(val requested: String, val candidates: List<SeriesCandidate>, val moreAvailable: Boolean)
internal val ToonamiSearches = listOf("Dragon Ball Z", "Batman Beyond", "Superman", "Powerpuff Girls", "ThunderCats",
"Dragon Ball", "Sailor Moon", "Voltron", "ReBoot", "Beast Wars",
"Jonny Quest", "Robotech", "Ronin Warriors", "Gundam Wing", "Tenchi Muyo",
"Outlaw Star", "The Big O", "Cardcaptors", "Zoids", "Dragon Ball GT",
"Naruto", "Samurai Jack", "Justice League")
/** At most five explicit series searches per action, ten candidates each. */
internal fun discoverToonamiSeries(transport: EmbyTransport, userId: String, token: () -> String, startIndex: Int, checkActive: () -> Unit = {}): List<SeriesMatches> {
require(EmbyClient.validId(userId))
require(startIndex in 0..ToonamiSearches.size)
return ToonamiSearches.drop(startIndex).take(5).map { requested ->
checkActive()
val term = URLEncoder.encode(requested, "UTF-8")
val reply = transport.execute(EmbyRequest("GET", "/Items?UserId=$userId&Recursive=true&IncludeItemTypes=Series&SearchTerm=$term&StartIndex=0&Limit=10&SortBy=SortName&SortOrder=Ascending&EnableImages=false&EnableUserData=false", token = token()))
EmbyClient.expectSuccess(reply.status, false)
val body = EmbyClient.parse(reply.body)
val items = body.optJSONArray("Items") ?: throw EmbyCheckException("The provider did not return series search results.")
val candidates = (0 until minOf(items.length(), 10)).mapNotNull { index ->
val item = items.optJSONObject(index) ?: return@mapNotNull null
val id = item.optString("Id")
val title = EmbyClient.clean(item.optString("Name"), 160)
if (item.optString("Type") != "Series" || !EmbyClient.validId(id) || title.isBlank()) return@mapNotNull null
SeriesCandidate(id, title, item.optInt("ProductionYear").takeIf { it in 1800..2200 },
EmbyClient.clean(item.optString("OfficialRating"), 40))
}.distinctBy { it.id }
SeriesMatches(requested, labelSeriesVariants(candidates), body.optLong("TotalRecordCount", items.length().toLong()) > 10)
}
}
internal fun discoverToonamiSeries(transport: EmbyTransport, userId: String, token: () -> String): List<SeriesMatches> =
discoverToonamiSeries(transport, userId, token, 0)
internal fun labelSeriesVariants(candidates: List<SeriesCandidate>): List<SeriesCandidate> {
fun key(series: SeriesCandidate) = series.title.trim().lowercase(java.util.Locale.ROOT) to series.year
val totals = candidates.groupingBy { key(it) }.eachCount()
val seen = mutableMapOf<Pair<String, Int?>, Int>()
return candidates.map { series ->
val key = key(series)
val index = (seen[key] ?: 0) + 1
seen[key] = index
series.copy(variantLabel = if ((totals[key] ?: 0) > 1) "Entry $index of ${totals[key]}" else "")
}
}
@@ -0,0 +1,70 @@
package tv.orbit.classic
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.Text
@Composable
internal fun SeriesReviewScreen(matches: List<SeriesMatches>, approved: Set<String>, episodes: List<SeriesEpisodes>?,
busy: Boolean, message: String?, modifier: Modifier,
onToggle: (String) -> Unit, onMore: () -> Unit, onEpisodes: () -> Unit, onBuild: () -> Unit, onBack: () -> Unit) {
val listState = rememberLazyListState()
LaunchedEffect(episodes) { if (episodes != null) listState.scrollToItem(0) }
BackHandler(onBack = onBack)
Column(modifier.fillMaxWidth().background(Panel).padding(18.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("103 Toonami / Series check", color = Yellow, fontSize = 26.sp)
Text("Choose up to three versions for this sample. Nothing is scheduled yet.", color = Muted, fontSize = 15.sp)
if (busy) Text("Preparing sample…", color = Yellow, fontSize = 15.sp)
message?.let { Text(it, color = Yellow, fontSize = 15.sp) }
LazyColumn(Modifier.weight(1f), state = listState, verticalArrangement = Arrangement.spacedBy(8.dp)) {
episodes?.forEach { sample ->
item("episodes-${sample.series.id}") {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text("Episode sample / ${sample.series.title} ${sample.series.variantLabel}", color = Yellow, fontSize = 21.sp)
if (sample.episodes.isEmpty()) Text("No usable episodes returned in this page.", color = Muted, fontSize = 15.sp)
sample.episodes.forEach { episode ->
val order = if (episode.season != null && episode.episode != null) "S${episode.season} E${episode.episode}" else "Order unknown"
val runtime = episode.durationMs?.let { "${it / 60_000} min" } ?: "Runtime unknown"
Text("$order • ${episode.title} • $runtime", color = Color.White, fontSize = 16.sp)
if (episode.mediaLabel.isNotBlank()) Text(episode.mediaLabel, color = Muted, fontSize = 14.sp)
}
Text(if (sample.moreAvailable) "Sample page only; more episodes exist." else "Sample page only.", color = Muted, fontSize = 13.sp)
}
}
}
matches.forEachIndexed { groupIndex, group ->
item("heading-$groupIndex") {
Text(group.requested, color = Yellow, fontSize = 21.sp)
if (group.candidates.isEmpty()) Text("No series match returned by this search.", color = Muted, fontSize = 15.sp)
if (group.moreAvailable) Text("Showing at most ten results; other versions may exist.", color = Muted, fontSize = 14.sp)
}
group.candidates.forEach { candidate ->
item("$groupIndex-${candidate.id}") {
val details = listOfNotNull(candidate.year?.toString(), candidate.rating.takeIf { it.isNotBlank() }, candidate.variantLabel.takeIf { it.isNotBlank() }).joinToString(" • ")
Column {
OrbitButton("${if (candidate.id in approved) "✓ Approved" else "+ Approve"} ${candidate.title}",
Modifier.fillMaxWidth(), selected = candidate.id in approved, enabled = !busy && (candidate.id in approved || approved.size < 3), onClick = { onToggle(candidate.id) })
if (details.isNotEmpty()) Text(details, color = Color.White, fontSize = 14.sp)
}
}
}
}
}
Text("${matches.size}/${ToonamiSearches.size} searches • ${approved.size}/3 series approved • Sample repeats; direct sources checked before scheduling.", color = Muted, fontSize = 14.sp)
if (episodes != null) OrbitButton("Create playable 103", enabled = !busy, onClick = onBuild)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if (matches.size < ToonamiSearches.size) OrbitButton("Check next five series", enabled = !busy, onClick = onMore)
OrbitButton("Check sample episodes", enabled = !busy && approved.isNotEmpty(), onClick = onEpisodes)
OrbitButton("Back", onClick = onBack)
}
}
}
@@ -0,0 +1,24 @@
package tv.orbit.classic
internal fun episodeSampleItem(series: SeriesCandidate, episode: EpisodeCandidate) = SampleItem(
"${series.title} / S${episode.season ?: "?"} E${episode.episode ?: "?"} / ${episode.title}",
"Episode", episode.durationMs?.div(60_000), "", episode.id, episode.durationMs)
/** Interleave the returned sample, preserving each show's known regular-episode order. */
internal fun toonamiEpisodeOrder(samples: List<SeriesEpisodes>): List<SampleItem> {
if (samples.size !in 1..3 || samples.map { it.series.id }.distinct().size != samples.size)
throw EmbyCheckException("Check episodes for one to three approved series first.")
val groups = samples.map { group ->
val episodes = group.episodes
if (episodes.size !in 1..5 || episodes.any { it.season == null || it.episode == null || it.season < 1 || it.episode < 1 })
throw EmbyCheckException("This sample has missing episode order or specials. Choose another series for this test.")
if (episodes.map { it.season to it.episode }.distinct().size != episodes.size)
throw EmbyCheckException("This sample contains duplicate episode numbers. Choose another series version.")
episodes.sortedWith(compareBy<EpisodeCandidate> { it.season }.thenBy { it.episode })
.map { episodeSampleItem(group.series, it) }
}
val result = (0 until groups.maxOf { it.size }).flatMap { round -> groups.mapNotNull { it.getOrNull(round) } }
if (result.size < 2 || result.map { it.id }.distinct().size != result.size)
throw EmbyCheckException("This channel needs at least two distinct sample episodes.")
return result
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

@@ -0,0 +1 @@
<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>
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
<resources><style name="OrbitTheme" parent="android:style/Theme.Material.NoActionBar"><item name="android:fontFamily">sans</item><item name="android:windowActionModeOverlay">true</item><item name="android:windowFullscreen">true</item><item name="android:colorAccent">#FFDE59</item><item name="android:windowBackground">#071632</item></style></resources>
@@ -0,0 +1,101 @@
package tv.orbit.classic
import org.json.JSONArray
import org.json.JSONObject
import org.junit.Assert.*
import org.junit.Test
class AfterDarkMoviesTest {
private fun movie(id: String, genre: String = "Horror") = JSONObject().put("Id", id).put("Type", "Movie")
.put("Name", "Fixture $id").put("ProductionYear", 1999).put("OfficialRating", "18")
.put("Genres", JSONArray().put(genre)).put("Container", "mkv").put("RunTimeTicks", 54_000_000_000L)
private fun page(items: JSONArray) = EmbyReply(200, JSONObject().put("Items", items).toString())
private fun candidate(id: String) = AfterDarkMovie(SampleItem("Fixture $id", "Movie", 90, "mkv", id, 5_400_000), 1999, "18", listOf("Horror"))
@Test fun genreQueryIsMovieOnlyBoundedAndCredentialsStayInHeaders() {
val requests = mutableListOf<EmbyRequest>()
val found = discoverAfterDarkMovies(EmbyTransport { r -> requests += r; page(JSONArray().put(movie("one", "Science Fiction"))) }, "fixture-user", { "fixture-token" }, "Science Fiction")
val r = requests.single(); assertEquals("GET", r.method); assertEquals("fixture-token", r.token)
assertTrue(r.path.contains("Genres=Science+Fiction&")); assertTrue(r.path.contains("Limit=20")); assertTrue(r.path.contains("IncludeItemTypes=Movie"))
assertFalse(r.path.contains("fixture-token")); assertEquals("one", found.single().item.id)
}
@Test fun requiresGenreEvidenceAndRejectsForeignTypesUnsafeIdsAndDuplicates() {
val items = JSONArray().put(movie("one")).put(movie("one")).put(movie("wrong", "Comedy"))
.put(movie("series").put("Type", "Series")).put(movie("../bad")).put(JSONObject().put("Type", "Movie").put("Id", "missing"))
val found = discoverAfterDarkMovies(EmbyTransport { page(items) }, "fixture-user", { "fixture-token" }, "Horror")
assertEquals(listOf("one"), found.map { it.item.id })
}
@Test fun ignoresRecordsBeyondTheRequestedTwentyAndCapsAccumulation() {
val items = JSONArray(); repeat(50) { items.put(movie("id-$it")) }
assertEquals(20, discoverAfterDarkMovies(EmbyTransport { page(items) }, "fixture-user", { "fixture-token" }, "Horror").size)
val merged = mergeAfterDarkMovies(List(55) { candidate("id-$it") }, List(20) { candidate("id-${it + 50}") })
assertEquals(60, merged.size); assertEquals(60, merged.map { it.item.id }.distinct().size)
}
@Test fun rejectsUnapprovedIdsAndDoesNotAutomaticallySelectFilms() {
val candidates = listOf(candidate("one"), candidate("two"), candidate("three"))
assertEquals(listOf("one", "three"), approvedAfterDarkItems(candidates, setOf("one", "three")).map { it.id })
for (ids in listOf(emptySet(), setOf("one"), setOf("one", "unknown"), setOf("one", "two", "three", "four"))) {
try { approvedAfterDarkItems(candidates, ids); fail("Invalid approval accepted") } catch (_: EmbyCheckException) { }
}
}
private class Fake : EmbyTransport {
val requests = mutableListOf<EmbyRequest>()
override fun execute(request: EmbyRequest): EmbyReply {
requests += request
if (request.method == "GET") {
val items = JSONArray()
for (id in listOf("one", "two")) items.put(JSONObject().put("Id", id).put("Type", "Movie").put("Name", "Fixture $id").put("Genres", JSONArray().put("Horror")))
return EmbyReply(200, JSONObject().put("Items", items).toString())
}
val id = request.path.substringAfter("/Items/").substringBefore('/')
return EmbyReply(200, JSONObject().put("MediaSources", JSONArray().put(JSONObject().put("Id", "$id-source").put("Container", "mkv")
.put("SupportsDirectStream", true).put("RunTimeTicks", 54_001_230_000L))).toString())
}
}
@Test fun builds102FromExplicitCheckedFilmsWithoutOpeningPlayback() {
val fake = Fake(); val active = EmbySampleSession(fake, "fixture-token", "fixture-user", EmbySample(emptyList(), 0))
active.checkAfterDarkMovies("Horror")
val channel = active.buildAfterDarkChannel(setOf("one", "two"))
assertEquals(102, channel.number); assertEquals("Orbit After Dark", channel.name)
assertEquals(5_400_123L, channel.lineup.first().durationMs)
assertEquals(3, fake.requests.size)
assertTrue(fake.requests.drop(1).all { !JSONObject(it.body!!).getBoolean("IsPlayback") })
active.prepare(channel.lineup.first().item, preferredSourceId = "one-source")
assertEquals("one-source", JSONObject(fake.requests.last().body!!).getString("MediaSourceId"))
}
@Test fun rateLimitsStopWithoutRetryOrSelection() {
var count = 0
try { discoverAfterDarkMovies(EmbyTransport { count++; EmbyReply(429, "private") }, "fixture-user", { "fixture-token" }, "Horror"); fail("Expected failure") }
catch (e: EmbyCheckException) { assertEquals(429, e.httpStatus) }
assertEquals(1, count)
}
@Test fun oldSavedSetupStillLoadsAnd102RoundTripKeepsTheExistingClock() {
val old = RememberedState(RememberedIdentity("fixture-user", "fixture-token", "fixture-device"), EmbySample(emptyList(), 0), emptyList(), emptySet(), null, null, null, 103)
val legacy = JSONObject(String(RememberedStateCodec.encode(old)))
for (key in listOf("afterDark", "darkMovies", "approvedDark", "selectedChannel")) legacy.remove(key)
val loaded = RememberedStateCodec.decode(legacy.toString().toByteArray())
assertEquals(103, loaded.selectedNumber); assertNull(loaded.afterDark); assertTrue(loaded.darkMovies.isEmpty())
val movies = listOf(candidate("one"), candidate("two"))
val channel = SampleChannel(movies.map { ScheduledMedia(it.item, "${it.item.id}-source", 5_400_123) }, "Orbit After Dark", 102)
val state = RememberedState(old.identity, old.sample, emptyList(), emptySet(), null, null, null, 102, channel, movies, setOf("one", "two"))
val restored = RememberedStateCodec.decode(RememberedStateCodec.encode(state))
assertEquals(channel.guide(1_793_491_234_567L), restored.afterDark!!.guide(1_793_491_234_567L))
assertEquals(102, restored.selectedNumber); assertEquals(state.approvedDark, restored.approvedDark)
assertEquals(102, state.withoutToken().afterDark!!.number)
assertEquals(101, JSONObject(String(RememberedStateCodec.encode(state))).getInt("selected"))
}
@Test fun adding102Preserves101And103AndThreeWayGuideOrder() {
val movies = listOf(candidate("one"), candidate("two"))
val one = SampleChannel(movies.map { ScheduledMedia(it.item, "${it.item.id}-source", 5_400_000) })
val toonami = SampleChannel(List(2) { i -> ScheduledMedia(SampleItem("Fixture episode", "Episode", 24, "mkv", "ep-$i", 1_440_000), "ep-$i-source", 1_440_000) }, "Toonami", 103)
val dark = SampleChannel(one.lineup, "Orbit After Dark", 102)
val saved = RememberedState(RememberedIdentity("fixture-user", "fixture-token", "fixture-device"), EmbySample(movies.map { it.item }, 2),
emptyList(), emptySet(), null, one, toonami, 102, dark, movies, setOf("one", "two"))
val restored = RememberedStateCodec.decode(RememberedStateCodec.encode(saved))
val time = 1_793_491_234_567L
assertEquals(one.guide(time), restored.orbitOne!!.guide(time))
assertEquals(toonami.guide(time), restored.toonami!!.guide(time))
assertEquals(dark.guide(time), restored.afterDark!!.guide(time))
assertEquals(listOf(101, 102, 103), availableSampleChannels(restored.toonami, restored.afterDark, restored.orbitOne, null, restored.orbitOne).map { it.number })
}
}
@@ -0,0 +1,136 @@
package tv.orbit.classic
import org.json.JSONArray
import org.json.JSONObject
import org.junit.Assert.*
import org.junit.Test
import java.io.ByteArrayInputStream
class EmbyClientTest {
private data class CheckedSample(val items: List<SampleItem>, val total: Long?, val signedOut: Boolean)
private fun EmbyClient.check(username: String, password: String): CheckedSample {
val session = connect(username, password)
val sample = session.sample
return CheckedSample(sample.items, sample.total, session.close())
}
private val login = """{"AccessToken":"test-token-only","User":{"Id":"fake-user-123"}}"""
private class Scripted(vararg replies: EmbyReply) : EmbyTransport {
val requests = mutableListOf<EmbyRequest>()
private val queue = ArrayDeque(replies.toList())
override fun execute(request: EmbyRequest): EmbyReply {
requests += request
return queue.removeFirstOrNull() ?: error("Unexpected extra request")
}
}
private fun movie(name: String = "Fixture Film") = JSONObject().put("Name", name).put("Type", "Movie").put("Id", "fixture-item")
.put("RunTimeTicks", 54_000_000_000L).put("Container", "mkv")
.put("MediaStreams", JSONArray().put(JSONObject().put("Type", "Video").put("Codec", "hevc")))
private fun page(items: JSONArray) = JSONObject().put("Items", items).put("TotalRecordCount", 900_000).toString()
private fun errorMessage(block: () -> Unit): String {
try { block(); fail("Expected a bounded check failure") }
catch (e: EmbyCheckException) { return e.message.orEmpty() }
return ""
}
@Test fun signsInReadsOnePageAndRevokesOnlyThatSession() {
val fake = Scripted(EmbyReply(200, login), EmbyReply(200, page(JSONArray().put(movie()))), EmbyReply(204, ""))
val result = EmbyClient(fake).check("fixture-user", " password with spaces ")
assertEquals(3, fake.requests.size)
val authBody = JSONObject(fake.requests[0].body!!)
assertEquals("fixture-user", authBody.getString("Username"))
assertEquals(" password with spaces ", authBody.getString("Pw"))
val library = fake.requests[1]
assertEquals("GET", library.method)
assertTrue(library.path.startsWith("/Users/fake-user-123/Items?"))
assertTrue(library.path.contains("Limit=20")); assertTrue(library.path.contains("StartIndex=0"))
assertFalse(library.path.contains("test-token-only"))
assertEquals("test-token-only", library.token)
assertEquals("/Sessions/Logout", fake.requests[2].path)
assertEquals("test-token-only", fake.requests[2].token)
assertEquals(900_000L, result.total)
assertEquals(90L, result.items.single().minutes)
assertEquals("mkv / hevc", result.items.single().format)
assertTrue(result.signedOut)
}
@Test fun refusesToDisplayMoreThanTwentyEvenIfProviderIgnoresLimit() {
val items = JSONArray(); repeat(100) { items.put(movie("Film $it")) }
val fake = Scripted(EmbyReply(200, login), EmbyReply(200, page(items)), EmbyReply(204, ""))
assertEquals(20, EmbyClient(fake).check("fixture", "fixture").items.size)
assertEquals(3, fake.requests.size)
}
@Test fun wrongPasswordDoesNotRetryOrFetchLibraryOrExposeProviderBody() {
val fake = Scripted(EmbyReply(401, "private-provider-detail"))
val message = errorMessage { EmbyClient(fake).check("fixture", "not-a-real-password") }
assertEquals(1, fake.requests.size)
assertFalse(message.contains("private-provider-detail")); assertFalse(message.contains("not-a-real-password"))
assertTrue(message.contains("Sign-in failed"))
}
@Test fun libraryFailureStillLogsOutAndDoesNotRetry() {
val fake = Scripted(EmbyReply(200, login), EmbyReply(403, "private"), EmbyReply(204, ""))
assertTrue(errorMessage { EmbyClient(fake).check("fixture", "fixture") }.contains("did not permit"))
assertEquals(3, fake.requests.size); assertEquals("/Sessions/Logout", fake.requests.last().path)
}
@Test fun malformedLibraryStillLogsOut() {
val fake = Scripted(EmbyReply(200, login), EmbyReply(200, "not JSON"), EmbyReply(204, ""))
assertTrue(errorMessage { EmbyClient(fake).check("fixture", "fixture") }.contains("cannot read"))
assertEquals("/Sessions/Logout", fake.requests.last().path)
}
@Test fun hostileUserIdentityCannotBecomeARequestPath() {
val auth = """{"AccessToken":"test-token-only","User":{"Id":"../other?token=bad"}}"""
val fake = Scripted(EmbyReply(200, auth), EmbyReply(204, ""))
errorMessage { EmbyClient(fake).check("fixture", "fixture") }
assertEquals(2, fake.requests.size); assertEquals("/Sessions/Logout", fake.requests.last().path)
}
@Test fun failedLogoutIsReportedWithoutRetainingTokenInResult() {
val fake = Scripted(EmbyReply(200, login), EmbyReply(200, page(JSONArray())), EmbyReply(500, "private"))
val result = EmbyClient(fake).check("fixture", "fixture")
assertFalse(result.signedOut); assertFalse(result.toString().contains("test-token-only"))
}
@Test fun providerTextIsBoundedAndNonMediaEntriesAreIgnored() {
val items = JSONArray().put(movie("x".repeat(500) + "\n")).put(JSONObject().put("Type", "Folder"))
val fake = Scripted(EmbyReply(200, login), EmbyReply(200, page(items)), EmbyReply(204, ""))
val result = EmbyClient(fake).check("fixture", "fixture")
assertEquals(1, result.items.size); assertEquals(160, result.items[0].title.length)
}
@Test fun redirectResponseIsNotTreatedAsSuccessfulSignIn() {
val fake = Scripted(EmbyReply(302, ""))
assertTrue(errorMessage { EmbyClient(fake).check("fixture", "fixture") }.contains("not forwarded"))
assertEquals(1, fake.requests.size)
}
@Test fun invalidCredentialsAreRejectedBeforeAnyNetworkRequest() {
val fake = Scripted()
for ((user, pass) in listOf("" to "fixture", "fixture" to "", "x".repeat(257) to "fixture", "fixture" to "x".repeat(1025)))
errorMessage { EmbyClient(fake).check(user, pass) }
assertTrue(fake.requests.isEmpty())
}
@Test fun responseReaderStopsAtTheByteLimit() {
val stream = ByteArrayInputStream(ByteArray(HttpsEmbyTransport.MAX_RESPONSE_BYTES + 1))
assertTrue(errorMessage { HttpsEmbyTransport.readBounded(stream) }.contains("too large"))
}
@Test fun responseReaderPreservesUtf8() {
assertEquals("A café", HttpsEmbyTransport.readBounded(ByteArrayInputStream("A café".toByteArray(Charsets.UTF_8))))
}
@Test fun invalidTokenCannotEnterAnHttpHeader() {
val auth = JSONObject().put("AccessToken", "bad\r\nInjected: yes").put("User", JSONObject().put("Id", "fake-user")).toString()
val fake = Scripted(EmbyReply(200, auth))
errorMessage { EmbyClient(fake).check("fixture", "fixture") }
assertEquals(1, fake.requests.size)
}
@Test fun tokenMustBeAStringNotAnObject() {
val auth = JSONObject().put("AccessToken", JSONObject().put("secret", "bad")).toString()
val fake = Scripted(EmbyReply(200, auth))
errorMessage { EmbyClient(fake).check("fixture", "fixture") }
assertEquals(1, fake.requests.size)
}
@Test fun rateLimitDoesNotTriggerAutomaticRetries() {
val fake = Scripted(EmbyReply(429, "private"))
assertTrue(errorMessage { EmbyClient(fake).check("fixture", "fixture") }.contains("slow down"))
assertEquals(1, fake.requests.size)
}
@Test fun metadataDurationKeepsMillisecondsForRealScheduling() {
val fake = Scripted(EmbyReply(200, login),
EmbyReply(200, page(JSONArray().put(movie().put("RunTimeTicks", 901_239_999L)))), EmbyReply(204, ""))
assertEquals(90_123L, EmbyClient(fake).check("fixture", "fixture").items.single().durationMs)
}
}
@@ -0,0 +1,180 @@
package tv.orbit.classic
import org.json.JSONArray
import org.json.JSONObject
import org.junit.Assert.*
import org.junit.Test
import java.net.InetAddress
class EmbyPlaybackTest {
private class Fake : EmbyTransport {
val requests = mutableListOf<EmbyRequest>()
var response = EmbyReply(200, "")
override fun execute(request: EmbyRequest): EmbyReply { requests += request; return response }
}
private val item = SampleItem("Fixture film", "Movie", 90, "mkv", "fixture-item")
private fun session(fake: Fake) = EmbySampleSession(fake, "fixture-token-private", "fixture-user", EmbySample(listOf(item), 1))
private fun source() = JSONObject().put("Id", "source-123").put("Container", "mkv")
.put("SupportsDirectStream", true).put("RequiresOpening", false)
private fun info(source: JSONObject) = JSONObject().put("PlaySessionId", "play-123").put("ErrorCode", JSONObject.NULL)
.put("MediaSources", JSONArray().put(source)).toString()
private fun fails(block: () -> Unit) {
try { block(); fail("Expected rejection") } catch (_: EmbyCheckException) { }
}
@Test fun preparesOnlySelectedSampleWithoutTranscodingOrTokenInUrl() {
val fake = Fake(); fake.response = EmbyReply(200, info(source()))
val session = session(fake); val plan = session.prepare(item)
assertEquals(1, fake.requests.size)
val body = JSONObject(fake.requests.single().body!!)
assertFalse(body.getBoolean("EnableTranscoding")); assertFalse(body.getBoolean("AutoOpenLiveStream"))
assertEquals("/Items/fixture-item/PlaybackInfo", fake.requests.single().path)
assertTrue(plan.url.contains("Static=true")); assertTrue(plan.url.contains("MediaSourceId=source-123"))
assertFalse(plan.url.contains("fixture-token-private"))
assertEquals("fixture-token-private", session.mediaHeaders(plan.url)["X-Emby-Token"])
}
@Test fun cannotPrepareAnItemOutsideTheSample() {
val fake = Fake()
fails { session(fake).prepare(item.copy(id = "not-in-sample")) }
assertTrue(fake.requests.isEmpty())
}
@Test fun refusesTranscodeOnlyAndLiveOpeningSources() {
for (source in listOf(source().put("SupportsDirectStream", false), source().put("RequiresOpening", true))) {
val fake = Fake(); fake.response = EmbyReply(200, info(source))
fails { session(fake).prepare(item) }
}
}
@Test fun refusesSourceIdentifiersThatCouldAlterTheUrl() {
val fake = Fake(); fake.response = EmbyReply(200, info(source().put("Id", "../../private")))
fails { session(fake).prepare(item) }
}
@Test fun providerApiKeyIsRemovedAndCdnGetsNoProviderHeaders() {
val session = session(Fake())
val url = session.mediaUrl("/emby/Videos/fixture-item/stream.mkv?api_key=fixture-token-private&Static=true")
assertFalse(url.contains("api_key")); assertTrue(url.contains("Static=true"))
val cdn = session.mediaUrl("https://media.example.test/file.mkv?signature=fixture-signature")
assertTrue(session.mediaHeaders(cdn).isEmpty())
assertTrue(session.mediaHeaders("https://bk2.appboxmanager.xyz:444/file").isEmpty())
}
@Test fun rejectsInsecureAddressesAndCredentialsInExternalUrls() {
val session = session(Fake())
for (url in listOf("http://media.example.test/a", "file:///private/a", "https://user:pass@media.example.test/a",
"https://media.example.test/a?key=fixture-token-private", "https://media.example.test/fixture-token-private"))
fails { session.mediaUrl(url) }
}
@Test fun externalSubtitleUrlIsValidatedAndUsesTokenHeaderOnlyAtProvider() {
val sub = JSONObject().put("Type", "Subtitle").put("IsExternal", true).put("Codec", "subrip")
.put("DeliveryUrl", "/emby/subtitle.srt?api_key=fixture-token-private").put("Language", "eng")
val fake = Fake(); fake.response = EmbyReply(200, info(source().put("MediaStreams", JSONArray().put(sub))))
val plan = session(fake).prepare(item)
assertEquals("application/x-subrip", plan.subtitles.single().mime)
assertFalse(plan.subtitles.single().url.contains("api_key"))
}
@Test fun reportsStopBeforeLogoutAndClearsSessionCredentials() {
val fake = Fake(); fake.response = EmbyReply(200, info(source()))
val session = session(fake); val plan = session.prepare(item)
fake.response = EmbyReply(204, "")
session.report(plan, "Playing", 12_000, false)
session.report(plan, "Playing/Progress", 25_000, true)
assertTrue(session.close())
assertEquals("/Sessions/Playing/Stopped", fake.requests[fake.requests.size - 2].path)
assertEquals("/Sessions/Logout", fake.requests.last().path)
val progress = JSONObject(fake.requests.first { it.path.endsWith("/Progress") }.body!!)
assertEquals(250_000_000L, progress.getLong("PositionTicks")); assertTrue(progress.getBoolean("IsPaused"))
fails { session.mediaHeaders(plan.url) }
val before = fake.requests.size; session.close(); assertEquals(before, fake.requests.size)
}
@Test fun leavingDuringSignInInvalidatesLateSessionAttachment() {
val slot = EmbySessionSlot(); val ticket = slot.ticket(); slot.clear()
val session = session(Fake())
assertFalse(slot.attach(session, ticket))
assertTrue(slot.attach(session, slot.ticket()))
assertSame(session, slot.clear()); assertNull(slot.clear())
}
@Test fun mediaAddressGuardRejectsLocalAndSharedAddressSpace() {
for (host in listOf("127.0.0.1", "10.0.0.1", "192.168.0.252", "169.254.169.254", "100.64.0.1", "::1", "fc00::1"))
assertFalse(host, isPublicMediaAddress(InetAddress.getByName(host)))
assertTrue(isPublicMediaAddress(InetAddress.getByName("8.8.8.8")))
}
@Test fun oldStopReportCannotOverwriteTheNewPlaybackPosition() {
val fake = Fake(); fake.response = EmbyReply(200, info(source()))
val session = session(fake); val old = session.prepare(item); val current = session.prepare(item)
fake.response = EmbyReply(204, "")
session.report(old, "Playing", 1_000, false)
session.report(current, "Playing", 3_000, false)
session.report(old, "Playing/Stopped", 99_000, true)
session.close()
val finalStop = fake.requests.last { it.path == "/Sessions/Playing/Stopped" }
assertEquals(30_000_000L, JSONObject(finalStop.body!!).getLong("PositionTicks"))
}
@Test fun validatesReturnedByteRangeBeforeSeeking() {
assertTrue(mediaRangeStartsAt("bytes 100-199/1000", 100))
assertFalse(mediaRangeStartsAt("bytes 0-99/1000", 100))
assertFalse(mediaRangeStartsAt(null, 100))
}
@Test fun sourceRuntimeWinsWithoutRoundingToMinutes() {
val fake = Fake(); fake.response = EmbyReply(200, info(source().put("RunTimeTicks", 901_230_000L)))
assertEquals(90_123L, session(fake).prepare(item).durationMs)
}
@Test fun locksChannelPlaybackToTheSourceUsedForScheduling() {
val fake = Fake(); fake.response = EmbyReply(200, info(source()))
session(fake).prepare(item, preferredSourceId = "source-123")
assertEquals("source-123", JSONObject(fake.requests.single().body!!).getString("MediaSourceId"))
fails { session(fake).prepare(item, preferredSourceId = "different-source") }
}
@Test fun nullPlaybackIdentityGetsAFreshIdentifier() {
val fake = Fake(); fake.response = EmbyReply(200, JSONObject(info(source())).put("PlaySessionId", JSONObject.NULL).toString())
val plan = session(fake).prepare(item, isPlayback = false)
assertNotEquals("null", plan.playSessionId); assertTrue(EmbyClient.validId(plan.playSessionId))
}
@Test fun retryAfterConnectionFailureRetainsSessionAndLockedSource() {
val requests = mutableListOf<EmbyRequest>()
val transport = EmbyTransport { request ->
requests += request
if (requests.size == 1) throw EmbyCheckException("Fixture connection failure")
EmbyReply(200, info(source()))
}
val active = EmbySampleSession(transport, "fixture-token-private", "fixture-user", EmbySample(listOf(item), 1))
fails { active.prepare(item, preferredSourceId = "source-123") }
assertEquals(1, requests.size) // No automatic retry or reauthentication.
val plan = active.prepare(item, preferredSourceId = "source-123")
assertEquals("source-123", plan.sourceId)
assertEquals(2, requests.size)
assertTrue(requests.all { it.path == "/Items/fixture-item/PlaybackInfo" })
assertTrue(requests.all { it.token == "fixture-token-private" })
assertTrue(requests.all { JSONObject(it.body!!).getString("MediaSourceId") == "source-123" })
}
@Test fun choosesCompatibleAlternativeInsteadOfFirstUnsupportedContainer() {
val fake = Fake()
val body = JSONObject().put("MediaSources", JSONArray()
.put(source().put("Id", "avi-source").put("Container", "avi"))
.put(source().put("Id", "mkv-source").put("Container", " MKV ")))
fake.response = EmbyReply(200, body.toString())
val active = session(fake)
val plan = active.prepare(item)
assertEquals("mkv-source", plan.sourceId); assertTrue(plan.url.contains("stream.mkv?"))
// A scheduled source lock must never switch to another version silently.
fails { active.prepare(item, preferredSourceId = "avi-source") }
}
@Test fun unsupportedContainerDiagnosticIsBoundedAndRejectsUrls() {
val fake = Fake(); fake.response = EmbyReply(200, info(source().put("Container", "avi")))
try { session(fake).prepare(item); fail("Expected unsupported format") }
catch (e: EmbyCheckException) { assertTrue(e.message.orEmpty().contains("avi")) }
assertEquals("unknown", safeContainerLabel("https://private.example/path?secret=value"))
assertEquals("unknown", safeContainerLabel("x".repeat(100)))
assertNull(supportedDirectContainer("avi")); assertEquals("mkv", supportedDirectContainer(" MKV "))
}
@Test fun accepts3gpAndAdvertisesItWithoutRenamingTheStream() {
val fake = Fake(); fake.response = EmbyReply(200, info(source().put("Container", "3gp")))
val plan = session(fake).prepare(item)
assertTrue(plan.url.contains("stream.3gp?"))
val profile = JSONObject(fake.requests.single().body!!).getJSONObject("DeviceProfile")
.getJSONArray("DirectPlayProfiles").getJSONObject(0)
assertTrue(profile.getString("Container").split(",").contains("3gp"))
}
}
@@ -0,0 +1,68 @@
package tv.orbit.classic
import org.json.JSONArray
import org.json.JSONObject
import org.junit.Assert.*
import org.junit.Test
class EpisodeDiscoveryTest {
private val series = SeriesCandidate("fixture-series", "Fixture show", 1996, "TV-PG")
private fun episode(id: String, season: Int, number: Int) = JSONObject().put("Id", id).put("Type", "Episode")
.put("SeriesId", series.id).put("Name", "Fixture episode").put("ParentIndexNumber", season)
.put("IndexNumber", number).put("RunTimeTicks", 13_200_000_000L)
private fun page(items: JSONArray, total: Int = items.length()) = EmbyReply(200,
JSONObject().put("Items", items).put("TotalRecordCount", total).toString())
@Test fun queriesOnlyApprovedSeriesWithBoundedReadOnlyPages() {
val requests = mutableListOf<EmbyRequest>()
val result = discoverSeriesEpisodes(EmbyTransport { r -> requests += r; page(JSONArray().put(episode("ep", 1, 1))) },
"fixture-user", { "fixture-token" }, listOf(series))
val request = requests.single()
assertEquals("GET", request.method); assertTrue(request.path.startsWith("/Shows/fixture-series/Episodes?"))
assertTrue(request.path.contains("Limit=5")); assertTrue(request.path.contains("EnableUserData=false"))
assertFalse(request.path.contains("fixture-token")); assertEquals("fixture-token", request.token)
assertEquals(1_320_000L, result.single().episodes.single().durationMs)
}
@Test fun capsProviderResultsAndOrdersOnlyReturnedSample() {
val items = JSONArray().put(episode("second", 1, 2)).put(episode("first", 1, 1))
repeat(10) { items.put(episode("other-$it", 2, it + 1)) }
val result = discoverSeriesEpisodes(EmbyTransport { page(items) }, "fixture-user", { "fixture-token" }, listOf(series)).single()
assertEquals(5, result.episodes.size); assertEquals("first", result.episodes.first().id); assertTrue(result.moreAvailable)
}
@Test fun rejectsForeignSeriesUnsafeIdsAndDuplicateEpisodes() {
val items = JSONArray().put(episode("same", 1, 1)).put(episode("same", 1, 1))
.put(episode("../bad", 1, 1)).put(episode("foreign", 1, 1).put("SeriesId", "another"))
val result = discoverSeriesEpisodes(EmbyTransport { page(items) }, "fixture-user", { "fixture-token" }, listOf(series))
assertEquals(listOf("same"), result.single().episodes.map { it.id })
}
@Test fun missingOrderAndInvalidRuntimeRemainUnknown() {
val row = episode("ep", 1, 1).put("RunTimeTicks", -1).put("IndexNumber", -1)
row.remove("ParentIndexNumber")
val result = discoverSeriesEpisodes(EmbyTransport { page(JSONArray().put(row)) }, "fixture-user", { "fixture-token" }, listOf(series)).single().episodes.single()
assertNull(result.season); assertNull(result.episode); assertNull(result.durationMs)
}
@Test fun rejectsOversizedOrUnsafeApprovalBeforeNetwork() {
var count = 0
val fake = EmbyTransport { count++; page(JSONArray()) }
for (approved in listOf(emptyList(), List(4) { series.copy(id = "series-$it") }, listOf(series.copy(id = "../bad")), listOf(series, series))) {
try { discoverSeriesEpisodes(fake, "fixture-user", { "fixture-token" }, approved); fail("Expected validation") }
catch (_: IllegalArgumentException) { }
}
assertEquals(0, count)
}
@Test fun rateLimitStopsBeforeSecondSeries() {
var count = 0
try {
discoverSeriesEpisodes(EmbyTransport { count++; EmbyReply(429, "private") }, "fixture-user", { "fixture-token" }, listOf(series, series.copy(id = "second")))
fail("Expected rate limit")
} catch (e: EmbyCheckException) { assertEquals(429, e.httpStatus) }
assertEquals(1, count)
}
@Test fun displaysOnlyReturnedMediaDetailsWithoutGuessingQuality() {
val streams = JSONArray().put(JSONObject().put("Type", "Video").put("Codec", "h264").put("Width", 1920).put("Height", 1080))
.put(JSONObject().put("Type", "Audio").put("Codec", "aac"))
assertEquals("1920×1080 • mkv • h264 / aac", episodeMediaLabel(JSONObject().put("Container", "mkv").put("MediaStreams", streams)))
assertEquals("", episodeMediaLabel(JSONObject()))
assertEquals("unknown", episodeMediaLabel(JSONObject().put("Container", "https://private.example/file")))
}
}
@@ -0,0 +1,95 @@
package tv.orbit.classic
import org.json.JSONArray
import org.json.JSONObject
import org.junit.Assert.*
import org.junit.Test
import javax.crypto.KeyGenerator
class RememberedStateTest {
private fun state(): RememberedState {
val series = SeriesCandidate("fixture-series", "Fixture series", 1996, "TV-PG")
val episodes = SeriesEpisodes(series, List(2) { EpisodeCandidate("episode-$it", "Fixture $it", 1, it + 1, 60_000) }, false)
val channel = SampleChannel(episodes.episodes.map { ScheduledMedia(episodeSampleItem(series, it), "source-${it.id}", 60_000) }, "Toonami", 103)
return RememberedState(RememberedIdentity("fixture-user", "fixture-token", "fixture-device"), EmbySample(emptyList(), 0),
listOf(SeriesMatches(ToonamiSearches.first(), listOf(series), false)), setOf(series.id), listOf(episodes), null, channel, 103)
}
private fun key() = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()
@Test fun encryptedRoundTripPreservesChoicesAndAbsoluteTimeline() {
val original = state(); val key = key()
val plain = RememberedStateCodec.encode(original)
val sealed = ConnectionCipher.seal(key, plain)
assertFalse(String(sealed, Charsets.ISO_8859_1).contains("fixture-token"))
val restored = RememberedStateCodec.decode(ConnectionCipher.open(key, sealed))
assertEquals(original.approved, restored.approved); assertEquals("fixture-token", restored.identity.token)
assertEquals(original.toonami!!.guide(1_793_491_234_567L), restored.toonami!!.guide(1_793_491_234_567L))
assertEquals(103, restored.selectedNumber); assertEquals(2, restored.episodes!!.single().episodes.size)
assertFalse(restored.toString().contains("fixture-token")); assertFalse(restored.identity.toString().contains("fixture-token"))
}
@Test fun freshNoncesProduceDifferentCiphertextAndTamperingIsRejected() {
val key = key(); val plain = RememberedStateCodec.encode(state())
val first = ConnectionCipher.seal(key, plain); val second = ConnectionCipher.seal(key, plain)
assertFalse(first.contentEquals(second))
first[first.lastIndex] = (first.last().toInt() xor 1).toByte()
try { ConnectionCipher.open(key, first); fail("Tampered record accepted") } catch (_: java.security.GeneralSecurityException) { }
try { ConnectionCipher.open(key(), second); fail("Wrong key accepted") } catch (_: java.security.GeneralSecurityException) { }
}
@Test fun wrongSchemaOriginAndOversizedRecordsAreRejected() {
val original = JSONObject(String(RememberedStateCodec.encode(state())))
for (changed in listOf(JSONObject(original.toString()).put("schema", 999), JSONObject(original.toString()).put("origin", "https://other.example"),
JSONObject(original.toString()).put("user", "../bad"), JSONObject(original.toString()).put("approved", JSONArray().put("unmatched")))) {
try { RememberedStateCodec.decode(changed.toString().toByteArray()); fail("Invalid saved record accepted") }
catch (_: IllegalArgumentException) { }
}
try { RememberedStateCodec.decode(ByteArray(RememberedStateCodec.MAX_BYTES + 1)); fail("Oversized record accepted") }
catch (_: IllegalArgumentException) { }
}
@Test fun expiredTokenCanBeRemovedWithoutLosingChannelChoices() {
val noToken = RememberedStateCodec.decode(RememberedStateCodec.encode(state().withoutToken()))
assertNull(noToken.identity.token); assertEquals(103, noToken.toonami!!.number)
assertEquals(setOf("fixture-series"), noToken.approved)
}
@Test fun resumesByValidatingSavedUserWithoutAuthenticatingOrRefreshingSchedule() {
val requests = mutableListOf<EmbyRequest>()
val fake = EmbyTransport { r ->
requests += r
if (r.path == "/Users/fixture-user") EmbyReply(200, """{"Id":"fixture-user"}""")
else EmbyReply(200, JSONObject().put("MediaSources", JSONArray().put(JSONObject().put("Id", "source-episode-0")
.put("Container", "mkv").put("SupportsDirectStream", true).put("RunTimeTicks", 600_000_000L))).toString())
}
val saved = state(); val resumed = EmbyClient(fake).resume(saved)
assertEquals(1, requests.size); assertEquals("GET", requests.single().method)
assertEquals("fixture-token", requests.single().token); assertNull(requests.single().body)
val plan = resumed.prepare(saved.toonami!!.lineup.first().item, preferredSourceId = "source-episode-0")
assertEquals("source-episode-0", plan.sourceId)
resumed.detach()
assertFalse(requests.any { it.path == "/Sessions/Logout" })
try { resumed.mediaHeaders(plan.url); fail("Detached token remains active") } catch (_: EmbyCheckException) { }
}
@Test fun expiredSavedLoginDoesNotTryPasswordOrLogout() {
val requests = mutableListOf<EmbyRequest>()
try { EmbyClient(EmbyTransport { r -> requests += r; EmbyReply(401, "private") }).resume(state()); fail("Expired session accepted") }
catch (e: EmbyCheckException) { assertEquals(401, e.httpStatus) }
assertEquals(1, requests.size); assertEquals("/Users/fixture-user", requests.single().path)
}
@Test fun differentAccountCannotReceiveSavedChoices() {
val active = EmbySampleSession(EmbyTransport { EmbyReply(204, "") }, "other-token", "other-user", EmbySample(emptyList(), 0))
assertFalse(active.matchesAccount(state().identity))
try { active.restoreRemembered(state()); fail("Cross-account restore accepted") } catch (_: IllegalArgumentException) { }
}
@Test fun detachStopsReportedPlaybackButKeepsProviderTokenUnrevoked() {
val requests = mutableListOf<EmbyRequest>()
val fake = EmbyTransport { r -> requests += r; EmbyReply(204, "") }
val saved = state()
val active = EmbySampleSession(fake, "fixture-token", "fixture-user", saved.sample)
active.restoreRemembered(saved)
val media = saved.toonami!!.lineup.first()
val plan = EmbyPlaybackPlan(media.item, "https://media.example.test/fixture.mkv", media.sourceId, "fixture-play", emptyList(), media.durationMs)
active.report(plan, "Playing", 12_345, false)
active.detach()
assertEquals(listOf("/Sessions/Playing", "/Sessions/Playing/Stopped"), requests.map { it.path })
assertEquals(123_450_000L, JSONObject(requests.last().body!!).getLong("PositionTicks"))
assertFalse(requests.any { it.path == "/Sessions/Logout" })
}
}
@@ -0,0 +1,93 @@
package tv.orbit.classic
import org.json.JSONArray
import org.json.JSONObject
import org.junit.Assert.*
import org.junit.Test
class SampleChannelTest {
private fun media(id: String, duration: Long) = ScheduledMedia(SampleItem("Fixture $id", "Movie", duration / 60_000, "mkv", id, duration), "$id-source", duration)
private val entries = listOf(media("a", 90_123), media("b", 120_456), media("c", 45_678))
private fun channel() = SampleChannel(entries)
@Test fun joinsWithinTheCurrentProgrammeUsingExactMilliseconds() {
val show = channel().at(12_345)
assertEquals("a", show.media.item.id); assertEquals(0L, show.startMs)
assertEquals(12_345L, show.offsetAt(12_345)); assertEquals(90_123L, show.endMs)
}
@Test fun exactEndSwitchesToTheNextTitleWithoutAGap() {
val channel = channel(); val before = channel.at(90_122); val after = channel.at(90_123)
assertEquals("a", before.media.item.id); assertEquals("b", after.media.item.id)
assertEquals(before.endMs, after.startMs); assertEquals(0L, after.offsetAt(90_123))
}
@Test fun repeatsLineupWhileKeepingAbsoluteBroadcastTimes() {
val channel = channel(); val first = channel.at(channel.cycleMs)
assertEquals("a", first.media.item.id); assertEquals(channel.cycleMs, first.startMs)
assertEquals("c", channel.at(channel.cycleMs - 1).media.item.id)
}
@Test fun timelineDoesNotDependOnLaunchingTheApp() {
val at = 1_793_491_234_567L
assertEquals(channel().guide(at, 48), SampleChannel(entries.toList()).guide(at, 48))
}
@Test fun guideIsContiguousAcrossMidnightAndClockChangeDates() {
val at = java.time.Instant.parse("2026-10-25T00:59:59Z").toEpochMilli()
channel().guide(at, 48).zipWithNext().forEach { (a, b) -> assertEquals(a.endMs, b.startMs) }
}
@Test fun negativeEpochsUseFloorDivision() {
val show = channel().at(-1)
assertEquals("c", show.media.item.id); assertEquals(-45_678L, show.startMs)
assertEquals(45_677L, show.offsetAt(-1))
}
@Test fun transitionCheckUsesRealDurationsAndCrossesTheBoundary() {
val channel = channel(); val now = 30_000L
val shifted = now + channel.transitionCheckOffset(now)
assertEquals(10_000L, channel.at(shifted).endMs - shifted)
assertEquals("b", channel.at(shifted + 10_000).media.item.id)
assertEquals(90_123L, channel.lineup.first().durationMs)
}
@Test fun keepsItsOwnLineupSnapshot() {
val mutable = entries.toMutableList(); val channel = SampleChannel(mutable); mutable.clear()
assertEquals(3, channel.lineup.size); assertEquals("a", channel.at(0).media.item.id)
}
@Test fun rejectsInvalidDurationDuplicatesAndCounts() {
for (bad in listOf(listOf(entries[0]), listOf(entries[0], entries[0]), listOf(media("a", 0), entries[1]),
listOf(media("a", SampleChannel.MAX_DURATION_MS + 1), entries[1]))) {
try { SampleChannel(bad); fail("Invalid lineup accepted") } catch (_: IllegalArgumentException) { }
}
for (count in listOf(0, 49)) try { channel().guide(0, count); fail("Invalid count") } catch (_: IllegalArgumentException) { }
}
private class ProbeTransport(private val validFrom: Int = 0, private val status: Int = 200) : EmbyTransport {
val requests = mutableListOf<EmbyRequest>()
override fun execute(request: EmbyRequest): EmbyReply {
requests += request
if (status != 200) return EmbyReply(status, "")
val itemId = request.path.substringAfter("/Items/").substringBefore('/')
val index = itemId.substringAfter('-').toInt()
val source = JSONObject().put("Id", "$itemId-source").put("Container", "mkv")
.put("RunTimeTicks", (60_123L + index) * 10_000).put("SupportsDirectStream", index >= validFrom)
return EmbyReply(200, JSONObject().put("PlaySessionId", "play-$index").put("MediaSources", JSONArray().put(source)).toString())
}
}
private fun session(transport: EmbyTransport) = EmbySampleSession(transport, "fixture-token", "fixture-user",
EmbySample(List(20) { SampleItem("Fixture $it", "Movie", 1, "mkv", "id-$it") }, 100_000))
@Test fun buildsOnlyThreeTitlesWithoutStartingPlaybackOrRequestingMorePages() {
val fake = ProbeTransport(); val built = session(fake).buildSampleChannel()
assertEquals(3, built.lineup.size); assertEquals(3, fake.requests.size)
assertEquals(60_123L, built.lineup.first().durationMs)
assertTrue(fake.requests.all { it.path.endsWith("/PlaybackInfo") && !JSONObject(it.body!!).getBoolean("IsPlayback") })
}
@Test fun probesAtMostFiveSampleItemsAndSkipsUnsupportedSources() {
val fake = ProbeTransport(validFrom = 2); val built = session(fake).buildSampleChannel()
assertEquals(5, fake.requests.size); assertEquals(listOf("id-2", "id-3", "id-4"), built.lineup.map { it.item.id })
val limited = ProbeTransport(validFrom = 5)
try { session(limited).buildSampleChannel(); fail("Should not reach sixth item") } catch (_: EmbyCheckException) { }
assertEquals(5, limited.requests.size)
}
@Test fun expiredLoginAndRateLimitStopTheProbeImmediately() {
for (status in listOf(401, 429)) {
val fake = ProbeTransport(status = status)
try { session(fake).buildSampleChannel(); fail("Should stop") }
catch (failure: EmbyCheckException) { assertEquals(status, failure.httpStatus) }
assertEquals(1, fake.requests.size)
}
}
}
@@ -0,0 +1,32 @@
package tv.orbit.classic
import org.junit.Assert.*
import org.junit.Test
class ScheduleTest {
private val channel = Schedule.channels.first()
@Test fun joinsEighteenMinutesIntoAnExistingBroadcast() {
val start = 100 * Schedule.SLOT
val now = start + 18 * 60_000
val show = Schedule.programme(channel, now)
assertEquals(start, show.start)
assertEquals(18 * 60_000L, show.offsetAt(now))
}
@Test fun boundaryBelongsToNextProgramme() {
val before = Schedule.programme(channel, Schedule.SLOT - 1)
val after = Schedule.programme(channel, Schedule.SLOT)
assertEquals(before.end, after.start)
assertNotEquals(before.title, after.title)
assertEquals(0, after.offsetAt(Schedule.SLOT))
}
@Test fun guideHasNoGapsAndSurvivesRestart() {
val time = 1793478000000L
val guide = Schedule.guide(channel, time, 48)
guide.zipWithNext().forEach { (a, b) -> assertEquals(a.end, b.start) }
assertEquals(guide, Schedule.guide(channel, time, 48))
}
@Test fun handlesDatesBeforeEpoch() {
val show = Schedule.programme(channel, -1)
assertEquals(-Schedule.SLOT, show.start)
assertEquals(Schedule.SLOT - 1, show.offsetAt(-1))
}
}
@@ -0,0 +1,77 @@
package tv.orbit.classic
import org.json.JSONArray
import org.json.JSONObject
import org.junit.Assert.*
import org.junit.Test
class SeriesDiscoveryTest {
private fun series(id: String, type: String = "Series") = JSONObject().put("Id", id).put("Type", type)
.put("Name", "Fixture version").put("ProductionYear", 1996).put("OfficialRating", "TV-PG")
private fun page(items: JSONArray, total: Int = items.length()) = EmbyReply(200,
JSONObject().put("Items", items).put("TotalRecordCount", total).toString())
@Test fun fiveBoundedSeriesOnlySearchesWithHeaderToken() {
val requests = mutableListOf<EmbyRequest>()
val result = discoverToonamiSeries(EmbyTransport { r -> requests += r; page(JSONArray().put(series("fixture"))) }, "fixture-user") { "fixture-token" }
assertEquals(5, requests.size); assertEquals(ToonamiSearches.take(5), result.map { it.requested })
assertTrue(requests.all { it.method == "GET" && it.path.startsWith("/Items?UserId=fixture-user&") })
assertTrue(requests.all { it.path.contains("IncludeItemTypes=Series") && it.path.contains("Limit=10") && it.path.contains("StartIndex=0") })
assertTrue(requests.all { it.token == "fixture-token" && !it.path.contains("fixture-token") })
assertTrue(requests[0].path.contains("SearchTerm=Dragon+Ball+Z&"))
assertEquals(1996, result[0].candidates.single().year)
}
@Test fun capsResultsEvenIfServerIgnoresLimit() {
val items = JSONArray(); repeat(50) { items.put(series("series-$it")) }
val result = discoverToonamiSeries(EmbyTransport { page(items) }, "fixture-user") { "fixture-token" }
assertTrue(result.all { it.candidates.size == 10 && it.moreAvailable })
}
@Test fun rejectsNonSeriesUnsafeIdsAndDuplicateIds() {
val items = JSONArray().put(series("good")).put(series("good")).put(series("../bad"))
.put(series("movie", "Movie")).put(series("empty").put("Name", "\n"))
val result = discoverToonamiSeries(EmbyTransport { page(items) }, "fixture-user") { "fixture-token" }
assertEquals(listOf("good"), result.first().candidates.map { it.id })
}
@Test fun rateLimitAndExpiredSessionStopFurtherSearches() {
for (status in listOf(401, 429)) {
var count = 0
try {
discoverToonamiSeries(EmbyTransport { count++; EmbyReply(status, "private") }, "fixture-user") { "fixture-token" }
fail("Expected failure")
} catch (e: EmbyCheckException) { assertEquals(status, e.httpStatus); assertFalse(e.message.orEmpty().contains("private")) }
assertEquals(1, count)
}
}
@Test fun emptyResultsAreNotAnAssertionThatLibraryHasNoEpisodes() {
val results = discoverToonamiSeries(EmbyTransport { page(JSONArray()) }, "fixture-user") { "fixture-token" }
assertEquals(5, results.size); assertTrue(results.all { it.candidates.isEmpty() })
}
@Test fun laterBatchUsesOnlyRemainingTermsAndNeverRepeatsFirstBatch() {
val requests = mutableListOf<EmbyRequest>()
val fake = EmbyTransport { r -> requests += r; page(JSONArray()) }
val results = discoverToonamiSeries(fake, "fixture-user", { "fixture-token" }, 20)
assertEquals(ToonamiSearches.drop(20), results.map { it.requested })
assertEquals(3, requests.size)
assertTrue(discoverToonamiSeries(fake, "fixture-user", { "fixture-token" }, 23).isEmpty())
assertEquals(3, requests.size)
}
@Test fun cancellationStopsBetweenSearches() {
var requests = 0
try {
discoverToonamiSeries(EmbyTransport { requests++; page(JSONArray()) }, "fixture-user", { "fixture-token" }, 0) {
if (requests == 1) throw java.util.concurrent.CancellationException()
}
fail("Expected cancellation")
} catch (_: java.util.concurrent.CancellationException) { }
assertEquals(1, requests)
}
@Test fun sameNamedSeriesKeepSeparateIdsAndGetNeutralEntryLabels() {
val first = SeriesCandidate("one", "Fixture show", 1999, "TV-PG")
val second = first.copy(id = "two")
val labelled = labelSeriesVariants(listOf(first, second, first.copy(id = "remake", year = 2020)))
assertEquals(listOf("one", "two", "remake"), labelled.map { it.id })
assertEquals(listOf("Entry 1 of 2", "Entry 2 of 2", ""), labelled.map { it.variantLabel })
assertFalse(labelled.any { it.variantLabel.contains("1080") || it.variantLabel.contains("4K") })
}
}
@@ -0,0 +1,72 @@
package tv.orbit.classic
import org.json.JSONArray
import org.json.JSONObject
import org.junit.Assert.*
import org.junit.Test
class ToonamiChannelTest {
private fun group(id: String, count: Int = 3) = SeriesEpisodes(SeriesCandidate(id, "Fixture $id", 1996, "TV-PG"),
List(count) { EpisodeCandidate("$id-${it + 1}", "Episode ${it + 1}", 1, it + 1, 60_000) }, false)
@Test fun rotatesShowsAndPreservesPerShowOrderThroughUnevenSamples() {
val ordered = toonamiEpisodeOrder(listOf(group("a", 3), group("b", 2), group("c", 1)))
assertEquals(listOf("a-1", "b-1", "c-1", "a-2", "b-2", "a-3"), ordered.map { it.id })
}
@Test fun rejectsUnknownOrderSpecialsAndDuplicateEpisodeNumbers() {
val original = group("a", 2)
for (bad in listOf(original.copy(episodes = original.episodes.map { it.copy(season = null) }),
original.copy(episodes = original.episodes.map { it.copy(season = 0) }),
original.copy(episodes = original.episodes.map { it.copy(episode = 1) }))) {
try { toonamiEpisodeOrder(listOf(bad)); fail("Expected order rejection") } catch (_: EmbyCheckException) { }
}
}
@Test fun fullFifteenEpisodeSampleUsesClockBoundariesAndRepeats() {
val ordered = toonamiEpisodeOrder(listOf(group("a", 5), group("b", 5), group("c", 5)))
val channel = SampleChannel(ordered.map { ScheduledMedia(it, "${it.id}-source", 60_000) }, "Toonami", 103)
assertEquals(15, channel.lineup.size); assertEquals("b-1", channel.at(60_000).media.item.id)
assertEquals("a-1", channel.at(channel.cycleMs).media.item.id)
assertEquals(12_345L, channel.at(72_345).offsetAt(72_345))
try { SampleChannel(List(16) { ScheduledMedia(SampleItem("x", "Episode", 1, "mkv", "id-$it"), "source", 60_000) }); fail("Expected cap") }
catch (_: IllegalArgumentException) { }
}
private class Fake(private val unsupported: Boolean = false) : EmbyTransport {
val requests = mutableListOf<EmbyRequest>()
override fun execute(request: EmbyRequest): EmbyReply {
requests += request
if (request.path.startsWith("/Shows/")) {
val id = request.path.substringAfter("/Shows/").substringBefore('/')
val items = JSONArray()
repeat(2) { i -> items.put(JSONObject().put("Id", "$id-${i + 1}").put("Name", "Episode ${i + 1}")
.put("Type", "Episode").put("SeriesId", id).put("ParentIndexNumber", 1).put("IndexNumber", i + 1)
.put("RunTimeTicks", 600_000_000L)) }
return EmbyReply(200, JSONObject().put("Items", items).toString())
}
val id = request.path.substringAfter("/Items/").substringBefore('/')
val source = JSONObject().put("Id", "$id-source").put("Container", "mkv")
.put("SupportsDirectStream", !unsupported).put("RunTimeTicks", 601_230_000L)
return EmbyReply(200, JSONObject().put("MediaSources", JSONArray().put(source)).toString())
}
}
private fun session(fake: Fake) = EmbySampleSession(fake, "fixture-token", "fixture-user", EmbySample(emptyList(), 0))
@Test fun onlyCheckedEpisodesAreEligibleAndBuildDoesNotStartPlayback() {
val fake = Fake(); val session = session(fake)
try { session.prepare(SampleItem("Injected", "Episode", 1, "mkv", "a-1")); fail("Expected allowlist") }
catch (_: EmbyCheckException) { }
assertTrue(fake.requests.isEmpty())
session.checkSeriesEpisodes(listOf(group("a").series, group("b").series))
val channel = session.buildToonamiChannel()
assertEquals(103, channel.number); assertEquals("Toonami", channel.name)
assertEquals(listOf("a-1", "b-1", "a-2", "b-2"), channel.lineup.map { it.item.id })
assertEquals(60_123L, channel.lineup.first().durationMs)
val probes = fake.requests.filter { it.path.endsWith("/PlaybackInfo") }
assertEquals(4, probes.size); assertTrue(probes.all { !JSONObject(it.body!!).getBoolean("IsPlayback") })
val plan = session.prepare(channel.lineup.first().item, preferredSourceId = "a-1-source")
assertEquals("a-1-source", plan.sourceId)
}
@Test fun unsupportedEpisodeStopsBuildInsteadOfSkippingAheadInSeries() {
val fake = Fake(true); val session = session(fake)
session.checkSeriesEpisodes(listOf(group("a").series))
try { session.buildToonamiChannel(); fail("Expected eligibility failure") } catch (_: EmbyCheckException) { }
assertEquals(1, fake.requests.count { it.path.endsWith("/PlaybackInfo") })
}
}
+4
View File
@@ -0,0 +1,4 @@
plugins {
id("com.android.application") version "9.1.1" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.2.10" apply false
}
+3
View File
@@ -0,0 +1,3 @@
org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8
org.gradle.workers.max=2
android.useAndroidX=true
Binary file not shown.
+9
View File
@@ -0,0 +1,9 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionSha256Sum=b266d5ff6b90eada6dc3b20cb090e3731302e553a27c5d3e4df1f0d76beaff06
Vendored Executable
+248
View File
@@ -0,0 +1,248 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
+93
View File
@@ -0,0 +1,93 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+4
View File
@@ -0,0 +1,4 @@
pluginManagement { repositories { google(); mavenCentral(); gradlePluginPortal() } }
dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS); repositories { google(); mavenCentral() } }
rootProject.name = "OrbitTV"
include(":app")
+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/env bash
set -euo pipefail
ORBIT_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
export GRADLE_USER_HOME="$ORBIT_ROOT/.tooling/gradle-cache"
export ANDROID_USER_HOME="$ORBIT_ROOT/.tooling/android"
export ANDROID_HOME="${ANDROID_HOME:-/opt/android-sdk}"
cd "$ORBIT_ROOT/android"
./gradlew --no-daemon :app:assembleDebug :app:testDebugUnitTest :app:lintDebug
@@ -0,0 +1,14 @@
# After Dark — Night settles / motion proof 03
Requested sequence: windows illuminate individually as the skyline settles, a magenta orbital trail draws across the sky, then the approved logo appears.
- Twelve separate window layers switch on at staggered times (0.28–1.24 s including settling). Four have a small second dip in brightness; the rest of the approved windows stay at a dim ambient level.
- At 1.4 s, the real vector stroke begins drawing with EffectCraft Trim Paths, completing at 2.4 s. A second low-opacity wider stroke supplies a restrained light edge.
- Logo reveal begins at 2.44 s and settles by 2.92 s. The clip holds, then ends at 4.2 s.
- Silent H.264 MP4, 1280×720, 25 fps, 105 frames. No transparent-video assumption.
Open `sources/collected-project/orbit_after_dark_night.ecproj` for the self-contained native project with relative Footage paths. It has 12 separately keyed window footage layers, two editable shape/trim layers, the base skyline and the logo. Collected media is included. Original SVG windows/skyline and the VectorCraft logo source are included in brand-source; Barlow fonts and licences accompany it.
`build-motion.py` rebuilds the source project and render using the local brand-source folder. It uses only installed VectorCraft/EffectCraft CLIs and the Python standard library. The script writes its command JSON and generated assets in this folder.
Validation: all frames decoded; four-point contact sheet and final native render inspected; the collected project reopened/rendered using relative footage paths. The motion is not bundled into APK 0.15 and does not run between programmes. Playback integration still needs explicit optional settings, clock-safe timing/fallback and a TV test. The approved static lit-window background and EPG logo are the artwork integrated into channel 102's review/guide screens.
Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

@@ -0,0 +1,93 @@
Copyright 2017 The Barlow Project Authors (https://github.com/jpt/barlow)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -0,0 +1,93 @@
Copyright 2017 The Barlow Project Authors (https://github.com/jpt/barlow)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1920" height="1080" viewBox="0 0 1920 1080"><defs><linearGradient id="night" x2="0" y2="1"><stop stop-color="#071632"/><stop offset=".62" stop-color="#101F46"/><stop offset="1" stop-color="#392552"/></linearGradient><radialGradient id="haze"><stop stop-color="#E84CCB" stop-opacity=".19"/><stop offset="1" stop-color="#071632" stop-opacity="0"/></radialGradient></defs><rect width="1920" height="1080" fill="url(#night)"/><ellipse cx="1530" cy="540" rx="700" ry="600" fill="url(#haze)"/></svg>

After

Width:  |  Height:  |  Size: 540 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1920" height="1080" viewBox="0 0 1920 1080"><path d="M0 1080V900H0V919H44V1080H51V964H121V1080H132V901H185V1080H199V999H271V1080H287V871H355V1080H369V877H451V1080H458V914H540V1080H552V880H601V1080H608V997H648V1080H664V863H710V1080H722V954H789V1080H805V876H873V1080H880V905H946V1080H961V993H1026V1080H1034V987H1108V1080H1126V914H1182V1080H1198V880H1245V1080H1256V900H1338V1080H1356V976H1416V1080H1433V917H1478V1080H1486V860H1525V1080H1533V935H1608V1080H1619V868H1662V1080H1676V942H1739V1080H1757V890H1812V1080H1827V896H1896V1080H1914V880H1989V1080H1920Z" fill="#061025"/><path d="M1560 250C1438 240 1379 408 1491 478C1423 391 1460 292 1560 250Z" fill="#B7CBE8" opacity=".4"/><path d="M1120 827C1360 793 1612 744 1810 679" fill="none" stroke="#E84CCB" stroke-width="2" opacity=".45"/></svg>

After

Width:  |  Height:  |  Size: 835 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1920" height="1080" viewBox="0 0 1920 1080"><circle cx="299" cy="402" r="1" fill="#B7CBE8" opacity=".45"/><circle cx="1545" cy="619" r="1" fill="#B7CBE8" opacity=".45"/><circle cx="582" cy="677" r="1.5" fill="#B7CBE8" opacity=".45"/><circle cx="462" cy="215" r="1.5" fill="#B7CBE8" opacity=".45"/><circle cx="1685" cy="251" r="1.5" fill="#B7CBE8" opacity=".45"/><circle cx="1795" cy="88" r="1" fill="#B7CBE8" opacity=".45"/><circle cx="870" cy="135" r="1.5" fill="#B7CBE8" opacity=".45"/><circle cx="168" cy="190" r="1.5" fill="#B7CBE8" opacity=".45"/><circle cx="635" cy="102" r="1.5" fill="#B7CBE8" opacity=".45"/><circle cx="420" cy="414" r="1.5" fill="#B7CBE8" opacity=".45"/><circle cx="1129" cy="187" r="1" fill="#B7CBE8" opacity=".45"/><circle cx="190" cy="590" r="1.5" fill="#B7CBE8" opacity=".45"/><circle cx="354" cy="100" r="1" fill="#B7CBE8" opacity=".45"/><circle cx="1241" cy="315" r="1" fill="#B7CBE8" opacity=".45"/><circle cx="1138" cy="483" r="1" fill="#B7CBE8" opacity=".45"/><circle cx="1579" cy="75" r="1" fill="#B7CBE8" opacity=".45"/><circle cx="698" cy="92" r="1" fill="#B7CBE8" opacity=".45"/><circle cx="1624" cy="526" r="1" fill="#B7CBE8" opacity=".45"/><circle cx="1281" cy="639" r="1" fill="#B7CBE8" opacity=".45"/><circle cx="785" cy="54" r="1" fill="#B7CBE8" opacity=".45"/><circle cx="1114" cy="610" r="1" fill="#B7CBE8" opacity=".45"/><circle cx="1742" cy="503" r="1" fill="#B7CBE8" opacity=".45"/><circle cx="668" cy="119" r="1" fill="#B7CBE8" opacity=".45"/><circle cx="366" cy="255" r="1" fill="#B7CBE8" opacity=".45"/><circle cx="1335" cy="664" r="1" fill="#B7CBE8" opacity=".45"/><circle cx="1425" cy="517" r="1" fill="#B7CBE8" opacity=".45"/><circle cx="1785" cy="118" r="1" fill="#B7CBE8" opacity=".45"/><circle cx="673" cy="607" r="1" fill="#B7CBE8" opacity=".45"/><circle cx="1801" cy="637" r="1" fill="#B7CBE8" opacity=".45"/><circle cx="1260" cy="381" r="1" fill="#B7CBE8" opacity=".45"/><circle cx="533" cy="392" r="1.5" fill="#B7CBE8" opacity=".45"/><circle cx="161" cy="717" r="1" fill="#B7CBE8" opacity=".45"/><circle cx="1632" cy="343" r="1" fill="#B7CBE8" opacity=".45"/><circle cx="834" cy="191" r="1.5" fill="#B7CBE8" opacity=".45"/><circle cx="1348" cy="727" r="1.5" fill="#B7CBE8" opacity=".45"/><circle cx="1074" cy="181" r="1" fill="#B7CBE8" opacity=".45"/><circle cx="1105" cy="253" r="1" fill="#B7CBE8" opacity=".45"/><circle cx="113" cy="550" r="1.5" fill="#B7CBE8" opacity=".45"/><circle cx="275" cy="121" r="1" fill="#B7CBE8" opacity=".45"/><circle cx="1527" cy="290" r="1" fill="#B7CBE8" opacity=".45"/><circle cx="830" cy="292" r="1.5" fill="#B7CBE8" opacity=".45"/><circle cx="738" cy="181" r="1" fill="#B7CBE8" opacity=".45"/><circle cx="1846" cy="706" r="1" fill="#B7CBE8" opacity=".45"/><circle cx="1525" cy="246" r="1" fill="#B7CBE8" opacity=".45"/><circle cx="1762" cy="553" r="1" fill="#B7CBE8" opacity=".45"/><circle cx="792" cy="304" r="1" fill="#B7CBE8" opacity=".45"/><circle cx="1811" cy="519" r="1" fill="#B7CBE8" opacity=".45"/><circle cx="1340" cy="506" r="1" fill="#B7CBE8" opacity=".45"/><circle cx="1094" cy="205" r="1.5" fill="#B7CBE8" opacity=".45"/><circle cx="1168" cy="293" r="1" fill="#B7CBE8" opacity=".45"/><circle cx="1744" cy="524" r="1" fill="#B7CBE8" opacity=".45"/><circle cx="418" cy="321" r="1" fill="#B7CBE8" opacity=".45"/><circle cx="612" cy="454" r="1" fill="#B7CBE8" opacity=".45"/><circle cx="1789" cy="633" r="1" fill="#B7CBE8" opacity=".45"/><circle cx="188" cy="154" r="1" fill="#B7CBE8" opacity=".45"/><circle cx="1087" cy="544" r="1.5" fill="#B7CBE8" opacity=".45"/><circle cx="1388" cy="304" r="1" fill="#B7CBE8" opacity=".45"/><circle cx="1003" cy="513" r="1" fill="#B7CBE8" opacity=".45"/></svg>

After

Width:  |  Height:  |  Size: 3.7 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 20 KiB

@@ -0,0 +1,53 @@
from pathlib import Path
import xml.etree.ElementTree as ET
import json,subprocess,os
P=Path(__file__).resolve().parent; B=P/'brand-source'
S=P/'sources'; E=P/'exports'; S.mkdir(exist_ok=True);E.mkdir(exist_ok=True)
def body(path):
root=ET.parse(path).getroot()
return ''.join(ET.tostring(c,encoding='unicode') for c in root)
def svg(content):return '<svg xmlns="http://www.w3.org/2000/svg" width="1920" height="1080" viewBox="0 0 1920 1080">'+content+'</svg>'
# Preserve the approved geometry, but leave its static trail out for the animated stroke.
city=ET.parse(B/'sources/orbit_after_dark_skyline.svg').getroot()
city_body=''.join(ET.tostring(c,encoding='unicode') for c in list(city)[:2])
windows=list(ET.parse(B/'sources/orbit_after_dark_windows.svg').getroot())
chosen=[windows[int((i+.4)*len(windows)/12)] for i in range(12)]
chosen_ids={id(v) for v in chosen}
dim_windows=''.join(ET.tostring(w,encoding='unicode') for w in windows if id(w) not in chosen_ids)
base=body(B/'sources/orbit_after_dark_sky.svg')+body(B/'sources/orbit_after_dark_stars.svg')+city_body+'<g opacity=".2">'+dim_windows+'</g>'
(S/'night_base.svg').write_text(svg(base))
env=dict(os.environ,XDG_DATA_HOME=str((P.parent.parent.parent/'.tooling/font-data').resolve()))
def render(src,dst):subprocess.run(['vectorcraft-cli','convert',str(src),str(dst)],env=env,check=True,stdout=subprocess.DEVNULL)
render(S/'night_base.svg',E/'night_base.png')
for i,w in enumerate(chosen):
w.set('opacity','.9')
(S/f'window_{i+1:02}.svg').write_text(svg(ET.tostring(w,encoding='unicode')))
render(S/f'window_{i+1:02}.svg',E/f'window_{i+1:02}.png')
steps=[{'command':'comp.new','params':{'name':'After Dark — night settles','width':1280,'height':720,'frameRate':25,'duration':4.2,'background':'#071632'}}]
def cmd(c,**p):steps.append({'command':c,'params':p})
def load(path,name,scale):
cmd('file.import',paths=[str(path)],addToComp=True)
cmd('layer.rename',layer='#1',name=name)
cmd('prop.set',layer=name,path='transform/scale',value=[scale,scale,100])
def key(layer,path,t,v):cmd('prop.addKey',layer=layer,path=path,time=round(t,3),value=v)
load(E/'night_base.png','Night skyline',66.6667)
for i in range(12):
name=f'Flat window {i+1:02}';load(E/f'window_{i+1:02}.png',name,66.6667)
t=.28+i*.075
for at,v in [(0,0),(t,0),(t+.035,100),(t+.08,35 if i%3==0 else 100),(t+.13,100)]:key(name,'transform/opacity',at,v)
# Real editable vector path with Trim Paths: the trail draws itself, rather than wiping a raster.
path={'t':'Path','v':{'vertices':[[135,545],[1125,270]],'in_tangents':[[0,0],[-190,200]],'out_tangents':[[200,140],[0,0]],'closed':False}}
for name,width,opacity in [('Orbital trail glow',10,14),('Magenta orbital trail',2.5,100)]:
cmd('layer.newShape',kind='none',name=name)
for kind in ['path','stroke','trim']:cmd('layer.addShapeItem',layer=name,kind=kind)
for path_name,value in [('transform/position',[0,0,0]),('contents/path/path',path),('contents/stroke/color',[232/255,76/255,203/255,1]),('contents/stroke/width',width),('contents/stroke/opacity',opacity),('contents/stroke/cap',1)]:cmd('prop.set',layer=name,path=path_name,value=value)
key(name,'contents/trim/end',0,0);key(name,'contents/trim/end',1.4,0);key(name,'contents/trim/end',2.4,100)
for t,v in [(0,100),(3.95,100),(4.2,0)]:key(name,'transform/opacity',t,v)
load(B/'exports/orbit_after_dark_primary.png','After Dark logo',62)
cmd('prop.set',layer='After Dark logo',path='transform/position',value=[640,330,0])
for t,v in [(0,0),(2.44,0),(2.92,100),(3.95,100),(4.2,0)]:key('After Dark logo','transform/opacity',t,v)
for t,v in [(2.44,[64,64,100]),(2.92,[62,62,100])]:key('After Dark logo','transform/scale',t,v)
file=S/'build-commands.json';file.write_text(json.dumps(steps,indent=2))
project=S/'orbit_after_dark_night.ecproj'
subprocess.run(['effectcraft-cli','--empty','run','--commands',str(file),'--save-as',str(project),'--json'],check=True,stdout=subprocess.DEVNULL)
subprocess.run(['effectcraft-cli','--project',str(project),'render','--out',str(E/'orbit_after_dark_night.mp4'),'--format','h264','--fps','25','--bitrate','3500','--audio','off','--progress'],check=True)
Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Some files were not shown because too many files have changed in this diff Show More