From f0669f1ca81ffff912bb0bd936adfcfcdd154ab2 Mon Sep 17 00:00:00 2001 From: Natalie Date: Tue, 9 Jun 2026 05:34:39 -0700 Subject: [PATCH] feat(ios): TVAnarchyiOS app target + UI tests (cherry picked from commit 7f8f4b0dd92358ba687f8230a922d8f316cb06e9) --- .../AppIcon.appiconset/Contents.json | 14 ++ .../AppIcon.appiconset/icon-1024.png | Bin 0 -> 20410 bytes .../Assets.xcassets/Contents.json | 6 + Sources/TVAnarchyiOS/BridgeClient.swift | 65 +++++++ Sources/TVAnarchyiOS/BridgeModels.swift | 32 ++++ Sources/TVAnarchyiOS/BridgeSettings.swift | 41 ++++ Sources/TVAnarchyiOS/Info.plist | 39 ++++ Sources/TVAnarchyiOS/LibraryView.swift | 178 ++++++++++++++++++ Sources/TVAnarchyiOS/PlayerScreen.swift | 113 +++++++++++ Sources/TVAnarchyiOS/SettingsView.swift | 67 +++++++ Sources/TVAnarchyiOS/TVAnarchyiOSApp.swift | 18 ++ Sources/TVAnarchyiOS/VLCPlayerModel.swift | 68 +++++++ .../TVAnarchyiOSUITests/PlaybackUITests.swift | 52 +++++ project.yml | 63 +++++++ 14 files changed, 756 insertions(+) create mode 100644 Sources/TVAnarchyiOS/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 Sources/TVAnarchyiOS/Assets.xcassets/AppIcon.appiconset/icon-1024.png create mode 100644 Sources/TVAnarchyiOS/Assets.xcassets/Contents.json create mode 100644 Sources/TVAnarchyiOS/BridgeClient.swift create mode 100644 Sources/TVAnarchyiOS/BridgeModels.swift create mode 100644 Sources/TVAnarchyiOS/BridgeSettings.swift create mode 100644 Sources/TVAnarchyiOS/Info.plist create mode 100644 Sources/TVAnarchyiOS/LibraryView.swift create mode 100644 Sources/TVAnarchyiOS/PlayerScreen.swift create mode 100644 Sources/TVAnarchyiOS/SettingsView.swift create mode 100644 Sources/TVAnarchyiOS/TVAnarchyiOSApp.swift create mode 100644 Sources/TVAnarchyiOS/VLCPlayerModel.swift create mode 100644 Tests/TVAnarchyiOSUITests/PlaybackUITests.swift diff --git a/Sources/TVAnarchyiOS/Assets.xcassets/AppIcon.appiconset/Contents.json b/Sources/TVAnarchyiOS/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..27a4f38 --- /dev/null +++ b/Sources/TVAnarchyiOS/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "icon-1024.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/TVAnarchyiOS/Assets.xcassets/AppIcon.appiconset/icon-1024.png b/Sources/TVAnarchyiOS/Assets.xcassets/AppIcon.appiconset/icon-1024.png new file mode 100644 index 0000000000000000000000000000000000000000..3213259969f7bbb1ee3f33f9092b0fb636215bfd GIT binary patch literal 20410 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 [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") + } +} diff --git a/Sources/TVAnarchyiOS/BridgeModels.swift b/Sources/TVAnarchyiOS/BridgeModels.swift new file mode 100644 index 0000000..a489234 --- /dev/null +++ b/Sources/TVAnarchyiOS/BridgeModels.swift @@ -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] +} diff --git a/Sources/TVAnarchyiOS/BridgeSettings.swift b/Sources/TVAnarchyiOS/BridgeSettings.swift new file mode 100644 index 0000000..5431eba --- /dev/null +++ b/Sources/TVAnarchyiOS/BridgeSettings.swift @@ -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" + } +} diff --git a/Sources/TVAnarchyiOS/Info.plist b/Sources/TVAnarchyiOS/Info.plist new file mode 100644 index 0000000..beb9419 --- /dev/null +++ b/Sources/TVAnarchyiOS/Info.plist @@ -0,0 +1,39 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + TVAnarchy + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + NSLocalNetworkUsageDescription + Connect to your media bridge on the local network. + UILaunchScreen + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/Sources/TVAnarchyiOS/LibraryView.swift b/Sources/TVAnarchyiOS/LibraryView.swift new file mode 100644 index 0000000..f015e0e --- /dev/null +++ b/Sources/TVAnarchyiOS/LibraryView.swift @@ -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)) + } +} diff --git a/Sources/TVAnarchyiOS/PlayerScreen.swift b/Sources/TVAnarchyiOS/PlayerScreen.swift new file mode 100644 index 0000000..caec917 --- /dev/null +++ b/Sources/TVAnarchyiOS/PlayerScreen.swift @@ -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) {} +} diff --git a/Sources/TVAnarchyiOS/SettingsView.swift b/Sources/TVAnarchyiOS/SettingsView.swift new file mode 100644 index 0000000..bacc83d --- /dev/null +++ b/Sources/TVAnarchyiOS/SettingsView.swift @@ -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) + } +} diff --git a/Sources/TVAnarchyiOS/TVAnarchyiOSApp.swift b/Sources/TVAnarchyiOS/TVAnarchyiOSApp.swift new file mode 100644 index 0000000..355d5dc --- /dev/null +++ b/Sources/TVAnarchyiOS/TVAnarchyiOSApp.swift @@ -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 + } + } +} diff --git a/Sources/TVAnarchyiOS/VLCPlayerModel.swift b/Sources/TVAnarchyiOS/VLCPlayerModel.swift new file mode 100644 index 0000000..a49823f --- /dev/null +++ b/Sources/TVAnarchyiOS/VLCPlayerModel.swift @@ -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() + } +} diff --git a/Tests/TVAnarchyiOSUITests/PlaybackUITests.swift b/Tests/TVAnarchyiOSUITests/PlaybackUITests.swift new file mode 100644 index 0000000..333aeff --- /dev/null +++ b/Tests/TVAnarchyiOSUITests/PlaybackUITests.swift @@ -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") + } +} diff --git a/project.yml b/project.yml index 7b41cf0..ae03cdf 100644 --- a/project.yml +++ b/project.yml @@ -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