feat(ios): TVAnarchyiOS app target + UI tests
(cherry picked from commit 7f8f4b0dd92358ba687f8230a922d8f316cb06e9)
This commit is contained in:
parent
6366a841f4
commit
f0669f1ca8
14 changed files with 756 additions and 0 deletions
|
|
@ -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 |
6
Sources/TVAnarchyiOS/Assets.xcassets/Contents.json
Normal file
6
Sources/TVAnarchyiOS/Assets.xcassets/Contents.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
65
Sources/TVAnarchyiOS/BridgeClient.swift
Normal file
65
Sources/TVAnarchyiOS/BridgeClient.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
32
Sources/TVAnarchyiOS/BridgeModels.swift
Normal file
32
Sources/TVAnarchyiOS/BridgeModels.swift
Normal 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]
|
||||
}
|
||||
41
Sources/TVAnarchyiOS/BridgeSettings.swift
Normal file
41
Sources/TVAnarchyiOS/BridgeSettings.swift
Normal 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"
|
||||
}
|
||||
}
|
||||
39
Sources/TVAnarchyiOS/Info.plist
Normal file
39
Sources/TVAnarchyiOS/Info.plist
Normal 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>
|
||||
178
Sources/TVAnarchyiOS/LibraryView.swift
Normal file
178
Sources/TVAnarchyiOS/LibraryView.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
113
Sources/TVAnarchyiOS/PlayerScreen.swift
Normal file
113
Sources/TVAnarchyiOS/PlayerScreen.swift
Normal 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) {}
|
||||
}
|
||||
67
Sources/TVAnarchyiOS/SettingsView.swift
Normal file
67
Sources/TVAnarchyiOS/SettingsView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
18
Sources/TVAnarchyiOS/TVAnarchyiOSApp.swift
Normal file
18
Sources/TVAnarchyiOS/TVAnarchyiOSApp.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
68
Sources/TVAnarchyiOS/VLCPlayerModel.swift
Normal file
68
Sources/TVAnarchyiOS/VLCPlayerModel.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
52
Tests/TVAnarchyiOSUITests/PlaybackUITests.swift
Normal file
52
Tests/TVAnarchyiOSUITests/PlaybackUITests.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
63
project.yml
63
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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue