tv-anarchy/Sources/TVAnarchyCore/VLCTarget.swift
Natalie 4a2ceb9781 feat(offline): inline star-to-keep and trash-to-cull on cache rows
Surface the existing pin (keep-from-cull) and per-file delete actions as
visible inline buttons on each offline cache row instead of context-menu-only:
a star toggles protection from auto-cull (and restore-if-missing), a trash
culls that file early. Aligns wording/icons to the star metaphor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 00:12:41 -04:00

178 lines
8.2 KiB
Swift

import Foundation
/// Any VLC reachable over its HTTP/Lua interface (host/port from config). Mirrors
/// tv-anarchy-mcp's vlc/client.ts. VLC's native volume is 0..512 (256 = 100%);
/// normalized to percent here. The password is resolved from the portable-net-tv
/// config, never stored in hosts.json.
public final class VLCTarget: PlayerTarget, MediaLaunchable, Enqueueable, TrackSelectable {
public let id: String
public let name: String
public let kind: HostKind = .vlc
public let volumeScale = 125
private let base: URL
private let password: String
private static let vlcFull = 256.0
public var detail: String { base.absoluteString }
public init(id: String, name: String, host: String, port: Int, password: String) {
self.id = id; self.name = name
self.base = URL(string: "http://\(host):\(port)") ?? URL(string: "http://127.0.0.1:8080")!
self.password = password
}
private func call(_ query: String) async -> [String: Any]? {
var comps = URLComponents(url: base.appendingPathComponent("requests/status.json"),
resolvingAgainstBaseURL: false)!
if !query.isEmpty { comps.query = query }
guard let url = comps.url else { return nil }
var req = URLRequest(url: url)
req.timeoutInterval = 4
req.setValue("Basic \(Data(":\(password)".utf8).base64EncodedString())",
forHTTPHeaderField: "Authorization")
guard let (data, resp) = try? await URLSession.shared.data(for: req),
(resp as? HTTPURLResponse)?.statusCode == 200,
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
return nil
}
return obj
}
/// Fire a command (side-effect only; we ignore the response body).
private func send(_ command: String, _ input: String? = nil) async -> Bool {
var comps = URLComponents(url: base.appendingPathComponent("requests/status.json"),
resolvingAgainstBaseURL: false)!
comps.queryItems = [URLQueryItem(name: "command", value: command)]
+ (input.map { [URLQueryItem(name: "input", value: $0)] } ?? [])
guard let url = comps.url else { return false }
var req = URLRequest(url: url); req.timeoutInterval = 6
req.setValue("Basic \(Data(":\(password)".utf8).base64EncodedString())",
forHTTPHeaderField: "Authorization")
guard let (_, resp) = try? await URLSession.shared.data(for: req),
(resp as? HTTPURLResponse)?.statusCode == 200 else { return false }
return true
}
public func poll() async -> PollResult {
guard let o = await call("") else { return .unreachable }
let state = o["state"] as? String ?? "stopped"
var st = PlaybackStatus(playing: state != "stopped")
st.paused = (state == "paused")
st.position = numeric(o["time"])
st.duration = numeric(o["length"])
if let vol = numeric(o["volume"]) { st.volume = vol / Self.vlcFull * 100.0 }
st.title = vlcTitle(o)
if let plid = o["currentplid"] as? Int, plid >= 0 { st.playlistPos = plid }
else if let plid = numeric(o["currentplid"]).map(Int.init) { if plid >= 0 { st.playlistPos = plid } }
return PollResult(reachable: true, status: st)
}
// MARK: MediaLaunchable
/// VLC is path-addressable only `show`/`resume` requests have no library
/// meaning here, so the caller routes those to black and sends `.file` to us.
@discardableResult
public func launch(_ request: LaunchRequest) async -> Bool {
guard case let .file(path) = request else { return false }
let uri = MediaPaths.toStreamURL(path)
// Always start fresh for a single launch (pl_empty + in_play). This makes
// "play this" from the Offline list (or Library) replace whatever was
// already loaded in a running VLC instance.
_ = await send("pl_empty")
return await send("in_play", uri)
}
// MARK: Enqueueable (pl_empty + in_play/in_enqueue; library paths resolve to a
// local downloaded copy via MediaPaths.toStreamURL non-downloaded items are
// rerouted to black by PlayerController before they reach VLC)
@discardableResult
public func enqueue(_ paths: [String], replace: Bool) async -> Bool {
guard !paths.isEmpty else { return false }
if replace { _ = await send("pl_empty") }
var ok = true
for (i, p) in paths.enumerated() {
let uri = MediaPaths.toStreamURL(p)
let command = (i == 0 && replace) ? "in_play" : "in_enqueue"
ok = await send(command, uri) && ok
}
return ok
}
// MARK: TrackSelectable (best-effort VLC has no persistent language pref,
// so the controller re-applies the preference on each launch)
public func tracks() async -> [MediaTrack] {
guard let o = await call(""),
let info = o["information"] as? [String: Any],
let cat = info["category"] as? [String: Any] else { return [] }
var out: [MediaTrack] = []
for (key, value) in cat {
guard key.lowercased().hasPrefix("stream"),
let meta = value as? [String: Any],
let type = (meta["Type"] as? String)?.lowercased() else { continue }
let kind: TrackKind? = type.contains("audio") ? .audio
: (type.contains("subtitle") || type.contains("spu")) ? .subtitle : nil
guard let kind else { continue }
let id = Int(key.split(separator: " ").last.map(String.init) ?? "0") ?? 0
out.append(MediaTrack(id: id, kind: kind, lang: meta["Language"] as? String,
title: meta["Description"] as? String, codec: meta["Codec"] as? String))
}
return out.sorted { $0.id < $1.id }
}
public func setAudioTrack(_ id: Int) async { _ = await call("command=audio_track&val=\(id)") }
public func setSubtitleTrack(_ id: Int?) async {
_ = await call("command=subtitle_track&val=\(id ?? -1)") // -1 disables in VLC
}
public func applyLanguagePreference(audioLangs: [String], subLangs: [String], subsEnabled: Bool) async {
let trks = await tracks()
let audio = trks.filter { $0.kind == .audio }
if let pick = audio.first(where: { trackLangMatches($0.lang, audioLangs) }) ?? audio.first {
await setAudioTrack(pick.id)
}
if subsEnabled {
let subs = trks.filter { $0.kind == .subtitle }
if let pick = subs.first(where: { trackLangMatches($0.lang, subLangs) }) ?? subs.first {
await setSubtitleTrack(pick.id)
}
} else {
await setSubtitleTrack(nil)
}
}
public func playPause() async { _ = await call("command=pl_pause") }
public func resume() async { _ = await call("command=pl_play") }
public func setVolume(_ percent: Int) async {
_ = await call("command=volume&val=\(Int((Double(percent) / 100.0) * Self.vlcFull))")
}
public func seek(relative seconds: Int) async {
_ = await call("command=seek&val=\(seconds >= 0 ? "+\(seconds)" : "\(seconds)")")
}
public func seek(toSeconds seconds: Int) async {
_ = await call("command=seek&val=\(max(0, seconds))") // bare number = absolute
}
public func next() async { _ = await call("command=pl_next") }
public func previous() async { _ = await call("command=pl_previous") }
public func stop() async { _ = await call("command=pl_stop") }
private func numeric(_ v: Any?) -> Double? {
if let d = v as? Double { return d }
if let i = v as? Int { return Double(i) }
if let s = v as? String { return Double(s) }
return nil
}
private func vlcTitle(_ o: [String: Any]) -> String? {
guard let info = o["information"] as? [String: Any],
let category = info["category"] as? [String: Any] else { return nil }
for (_, value) in category {
if let meta = value as? [String: Any] {
if let t = meta["title"] as? String, !t.isEmpty { return t }
if let f = meta["filename"] as? String, !f.isEmpty { return f }
}
}
return nil
}
}