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 0000000..3213259 Binary files /dev/null and b/Sources/TVAnarchyiOS/Assets.xcassets/AppIcon.appiconset/icon-1024.png differ diff --git a/Sources/TVAnarchyiOS/Assets.xcassets/Contents.json b/Sources/TVAnarchyiOS/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Sources/TVAnarchyiOS/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/TVAnarchyiOS/BridgeClient.swift b/Sources/TVAnarchyiOS/BridgeClient.swift new file mode 100644 index 0000000..1a115c2 --- /dev/null +++ b/Sources/TVAnarchyiOS/BridgeClient.swift @@ -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") + } +} 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