feat(ios): TVAnarchyiOS app target + UI tests

(cherry picked from commit 7f8f4b0dd92358ba687f8230a922d8f316cb06e9)
This commit is contained in:
Natalie 2026-06-09 05:34:39 -07:00
parent 6366a841f4
commit f0669f1ca8
14 changed files with 756 additions and 0 deletions

View file

@ -0,0 +1,14 @@
{
"images" : [
{
"filename" : "icon-1024.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View file

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -0,0 +1,65 @@
// HTTP client for the plum-control-bridge. Phase-1 surface: list shows, and
// build a raw-file stream URL for VLCKit. Auth (when the bridge has a token set)
// rides as a Bearer header for JSON and a ?token= query for the stream URL,
// because VLCKit is handed a bare URL with no header hook.
import Foundation
enum BridgeError: LocalizedError {
case badStatus(Int)
case transport(String)
var errorDescription: String? {
switch self {
case .badStatus(let code): return "Bridge returned HTTP \(code)."
case .transport(let msg): return msg
}
}
}
struct BridgeClient {
let baseURL: URL
let token: String?
func fetchShows(refresh: Bool = false) async throws -> [BridgeShow] {
var comps = URLComponents(
url: baseURL.appendingPathComponent("library").appendingPathComponent("shows"),
resolvingAgainstBaseURL: false
)!
if refresh { comps.queryItems = [URLQueryItem(name: "refresh", value: "1")] }
var request = URLRequest(url: comps.url!)
request.timeoutInterval = 30
if let token { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") }
let data: Data
let response: URLResponse
do {
(data, response) = try await URLSession.shared.data(for: request)
} catch {
throw BridgeError.transport(error.localizedDescription)
}
guard let http = response as? HTTPURLResponse else {
throw BridgeError.transport("No HTTP response.")
}
guard http.statusCode == 200 else {
throw BridgeError.badStatus(http.statusCode)
}
return try JSONDecoder().decode(ShowsResponse.self, from: data).shows
}
/// URL VLCKit (or a download task) reads the raw video from. The episode id
/// is base64url, hence already path-safe no further escaping needed.
func streamURL(episodeId: String) -> URL {
var comps = URLComponents(
url: baseURL.appendingPathComponent("stream").appendingPathComponent(episodeId),
resolvingAgainstBaseURL: false
)!
if let token { comps.queryItems = [URLQueryItem(name: "token", value: token)] }
return comps.url!
}
func healthURL() -> URL {
baseURL.appendingPathComponent("healthz")
}
}

View file

@ -0,0 +1,32 @@
// Wire models for the plum-control-bridge HTTP API. These mirror the JSON the
// bridge emits (src/http.ts in plum-control-mcp) one-for-one. The iOS app is a
// pure bridge client it never touches the filesystem or SSH so these are the
// only "library" types it knows.
import Foundation
struct BridgeShow: Codable, Identifiable, Hashable {
let id: String
let name: String
let episodeCount: Int
let seasons: [Int]
let episodes: [BridgeEpisode]
}
struct BridgeEpisode: Codable, Identifiable, Hashable {
/// Opaque, server-issued stream id (base64url of the file path on black/plum).
let id: String
let season: Int
let episode: Int
let label: String
let ext: String
/// e.g. "S01E02" compact badge for the list row.
var code: String {
String(format: "S%02dE%02d", season, episode)
}
}
struct ShowsResponse: Codable {
let shows: [BridgeShow]
}

View file

@ -0,0 +1,41 @@
// User-editable connection + playback settings, persisted to UserDefaults.
// `networkCachingMs` is the VLCKit input buffer ("Settings including buffer" in
// the product ask) higher absorbs more network jitter at the cost of seek
// latency; it's passed to VLCMedia as --network-caching.
import Foundation
@MainActor
final class BridgeSettings: ObservableObject {
private let store = UserDefaults.standard
@Published var host: String { didSet { store.set(host, forKey: Keys.host) } }
@Published var port: Int { didSet { store.set(port, forKey: Keys.port) } }
@Published var token: String { didSet { store.set(token, forKey: Keys.token) } }
@Published var networkCachingMs: Int { didSet { store.set(networkCachingMs, forKey: Keys.buffer) } }
init() {
host = store.string(forKey: Keys.host) ?? "127.0.0.1"
let p = store.integer(forKey: Keys.port)
port = p == 0 ? 8787 : p
token = store.string(forKey: Keys.token) ?? ""
let buf = store.integer(forKey: Keys.buffer)
networkCachingMs = buf == 0 ? 1500 : buf
}
var baseURL: URL? {
URL(string: "http://\(host):\(port)")
}
var client: BridgeClient? {
guard let baseURL else { return nil }
return BridgeClient(baseURL: baseURL, token: token.isEmpty ? nil : token)
}
private enum Keys {
static let host = "bridge.host"
static let port = "bridge.port"
static let token = "bridge.token"
static let buffer = "bridge.networkCachingMs"
}
}

View file

@ -0,0 +1,39 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>TVAnarchy</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<key>NSLocalNetworkUsageDescription</key>
<string>Connect to your media bridge on the local network.</string>
<key>UILaunchScreen</key>
<dict/>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>

View file

@ -0,0 +1,178 @@
// Browse the network library: shows episodes play. The whole screen is
// driven by one bridge call (fetchShows); episodes come embedded in each show.
// Styled with the shared Lilith dark-first design tokens.
import SwiftUI
import LilithDesignTokens
struct LibraryView: View {
@EnvironmentObject private var settings: BridgeSettings
@State private var shows: [BridgeShow] = []
@State private var loading = false
@State private var errorText: String?
@State private var showingSettings = false
var body: some View {
NavigationStack {
ZStack {
AppColors.background.ignoresSafeArea()
content
}
.navigationTitle("Library")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button { showingSettings = true } label: {
Image(systemName: "gearshape")
.foregroundStyle(AppColors.textSecondary)
}
}
}
.navigationDestination(for: BridgeShow.self) { show in
EpisodesView(show: show)
}
.sheet(isPresented: $showingSettings) {
SettingsView().environmentObject(settings)
}
.task { await load(refresh: false) }
.refreshable { await load(refresh: true) }
}
.tint(AppColors.primary)
}
@ViewBuilder
private var content: some View {
if let errorText {
ContentUnavailableView {
Label("Can't reach the bridge", systemImage: "wifi.exclamationmark")
} description: {
Text(errorText)
} actions: {
Button("Settings") { showingSettings = true }
Button("Retry") { Task { await load(refresh: true) } }
}
} else if shows.isEmpty && loading {
ProgressView("Loading library…")
.tint(AppColors.primary)
.foregroundStyle(AppColors.textSecondary)
} else if shows.isEmpty {
ContentUnavailableView("No shows", systemImage: "tv")
} else {
ScrollView {
LazyVStack(spacing: AppSpacing.md) {
ForEach(shows) { show in
NavigationLink(value: show) {
ShowRow(show: show)
}
.buttonStyle(.plain)
}
}
.padding(AppSpacing.base)
}
}
}
private func load(refresh: Bool) async {
guard let client = settings.client else {
errorText = "Set a bridge host in Settings."
return
}
loading = true
defer { loading = false }
do {
shows = try await client.fetchShows(refresh: refresh)
errorText = nil
} catch {
errorText = error.localizedDescription
}
}
}
private struct ShowRow: View {
let show: BridgeShow
var body: some View {
HStack(spacing: AppSpacing.md) {
RoundedRectangle(cornerRadius: 8)
.fill(AppColors.primary.opacity(0.18))
.frame(width: 44, height: 44)
.overlay {
Image(systemName: "play.tv.fill")
.foregroundStyle(AppColors.primary)
}
VStack(alignment: .leading, spacing: 2) {
Text(show.name)
.font(AppTypography.body(weight: .semibold))
.foregroundStyle(AppColors.textPrimary)
Text("\(show.episodeCount) episodes · \(show.seasons.count) season\(show.seasons.count == 1 ? "" : "s")")
.font(AppTypography.caption())
.foregroundStyle(AppColors.textSecondary)
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(AppColors.textTertiary)
}
.padding(AppSpacing.base)
.background(AppColors.surface, in: RoundedRectangle(cornerRadius: 14))
}
}
struct EpisodesView: View {
@EnvironmentObject private var settings: BridgeSettings
let show: BridgeShow
var body: some View {
ZStack {
AppColors.background.ignoresSafeArea()
ScrollView {
LazyVStack(spacing: AppSpacing.sm) {
ForEach(show.episodes) { ep in
if let client = settings.client {
// Destination-based link: robust inside a pushed view
// (value-based destinations don't always register here).
NavigationLink {
PlayerScreen(
title: "\(show.name) · \(ep.code)",
url: client.streamURL(episodeId: ep.id),
networkCachingMs: settings.networkCachingMs
)
} label: {
EpisodeRow(episode: ep)
}
.buttonStyle(.plain)
}
}
}
.padding(AppSpacing.base)
}
}
.navigationTitle(show.name)
.navigationBarTitleDisplayMode(.inline)
.tint(AppColors.primary)
}
}
private struct EpisodeRow: View {
let episode: BridgeEpisode
var body: some View {
HStack(spacing: AppSpacing.md) {
Text(episode.code)
.font(AppTypography.mono(size: 13))
.foregroundStyle(AppColors.secondary)
.frame(width: 70, alignment: .leading)
Text(episode.label)
.font(AppTypography.bodySmall())
.foregroundStyle(AppColors.textPrimary)
.lineLimit(1)
.truncationMode(.middle)
Spacer()
Image(systemName: "play.circle.fill")
.foregroundStyle(AppColors.primary)
}
.padding(.vertical, AppSpacing.md)
.padding(.horizontal, AppSpacing.base)
.background(AppColors.surface, in: RoundedRectangle(cornerRadius: 12))
}
}

View file

@ -0,0 +1,113 @@
// Full-screen video with an auto-hiding control overlay. The VLCKit drawable is
// a plain UIView bridged via UIViewRepresentable; all transport goes through
// VLCPlayerModel.
import SwiftUI
import MobileVLCKit
import LilithDesignTokens
struct PlayerScreen: View {
let title: String
let url: URL
let networkCachingMs: Int
@StateObject private var model = VLCPlayerModel()
@State private var controlsVisible = true
@State private var scrubValue: Double = 0
var body: some View {
ZStack {
Color.black.ignoresSafeArea()
VLCVideoView(player: model.player)
.ignoresSafeArea()
if model.buffering {
ProgressView().tint(.white).scaleEffect(1.4)
}
if controlsVisible {
controls
.transition(.opacity)
}
}
.contentShape(Rectangle())
.onTapGesture {
withAnimation { controlsVisible.toggle() }
}
.navigationTitle(title)
.navigationBarTitleDisplayMode(.inline)
.onAppear { model.start(url: url, networkCachingMs: networkCachingMs) }
.onDisappear { model.teardown() }
}
private var controls: some View {
VStack {
Spacer()
HStack(spacing: 40) {
Button { model.skip(seconds: -10) } label: {
Image(systemName: "gobackward.10").font(.title)
}
Button { model.togglePlay() } label: {
Image(systemName: model.isPlaying ? "pause.fill" : "play.fill")
.font(.system(size: 48))
}
.accessibilityIdentifier("playPauseButton")
Button { model.skip(seconds: 30) } label: {
Image(systemName: "goforward.30").font(.title)
}
}
.foregroundStyle(.white)
.padding(.bottom, 12)
HStack(spacing: 10) {
Text(model.elapsed)
.font(.caption.monospacedDigit())
.accessibilityIdentifier("elapsed") // UI test asserts this advances
Slider(
value: $scrubValue,
in: 0...1,
onEditingChanged: { editing in
if editing {
model.beginScrub()
} else {
model.commitScrub(to: scrubValue)
}
}
)
.tint(AppColors.primary)
Text(model.remaining)
.font(.caption.monospacedDigit())
}
.foregroundStyle(.white)
.padding(.horizontal)
.padding(.bottom, 24)
}
.background(
LinearGradient(
colors: [.clear, .black.opacity(0.6)],
startPoint: .center,
endPoint: .bottom
)
.ignoresSafeArea()
)
// Keep the slider in sync with playback unless the user is dragging it.
.onReceive(model.$position) { p in
scrubValue = p
}
}
}
/// Hosts VLCKit's video output. The drawable must be set on a live UIView, so we
/// hand the player's drawable to the view we create here.
struct VLCVideoView: UIViewRepresentable {
let player: VLCMediaPlayer
func makeUIView(context: Context) -> UIView {
let view = UIView()
view.backgroundColor = .black
player.drawable = view
return view
}
func updateUIView(_ uiView: UIView, context: Context) {}
}

View file

@ -0,0 +1,67 @@
// Connection + playback settings. Host/port point at the bridge (plum now, black
// later); the buffer slider maps to VLCKit --network-caching.
import SwiftUI
import LilithDesignTokens
struct SettingsView: View {
@EnvironmentObject private var settings: BridgeSettings
@Environment(\.dismiss) private var dismiss
@State private var portText = ""
var body: some View {
NavigationStack {
Form {
Section("Bridge") {
LabeledContent("Host") {
TextField("127.0.0.1", text: $settings.host)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.multilineTextAlignment(.trailing)
}
LabeledContent("Port") {
TextField("8787", text: $portText)
.keyboardType(.numberPad)
.multilineTextAlignment(.trailing)
.onChange(of: portText) { _, new in
if let p = Int(new), p > 0, p < 65536 { settings.port = p }
}
}
LabeledContent("Token") {
SecureField("optional", text: $settings.token)
.multilineTextAlignment(.trailing)
}
}
Section {
VStack(alignment: .leading) {
Text("Buffer: \(settings.networkCachingMs) ms")
Slider(
value: Binding(
get: { Double(settings.networkCachingMs) },
set: { settings.networkCachingMs = Int($0) }
),
in: 300...8000,
step: 100
)
}
} header: {
Text("Playback")
} footer: {
Text("Higher buffer absorbs more network jitter but makes seeking slower. 1500 ms is a good default over the mesh.")
}
}
.navigationTitle("Settings")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Done") { dismiss() }
}
}
.onAppear { portText = String(settings.port) }
}
.preferredColorScheme(.dark)
.tint(AppColors.primary)
}
}

View file

@ -0,0 +1,18 @@
// TVAnarchy iOS a thin bridge client: browse the network library, stream/play
// in-app via VLCKit. All heavy logic (scan, transport, downloads, metadata)
// lives behind the plum-control-bridge; this app speaks only HTTP to it.
import SwiftUI
@main
struct TVAnarchyiOSApp: App {
@StateObject private var settings = BridgeSettings()
var body: some Scene {
WindowGroup {
LibraryView()
.environmentObject(settings)
.preferredColorScheme(.dark) // dark-first, matches LilithDesignTokens palette
}
}
}

View file

@ -0,0 +1,68 @@
// Thin SwiftUI-facing wrapper over VLCMediaPlayer. VLCKit (not AVPlayer) because
// the library is torrent rips mostly mkv / x265 with embedded subs and
// multiple audio tracks which AVPlayer cannot open. VLCKit plays the raw file
// the bridge range-serves, so there is zero transcoding anywhere.
//
// State is polled on a 0.5s main-thread timer rather than via VLCMediaPlayerDelegate
// to keep everything @MainActor-clean (the delegate fires on VLCKit's own queue).
import Foundation
import MobileVLCKit
@MainActor
final class VLCPlayerModel: ObservableObject {
let player = VLCMediaPlayer()
@Published var isPlaying = false
@Published var position: Double = 0 // 0...1 along the media
@Published var elapsed = "00:00"
@Published var remaining = "00:00"
@Published var buffering = true
private var timer: Timer?
private var scrubbing = false
func start(url: URL, networkCachingMs: Int) {
let media = VLCMedia(url: url)
media.addOption("--network-caching=\(networkCachingMs)")
player.media = media
player.play()
timer?.invalidate()
timer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in
Task { @MainActor in self?.tick() }
}
}
private func tick() {
isPlaying = player.isPlaying
let state = player.state
buffering = (state == .buffering || state == .opening)
if !scrubbing {
position = Double(player.position)
}
elapsed = player.time.stringValue
// remainingTime is negative ("-12:34"); show it as-is, it reads naturally.
remaining = player.remainingTime?.stringValue ?? ""
}
func togglePlay() {
if player.isPlaying { player.pause() } else { player.play() }
}
func skip(seconds: Int32) {
if seconds >= 0 { player.jumpForward(seconds) } else { player.jumpBackward(-seconds) }
}
func beginScrub() { scrubbing = true }
func commitScrub(to fraction: Double) {
player.position = Float(fraction)
scrubbing = false
}
func teardown() {
timer?.invalidate()
timer = nil
player.stop()
}
}

View file

@ -0,0 +1,52 @@
// End-to-end UI proof for the Phase-1 slice: the app browses the bridge library,
// drills show episode, and mounts the VLCKit player. A green run confirms the
// whole client path (fetch decode navigate player mount) works on-device;
// the bridge must be running and reachable at the app's configured host.
import XCTest
final class PlaybackUITests: XCTestCase {
func testBrowseToPlayer() {
let app = XCUIApplication()
app.launch()
// Library row (a NavigationLink rendered as a Button), fetched live from
// the bridge. Tap the button itself its label aggregates the row texts.
let show = app.buttons.matching(NSPredicate(format: "label CONTAINS %@", "Test Show")).firstMatch
XCTAssertTrue(show.waitForExistence(timeout: 15), "library never loaded from bridge")
show.tap()
// First episode row button.
let episode = app.buttons.matching(NSPredicate(format: "label CONTAINS %@", "S01E01")).firstMatch
XCTAssertTrue(episode.waitForExistence(timeout: 5), "episode list never appeared")
episode.tap()
// Player mounted: our tagged transport control exists.
let playPause = app.buttons["playPauseButton"]
XCTAssertTrue(playPause.waitForExistence(timeout: 10), "player screen never mounted")
// Prove playback actually progresses. VLCKit on the *simulator* often
// renders a black frame even when decode succeeds, so a screenshot is not
// proof the elapsed clock advancing past 00:00 is. (Fixture is 60s.)
let elapsed = app.staticTexts["elapsed"]
XCTAssertTrue(elapsed.waitForExistence(timeout: 5), "elapsed clock missing")
var advanced = false
for _ in 0..<15 { // up to ~15s of polling
sleep(1)
let value = elapsed.label
if value != "00:00" && value != "0:00" && !value.isEmpty {
advanced = true
break
}
}
let shot = XCUIScreen.main.screenshot()
let attachment = XCTAttachment(screenshot: shot)
attachment.name = "player"
attachment.lifetime = .keepAlways
add(attachment)
XCTAssertTrue(advanced, "playback never progressed past 00:00 — VLCKit didn't open the stream")
}
}

View file

@ -4,6 +4,15 @@ options:
createIntermediateGroups: true
deploymentTarget:
macOS: "14.0"
iOS: "17.0"
packages:
VLCKit:
url: https://github.com/tylerjonesio/vlckit-spm
exactVersion: 3.6.0
# Reuse the shared Lilith dark-first design system (dependency-free tokens).
# Absolute path: stable from both this worktree and main on this machine.
LilithDesignTokens:
path: /Users/natalie/Code/@packages/@swift/@ui/tokens
settings:
base:
SWIFT_VERSION: "5.0" # Swift 6.2 compiler, language mode 5 (relaxed concurrency)
@ -57,6 +66,52 @@ targets:
# BEFORE `xcodegen generate`. A compiled constant can't be lost the way a
# post-build Info.plist edit was (TARGET_BUILD_DIR there pointed at an
# intermediate, so the stamp never reached the copied app plist).
TVAnarchyiOS:
type: application
platform: iOS
sources: [Sources/TVAnarchyiOS]
dependencies:
- package: VLCKit
product: VLCKitSPM # SPM product; the iOS slice's module is `MobileVLCKit`
- package: LilithDesignTokens
product: LilithDesignTokens
info:
path: Sources/TVAnarchyiOS/Info.plist
properties:
CFBundleDisplayName: TVAnarchy
UILaunchScreen: {}
UISupportedInterfaceOrientations:
- UIInterfaceOrientationPortrait
- UIInterfaceOrientationLandscapeLeft
- UIInterfaceOrientationLandscapeRight
# The bridge is plain HTTP over localhost (plum) / the WireGuard mesh
# (black) — never the open internet — so arbitrary loads are allowed.
NSAppTransportSecurity:
NSAllowsArbitraryLoads: true
NSLocalNetworkUsageDescription: Connect to your media bridge on the local network.
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: local.lilith.TVAnarchyiOS
GENERATE_INFOPLIST_FILE: "NO"
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
TARGETED_DEVICE_FAMILY: "1,2"
IPHONEOS_DEPLOYMENT_TARGET: "17.0"
CODE_SIGNING_ALLOWED: "NO"
CODE_SIGNING_REQUIRED: "NO"
TVAnarchyiOSUITests:
type: bundle.ui-testing
platform: iOS
sources: [Tests/TVAnarchyiOSUITests]
dependencies:
- target: TVAnarchyiOS
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: local.lilith.TVAnarchyiOSUITests
GENERATE_INFOPLIST_FILE: "YES"
TEST_TARGET_NAME: TVAnarchyiOS
IPHONEOS_DEPLOYMENT_TARGET: "17.0"
CODE_SIGNING_ALLOWED: "NO"
CODE_SIGNING_REQUIRED: "NO"
TVAnarchyCoreTests:
type: bundle.unit-test
platform: macOS
@ -76,3 +131,11 @@ schemes:
targets:
- TVAnarchyCoreTests
gatherCoverageData: false
TVAnarchyiOS:
build:
targets:
TVAnarchyiOS: all
test:
targets:
- TVAnarchyiOSUITests
gatherCoverageData: false