An Apple TV, iPhone, iPad, Mac, Google TV, Fire TV and Android app (LG and Samsung TVs run the web port in web/, Roku players and TVs the BrightScript port in roku/) that turns your Plex / Emby / Jellyfin library into an old-school TV channel guide. Channels are built from your library (genre + decade, series marathons, directors, actors, years, plus ones you make); programs run on a fixed schedule generated deterministically from the clock, so every device agrees with no backend. The app ships two themes, Late Show (default) and Midnight Movie, switchable from Options and Settings › Appearance and stored per profile.
Get it for Apple TV on the App Store, for Android phones, tablets and Google TV on Google Play, and for Fire TV on the Amazon Appstore.
Apple TV, running the Sample station. Every title, person and listing in it is made up.
Important
Rerun Guide is free and always will be. It is not for sale: no price, no in-app purchases, no subscriptions, and its license doesn't let anyone else sell it either. The only ads are the occasional "Message from Soyboi" spots for the beacon, Soyboi's other project, and you can turn them off in Settings.
project.yml: xcodegen spec. Regenerate after adding files:xcodegen generate.Design/handoff/(local only:.gitignorekeepsDesign/out of the repo): the design handoff.README.mdis the spec (positions, colors, type, behaviour); the.dc.htmlfiles are the clickable prototype, icon and App Store art (open them in a browser; they all load the onesupport.jsat the top level).App/: the SwiftUI app, one source tree for two targets:RerunGuide(tvOS) andRerunGuide-iOS(iPhone, iPad, and Mac via Mac Catalyst).AppModel.swift: the single@Observablestate object (navigation, profiles, settings, channels, playback). Screen-specific remote handlers live inApp/<Area>/AppModel+<Area>.swift.Remote.swift:RemoteCaptureViewowns the Siri Remote and turns presses/swipes intoRemoteCommands. The app drives its own focus model (the guide scrolls on its own, loops and pages), so screens never use focusable SwiftUI controls, except a text field whileeditingFieldis set. Off tvOS the same view is the first responder for hardware keyboards (arrows, Return = Select, Space = Play/Pause, Esc or Delete = Back) and reads the scroll wheel / trackpad. A keyboard's number keys type a channel number on the guide and the player (the box in the top-right corner,AppModel+Watch.swift).Touch.swift: iPhone, iPad and Mac input layered on the remote model. A tap focuses an element and presses Select (tapSelects), drags and the scroll wheel press the arrows (window pan recognizers inRemote.swift), and a touch they take as a drag is not also a tap (tap),TouchChromedraws the guide's menu button (in the iPhone's landscape margin beside the Dynamic Island) and Back, with Play beside it in a break, a tap on the picture is Play/Pause (watchTap), andPictureControlsdraws Play/Pause over it for a few seconds after (and for as long as it is paused), with 10 s back and ahead beside it on a program (not on the Sample station or channel 99),MenuHintbounces the menu's edge in from the left on the first three launches (a swipe in from the left edge or a tap on the You chip also opens the menu), andTextEntryeditseditingFieldin an alert instead of an inline field. Every modifier there is a no-op on tvOS.RootViewscales the 1080-tall stage to fit the window: on screens wider than 16:9 (iPhone, wide Mac windows) it uses the full height and widens (\.stageWidth), so the guide, overlays and toast stretch while other screens stay 1920 wide and centered; iPad letterboxes. Held upright, iPhone and iPad switch toPortrait/on the screens that have a portrait layout; the rest turn an iPhone sideways (OrientationLockinRerunGuideApp.swift) and letterbox on iPad.Theme.swift: the Late Show and Midnight Movie tokens and fonts (ThemeID,AppModel.setTheme); views branch ont.id == .midnightwhere the geometry differs;.at(x, y, w:, h:)places views on the 1920×1080 stage the handoff is drawn on.Portrait/: iPhone and iPad held upright. Lineup (preview plus a grid that loops down the channels and scrolls 24 hours across), Program info and New channel as sheets, Now playing, Channels and Settings, drawn in window space over the sameAppModel: a tap sets the focus and presses Select, as on the stage. Now playing keeps the stage'sWatchScreenin its picture box, so turning the device keeps the stream; its full-screen button turns a phone to the TV player. Not built for tvOS. Its text follows the device's text size setting (Dynamic Type): content all the way, bars, their buttons and chips up to the largest standard size (barType()), with the lineup becoming a list at the accessibility sizes. The stage is a fixed artboard and keeps its type whatever the setting (RootViewpins it).Guide/,Watch/,Onboarding/,Settings/,Overlays/,Search/: one folder per screen group.Search/is Options › Search: the page, Program info's Make a channel, and the Make a channel page (MakeChannelOverlayinOverlays.swift).Assets.xcassets: tvOS app icon (layered), Top Shelf images andLaunchSplash(the Apple TV launch screen, shown byLaunchScreen.storyboard);AppIconis the iPhone/iPad/Mac icon. The tvOS icon layers and Top Shelf images are the designer's art (Design/handoff/app-icon); only the launch splash is a placeholder.PrivacyInfo.xcprivacyis the privacy manifest (UserDefaults, reason CA92.1).
Core/: theRerunGuideCoreSwift package (no UI, tested on macOS):Models.swift,MediaServer.swift: the contract every backend implements (MediaItem,LibrarySection,ChannelDefinition+RuleChip,Channel,Slot,PlaybackSource).PlexClient.swift: plex.tv PIN and password sign-in, server discovery, library, extras/trailers, HLS transcode URLs.JellyfinClient.swift: Emby and Jellyfin password and Quick Connect sign-in (over https when the typed address answers there), views, library, local trailers,PlaybackInfo-driven direct play / transcode.EmbyConnect.swift: Emby's PIN sign-in (the viewer types a code at emby.media/pin, like plex.tv/link). It covers the PIN, the Emby Connect account's servers, andJellyfinClient.connectExchangeto trade a server's Connect key for a local sign-in. Emby doesn't document these calls; they follow Emby.ApiClient.Java'sConnectService.GuideGenerator.swift:ChannelBuilder(library → automatic channels, rule resolution, "new channel" candidates) and the seeded, day-tiling scheduler with program status.TitleSearch.swift: Options › Search. Films by title and shows by series title, matched ignoring case, accents, punctuation and a leading "The", "A" or "An"; exact titles first, then ones that start with the text, then the rest, 50 at most; and when each airs next in the coming 24 hours.DemoServer.swift: fixed fake library so the whole app runs without a server ("Sample station" on the welcome screen).
Needs Xcode 16 or later, xcodegen (brew install xcodegen) and, for Android, JDK 17 and the Android SDK (API 36).
xcodegen generate
open RerunGuide.xcodeprojPick an Apple TV simulator and run. Drive it with the simulator's remote (arrow keys, Return = Select, Space = Play/Pause, Esc = Back).
For iPhone, iPad or Mac, pick the RerunGuide-iOS scheme and an iPhone/iPad simulator or My Mac (Mac Catalyst). Tap or click to focus and select, drag or scroll to move through lists. Both apps share the bundle ID com.rerunguide.RerunGuide (universal purchase). The Mac build is sandboxed and keeps the server token in the Keychain, so it needs a development provisioning profile. To build under your own Apple developer team, set DEVELOPMENT_TEAM (in the top-level settings of project.yml) and the bundle IDs there, then run xcodegen generate. From the command line:
xcodebuild build -project RerunGuide.xcodeproj -scheme RerunGuide-iOS -destination 'platform=macOS,variant=Mac Catalyst' -allowProvisioningUpdatesLAN discovery broadcasts over UDP for both Plex (GDM) and Jellyfin/Emby, so on iPhone and iPad it needs Apple's Multicast Networking entitlement (com.apple.developer.networking.multicast, requested at developer.apple.com/contact/request/networking-multicast); until it is granted and added to project.yml, the address screen there offers typed addresses only. The Mac build has the sandbox's com.apple.security.network.server entitlement so it can receive the replies.
Core tests (macOS, no simulator needed):
cd Core && swift testRemote walkthrough on a booted Apple TV simulator (drives the app with XCUIRemote, saves a screenshot per step to SHOT_DIR):
TEST_RUNNER_SHOT_DIR=/tmp/rerun-shots xcodebuild test -project RerunGuide.xcodeproj -scheme RerunGuide -destination 'platform=tvOS Simulator,name=Apple TV 4K (3rd generation)' -only-testing:RerunGuideUITestsandroid/ is the same app for Google TV and Amazon Fire TV (D-pad remote) and Android phones and tablets (touch), one APK, Kotlin + Jetpack Compose. It is a line-by-line port that keeps the Swift names, so a change in one app maps directly onto the other; the Swift app is the reference.
android/core: plain-JVM Kotlin port ofCore/(same types and functions).ParityTestchecks the scheduler bit for bit against values the Swift Core produced (core/src/test/resources/parity-fixture.json). After changing the Swift Core, regenerate it withswift run --package-path tools/fixturegen fixturegen android/core/src/test/resources/parity-fixture.json(from the repo root).LiveServersTestandSubtitleLiveTesthit real servers only whenPLEX_URL,PLEX_TOKEN,EMBY_URL,EMBY_TOKENandEMBY_USER_IDare set; keep those in a file outside the repo and load it withset -a; . path/to/test.env; set +a.android/app: port ofApp/.AppModel*.ktmirrorAppModel+*.swift, screens are composables with the same names.Stage.ktdraws the 1920×1080 stage with 1 dp = 1 stage point, so every number from the Swift views carries over;MainActivityturns remote keys intoRemoteCommands. Text entry is always a dialog (the Swift non-tvOS path).Portrait.kt,PortraitGuide.ktandPortraitWatch.ktareApp/Portraitfor phones and tablets held upright; phones turn sideways for screens without a portrait layout. Their text follows the system font size, bars up to 1.3 (BarType); the stage pins its font scale to 1. The player is Media3 ExoPlayer; channel 99's scramble is an AGSL shader on Android 13+ with a fallback below.Platform.isFireTV(Amazon'samazon.hardware.fire_tvfeature) names the device and the remote's Select in the copy, and on Fire TV channel 99 is the web port's silentweb/assets/scramble.gif(packaged from there as an asset) instead of the shader and warble.
cd android
./gradlew :core:test # Kotlin core tests, including the Swift parity check
./gradlew :app:assembleDebug # app/build/outputs/apk/debug/app-debug.apk
adb install -r app/build/outputs/apk/debug/app-debug.apkDebug builds accept the Swift launch arguments as intent extras (--ei tuneOverride 2, --es themeOverride midnight, --ei attractMinutesOverride 1, --ez spotOverride true, --es serversOverride 3) plus --ez forceTV true, which treats a phone or TV-profile emulator image as a TV, and --ez forceFireTV true, which treats it as a Fire TV (there is no Fire OS emulator):
adb shell am start -n com.rerunguide.app/.MainActivity --ez forceTV trueTo try a build on a Fire TV, turn on Settings › My Fire TV › Developer options › ADB debugging (on newer Fire OS the menu appears after clicking the device name in About 7 times), then adb connect <fire-tv-ip>:5555 and adb install -r the APK.
roku/ is the same app for Roku players and Roku TVs: BrightScript and SceneGraph (Roku has no browser engine for the web port), ported through web/ and keeping its names. The scheduler passes the same Swift parity fixture, run off-device by brs-cli; npm run sim runs the channel in a browser simulator. Build, sideload, the BrightScript porting rules and what isn't on Roku yet are in roku/README.md.
cd roku
npm install
npm test # the Swift parity check (Node 22 or later)
npm run build # dist/rerun-guide.zip, sideloadable in developer modeBricolage Grotesque 500–700 + Hanken Grotesk (Late Show) and Barlow Condensed 500–800 + 800 italic (Midnight Movie) are bundled under App/Fonts/ with their SIL Open Font License texts and registered in project.yml (UIAppFonts). Theme.swift looks them up by PostScript name (Family-Weight) and falls back to the system font if a file is missing.
- Station idents: a channel card with a two-note sting on every channel change, and a "We'll be right back" card before trailer breaks (Settings › Playback › Station idents).
- Tonight on…: the channel banner lists the next programs on the channel you're watching instead of remote hints.
- Attract mode: leave the guide alone and it settles into a slow storefront wall: enlarged preview, big clock, listings drifting at half speed; the first press wakes it (Settings › Guide › Attract mode after).
- Favorites: Program info's Add to favorites stars a channel for the profile; Options › Favorites shows only the starred ones, and channel up and down surf them.
- Sleep timer: the banner's last chip (Off, End of program, 30, 60 or 90 minutes) pauses the program at the deadline, with a toast a minute before, so the device's own screen saver and sleep take over.
- Top Shelf and widget: on its way to the Home screen the app leaves a week of listings in its App Group (
Shelf); the Apple TV Top Shelf shows what is on now, the iPhone and iPad widget the first channels' programs, and a tap tunes in (rerunguide://tune?channel=<id>). - Match frame rate and HDR (Apple TV, Settings › Playback): each program's frame rate and dynamic range go to the TV before its stream starts, when Match Content is on in the Apple TV's settings.
- Link by QR code: the Plex link screen shows plex.tv's picture of the PIN; a phone camera opens plex.tv/link with the code filled in.
ChannelBuilder.automaticChannels(from:)turns the library into channel definitions; user edits and custom channels are layered on top inAppModel.rebuildChannelsAsync().GuideGenerator.schedule(lineup:day:)schedules the whole lineup for a day at once and tiles it in 30‑minute blocks (runtime + 3 min, rounded up). In order channels, and Shuffle channels of two or more shows' episodes, run on a tape: a marathon carries on tomorrow where it stopped tonight, through the calendar year (each January 1 it restarts at a seeded episode), and on a shelf of several shows the shows take turns, each in order (the editor's Order reads "Shows take turns"). Other channels seed a PRNG withchannelID + dateand shuffle or rotate their pool daily. Channels are scheduled In order first, then turns, then the rest, with the "what's new" channels and Unwatched last, and each passes over a film or episode a channel before it is airing while anything else will do, short of a repeat: a shuffle channel takes its next free item, a turns channel brings another show's turn forward. So a small library can still air one thing on two channels at once: an In order channel never gives way, a shuffle or turns channel with nothing free airs it anyway, a shuffle day opens with its first pick when another channel airs its first two, and its last slot takes an aired film before a repeat. A short program (half its half hour or less) shares that half hour with the next ones that fit, back to back, so a channel of short cartoons isn't mostly breaks; nothing crosses a half-hour mark. Time left in a slot after the program ends is a "break": trailers from your library, or a station card. Nothing airs twice in a row, across midnight too (except on a channel with nothing else that fits, and rarely on a shuffle channel of two or three films, around programs half a day long or more, or where a turns channel swaps its first episode): the same item, or another copy of the same film (same title and year). A film's cut is part of its title ("Watchmen (Directors Cut)", from Plex's edition or a{edition-…}file name), so two cuts are two films.- Tuning in joins in progress (
now - slotStart); servers that transcode get the offset in the URL, direct-play streams are seeked in AVPlayer. - A paused, held or started-over program runs behind the clock. When it ends it is live again (
watchInfo): its break is the channel's own and ends with the slot, so the next program starts on time; further behind than the break is long, it joins what is on. - A program started from its beginning (Start over, Watch now, the guide's Play, the "Start from the beginning" tune mode) is marked watched on the server once it has played to 90%: Plex
PUT /:/scrobble, Jellyfin and EmbyPOST /Users/{user}/PlayedItems/{item}. Joining live never marks anything, even after a pause or a skip back. The app's copy of the library keeps its flag until the next library refresh, so the Unwatched channel doesn't change mid-program.
Licensed under PolyForm Noncommercial 1.0.0: free to use, change and share for noncommercial purposes; selling it or using it commercially needs a separate license from Soyboi LLC. Bundled fonts keep their SIL Open Font License.


