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>
178 lines
8.2 KiB
Swift
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
|
|
}
|
|
}
|