153 lines
7.1 KiB
Swift
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 }
|
|
}
|
|
}
|