tv-anarchy/Sources/TVAnarchyCore/RokuTarget.swift
Natalie ca1871f5dd feat(@applications/tv-anarchy): add roku device support
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
2026-06-09 21:37:34 -07:00

153 lines
7.1 KiB
Swift

import Foundation
/// Drives a Roku over its External Control Protocol unauthenticated REST on
/// the LAN (default port 8060). The Roku plays its OWN channels (Netflix, etc.);
/// this target gives the app transport control and now-playing visibility over
/// whatever the stick is doing. It is deliberately NOT `MediaLaunchable`: a Roku
/// can't open our NFS paths, so the library never routes playback here (the
/// future Roku dev channel + HTTP media plane is the path to that see
/// docs/roadmap.md).
///
/// ECP verb mapping (the protocol is keypresses + queries, not a player API):
/// poll GET /query/media-player (+ /query/active-app for the title)
/// playPause POST /keypress/Play (Play is a toggle on Roku)
/// resume Play only when currently paused (toggle made idempotent)
/// seek(-n) POST /keypress/InstantReplay (the fixed ~10s jump-back; ECP
/// has no forward equivalent or absolute seek those are no-ops,
/// same partial-capability pattern as QuickTimeTarget)
/// stop POST /keypress/Back (exits the channel's player UI)
/// setVolume no-op: the stick only emits relative CEC volume nudges at the
/// TV; there is no absolute level to set or read
public final class RokuTarget: PlayerTarget {
public let id: String
public let name: String
public let kind: HostKind = .roku
public let volumeScale = 100
private let host: String
private let port: Int
private let session: URLSession
public init(id: String, name: String, host: String, port: Int = 8060) {
self.id = id; self.name = name; self.host = host; self.port = port
let cfg = URLSessionConfiguration.ephemeral
cfg.timeoutIntervalForRequest = 4
session = URLSession(configuration: cfg)
}
public var detail: String { "ecp://\(host):\(port)" }
// MARK: ECP plumbing
private func url(_ path: String) -> URL? { URL(string: "http://\(host):\(port)\(path)") }
private func get(_ path: String) async -> String? {
guard let u = url(path) else { return nil }
guard let (data, resp) = try? await session.data(from: u),
(resp as? HTTPURLResponse)?.statusCode == 200 else { return nil }
return String(data: data, encoding: .utf8)
}
@discardableResult
private func post(_ path: String) async -> Bool {
guard let u = url(path) else { return false }
var req = URLRequest(url: u); req.httpMethod = "POST"
guard let (_, resp) = try? await session.data(for: req) else { return false }
return (200..<300).contains((resp as? HTTPURLResponse)?.statusCode ?? 0)
}
private func keypress(_ key: String) async { await post("/keypress/\(key)") }
// MARK: PlayerTarget
public func poll() async -> PollResult {
// Roku's "Control by mobile apps Network access" setting gates ECP:
// in **Limited** mode (a common default) media-player and keypresses
// return 403 while active-app/device-info still answer so a failed
// media-player read must NOT render the device unreachable. Degrade to
// the active-app title; transport works once the user flips the Roku to
// Permissive (Settings System Advanced Control by mobile apps).
if let mp = await get("/query/media-player") {
var status = Self.parseMediaPlayer(mp)
// The plugin name is missing/blank on the home screen and in some
// channels fall back to the active app for the title line.
if status.title == nil, let aa = await get("/query/active-app"),
let app = Self.parseActiveApp(aa), !app.isHome {
status.title = app.name
}
return PollResult(reachable: true, status: status)
}
if let aa = await get("/query/active-app") {
let app = Self.parseActiveApp(aa)
let title = (app?.isHome == false) ? app?.name : nil
return PollResult(reachable: true,
status: PlaybackStatus(playing: false, title: title))
}
return .unreachable
}
public func playPause() async { await keypress("Play") }
public func resume() async {
// Play is a toggle only send it when actually paused, so "ensure
// playing" can't accidentally pause.
if case let r = await poll(), r.status?.paused == true { await keypress("Play") }
}
public func setVolume(_ percent: Int) async {
// No absolute volume on a stick: it nudges the TV over CEC. Nothing to set.
}
public func seek(relative seconds: Int) async {
// ECP's only deterministic jump is InstantReplay (a fixed hop back).
if seconds < 0 { await keypress("InstantReplay") }
}
public func seek(toSeconds seconds: Int) async {} // no absolute seek in ECP
public func next() async {} // no playlist semantics
public func previous() async {}
public func stop() async { await keypress("Back") }
// MARK: extra ECP verbs (Devices tab affordances)
/// Launch a channel by its store id (e.g. 12 = Netflix). Plain ECP launch.
@discardableResult
public func launchApp(id appID: String) async -> Bool {
await post("/launch/\(appID)")
}
// MARK: pure parsers (unit-tested without a device)
/// Parse `/query/media-player`: `<player state="play|pause|stop|none|open">`
/// with `<plugin name=""/>` and `<position>/<duration>` in "<n> ms".
static func parseMediaPlayer(_ xml: String) -> PlaybackStatus {
let state = firstMatch(#"<player[^>]*\bstate="([^"]*)""#, in: xml)
let plugin = firstMatch(#"<plugin[^>]*\bname="([^"]*)""#, in: xml)
let pos = msValue(#"<position>([0-9]+) *ms</position>"#, in: xml)
let dur = msValue(#"<duration>([0-9]+) *ms</duration>"#, in: xml)
return PlaybackStatus(playing: state == "play",
paused: state.map { $0 == "pause" },
title: plugin?.isEmpty == false ? plugin : nil,
position: pos, duration: dur)
}
/// Parse `/query/active-app`: `<app id="" type="home|appl" >Name</app>`.
static func parseActiveApp(_ xml: String) -> (name: String, isHome: Bool)? {
guard let tag = firstMatch(#"(<app\b[^>]*>[^<]*)</app>"#, in: xml) else { return nil }
let name = firstMatch(#">([^<]*)$"#, in: tag)?.trimmingCharacters(in: .whitespaces) ?? ""
let isHome = tag.contains(#"type="home""#)
return name.isEmpty ? nil : (name, isHome)
}
private static func firstMatch(_ pattern: String, in s: String) -> String? {
guard let re = try? NSRegularExpression(pattern: pattern),
let m = re.firstMatch(in: s, range: NSRange(s.startIndex..., in: s)),
m.numberOfRanges > 1, let r = Range(m.range(at: 1), in: s) else { return nil }
return String(s[r])
}
private static func msValue(_ pattern: String, in s: String) -> Double? {
firstMatch(pattern, in: s).flatMap(Double.init).map { $0 / 1000 }
}
}