Compare commits

..

9 Commits

Author SHA1 Message Date
hl-valdemar 0641859d95 feat: support YouTube VTT subtitle selection 2026-10-27 13:19:43 +01:00
hl-valdemar a2c8cc8e4d feat: redesign app icon with sunset palette 2026-10-26 14:43:21 +01:00
hl-valdemar 491252e35f fix: simplify television icon for small sizes 2026-10-26 14:37:05 +01:00
hl-valdemar 98dccd7593 feat: add television application icon 2026-10-26 14:29:51 +01:00
hl-valdemar 82d47741eb chore: update todo 2026-10-25 23:17:49 +01:00
hl-valdemar 41aa0abd56 fix: retain Zoko opening and ending metadata through playback 2026-10-25 23:08:38 +01:00
hl-valdemar d8fb648c90 fix: use a nonactivating compact panel across fullscreen spaces 2026-10-25 22:59:36 +01:00
hl-valdemar 599e2f548a feat: add opening/ending autoskip and correct sponsor hover anchors
Sponsor hover labels used the overlapping chapter midpoint; retain chapter-title lookup but anchor the label to the sponsored range midpoint.
2026-10-25 22:21:55 +01:00
hl-valdemar a604810319 feat: make sponsored blocks skippable 2026-10-25 21:43:44 +01:00
27 changed files with 1498 additions and 219 deletions
+48 -15
View File
@@ -275,6 +275,10 @@ private enum WindowDragCheck {
} }
static func interceptDrags(in window: NSWindow) { static func interceptDrags(in window: NSWindow) {
for (method, original) in implementations.reversed() {
imp_removeBlock(method_setImplementation(method, original))
}
implementations.removeAll()
target = window target = window
let method = class_getInstanceMethod(type(of: window), #selector(NSWindow.performDrag(with:)))! let method = class_getInstanceMethod(type(of: window), #selector(NSWindow.performDrag(with:)))!
let original = unsafeBitCast(method_getImplementation(method), let original = unsafeBitCast(method_getImplementation(method),
@@ -309,8 +313,9 @@ private enum WindowDragCheck {
guard ContinuousClock.now < deadline else { guard ContinuousClock.now < deadline else {
finish("Window drag timed out at \(names[probe]), phase \(phase)"); return finish("Window drag timed out at \(names[probe]), phase \(phase)"); return
} }
guard let window = NSApp.windows.first(where: { $0.canBecomeMain && !($0 is NSPanel) }) else { return }
let model = PlayerModel.shared let model = PlayerModel.shared
guard let window = model.compactWindow.presentationWindow
?? NSApp.windows.first(where: { $0.canBecomeMain && !($0 is NSPanel) }) else { return }
let fixtures = URL(fileURLWithPath: CommandLine.arguments.last!, isDirectory: true) let fixtures = URL(fileURLWithPath: CommandLine.arguments.last!, isDirectory: true)
if target == nil { if target == nil {
guard model.canTogglePlayback else { return } guard model.canTogglePlayback else { return }
@@ -322,10 +327,11 @@ private enum WindowDragCheck {
window.makeMain() window.makeMain()
NSApp.activate(ignoringOtherApps: true) NSApp.activate(ignoringOtherApps: true)
} }
if target !== window { interceptDrags(in: window) }
if phase == 0 { if phase == 0 {
guard NSApp.isActive, window.isKeyWindow, window.isMainWindow else { guard NSApp.isActive, window.isKeyWindow, !window.canBecomeMain || window.isMainWindow else {
window.makeKeyAndOrderFront(nil) window.makeKeyAndOrderFront(nil)
window.makeMain() if window.canBecomeMain { window.makeMain() }
NSApp.activate(ignoringOtherApps: true) NSApp.activate(ignoringOtherApps: true)
return return
} }
@@ -588,7 +594,10 @@ private enum WindowLayoutCheck {
static var timer: Timer? static var timer: Timer?
static var resultURL: URL! static var resultURL: URL!
static var step = 0 static var step = 0
static var windowIdentity: ObjectIdentifier? static var normalWindow: NSWindow?
static var renderer: NSView?
static var content: NSView?
static var activationPolicy: NSApplication.ActivationPolicy?
static var normalFrame = NSRect.zero static var normalFrame = NSRect.zero
static var compactFrame = NSRect.zero static var compactFrame = NSRect.zero
static var animationStart: NSRect? static var animationStart: NSRect?
@@ -676,7 +685,28 @@ private enum WindowLayoutCheck {
let model = PlayerModel.shared let model = PlayerModel.shared
guard ContinuousClock.now < deadline else { finish("Window layout did not settle at step \(step)"); return } guard ContinuousClock.now < deadline else { finish("Window layout did not settle at step \(step)"); return }
guard model.canTogglePlayback, model.state.has_video, guard model.canTogglePlayback, model.state.has_video,
let window = NSApp.windows.first(where: { $0.canBecomeMain && !($0 is NSPanel) }) else { return } let normal = normalWindow ?? NSApp.windows.first(where: { $0.canBecomeMain && !($0 is NSPanel) }),
let video = model.videoView else { return }
let window = model.compactWindow.presentationWindow ?? normal
let hostedContent = model.hostedPlayerView ?? window.contentView
if normalWindow == nil {
normalWindow = normal
renderer = video
content = hostedContent
activationPolicy = NSApp.activationPolicy()
}
guard video === renderer, hostedContent === content, hostedContent?.window === window,
video.window === window, NSApp.activationPolicy() == activationPolicy else {
finish("Transition replaced the content/renderer or changed app activation policy"); return
}
if window !== normal {
guard let panel = window as? NSPanel, panel.styleMask.contains(.nonactivatingPanel),
!normal.isVisible, !normal.isKeyWindow else {
finish("Compact presentation is not an independent nonactivating panel hiding the normal window"); return
}
} else if model.isCompact {
finish("Compact playback retained the normal window"); return
}
if model.compactWindow.isTransitioning || model.isQueueTransitioning { if model.compactWindow.isTransitioning || model.isQueueTransitioning {
window.layoutIfNeeded() window.layoutIfNeeded()
if model.isQueueTransitioning && !model.isCompact && !sidebarHeaderIsVisible(in: window) { if model.isQueueTransitioning && !model.isCompact && !sidebarHeaderIsVisible(in: window) {
@@ -718,8 +748,9 @@ private enum WindowLayoutCheck {
queueVideoSize = nil queueVideoSize = nil
observingQueue = false observingQueue = false
} }
if windowIdentity == nil { windowIdentity = ObjectIdentifier(window) } guard model.isCompact ? window !== normal : window === normal else {
guard ObjectIdentifier(window) == windowIdentity else { finish("Transition replaced the playback window"); return } finish("Settled presentation does not match the requested mode"); return
}
if model.fullScreenIntent != nil { return } if model.fullScreenIntent != nil { return }
window.layoutIfNeeded() window.layoutIfNeeded()
if compactQueueOnly && step == 0 { if compactQueueOnly && step == 0 {
@@ -1079,20 +1110,21 @@ private enum CLIInstanceCheck {
let model = PlayerModel.shared let model = PlayerModel.shared
let pid = ProcessInfo.processInfo.processIdentifier let pid = ProcessInfo.processInfo.processIdentifier
// Text input can create non-main auxiliary windows; count actual player windows. // Text input can create non-main auxiliary windows; count actual player windows.
let windows = NSApp.windows.filter { $0.canBecomeMain && !($0 is NSPanel) && $0.contentView != nil } let windows = NSApp.windows.filter { $0.canBecomeMain && !($0 is NSPanel) }
let presentation = model.compactWindow.presentationWindow ?? windows.first
for window in windows { window.alphaValue = 0 } for window in windows { window.alphaValue = 0 }
let request = directory.appendingPathComponent("keys-\(pid).json") let request = directory.appendingPathComponent("keys-\(pid).json")
var pendingRequestID = "" var pendingRequestID = ""
if let data = try? Data(contentsOf: request), if let data = try? Data(contentsOf: request),
let command = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let command = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let id = command["id"] as? String, id != requestID, let window = windows.first { let id = command["id"] as? String, id != requestID, let window = presentation {
pendingRequestID = id pendingRequestID = id
if !NSApp.isActive || !window.isKeyWindow || !window.isMainWindow { if !NSApp.isActive || !window.isKeyWindow || window.canBecomeMain && !window.isMainWindow {
// Synthetic native key requests need foreground focus; cooperative activation // Synthetic native key requests need foreground focus; cooperative activation
// can be refused by the unrelated app currently active on the workstation. // can be refused by the unrelated app currently active on the workstation.
NSApp.activate(ignoringOtherApps: true) NSApp.activate(ignoringOtherApps: true)
window.makeKeyAndOrderFront(nil) window.makeKeyAndOrderFront(nil)
window.makeMain() if window.canBecomeMain { window.makeMain() }
} else { } else {
requestID = id requestID = id
if let media = command["media"] as? String { model.open(URL(fileURLWithPath: media)) } if let media = command["media"] as? String { model.open(URL(fileURLWithPath: media)) }
@@ -1140,8 +1172,9 @@ private enum CLIInstanceCheck {
} }
} }
if let events = command["events"] as? [[String: Any]] { if let events = command["events"] as? [[String: Any]] {
window.makeKey()
for input in events { for input in events {
let window = model.compactWindow.presentationWindow ?? window
window.makeKey()
if let title = input["select"] as? String { if let title = input["select"] as? String {
guard let menu = NSApp.mainMenu else { fatalError("Native application menu is missing") } guard let menu = NSApp.mainMenu else { fatalError("Native application menu is missing") }
prepareMenu(menu) prepareMenu(menu)
@@ -1206,14 +1239,14 @@ private enum CLIInstanceCheck {
"requestID": requestID, "requestID": requestID,
"pendingRequestID": pendingRequestID, "pendingRequestID": pendingRequestID,
"active": NSApp.isActive, "active": NSApp.isActive,
"keyWindow": windows.first?.isKeyWindow ?? false, "keyWindow": presentation?.isKeyWindow ?? false,
"mainWindow": windows.first?.isMainWindow ?? false, "mainWindow": presentation?.isMainWindow ?? false,
"editorText": editorText, "editorText": editorText,
"compact": model.isCompact, "compact": model.isCompact,
"showsQueue": model.showsQueue, "showsQueue": model.showsQueue,
"queueURLs": model.queue.items.map(\.url.absoluteString), "queueURLs": model.queue.items.map(\.url.absoluteString),
"queueIndex": model.queue.items.firstIndex { $0.id == model.queue.currentID } ?? -1, "queueIndex": model.queue.items.firstIndex { $0.id == model.queue.currentID } ?? -1,
"window": windowSnapshot(windows.first), "window": windowSnapshot(presentation),
"onTop": model.isOnTop, "onTop": model.isOnTop,
] ]
do { do {
+16 -2
View File
@@ -68,7 +68,8 @@ struct AutomaticQualityPlaybackChecks {
until(model, "compact mode remains usable across empty playback polls while resolving") { until(model, "compact mode remains usable across empty playback polls while resolving") {
model.isCompact && !model.compactWindow.isTransitioning model.isCompact && !model.compactWindow.isTransitioning
} }
require(window.level == .floating && window.frame.width < normalFrame.width, require(model.compactWindow.presentationWindow!.level == .floating &&
model.compactWindow.presentationWindow!.frame.width < normalFrame.width,
"unresolved video has a usable compact window") "unresolved video has a usable compact window")
model.handleKey(compactKey) model.handleKey(compactKey)
until(model, "compact shortcut restores while YouTube resolution is still pending") { until(model, "compact shortcut restores while YouTube resolution is still pending") {
@@ -86,7 +87,8 @@ struct AutomaticQualityPlaybackChecks {
require(model.isCompact, "loading completion preserves the requested compact mode") require(model.isCompact, "loading completion preserves the requested compact mode")
until(model, "decoded metadata updates compact geometry") { until(model, "decoded metadata updates compact geometry") {
!model.compactWindow.isTransitioning && !model.compactWindow.isTransitioning &&
abs(window.frame.width / window.frame.height - model.state.video_aspect_ratio) < 0.01 abs(model.compactWindow.presentationWindow!.frame.width / model.compactWindow.presentationWindow!.frame.height -
model.state.video_aspect_ratio) < 0.01
} }
model.handleKey(compactKey) model.handleKey(compactKey)
until(model, "compact shortcut restores during network buffering") { until(model, "compact shortcut restores during network buffering") {
@@ -100,6 +102,12 @@ struct AutomaticQualityPlaybackChecks {
polled(model) polled(model)
require(model.youtubeHeight == 1080 && !model.resolvingStream, require(model.youtubeHeight == 1080 && !model.resolvingStream,
"disabled automatic quality leaves a stalled manual/default stream alone") "disabled automatic quality leaves a stalled manual/default stream alone")
let captionCountURL = URL(string: ProcessInfo.processInfo.environment["TEREBI_CAPTION_COUNT"]!)!
require(try! String(contentsOf: captionCountURL, encoding: .utf8) == "0",
"Off never requests captions during extraction or playback")
require(model.selectedSubtitle == nil && model.subtitleTracks.count == 1, "YouTube captions default Off")
model.selectSubtitle(0)
until(model, "selected original-auto VTT loads through native HTTP") { model.subtitleText == "Automatic caption" }
model.setAutomaticYouTubeQuality(true) model.setAutomaticYouTubeQuality(true)
polled(model) polled(model)
let position = model.state.position let position = model.state.position
@@ -110,11 +118,16 @@ struct AutomaticQualityPlaybackChecks {
} }
require(model.state.position >= position, require(model.state.position >= position,
"automatic quality reopening does not reset acknowledged progress") "automatic quality reopening does not reset acknowledged progress")
until(model, "automatic downshift restores the selected caption") { model.subtitleText == "Automatic caption" }
require(model.selectedSubtitle == 0, "automatic downshift retains caption identity")
polled(model) polled(model)
clock.now = 2063 clock.now = 2063
until(model, "sustained health automatically restores actual 1080p media") { until(model, "sustained health automatically restores actual 1080p media") {
model.youtubeHeight == 1080 && model.canTogglePlayback && model.state.status == PLAYER_PLAYING && !model.state.seek_pending model.youtubeHeight == 1080 && model.canTogglePlayback && model.state.status == PLAYER_PLAYING && !model.state.seek_pending
} }
until(model, "automatic upshift restores the selected caption") { model.subtitleText == "Automatic caption" }
require(model.selectedSubtitle == 0, "automatic upshift retains caption identity")
model.selectSubtitle(nil)
model.setYouTubeQuality(model.youtubeHeight) model.setYouTubeQuality(model.youtubeHeight)
require(!model.automaticYouTubeQuality && !model.isBusy && model.state.status == PLAYER_PLAYING, require(!model.automaticYouTubeQuality && !model.isBusy && model.state.status == PLAYER_PLAYING,
"reselecting the current manual resolution disables auto without disrupting playback") "reselecting the current manual resolution disables auto without disrupting playback")
@@ -134,6 +147,7 @@ struct AutomaticQualityPlaybackChecks {
model.youtubeHeight == 720 && model.canChangeYouTubeQuality && model.state.status == PLAYER_PLAYING && model.youtubeHeight == 720 && model.canChangeYouTubeQuality && model.state.status == PLAYER_PLAYING &&
!model.state.seek_pending && (model.bufferedRange.map { $0.upperBound - model.state.position >= 20 } ?? false) !model.state.seek_pending && (model.bufferedRange.map { $0.upperBound - model.state.position >= 20 } ?? false)
} }
require(model.selectedSubtitle == nil && model.subtitleText.isEmpty, "manual changes preserve Off after automatic changes")
let selectedTitle = model.fileName let selectedTitle = model.fileName
let selectedQuality = model.youtubeQuality let selectedQuality = model.youtubeQuality
mark("gate-1080") mark("gate-1080")
+34 -3
View File
@@ -7,16 +7,45 @@ from pathlib import Path
import shlex import shlex
import subprocess import subprocess
import tempfile import tempfile
from urllib.parse import urlsplit
from streaming import StreamingServer from streaming import StreamingServer
class CaptionStreamingServer(StreamingServer):
def __init__(self, fixtures):
super().__init__(fixtures)
self.caption_requests = 0
def _get(self, request):
path = urlsplit(request.path).path
if path == "/caption-count":
self._reply(request, 200, str(self.caption_requests).encode())
return
if path == "/captions.vtt":
if request.headers.get("X-Subtitle") != "caption-private":
self._reply(request, 403, b"Missing caption credentials")
return
self.caption_requests += 1
self._reply(request, 200, b"WEBVTT\n\n00:00.000 --> 02:00.000\nAutomatic caption\n", "text/vtt")
return
super()._get(request)
def main(): def main():
parser = argparse.ArgumentParser(description=__doc__) parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("binary", type=Path) parser.add_argument("binary", type=Path)
parser.add_argument("fixtures", type=Path) parser.add_argument("fixtures", type=Path)
parser.add_argument("app", type=Path) parser.add_argument("app", type=Path)
parser.add_argument("--cast-session", type=Path)
args = parser.parse_args() args = parser.parse_args()
if args.cast_session:
with CaptionStreamingServer(args.fixtures) as server:
result = subprocess.run([str(args.cast_session.resolve()), "--youtube-subtitles",
str(args.binary.resolve()), str(args.fixtures.resolve() / "cache.mp4")],
env=dict(os.environ, TEREBI_CAST_YOUTUBE_SOURCE=server.base_url), timeout=150, check=True)
assert server.caption_requests == 2, "only initial selection and local restoration fetch captions"
return result.returncode
macOS = args.app.resolve() / "Contents/MacOS" macOS = args.app.resolve() / "Contents/MacOS"
extractor = str(macOS / "yt-dlp") extractor = str(macOS / "yt-dlp")
runtime = macOS / "deno" runtime = macOS / "deno"
@@ -25,7 +54,7 @@ def main():
required = ("quality-1080.mp4", "quality-720.mp4") required = ("quality-1080.mp4", "quality-720.mp4")
if any(not (args.fixtures / name).is_file() for name in required): if any(not (args.fixtures / name).is_file() for name in required):
parser.error("Missing real quality playback fixtures") parser.error("Missing real quality playback fixtures")
with StreamingServer(args.fixtures) as server, tempfile.TemporaryDirectory(prefix="terebi-quality-check-") as directory: with CaptionStreamingServer(args.fixtures) as server, tempfile.TemporaryDirectory(prefix="terebi-quality-check-") as directory:
root = Path(directory) root = Path(directory)
(root / "deno").symlink_to(runtime) (root / "deno").symlink_to(runtime)
formats = [] formats = []
@@ -37,7 +66,9 @@ def main():
"http_headers": {"X-Terebi-Fixture": "combined"}}) "http_headers": {"X-Terebi-Fixture": "combined"}})
info = {"id": "aqz-KE-bpKQ", "title": "Automatic quality playback fixture", "duration": 120, info = {"id": "aqz-KE-bpKQ", "title": "Automatic quality playback fixture", "duration": 120,
"is_live": False, "webpage_url": "https://www.youtube.com/watch?v=aqz-KE-bpKQ", "is_live": False, "webpage_url": "https://www.youtube.com/watch?v=aqz-KE-bpKQ",
"extractor": "youtube", "extractor_key": "Youtube", "formats": formats} "extractor": "youtube", "extractor_key": "Youtube", "formats": formats,
"automatic_captions": {"en-orig": [{"ext": "vtt", "name": "English (Original)",
"url": server.base_url + "/captions.vtt", "http_headers": {"X-Subtitle": "caption-private"}}]}}
metadata = root / "info.json" metadata = root / "info.json"
metadata.write_text(json.dumps(info)) metadata.write_text(json.dumps(info))
wrapper = root / "yt-dlp" wrapper = root / "yt-dlp"
@@ -67,7 +98,7 @@ def main():
+ ' "${args[@]:0:${#args[@]}-2}"\n') + ' "${args[@]:0:${#args[@]}-2}"\n')
wrapper.chmod(0o755) wrapper.chmod(0o755)
environment = dict(os.environ, PATH=str(root) + os.pathsep + os.environ.get("PATH", ""), environment = dict(os.environ, PATH=str(root) + os.pathsep + os.environ.get("PATH", ""),
TEREBI_QUALITY_CONTROL=str(control)) TEREBI_QUALITY_CONTROL=str(control), TEREBI_CAPTION_COUNT=server.base_url + "/caption-count")
try: try:
result = subprocess.run([str(args.binary.resolve())], env=environment, timeout=90, check=False) result = subprocess.run([str(args.binary.resolve())], env=environment, timeout=90, check=False)
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
+30 -3
View File
@@ -211,7 +211,19 @@ struct CastPlaybackCheck {
let suite = "terebi.cast-playback-check." + UUID().uuidString let suite = "terebi.cast-playback-check." + UUID().uuidString
let defaults = UserDefaults(suiteName: suite)! let defaults = UserDefaults(suiteName: suite)!
defer { defaults.removePersistentDomain(forName: suite) } defer { defaults.removePersistentDomain(forName: suite) }
let model = PlayerModel(defaults: defaults, skipClock: { 100 }) let youtubeSource = ProcessInfo.processInfo.environment["TEREBI_CAST_YOUTUBE_SOURCE"]
let identity = youtubeSource == nil ? source : URL(string: "https://www.youtube.com/watch?v=AAAAAAAAAAA")!
let model = PlayerModel(defaults: defaults, youtubeResolver: { url, _ in
let base = youtubeSource!
return YouTubeVideo(title: "YouTube Cast captions", video: .init(url: base + "/media/cache.mp4",
headers: "X-Terebi-Fixture: combined\r\n"), audio: nil, duration: 120, isLive: false,
webpageURL: url, height: 1080, availableHeights: [1080], chapters: [],
subtitles: [
.init(url: base + "/captions.vtt", format: "vtt", language: "en", label: "English",
isDefault: false, headers: ["X-Subtitle": "caption-private"], selectionID: "youtube:manual:en"),
.init(url: base + "/unavailable.vtt", format: "vtt", language: "fr", label: "Unavailable",
isDefault: false, headers: [:], selectionID: "youtube:manual:fr")])
}, youtubeTitleResolver: { _ in nil }, skipClock: { 100 })
defer { model.shutdown() } defer { model.shutdown() }
if CommandLine.arguments.count == 5 { if CommandLine.arguments.count == 5 {
if CommandLine.arguments[3] == "queue" { if CommandLine.arguments[3] == "queue" {
@@ -223,7 +235,7 @@ struct CastPlaybackCheck {
return return
} }
model.setVolume(0) model.setVolume(0)
model.open(source) model.open(identity)
try await until(model, "local source plays") { model.state.status == PLAYER_PLAYING && model.canSeek } try await until(model, "local source plays") { model.state.status == PLAYER_PLAYING && model.canSeek }
requirePlaybackPower(system: true, display: true, "local video prevents system and display sleep") requirePlaybackPower(system: true, display: true, "local video prevents system and display sleep")
require(model.setPlayback(playing: false), "local pause accepted") require(model.setPlayback(playing: false), "local pause accepted")
@@ -232,6 +244,10 @@ struct CastPlaybackCheck {
require(model.seekRelative(9.25 - model.state.position), "local restoration position accepted") require(model.seekRelative(9.25 - model.state.position), "local restoration position accepted")
require(!model.canCast, "Cast cannot transfer an unacknowledged local seek") require(!model.canCast, "Cast cannot transfer an unacknowledged local seek")
try await until(model, "local position acknowledged") { !model.state.seek_pending && abs(model.state.position - 9.25) < 0.15 } try await until(model, "local position acknowledged") { !model.state.seek_pending && abs(model.state.position - 9.25) < 0.15 }
if youtubeSource != nil {
model.selectSubtitle(0)
try await until(model, "local YouTube caption selected before handoff") { model.subtitleText == "Automatic caption" }
}
model.cast(to: CastDevice(id: "loopback", name: "Loopback TV", host: "127.0.0.1", port: port)) model.cast(to: CastDevice(id: "loopback", name: "Loopback TV", host: "127.0.0.1", port: port))
requirePlaybackPower(system: true, display: false, "pending Cast handoff protects the relay") requirePlaybackPower(system: true, display: false, "pending Cast handoff protects the relay")
try await until(model, "receiver load acknowledges paused handoff") { try await until(model, "receiver load acknowledges paused handoff") {
@@ -240,6 +256,12 @@ struct CastPlaybackCheck {
requirePlaybackPower(system: false, display: false, "paused Cast permits sleep") requirePlaybackPower(system: false, display: false, "paused Cast permits sleep")
require(abs(model.state.position - 9.25) < 0.15, "Cast starts at acknowledged local position") require(abs(model.state.position - 9.25) < 0.15, "Cast starts at acknowledged local position")
require(!model.canStepFrame && model.bufferedRange == nil, "local decoder-only controls are not exposed while casting") require(!model.canStepFrame && model.bufferedRange == nil, "local decoder-only controls are not exposed while casting")
if youtubeSource != nil {
require(!model.canSelectSubtitle && model.selectedSubtitle == 0 && model.subtitleText.isEmpty,
"YouTube Cast disables local captions without losing the local choice")
model.selectSubtitle(1)
require(model.commandError == nil && model.selectedSubtitle == 0, "unsupported Cast captions cannot interrupt playback")
}
require(model.setPlayback(playing: true), "remote play accepted") require(model.setPlayback(playing: true), "remote play accepted")
try await until(model, "receiver play acknowledged") { model.state.status == PLAYER_PLAYING && model.playbackIntent == nil } try await until(model, "receiver play acknowledged") { model.state.status == PLAYER_PLAYING && model.playbackIntent == nil }
requirePlaybackPower(system: true, display: false, "playing Cast protects the relay but permits display sleep") requirePlaybackPower(system: true, display: false, "playing Cast protects the relay but permits display sleep")
@@ -266,7 +288,12 @@ struct CastPlaybackCheck {
abs(model.state.position - 24.25) < 0.15 && !model.isBusy abs(model.state.position - 24.25) < 0.15 && !model.isBusy
} }
requirePlaybackPower(system: false, display: false, "paused local restoration permits sleep") requirePlaybackPower(system: false, display: false, "paused local restoration permits sleep")
require(abs((PlaybackProgress(defaults: defaults).position(for: source) ?? 0) - 24.25) < 0.15, if youtubeSource != nil {
try await until(model, "return to Mac restores local YouTube caption selection") {
model.canSelectSubtitle && model.selectedSubtitle == 0 && model.subtitleText == "Automatic caption"
}
}
require(abs((PlaybackProgress(defaults: defaults).position(for: identity) ?? 0) - 24.25) < 0.15,
"remote history is not overwritten by the abandoned local position") "remote history is not overwritten by the abandoned local position")
require(model.volume == 0, "local volume survives receiver volume changes") require(model.volume == 0, "local volume survives receiver volume changes")
require(model.setPlayback(playing: true), "resume local playback before a failed Cast connection") require(model.setPlayback(playing: true), "resume local playback before a failed Cast connection")
+11
View File
@@ -879,6 +879,12 @@ struct CastSessionCheck {
try await until("Disconnect STOP reaches receiver") { peer.commands.contains { $0.0 == "STOP" } } try await until("Disconnect STOP reaches receiver") { peer.commands.contains { $0.0 == "STOP" } }
let load = peer.commands.first { $0.0 == "LOAD" }!.1 let load = peer.commands.first { $0.0 == "LOAD" }!.1
require(load["currentTime"] as? Double == 9.25 && load["autoplay"] as? Bool == false, "Acknowledged paused handoff") require(load["currentTime"] as? Double == 9.25 && load["autoplay"] as? Bool == false, "Acknowledged paused handoff")
if ProcessInfo.processInfo.environment["TEREBI_CAST_YOUTUBE_SOURCE"] != nil {
let media = load["media"] as! [String: Any]
require((media["tracks"] as? [Any] ?? []).isEmpty &&
(load["activeTrackIds"] as? [Any] ?? []).isEmpty,
"YouTube Cast LOAD contains no captions or invalid local track selection")
}
require(peer.relayVerified, "Receiver fetches real relay HLS and media bytes") require(peer.relayVerified, "Receiver fetches real relay HLS and media bytes")
} }
@@ -1034,6 +1040,11 @@ struct CastSessionCheck {
print("PASS: latest slider intent, stale volume feedback, authoritative acknowledgement, external TV changes, timeout") print("PASS: latest slider intent, stale volume feedback, authoritative acknowledgement, external TV changes, timeout")
return return
} }
if CommandLine.arguments.count == 4, CommandLine.arguments[1] == "--youtube-subtitles" {
try await playback(identity: identity, executable: CommandLine.arguments[2], file: CommandLine.arguments[3])
print("PASS: YouTube Cast omits optional captions and restores the local choice on return")
return
}
if CommandLine.arguments.count == 4, CommandLine.arguments[1] == "--playback" { if CommandLine.arguments.count == 4, CommandLine.arguments[1] == "--playback" {
try await playback(identity: identity, executable: CommandLine.arguments[2], file: CommandLine.arguments[3]) try await playback(identity: identity, executable: CommandLine.arguments[2], file: CommandLine.arguments[3])
print("PASS: native TLS receiver fetches relay and acknowledges PlayerModel handoff/control/disconnect") print("PASS: native TLS receiver fetches relay and acknowledges PlayerModel handoff/control/disconnect")
+131 -54
View File
@@ -99,12 +99,20 @@ struct CompactWindowChecks {
line: UInt = #line, checking: () throws -> Void = {}) throws { line: UInt = #line, checking: () throws -> Void = {}) throws {
let deadline = ContinuousClock.now.advanced(by: .seconds(5)) let deadline = ContinuousClock.now.advanced(by: .seconds(5))
while controller.isTransitioning { while controller.isTransitioning {
if let presentation = controller.presentationWindow {
try require(!presentation.isVisible && !presentation.isKeyWindow,
"native transitions never display or key a hidden presentation")
}
try checking() try checking()
try require(ContinuousClock.now < deadline, "native compact transition completes (call at line \(line), compact \(controller.isCompact), target \(compact))") try require(ContinuousClock.now < deadline, "native compact transition completes (call at line \(line), compact \(controller.isCompact), target \(compact))")
CFRunLoopRunInMode(.defaultMode, 0.01, false) CFRunLoopRunInMode(.defaultMode, 0.01, false)
} }
try checking() try checking()
try require(controller.isCompact == compact, "settled mode matches the latest destination") try require(controller.isCompact == compact, "settled mode matches the latest destination")
if let presentation = controller.presentationWindow {
try require(!presentation.isVisible && !presentation.isKeyWindow,
"settled hidden presentation remains undisplayed and non-key")
}
} }
@MainActor @MainActor
@@ -151,6 +159,12 @@ struct CompactWindowChecks {
} }
} }
@MainActor
final class WindowDelegate: NSObject, NSWindowDelegate {
var closes = 0
func windowWillClose(_ notification: Notification) { closes += 1 }
}
@MainActor @MainActor
final class WindowFixture { final class WindowFixture {
let window: NSWindow let window: NSWindow
@@ -158,7 +172,8 @@ struct CompactWindowChecks {
let sentinel: NSView let sentinel: NSView
let sentinelFrame: NSRect let sentinelFrame: NSRect
let original: WindowState let original: WindowState
let identity: ObjectIdentifier let delegate = WindowDelegate()
let activationPolicy: NSApplication.ActivationPolicy
init() throws { init() throws {
// Never orderFront, makeKey, or create an actual native fullscreen Space. // Never orderFront, makeKey, or create an actual native fullscreen Space.
@@ -180,19 +195,24 @@ struct CompactWindowChecks {
window.level = .normal window.level = .normal
window.hidesOnDeactivate = true window.hidesOnDeactivate = true
window.isMovableByWindowBackground = false window.isMovableByWindowBackground = false
window.delegate = delegate
original = WindowState(window) original = WindowState(window)
identity = ObjectIdentifier(window) activationPolicy = NSApp.activationPolicy()
} }
func preservedContent() throws { func preservedContent(in presentation: NSWindow? = nil) throws {
try CompactWindowChecks.require(ObjectIdentifier(window) == identity && sentinel.window === window, let presentation = presentation ?? window
"compact mode keeps the actual rendering window") try CompactWindowChecks.require(sentinel.window === presentation
try CompactWindowChecks.require(window.contentView === content && sentinel.superview === content && presentation.contentView === content
&& sentinel.superview === content
&& content.subviews.count == 1 && content.subviews.count == 1
&& CompactWindowChecks.sameFrame(sentinel.frame, sentinelFrame), && CompactWindowChecks.sameFrame(sentinel.frame, sentinelFrame),
"compact mode preserves content and attached sentinel without reparenting") "presentation retains the same content subtree and renderer sentinel")
try CompactWindowChecks.require(!window.isVisible && !window.isKeyWindow, try CompactWindowChecks.require(!window.isVisible && !window.isKeyWindow
"checks never display or key a window") && !presentation.isVisible && !presentation.isKeyWindow,
"hidden checks never display or key either window")
try CompactWindowChecks.require(NSApp.activationPolicy() == activationPolicy,
"compact preserves the app's Dock and CmdTab activation policy")
} }
func restored() throws { func restored() throws {
@@ -200,28 +220,35 @@ struct CompactWindowChecks {
try preservedContent() try preservedContent()
} }
func compact(aspect: Double) throws { func compact(_ controller: CompactWindowController, aspect: Double) throws {
try CompactWindowChecks.require(window.level == .floating && !window.hidesOnDeactivate, guard let panel = controller.presentationWindow as? NSPanel else {
throw Failure(description: "FAIL: compact presents a dedicated NSPanel")
}
try CompactWindowChecks.require(panel !== window && panel.styleMask.contains(.nonactivatingPanel),
"compact uses a nonactivating panel, not the normal window")
try CompactWindowChecks.require(window.contentView !== content,
"hidden normal window relinquishes the renderer content")
try original.requireMatches(window)
try CompactWindowChecks.require(panel.level == .floating && !panel.hidesOnDeactivate,
"compact floats and remains visible on deactivate") "compact floats and remains visible on deactivate")
try CompactWindowChecks.require(window.collectionBehavior.contains( try CompactWindowChecks.require(panel.collectionBehavior.contains(
[.canJoinAllSpaces, .canJoinAllApplications, .fullScreenAuxiliary]), [.canJoinAllSpaces, .canJoinAllApplications, .fullScreenAuxiliary]),
"compact can join Spaces, other-app fullscreen, and Stage Manager") "compact can join Spaces, other-app fullscreen, and Stage Manager")
try CompactWindowChecks.require(window.collectionBehavior.intersection( try CompactWindowChecks.require(panel.collectionBehavior.intersection(
[.primary, .auxiliary, .fullScreenPrimary, .fullScreenNone]).isEmpty, [.primary, .auxiliary, .fullScreenPrimary, .fullScreenNone]).isEmpty,
"compact removes incompatible primary and auxiliary policies") "compact removes incompatible primary and auxiliary policies")
try CompactWindowChecks.require(window.contentMinSize == NSSize(width: 160, height: 90), try CompactWindowChecks.require(panel.contentMinSize == NSSize(width: 160, height: 90),
"compact uses 160x90 content minimum") "compact uses 160x90 content minimum")
try CompactWindowChecks.require(window.canBecomeKey, "compact accepts keyboard playback controls") try CompactWindowChecks.require(panel.canBecomeKey, "compact accepts keyboard playback controls")
try CompactWindowChecks.require(window.titleVisibility == .hidden try CompactWindowChecks.require(panel.titleVisibility == .hidden && panel.titlebarAppearsTransparent,
&& window.titlebarAppearsTransparent,
"compact hides native title chrome") "compact hides native title chrome")
let visible = (window.screen ?? NSScreen.main!).visibleFrame let visible = (panel.screen ?? NSScreen.main!).visibleFrame
try CompactWindowChecks.require(CompactWindowChecks.close(window.frame.maxX, visible.maxX - 16) try CompactWindowChecks.require(CompactWindowChecks.close(panel.frame.maxX, visible.maxX - 16)
&& CompactWindowChecks.close(window.frame.maxY, visible.maxY - 16) && CompactWindowChecks.close(panel.frame.maxY, visible.maxY - 16)
&& CompactWindowChecks.close(window.frame.width / window.frame.height, && CompactWindowChecks.close(panel.frame.width / panel.frame.height,
CGFloat(aspect)), CGFloat(aspect)),
"compact enters at inset top right with playback aspect") "compact enters at inset top right with playback aspect")
try preservedContent() try preservedContent(in: panel)
} }
} }
@@ -238,7 +265,7 @@ struct CompactWindowChecks {
controller.toggle(aspect: 16.0 / 9.0) controller.toggle(aspect: 16.0 / 9.0)
try require(controller.isTransitioning, "native completion barrier is an actual animated entry") try require(controller.isTransitioning, "native completion barrier is an actual animated entry")
try settled(controller, compact: true, checking: checking) try settled(controller, compact: true, checking: checking)
try fixture.compact(aspect: 16.0 / 9.0) try fixture.compact(controller, aspect: 16.0 / 9.0)
controller.restore(resumeFullScreen: false) controller.restore(resumeFullScreen: false)
try settled(controller, compact: false, checking: checking) try settled(controller, compact: false, checking: checking)
try fixture.restored() try fixture.restored()
@@ -257,47 +284,53 @@ struct CompactWindowChecks {
window.close() window.close()
} }
controller.attach(window) controller.attach(window)
try require(!controller.isCompact && !controller.isTransitioning, "attached window starts ordinary") try require(!controller.isCompact && !controller.isTransitioning
&& controller.presentationWindow === window, "attached window starts ordinary")
try require(controller.snappingEnabled, "isolated controller defaults snapping on") try require(controller.snappingEnabled, "isolated controller defaults snapping on")
controller.toggle(aspect: 16.0 / 9.0) controller.toggle(aspect: 16.0 / 9.0)
try require(controller.isCompact && controller.isTransitioning, try require(controller.isCompact && controller.isTransitioning,
"entry presents the compact destination while native geometry settles") "entry presents the compact destination while native geometry settles")
try fixture.preservedContent() try fixture.preservedContent(in: controller.presentationWindow)
try settled(controller, compact: true) try settled(controller, compact: true)
try fixture.compact(aspect: 16.0 / 9.0) try fixture.compact(controller, aspect: 16.0 / 9.0)
let visible = (window.screen ?? NSScreen.main!).visibleFrame let panel = controller.presentationWindow!
let visible = (panel.screen ?? NSScreen.main!).visibleFrame
let nearEdge = NSRect(x: visible.minX + 26, y: visible.minY + 27, let nearEdge = NSRect(x: visible.minX + 26, y: visible.minY + 27,
width: window.frame.width, height: window.frame.height) width: panel.frame.width, height: panel.frame.height)
window.setFrame(nearEdge, display: false) panel.setFrame(nearEdge, display: false)
try require(sameFrame(window.frame, nearEdge), "native move installs unsnapped drag position") try require(sameFrame(panel.frame, nearEdge), "native move installs unsnapped drag position")
controller.snapAfterDrag() controller.snapAfterDrag()
try require(close(window.frame.minX, visible.minX + 16) && close(window.frame.minY, visible.minY + 16) try require(close(panel.frame.minX, visible.minX + 16) && close(panel.frame.minY, visible.minY + 16)
&& window.frame.size == nearEdge.size, "enabled preference snaps the actual moved window") && panel.frame.size == nearEdge.size, "enabled preference snaps the actual moved window")
controller.snappingEnabled = false controller.snappingEnabled = false
let second = CompactWindowController(snappingEnabled: false, reduceMotion: { false }) let second = CompactWindowController(snappingEnabled: false, reduceMotion: { false })
try require(!second.snappingEnabled, "configuration can disable snapping for a new controller") try require(!second.snappingEnabled, "configuration can disable snapping for a new controller")
window.setFrame(nearEdge, display: false) panel.setFrame(nearEdge, display: false)
controller.snapAfterDrag() controller.snapAfterDrag()
try require(sameFrame(window.frame, nearEdge), "disabled preference leaves an actual moved window untouched") try require(sameFrame(panel.frame, nearEdge), "disabled preference leaves an actual moved window untouched")
second.snappingEnabled = true second.snappingEnabled = true
let third = CompactWindowController(snappingEnabled: false, reduceMotion: { false }) let third = CompactWindowController(snappingEnabled: false, reduceMotion: { false })
try require(!third.snappingEnabled, "a session override does not change the next controller's settings") try require(!third.snappingEnabled, "a session override does not change the next controller's settings")
controller.snappingEnabled = true controller.snappingEnabled = true
let resized = NSRect(x: nearEdge.minX, y: nearEdge.minY, width: 240, height: 135) let resized = NSRect(x: nearEdge.minX, y: nearEdge.minY, width: 240, height: 135)
window.setFrame(resized, display: false) panel.setFrame(resized, display: false)
controller.updateAspect(16.0 / 9.0) controller.updateAspect(16.0 / 9.0)
try require(sameFrame(window.frame, resized), try require(sameFrame(panel.frame, resized),
"repeated aspect updates preserve a user's compact resize and placement") "repeated aspect updates preserve a user's compact resize and placement")
controller.updateAspect(4.0 / 3.0) controller.updateAspect(4.0 / 3.0)
try require(controller.isCompact && !controller.isTransitioning, "aspect update retains compact mode") try require(controller.isCompact && !controller.isTransitioning, "aspect update retains compact mode")
try require(close(window.frame.width / window.frame.height, 4.0 / 3.0), try require(close(panel.frame.width / panel.frame.height, 4.0 / 3.0),
"actual compact window follows changed playback aspect") "actual compact window follows changed playback aspect")
try fixture.preservedContent() try fixture.preservedContent(in: panel)
controller.toggle(aspect: 4.0 / 3.0) controller.toggle(aspect: 4.0 / 3.0)
try require(controller.presentationWindow === panel,
"restoring continues presenting the panel until content is handed back")
try settled(controller, compact: false) try settled(controller, compact: false)
try fixture.restored() try fixture.restored()
try require(controller.presentationWindow === window && panel.contentView !== fixture.content
&& !panel.isVisible && !panel.isKeyWindow, "restoration retires the compact panel")
// Enter -> exit before entry completes: the original normal snapshot must survive. // Enter -> exit before entry completes: the original normal snapshot must survive.
controller.toggle(aspect: 16.0 / 9.0) controller.toggle(aspect: 16.0 / 9.0)
@@ -314,7 +347,7 @@ struct CompactWindowChecks {
"exit presents the ordinary destination while native geometry settles") "exit presents the ordinary destination while native geometry settles")
controller.toggle(aspect: 4.0 / 3.0) controller.toggle(aspect: 4.0 / 3.0)
try settled(controller, compact: true) try settled(controller, compact: true)
try fixture.compact(aspect: 4.0 / 3.0) try fixture.compact(controller, aspect: 4.0 / 3.0)
controller.restore(resumeFullScreen: false) controller.restore(resumeFullScreen: false)
try settled(controller, compact: false) try settled(controller, compact: false)
try fixture.restored() try fixture.restored()
@@ -328,7 +361,7 @@ struct CompactWindowChecks {
} }
try settled(controller, compact: compactDestination) try settled(controller, compact: compactDestination)
if compactDestination { if compactDestination {
try fixture.compact(aspect: 16.0 / 9.0) try fixture.compact(controller, aspect: 16.0 / 9.0)
controller.restore(resumeFullScreen: false) controller.restore(resumeFullScreen: false)
try settled(controller, compact: false) try settled(controller, compact: false)
} }
@@ -354,17 +387,39 @@ struct CompactWindowChecks {
"stale completion cannot revive an urgently restored session") "stale completion cannot revive an urgently restored session")
try fixture.restored() try fixture.restored()
} }
controller.toggle(aspect: 16.0 / 9.0)
try settled(controller, compact: true)
let interruptedPanel = controller.presentationWindow!
controller.restore(resumeFullScreen: false)
try require(controller.isTransitioning && controller.presentationWindow === interruptedPanel,
"animated restoration keeps presenting the renderer's panel")
try fixture.preservedContent(in: interruptedPanel)
controller.restore(resumeFullScreen: false, animated: false)
try require(!controller.isTransitioning && controller.presentationWindow === window
&& interruptedPanel.contentView !== fixture.content,
"urgent restore interrupts exit and returns content immediately")
try fixture.restored()
try nativeCompletionOpportunity {
try require(controller.presentationWindow === window && !controller.isTransitioning,
"stale exit completion cannot reclaim restored content")
try fixture.restored()
}
let replacement = try WindowFixture() let replacement = try WindowFixture()
defer { replacement.window.close() } defer { replacement.window.close() }
controller.toggle(aspect: 16.0 / 9.0) controller.toggle(aspect: 16.0 / 9.0)
try require(controller.isTransitioning, "attachment interrupts native entry") try require(controller.isTransitioning, "attachment interrupts native entry")
let replacedPanel = controller.presentationWindow!
controller.attach(replacement.window) controller.attach(replacement.window)
try require(!controller.isCompact && !controller.isTransitioning, "replacement starts ordinary") try require(!controller.isCompact && !controller.isTransitioning, "replacement starts ordinary")
try fixture.restored() try fixture.restored()
try require(controller.presentationWindow === replacement.window
&& window.delegate === fixture.delegate && replacedPanel.contentView !== fixture.content
&& !replacedPanel.isVisible && !replacedPanel.isKeyWindow,
"replacement restores old delegate and content and retires its panel")
controller.toggle(aspect: 4.0 / 3.0) controller.toggle(aspect: 4.0 / 3.0)
try settled(controller, compact: true) { try fixture.restored() } try settled(controller, compact: true) { try fixture.restored() }
try replacement.compact(aspect: 4.0 / 3.0) try replacement.compact(controller, aspect: 4.0 / 3.0)
controller.restore(resumeFullScreen: false) controller.restore(resumeFullScreen: false)
try settled(controller, compact: false) { try fixture.restored() } try settled(controller, compact: false) { try fixture.restored() }
try replacement.restored() try replacement.restored()
@@ -386,8 +441,13 @@ struct CompactWindowChecks {
controller.toggle(aspect: 16.0 / 9.0) controller.toggle(aspect: 16.0 / 9.0)
try require(controller.isTransitioning, "close interrupts native entry") try require(controller.isTransitioning, "close interrupts native entry")
let closingPanel = controller.presentationWindow!
window.close() window.close()
try require(!controller.isCompact && !controller.isTransitioning, "closed window ends its session") try require(!controller.isCompact && !controller.isTransitioning, "closed window ends its session")
try require(controller.presentationWindow == nil && window.delegate === fixture.delegate
&& fixture.delegate.closes == 1 && closingPanel.contentView !== fixture.content
&& !closingPanel.isVisible && !closingPanel.isKeyWindow,
"closing the normal window restores its delegate and closes its compact panel")
let closedState = WindowState(window) let closedState = WindowState(window)
controller.attach(replacement.window) controller.attach(replacement.window)
controller.toggle(aspect: 16.0 / 9.0) controller.toggle(aspect: 16.0 / 9.0)
@@ -396,10 +456,23 @@ struct CompactWindowChecks {
try fixture.preservedContent() try fixture.preservedContent()
} }
try settled(controller, compact: true, checking: closedWindowUnchanged) try settled(controller, compact: true, checking: closedWindowUnchanged)
try replacement.compact(aspect: 16.0 / 9.0) try replacement.compact(controller, aspect: 16.0 / 9.0)
controller.restore(resumeFullScreen: false) controller.restore(resumeFullScreen: false)
try settled(controller, compact: false, checking: closedWindowUnchanged) try settled(controller, compact: false, checking: closedWindowUnchanged)
try replacement.restored() try replacement.restored()
controller.toggle(aspect: 16.0 / 9.0)
let userClosedPanel = controller.presentationWindow!
userClosedPanel.close()
try require(!controller.isCompact && !controller.isTransitioning
&& controller.presentationWindow == nil && replacement.delegate.closes == 1
&& replacement.window.delegate === replacement.delegate,
"closing compact presentation also closes and detaches the normal window")
try replacement.restored()
try nativeCompletionOpportunity {
try require(controller.presentationWindow == nil && !controller.isTransitioning,
"stale completion cannot revive a user-closed compact panel")
try replacement.restored()
}
} }
static func rememberedSize() throws { static func rememberedSize() throws {
@@ -414,19 +487,21 @@ struct CompactWindowChecks {
} }
controller.attach(fixture.window) controller.attach(fixture.window)
controller.toggle(aspect: 16.0 / 9.0) controller.toggle(aspect: 16.0 / 9.0)
var resized = fixture.window.frame let panel = controller.presentationWindow!
var resized = panel.frame
resized.size = NSSize(width: 240, height: 135) resized.size = NSSize(width: 240, height: 135)
fixture.window.setFrame(resized, display: false) panel.setFrame(resized, display: false)
NotificationCenter.default.post(name: NSWindow.didEndLiveResizeNotification, object: fixture.window) NotificationCenter.default.post(name: NSWindow.didEndLiveResizeNotification, object: panel)
controller.restore(resumeFullScreen: false) controller.restore(resumeFullScreen: false)
try fixture.restored() try fixture.restored()
controller.toggle(aspect: 16.0 / 9.0) controller.toggle(aspect: 16.0 / 9.0)
try require(close(fixture.window.frame.width, 240) && close(fixture.window.frame.height, 135), let reentered = controller.presentationWindow!
try require(close(reentered.frame.width, 240) && close(reentered.frame.height, 135),
"reentering compact restores the manually chosen size") "reentering compact restores the manually chosen size")
// Programmatic fitting and ordinary-window resizing must not replace the preference. // Programmatic fitting and ordinary-window resizing must not replace the preference.
resized.size = NSSize(width: 320, height: 180) resized.size = NSSize(width: 320, height: 180)
fixture.window.setFrame(resized, display: false) reentered.setFrame(resized, display: false)
controller.updateAspect(4.0 / 3.0) controller.updateAspect(4.0 / 3.0)
controller.restore(resumeFullScreen: false) controller.restore(resumeFullScreen: false)
try fixture.restored() try fixture.restored()
@@ -440,9 +515,10 @@ struct CompactWindowChecks {
} }
reloaded.attach(replacement.window) reloaded.attach(replacement.window)
reloaded.toggle(aspect: 4.0 / 3.0) reloaded.toggle(aspect: 4.0 / 3.0)
try require(close(replacement.window.frame.width, 240) && close(replacement.window.frame.height, 180), let reloadedPanel = reloaded.presentationWindow!
try require(close(reloadedPanel.frame.width, 240) && close(reloadedPanel.frame.height, 180),
"a recreated controller restores preferred width with the new video's aspect") "a recreated controller restores preferred width with the new video's aspect")
try replacement.compact(aspect: 4.0 / 3.0) try replacement.compact(reloaded, aspect: 4.0 / 3.0)
reloaded.restore(resumeFullScreen: false) reloaded.restore(resumeFullScreen: false)
try replacement.restored() try replacement.restored()
} }
@@ -458,7 +534,7 @@ struct CompactWindowChecks {
controller.toggle(aspect: 16.0 / 9.0) controller.toggle(aspect: 16.0 / 9.0)
try require(controller.isCompact && !controller.isTransitioning, try require(controller.isCompact && !controller.isTransitioning,
"Reduce Motion enters with geometry and controls already settled") "Reduce Motion enters with geometry and controls already settled")
try fixture.compact(aspect: 16.0 / 9.0) try fixture.compact(controller, aspect: 16.0 / 9.0)
controller.toggle(aspect: 16.0 / 9.0) controller.toggle(aspect: 16.0 / 9.0)
try require(!controller.isCompact && !controller.isTransitioning, try require(!controller.isCompact && !controller.isTransitioning,
"Reduce Motion restores without a pending frame animation") "Reduce Motion restores without a pending frame animation")
@@ -507,13 +583,14 @@ struct CompactWindowChecks {
static func compactQueue() throws { static func compactQueue() throws {
let fixture = try WindowFixture() let fixture = try WindowFixture()
let controller = CompactWindowController(reduceMotion: { true }) let controller = CompactWindowController(reduceMotion: { true })
let window = fixture.window let normal = fixture.window
defer { defer {
controller.restore(resumeFullScreen: false, animated: false) controller.restore(resumeFullScreen: false, animated: false)
window.close() normal.close()
} }
controller.attach(window) controller.attach(normal)
controller.toggle(aspect: 16.0 / 9.0) controller.toggle(aspect: 16.0 / 9.0)
let window = controller.presentationWindow!
try require(controller.queueHeight == 0, "compact entry hides the queue") try require(controller.queueHeight == 0, "compact entry hides the queue")
let videoFrame = window.frame let videoFrame = window.frame
let expanded = controller.queueLayout(visible: true)! let expanded = controller.queueLayout(visible: true)!
@@ -559,7 +636,7 @@ struct CompactWindowChecks {
try fixture.restored() try fixture.restored()
controller.toggle(aspect: 4.0 / 3.0) controller.toggle(aspect: 4.0 / 3.0)
try require(controller.queueHeight == 0, "each compact entry resets queue visibility") try require(controller.queueHeight == 0, "each compact entry resets queue visibility")
try fixture.compact(aspect: 4.0 / 3.0) try fixture.compact(controller, aspect: 4.0 / 3.0)
controller.restore(resumeFullScreen: false) controller.restore(resumeFullScreen: false)
try fixture.restored() try fixture.restored()
} }
+18
View File
@@ -136,6 +136,24 @@ struct ConfigurationChecks {
try require(try PlayerConfiguration.load(from: sponsor) == PlayerConfiguration(sponsorBlock: enabled), try require(try PlayerConfiguration.load(from: sponsor) == PlayerConfiguration(sponsorBlock: enabled),
"SponsorBlock opt-in and explicit opt-out load without changing other defaults") "SponsorBlock opt-in and explicit opt-out load without changing other defaults")
} }
for enabled in [true, false] {
try Data("[playback]\nsponsorblock_autoskip = \(enabled)\n".utf8).write(to: sponsor)
try require(try PlayerConfiguration.load(from: sponsor) == PlayerConfiguration(sponsorBlockAutoSkip: enabled),
"sponsor autoskip loads independently without enabling network lookups")
}
for value in ["\"true\"", "1", "[]"] {
try Data("[playback]\nsponsorblock_autoskip = \(value)\n".utf8).write(to: sponsor)
try requireLoadError(at: sponsor, "non-boolean sponsor autoskip", containing: ["sponsorblock_autoskip"])
}
for enabled in [true, false] {
try Data("[playback]\nopening_ending_autoskip = \(enabled)\n".utf8).write(to: sponsor)
try require(try PlayerConfiguration.load(from: sponsor) == PlayerConfiguration(openingEndingAutoSkip: enabled),
"opening/ending autoskip is independent of SponsorBlock")
}
for value in ["\"true\"", "1", "[]"] {
try Data("[playback]\nopening_ending_autoskip = \(value)\n".utf8).write(to: sponsor)
try requireLoadError(at: sponsor, "non-boolean opening/ending autoskip", containing: ["opening_ending_autoskip"])
}
let miruro = root.appendingPathComponent("miruro.toml") let miruro = root.appendingPathComponent("miruro.toml")
for (settings, video, captions) in [ for (settings, video, captions) in [
("video_provider = \"new-Host_2\"", "new-Host_2", ""), ("video_provider = \"new-Host_2\"", "new-Host_2", ""),
+20 -10
View File
@@ -13,6 +13,7 @@ binary = str(Path(sys.argv[1]).resolve())
fixtures = Path(sys.argv[2]).resolve() fixtures = Path(sys.argv[2]).resolve()
seen = [] seen = []
errors = [] errors = []
zoko_only = "--zoko-intervals" in sys.argv[3:]
class Handler(http.server.BaseHTTPRequestHandler): class Handler(http.server.BaseHTTPRequestHandler):
def log_message(self, *_): def log_message(self, *_):
@@ -69,7 +70,7 @@ class Handler(http.server.BaseHTTPRequestHandler):
captions = path.startswith("/captions/") captions = path.startswith("/captions/")
if alternate or captions: if alternate or captions:
path = "/" + path.split("/", 2)[2] path = "/" + path.split("/", 2)[2]
expected = "alternate-private" if alternate else "subtitle-private" if captions or path == "/english.jpeg" else "stream-private" expected = "alternate-private" if alternate else "subtitle-private" if captions or path in ("/english.jpeg", "/denied.vtt") else "stream-private"
if self.headers.get("X-Resource") != expected: if self.headers.get("X-Resource") != expected:
errors.append("wrong resource headers: " + path) errors.append("wrong resource headers: " + path)
self.send_error(403) self.send_error(403)
@@ -89,6 +90,11 @@ class Handler(http.server.BaseHTTPRequestHandler):
elif path == "/english.jpeg": elif path == "/english.jpeg":
data = (b'WEBVTT\n\n00:00.000 --> 00:10.000\nAlternate English cue\n' if alternate else data = (b'WEBVTT\n\n00:00.000 --> 00:10.000\nAlternate English cue\n' if alternate else
b'WEBVTT\n\n00:00.000 --> 00:02.000\nFirst English cue\n\n00:02.000 --> 00:04.000\nSecond English cue\n') b'WEBVTT\n\n00:00.000 --> 00:02.000\nFirst English cue\n\n00:02.000 --> 00:04.000\nSecond English cue\n')
if parse_qs(urlsplit(self.path).query).get("auto") == ["1"]:
data = data.replace(b"First English cue", b"First automatic cue").replace(b"Second English cue", b"Second automatic cue")
elif path == "/denied.vtt":
self.send_error(403)
return
elif path.startswith("/resolved-vod-") and path.endswith(".ts"): elif path.startswith("/resolved-vod-") and path.endswith(".ts"):
data = (fixtures / path[1:]).read_bytes() data = (fixtures / path[1:]).read_bytes()
else: else:
@@ -117,17 +123,21 @@ try:
"title": "Local resolved fixture", "subtitles": [{"url": base + "/english.jpeg", "format": "vtt", "language": "en", "title": "Local resolved fixture", "subtitles": [{"url": base + "/english.jpeg", "format": "vtt", "language": "en",
"label": "English", "default": True, "headers": {"X-Resource": "subtitle-private"}}]})) "label": "English", "default": True, "headers": {"X-Resource": "subtitle-private"}}]}))
source.chmod(0o600) source.chmod(0o600)
subprocess.run([binary, str(source), str(progress)], check=True, timeout=100) subprocess.run([binary, str(source), "--zoko-intervals"] if zoko_only else
records = [json.loads(line) for line in progress.read_text().splitlines()] [binary, str(source), str(progress)], check=True, timeout=100)
assert records and all(record["version"] == 2 and record["media_identity"] == if not zoko_only:
{"provider": "miruro", "media_id": "fixture-ID", "episode": 1} for record in records), "stable episode progress identity" records = [json.loads(line) for line in progress.read_text().splitlines()]
assert any(record["completion_eligible"] for record in records), "acknowledged video progress eligible" assert records and all(record["version"] == 2 and record["media_identity"] ==
assert "stream-private" not in progress.read_text() and "token=" not in progress.read_text(), "progress has no transport secrets" {"provider": "miruro", "media_id": "fixture-ID", "episode": 1} for record in records), "stable episode progress identity"
assert any(record["completion_eligible"] for record in records), "acknowledged video progress eligible"
assert "stream-private" not in progress.read_text() and "token=" not in progress.read_text(), "progress has no transport secrets"
assert not errors, errors assert not errors, errors
assert {"/master.m3u8", "/variant.m3u8", "/english.jpeg"}.issubset(seen), "master, nested playlist and external VTT fetched" assert {"/master.m3u8", "/variant.m3u8"}.issubset(seen), "master and nested playlist fetched"
assert {"/alternate/master.m3u8", "/alternate/english.jpeg"}.issubset(seen), "alternate video host and independent caption provider fetched" if not zoko_only:
assert {"/english.jpeg", "/alternate/master.m3u8", "/alternate/english.jpeg"}.issubset(seen), "external VTT, alternate host and independent captions fetched"
assert any(path.endswith(".ts") for path in seen), "media segment fetched" assert any(path.endswith(".ts") for path in seen), "media segment fetched"
print("PASS: model HLS/VTT playback, independent resource headers, tokens, stable progress") print("PASS: local Zoko metadata native playback and source preparation" if zoko_only else
"PASS: model HLS/VTT playback, independent resource headers, tokens, stable progress")
finally: finally:
server.shutdown() server.shutdown()
server.server_close() server.server_close()
+374 -11
View File
@@ -44,6 +44,11 @@ struct ResolvedPlaybackCheck {
} else { } else {
source = try ResolvedSource.load(from: file) source = try ResolvedSource.load(from: file)
} }
if CommandLine.arguments.contains("--zoko-intervals") {
try await transportProtocol(source: source)
try await zokoIntervalLifecycle(source: source)
return
}
if CommandLine.arguments.contains("--enqueue-only") { if CommandLine.arguments.contains("--enqueue-only") {
try await enqueueLifecycle(source: source) try await enqueueLifecycle(source: source)
return return
@@ -119,6 +124,8 @@ struct ResolvedPlaybackCheck {
if !input.hasPrefix("https://") { if !input.hasPrefix("https://") {
try await transportProtocol(source: source) try await transportProtocol(source: source)
model.shutdown() model.shutdown()
try await youtubeSubtitleLifecycle(source: source)
try await automaticSegmentSkips(file: file)
try await providerLifecycle(source: source) try await providerLifecycle(source: source)
try await dubOnlySelection(source: source) try await dubOnlySelection(source: source)
try await queueMetadataLifecycle(source: source) try await queueMetadataLifecycle(source: source)
@@ -127,32 +134,283 @@ struct ResolvedPlaybackCheck {
try await streamQueueLifecycle(source: source) try await streamQueueLifecycle(source: source)
try await enqueueLifecycle(source: source) try await enqueueLifecycle(source: source)
try await sidebarQueueLifecycle(source: source) try await sidebarQueueLifecycle(source: source)
try await zokoIntervalLifecycle(source: source)
} }
} }
static func youtubeSubtitleLifecycle(source: ResolvedSource) async throws {
let suite = "terebi.youtube-subtitle-check." + UUID().uuidString
let defaults = UserDefaults(suiteName: suite)!
defer { defaults.removePersistentDomain(forName: suite) }
var resolution = 0
let model = PlayerModel(defaults: defaults, youtubeResolver: { url, height in
resolution += 1
let manual = ResolvedSubtitle(url: source.subtitles[0].url + "?revision=\(resolution)",
format: "vtt", language: "en", label: "English", isDefault: false,
headers: ["X-Resource": "subtitle-private"], selectionID: "youtube:manual:en")
let automatic = ResolvedSubtitle(url: manual.url + "&auto=1", format: "vtt",
language: "en", label: "English (auto-generated)", isDefault: false,
headers: ["X-Resource": "subtitle-private"], selectionID: "youtube:auto:en")
let denied = ResolvedSubtitle(url: source.subtitles[0].url.replacingOccurrences(of: "english.jpeg", with: "denied.vtt"),
format: "vtt", language: "fr", label: "Unavailable", isDefault: false,
headers: ["X-Resource": "subtitle-private"], selectionID: "youtube:manual:fr")
let malformed = ResolvedSubtitle(url: source.url, format: "vtt", language: "de",
label: "Malformed", isDefault: false, headers: ["X-Resource": "stream-private"],
selectionID: "youtube:manual:de")
return YouTubeVideo(title: "YouTube caption fixture", video: .init(url: source.url, headers: source.headers),
audio: nil, duration: 0, isLive: false, webpageURL: url, height: height ?? 1080,
availableHeights: [1080, 720], chapters: [],
subtitles: resolution >= 4 ? [manual, denied, malformed] :
(resolution.isMultiple(of: 2) ? [automatic, manual, denied, malformed] : [manual, automatic, denied, malformed]))
}, youtubeTitleResolver: { _ in nil })
defer { model.shutdown() }
model.setVolume(0)
let url = URL(string: "https://www.youtube.com/watch?v=AAAAAAAAAAA")!
model.openStream(url.absoluteString)
try await until("YouTube playback starts with optional captions Off", model: model) {
model.canChangeYouTubeQuality && model.state.position > 0.2
}
require(model.subtitleTracks.count == 4 && model.selectedSubtitle == nil &&
model.subtitleText.isEmpty && model.subtitleError == nil, "YouTube defaults Off")
require(model.setPlayback(playing: false), "pause caption fixture")
try await until("caption fixture pause acknowledged", model: model) { model.state.status == PLAYER_PAUSED }
model.selectSubtitle(1)
try await until("native caption HTTP loader renders automatic English", model: model) {
model.subtitleText == "First automatic cue"
}
require(model.seekRelative(2.5 - model.state.position), "forward caption seek accepted")
try await until("forward seek uses acknowledged caption position", model: model) {
!model.state.seek_pending && model.subtitleText == "Second automatic cue"
}
model.setYouTubeQuality(720)
try await until("quality preserves auto identity despite reordered tracks and refreshed URLs", model: model) {
model.canChangeYouTubeQuality && model.youtubeHeight == 720 && model.selectedSubtitle == 0 &&
model.subtitleText == "Second automatic cue" && model.state.status == PLAYER_PAUSED
}
require(model.seekRelative(0.5 - model.state.position), "backward caption seek accepted")
try await until("backward seek restores earlier caption", model: model) {
!model.state.seek_pending && model.subtitleText == "First automatic cue"
}
model.selectSubtitle(nil)
model.setYouTubeQuality(1080)
try await until("manual quality preserves explicit Off", model: model) {
model.canChangeYouTubeQuality && model.youtubeHeight == 1080
}
require(model.selectedSubtitle == nil && model.subtitleText.isEmpty, "quality must not re-enable captions")
model.selectSubtitle(1)
try await until("automatic track can be selected again", model: model) { model.subtitleText == "First automatic cue" }
model.setYouTubeQuality(720)
try await until("missing automatic track switches Off, not to manual English", model: model) {
model.canChangeYouTubeQuality && model.youtubeHeight == 720
}
require(model.selectedSubtitle == nil && model.subtitleText.isEmpty, "missing identity has no language fallback")
require(model.setPlayback(playing: true), "resume before optional caption failures")
for index in [1, 2] {
let position = model.state.position
model.selectSubtitle(index)
try await until("inaccessible/malformed captions fail while video keeps advancing", model: model) {
model.subtitleError != nil && model.state.status == PLAYER_PLAYING && model.state.position > position + 0.1
}
require(model.commandError == nil && model.state.has_video && model.state.has_audio &&
model.canSeek, "caption failures retain usable video")
}
require(model.setPlayback(playing: false), "pause after optional caption failures")
try await until("video still pauses normally after caption errors", model: model) { model.state.status == PLAYER_PAUSED }
require(model.seekRelative(0.5 - model.state.position), "video still seeks normally after caption errors")
try await until("post-error seek acknowledged", model: model) { !model.state.seek_pending && abs(model.state.position - 0.5) < 0.1 }
model.selectSubtitle(0)
try await until("valid captions recover without reopening video", model: model) {
model.subtitleText == "First English cue" && model.subtitleError == nil
}
try await model.addStreamToQueue("https://www.youtube.com/watch?v=BBBBBBBBBBB")
model.selectQueueItem(model.queue.items.last!.id)
require(model.subtitleTracks.isEmpty && model.selectedSubtitle == nil &&
model.subtitleText.isEmpty && model.subtitleError == nil, "queue change immediately clears old captions")
try await until("next queued video starts with fresh tracks Off", model: model) {
!model.isBusy && model.state.status == PLAYER_PLAYING && !model.subtitleTracks.isEmpty
}
require(model.selectedSubtitle == nil && model.subtitleText.isEmpty, "queue selection never inherits prior captions")
model.closeFile()
require(model.subtitleTracks.isEmpty && model.subtitleError == nil, "close clears caption state")
print("PASS: YouTube native VTT fetch, Off/On, seeks, stable auto/manual quality identity, missing tracks, optional failures and queue reset")
}
static func zokoIntervalLifecycle(source: ResolvedSource) async throws {
let directory = FileManager.default.temporaryDirectory
.appendingPathComponent("terebi.zoko-interval-check." + UUID().uuidString)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: directory) }
let suite = "terebi.zoko-interval-check." + UUID().uuidString
let defaults = UserDefaults(suiteName: suite)!
defer { defaults.removePersistentDomain(forName: suite) }
func url(_ episode: Int) -> URL {
URL(string: "https://zokoanime.video/stream/ani/1/\(episode)/sub")!
}
let rows: [ProviderInterval] = [
.init(kind: .intro, start: 0.5, end: 1.5), .init(kind: .outro, start: 2.25, end: 3.25),
]
let expected = [
MiruroInterval(kind: .intro, start: 0.5, end: 1.5, episode: 1),
MiruroInterval(kind: .outro, start: 2.25, end: 3.25, episode: 1),
]
func resolved(_ episode: Int) -> ResolvedSource {
var result = ResolvedSource(url: source.url, headers: source.headers, identity: "anilist:1:\(episode)",
title: "Zoko interval fixture \(episode)", subtitles: [])
result.intervals = episode == 2 ? [] : rows
if episode == 3 { result.intervals[1] = .init(kind: .outro, start: 2.25, end: 500) }
return result
}
var requests: [Int: Int] = [:]
var held: CheckedContinuation<ResolvedSource, Never>?
var heldMetadata: CheckedContinuation<MiruroMetadata, Never>?
var staleCompleted = false
var metadataRequests = 0
let clock = Clock()
let model = PlayerModel(defaults: defaults, progressFile: directory.appendingPathComponent("direct.jsonl").path,
miruroResolver: { _, _ in try providerSources(source) },
zokoResolver: { _, episode in
requests[episode, default: 0] += 1
if episode == 4 {
let result = await withCheckedContinuation { held = $0 }
staleCompleted = true
return result
}
return resolved(episode)
}, miruroMetadataResolver: { _, _ in
metadataRequests += 1
return await withCheckedContinuation { heldMetadata = $0 }
}, skipClock: { clock.now })
defer { model.shutdown() }
model.openStream(url(1).absoluteString)
require(model.visibleIntervals.isEmpty, "direct launch hides bands until native media is acknowledged")
try await until("direct Zoko native decode and provider bands", model: model) {
!model.isBusy && model.state.status == PLAYER_PLAYING && model.state.position > 0.2 &&
model.visibleIntervals == expected
}
require(model.state.has_audio && model.state.has_video && model.state.duration >= 3.25,
"local fixture supplies real video/audio and measured duration")
require(model.sliderBoundaries == [0.5, 1.5, 2.25, 3.25] &&
model.sliderSegmentTitle(at: 0.75) == "Opening" && model.sliderSegmentTitle(at: 2.5) == "Ending",
"provider bands reuse existing slider labels and fractional boundaries")
require(model.setPlayback(playing: false), "pause provider playback")
try await until("provider pause acknowledged", model: model) { model.state.status == PLAYER_PAUSED }
require(model.seekRelative(0.8 - model.state.position), "save provider bookmark inside opening")
try await until("provider bookmark and skip offer acknowledged", model: model) {
!model.state.seek_pending && abs(model.state.position - 0.8) < 0.1 && model.segmentToSkip == expected[0]
}
require(model.skipButtonTitle == "Skip Opening", "provider timing feeds unchanged manual skip policy")
model.closeFile()
require(model.visibleIntervals.isEmpty && model.sliderBoundaries.isEmpty, "close clears provider bands immediately")
try await until("provider playback closed", model: model) { model.state.status == PLAYER_EMPTY }
require(model.savedPosition(for: URL(string: "anilist:1:1")!).map { abs($0 - 0.8) < 0.1 } == true,
"provider bookmark is saved under stable episode identity")
let queue = StreamQueue(entries: (1...3).map { episode in
.init(url: url(episode), identity: "anilist:1:\(episode)",
progressFile: directory.appendingPathComponent("queue-\(episode).jsonl"), label: "Episode \(episode)")
})
let reopened = PlayerModel(defaults: UserDefaults(suiteName: suite)!, streamQueue: queue,
zokoResolver: { _, episode in
requests[episode, default: 0] += 1
return resolved(episode)
}, miruroMetadataResolver: { _, _ in
metadataRequests += 1
return MiruroMetadata(title: nil, intervals: [])
}, skipClock: { clock.now })
defer { reopened.shutdown() }
reopened.openStreamQueue(queue)
try await until("fresh model reopens saved Zoko episode with newly resolved bands", model: reopened) {
!reopened.isBusy && reopened.state.status == PLAYER_PLAYING && reopened.state.position >= 0.7 &&
reopened.visibleIntervals == expected
}
require(requests[1] == 2 && requests[2] == nil, "saved queued reopen resolves only selected episode afresh")
reopened.advanceQueue(1)
require(reopened.visibleIntervals.isEmpty && reopened.segmentToSkip == nil, "queue replacement clears opening offer and bands")
try await until("queued episode without timing still decodes", model: reopened) {
!reopened.isBusy && reopened.state.status == PLAYER_PLAYING && reopened.fileName == resolved(2).title
}
require(reopened.visibleIntervals.isEmpty && reopened.sliderBoundaries.isEmpty, "missing timing never inherits previous episode")
reopened.advanceQueue(1)
try await until("out-of-duration provider range does not block queued playback", model: reopened) {
!reopened.isBusy && reopened.state.status == PLAYER_PLAYING && reopened.fileName == resolved(3).title
}
require(reopened.visibleIntervals == [MiruroInterval(kind: .intro, start: 0.5, end: 1.5, episode: 3)],
"actual native duration rejects oversized provider ending without clipping or losing valid opening")
reopened.advanceQueue(-2)
try await until("queue revisit restores fresh first-episode bands", model: reopened) {
!reopened.isBusy && reopened.state.status == PLAYER_PLAYING && reopened.visibleIntervals == expected
}
require(requests[1] == 3, "queue revisit resolves again instead of retaining stale timing")
reopened.closeFile()
try await until("queued provider playback closed", model: reopened) { reopened.state.status == PLAYER_EMPTY }
model.openStream(url(4).absoluteString)
try await until("stale provider resolution held", model: model) { held != nil }
model.openStream(url(2).absoluteString)
held!.resume(returning: resolved(4))
held = nil
try await until("late canceled provider resolution cannot replace new episode", model: model) {
staleCompleted && !model.isBusy && model.state.status == PLAYER_PLAYING && model.fileName == resolved(2).title
}
require(model.visibleIntervals.isEmpty && model.sliderBoundaries.isEmpty, "late source timing cannot paint replacement episode")
model.closeFile()
try await until("empty player before sidebar provider launch", model: model) { model.state.status == PLAYER_EMPTY }
try await model.addStreamToQueue(url(1).absoluteString)
try await until("cold sidebar provider launch preserves prepared source timing", model: model) {
!model.isBusy && model.state.status == PLAYER_PLAYING && model.visibleIntervals == expected
}
require(metadataRequests == 0, "AniList paths never request Miruro timing metadata")
model.openStream("https://barelystarted.miruro.tv/watch/fixture-ID/episode?ep=1")
require(model.visibleIntervals.isEmpty, "switching away from provider timing clears bands immediately")
try await until("Miruro metadata lookup held", model: model) { heldMetadata != nil }
model.openStream(url(1).absoluteString)
heldMetadata!.resume(returning: MiruroMetadata(title: "Stale Miruro title",
intervals: [.init(kind: .intro, start: 0.1, end: 0.4, episode: 1)]))
heldMetadata = nil
try await until("replacement Zoko episode keeps its own provider metadata", model: model) {
!model.isBusy && model.state.status == PLAYER_PLAYING && model.visibleIntervals == expected
}
for _ in 0..<10 { await Task.yield() }
require(metadataRequests == 1 && model.fileName == resolved(1).title && model.visibleIntervals == expected,
"canceled Miruro enrichment cannot rename or paint provider replacement")
print("PASS: Zoko direct/sidebar launch, fractional bands, manual skip, saved fresh-model reopen, queue/revisit, missing/out-of-duration timing and stale source/metadata rejection")
}
static func sponsorPreferenceCheck() { static func sponsorPreferenceCheck() {
let suite = "terebi.sponsor-preference." + UUID().uuidString let suite = "terebi.sponsor-preference." + UUID().uuidString
let defaults = UserDefaults(suiteName: suite)! let defaults = UserDefaults(suiteName: suite)!
defer { defaults.removePersistentDomain(forName: suite) } defer { defaults.removePersistentDomain(forName: suite) }
func launch(_ configured: Bool = false) -> PlayerModel { func launch(_ configured: Bool = false, autoSkip: Bool = false, openingEnding: Bool = false) -> PlayerModel {
PlayerModel(defaults: UserDefaults(suiteName: suite)!, configuration: .init(sponsorBlock: configured)) PlayerModel(defaults: UserDefaults(suiteName: suite)!,
configuration: .init(sponsorBlock: configured, sponsorBlockAutoSkip: autoSkip,
openingEndingAutoSkip: openingEnding))
} }
let first = launch() let first = launch()
require(!first.sponsorBlockEnabled, "unconfigured SponsorBlock defaults to disabled") require(!first.sponsorBlockEnabled, "unconfigured SponsorBlock defaults to disabled")
require(!first.sponsorBlockAutoSkip, "sponsors are not skipped without opt-in")
require(!first.openingEndingAutoSkip, "openings and endings are not skipped without opt-in")
first.setOpeningEndingAutoSkip(true)
first.setSponsorBlockAutoSkip(true)
first.setSponsorBlockEnabled(true) first.setSponsorBlockEnabled(true)
first.shutdown() first.shutdown()
let second = launch() let second = launch()
require(second.sponsorBlockEnabled, "enabled preference survives a fresh model/defaults instance") require(second.sponsorBlockEnabled, "enabled preference survives a fresh model/defaults instance")
require(second.sponsorBlockAutoSkip, "autoskip preference survives restart")
require(second.openingEndingAutoSkip, "opening/ending autoskip preference survives restart")
second.setOpeningEndingAutoSkip(false)
second.setSponsorBlockAutoSkip(false)
second.setSponsorBlockEnabled(false) second.setSponsorBlockEnabled(false)
second.shutdown() second.shutdown()
let third = launch(true) let third = launch(true, autoSkip: true, openingEnding: true)
require(!third.sponsorBlockEnabled, "saved disabled preference survives restart and overrides config") require(!third.sponsorBlockEnabled && !third.sponsorBlockAutoSkip && !third.openingEndingAutoSkip,
"saved disabled skip preferences override config")
third.shutdown() third.shutdown()
defaults.removePersistentDomain(forName: suite) defaults.removePersistentDomain(forName: suite)
let configured = launch(true) let configured = launch(true, autoSkip: true, openingEnding: true)
require(configured.sponsorBlockEnabled, "config supplies the initial choice without a saved preference") require(configured.sponsorBlockEnabled && configured.sponsorBlockAutoSkip && configured.openingEndingAutoSkip,
"config supplies unsaved skip choices")
configured.shutdown() configured.shutdown()
print("PASS: SponsorBlock preference persists enable/disable across launches, with config fallback") print("PASS: sponsor and opening/ending skip preferences persist across launches, with config fallback")
} }
static var fixtureSponsors: [SponsorSegment] { static var fixtureSponsors: [SponsorSegment] {
@@ -167,6 +425,7 @@ struct ResolvedPlaybackCheck {
var extraction: CheckedContinuation<YouTubeVideo, Never>? var extraction: CheckedContinuation<YouTubeVideo, Never>?
var held: [CheckedContinuation<[SponsorSegment], Never>] = [] var held: [CheckedContinuation<[SponsorSegment], Never>] = []
var extractionCount = 0 var extractionCount = 0
let clock = Clock()
let model = PlayerModel(defaults: defaults, youtubeResolver: { url, height in let model = PlayerModel(defaults: defaults, youtubeResolver: { url, height in
extractionCount += 1 extractionCount += 1
let video = YouTubeVideo(title: "Sponsor fixture", video: .init(url: source.url, headers: source.headers), let video = YouTubeVideo(title: "Sponsor fixture", video: .init(url: source.url, headers: source.headers),
@@ -177,7 +436,7 @@ struct ResolvedPlaybackCheck {
}, youtubeTitleResolver: { _ in nil }, sponsorResolver: { id in }, youtubeTitleResolver: { _ in nil }, sponsorResolver: { id in
if id == "BBBBBBBBBBB" { throw URLError(.timedOut) } if id == "BBBBBBBBBBB" { throw URLError(.timedOut) }
return await withCheckedContinuation { held.append($0) } return await withCheckedContinuation { held.append($0) }
}) }, skipClock: { clock.now })
defer { model.shutdown() } defer { model.shutdown() }
model.open(url) model.open(url)
try await until("playback extraction held with SponsorBlock disabled", model: model) { extraction != nil } try await until("playback extraction held with SponsorBlock disabled", model: model) { extraction != nil }
@@ -199,7 +458,34 @@ struct ResolvedPlaybackCheck {
try await until("sponsor seek acknowledged without automatic skipping", model: model) { try await until("sponsor seek acknowledged without automatic skipping", model: model) {
!model.state.seek_pending && abs(model.state.position - 0.75) < 0.1 !model.state.seek_pending && abs(model.state.position - 0.75) < 0.1
} }
require(model.segmentToSkip == nil, "sponsors never offer Miruro skip actions") clock.now += 60
require(model.skipSegment(), "sponsor skip stays available beyond the opening/ending timeout")
try await until("manual sponsor skip reaches range end while paused", model: model) {
!model.state.seek_pending && abs(model.state.position - 1.5) < 0.1
}
require(!model.effectivePlaying, "manual sponsor skip preserves paused intent")
model.setSponsorBlockAutoSkip(true)
require(model.seekRelative(0.75 - model.state.position), "deliberately revisit sponsor with autoskip enabled")
try await until("intentional seek into sponsor settles", model: model) {
!model.state.seek_pending && abs(model.state.position - 0.75) < 0.1
}
require(model.setPlayback(playing: true), "play deliberately selected sponsor")
try await until("deliberately selected sponsor plays", model: model) { model.state.position >= 1 }
require(model.state.position < 1.5, "autoskip does not fight a seek into the sponsor")
require(model.setPlayback(playing: false), "pause before seeking just before sponsor")
try await until("sponsor paused again", model: model) { model.state.status == PLAYER_PAUSED }
model.isSeeking = true
model.seekPosition = 0.25
model.finishSeeking()
try await until("slider seek just before sponsor settles", model: model) {
!model.state.seek_pending && abs(model.state.position - 0.25) < 0.1
}
require(model.setPlayback(playing: true), "play from just before sponsor")
try await until("sponsor entered after nearby slider seek", model: model) { model.state.position >= 0.9 }
require(model.state.position < 1.5, "autoskip does not fight a nearby slider seek")
require(model.skipButtonTitle == "Skip Sponsor", "suppressed autoskip keeps the manual button")
require(model.setPlayback(playing: false), "pause before metadata lifecycle checks")
try await until("sponsor lifecycle playback paused", model: model) { model.state.status == PLAYER_PAUSED }
model.setSponsorBlockEnabled(false) model.setSponsorBlockEnabled(false)
require(model.sponsoredRanges.isEmpty, "disabling immediately clears paint") require(model.sponsoredRanges.isEmpty, "disabling immediately clears paint")
model.setSponsorBlockEnabled(true) model.setSponsorBlockEnabled(true)
@@ -210,7 +496,9 @@ struct ResolvedPlaybackCheck {
} }
require(held.count == 2, "quality replacement coalesces the in-flight current-video lookup") require(held.count == 2, "quality replacement coalesces the in-flight current-video lookup")
held[1].resume(returning: fixtureSponsors) held[1].resume(returning: fixtureSponsors)
try await until("pending metadata survives quality replacement", model: model) { model.sponsoredRanges == [0.5...1.5] } try await until("pending metadata survives quality replacement and the next quality choice is available", model: model) {
model.sponsoredRanges == [0.5...1.5] && model.canChangeYouTubeQuality
}
model.setYouTubeQuality(1080) model.setYouTubeQuality(1080)
try await until("completed metadata survives quality replacement", model: model) { try await until("completed metadata survives quality replacement", model: model) {
!model.isBusy && model.youtubeHeight == 1080 && model.sponsoredRanges == [0.5...1.5] !model.isBusy && model.youtubeHeight == 1080 && model.sponsoredRanges == [0.5...1.5]
@@ -247,7 +535,28 @@ struct ResolvedPlaybackCheck {
!model.isBusy && model.state.status == PLAYER_PLAYING && model.state.has_audio !model.isBusy && model.state.status == PLAYER_PLAYING && model.state.has_audio
} }
require(held.count == 5 && model.sponsoredRanges.isEmpty, "generic sites never send a SponsorBlock lookup") require(held.count == 5 && model.sponsoredRanges.isEmpty, "generic sites never send a SponsorBlock lookup")
print("PASS: SponsorBlock opt-in, independent lookup, crimson-only ranges, quality reuse, same-video cancellation, close, generic sites and timeout isolation") print("PASS: persistent manual sponsor skip, deliberate-seek protection, opt-in, quality reuse, cancellation and failure isolation")
try await sponsorAutoSkip(source: source)
}
static func sponsorAutoSkip(source: ResolvedSource) async throws {
let suite = "terebi.sponsor-autoskip." + UUID().uuidString
let defaults = UserDefaults(suiteName: suite)!
defer { defaults.removePersistentDomain(forName: suite) }
let model = PlayerModel(defaults: defaults,
configuration: .init(sponsorBlock: true, sponsorBlockAutoSkip: true),
youtubeResolver: { url, _ in
YouTubeVideo(title: "Autoskip fixture", video: .init(url: source.url, headers: source.headers),
audio: nil, duration: 0, isLive: false, webpageURL: url, height: 720, availableHeights: [720], chapters: [])
}, youtubeTitleResolver: { _ in nil }, sponsorResolver: { _ in fixtureSponsors })
defer { model.shutdown() }
model.open(URL(string: "https://www.youtube.com/watch?v=CCCCCCCCCCC")!)
try await until("natural sponsor entry queues exact-end autoskip", model: model) { model.seekPosition == 1.5 }
try await until("native player acknowledges autoskip and keeps playing", model: model) {
!model.state.seek_pending && model.state.position >= 1.5 && model.state.status == PLAYER_PLAYING
}
require(model.effectivePlaying, "autoskip preserves playing intent")
print("PASS: natural sponsor entry automatically seeks to range end in the native player")
} }
static var fixtureChapters: [YouTubeChapter] { static var fixtureChapters: [YouTubeChapter] {
@@ -370,9 +679,11 @@ struct ResolvedPlaybackCheck {
var input = ResolvedSource(url: base + "/transport-master.m3u8?token=private", headers: var input = ResolvedSource(url: base + "/transport-master.m3u8?token=private", headers:
"Referer: https://transport.example/\r\nX-Secret: private\r\n", "Referer: https://transport.example/\r\nX-Secret: private\r\n",
identity: source.identity, title: source.title, subtitles: []) identity: source.identity, title: source.title, subtitles: [])
input.intervals = [.init(kind: .intro, start: 0.25, end: 1.75)]
input.requirements = .init(proxy: .init(rotate: false, segments: true), input.requirements = .init(proxy: .init(rotate: false, segments: true),
segmentOrigin: base, playlistQuery: ["skip": "fixture"]) segmentOrigin: base, playlistQuery: ["skip": "fixture"])
let prepared = try await MiruroTransport.prepare(input, deployment: deployment) let prepared = try await MiruroTransport.prepare(input, deployment: deployment)
require(prepared.intervals == input.intervals, "transport source reconstruction preserves provider intervals")
let (master, _) = try await URLSession.shared.data(from: URL(string: prepared.url)!) let (master, _) = try await URLSession.shared.data(from: URL(string: prepared.url)!)
let nested = String(data: master, encoding: .utf8)!.components(separatedBy: .newlines) let nested = String(data: master, encoding: .utf8)!.components(separatedBy: .newlines)
.first { $0.hasPrefix("http://") }! .first { $0.hasPrefix("http://") }!
@@ -1088,4 +1399,56 @@ struct ResolvedPlaybackCheck {
require(model.effectivePlaying, "opening skip preserves playing intent") require(model.effectivePlaying, "opening skip preserves playing intent")
print("PASS: timed opening/ending offers, dismissal, pending seeks, exact-end skips and reopen reset") print("PASS: timed opening/ending offers, dismissal, pending seeks, exact-end skips and reopen reset")
} }
static func automaticSegmentSkips(file: URL) async throws {
for seekTarget: Double? in [nil, 0.75, 0.25] {
let suite = "terebi.segment-autoskip." + UUID().uuidString
let defaults = UserDefaults(suiteName: suite)!
defer { defaults.removePersistentDomain(forName: suite) }
let model = PlayerModel(defaults: defaults, configuration: .init(openingEndingAutoSkip: true),
miruroMetadataResolver: { _, _ in MiruroMetadata(title: nil, intervals: []) }, skipClock: { 100 })
defer { model.shutdown() }
model.openResolvedSource(file)
try await until("automatic segment fixture plays", model: model) {
model.state.status == PLAYER_PLAYING && model.canSeek && !model.isBusy
}
let opening = MiruroInterval(kind: .intro, start: 0.5, end: 1.5, episode: 1)
let ending = MiruroInterval(kind: .outro, start: 2, end: model.duration, episode: 1)
if let seekTarget {
require(model.setPlayback(playing: false), "pause before intentional opening seek")
try await until("automatic segment fixture pauses", model: model) { model.state.status == PLAYER_PAUSED }
if seekTarget == 0.25 {
model.isSeeking = true
model.seekPosition = seekTarget
model.finishSeeking()
} else {
require(model.seekRelative(seekTarget - model.state.position), "seek into opening")
}
try await until("intentional opening seek settles", model: model) {
!model.state.seek_pending && abs(model.state.position - seekTarget) < 0.1
}
}
model.supplyMetadataForCheck(.init(title: nil, intervals: [opening, ending]), episode: 1)
if let seekTarget {
require(abs(model.seekPosition - seekTarget) < 0.1 && model.state.status == PLAYER_PAUSED,
"late metadata does not autoskip paused playback")
require(model.setPlayback(playing: true), "resume deliberately selected opening")
try await until("deliberately selected opening plays", model: model) { model.state.position >= 1 }
require(model.state.position < opening.end && model.skipButtonTitle == "Skip Opening",
"opening autoskip respects seeking into or just before it and keeps the manual button")
continue
}
try await until("opening autoskip queues its exact end", model: model) { model.seekPosition == opening.end }
try await until("native player acknowledges opening autoskip", model: model) {
!model.state.seek_pending && model.state.position >= opening.end && model.state.status == PLAYER_PLAYING
}
try await until("ending autoskip queues exact media end before playing through ending", model: model) {
model.seekPosition == ending.end && model.state.position < ending.end - 0.5
}
try await until("native player acknowledges ending autoskip", model: model) {
!model.state.seek_pending && abs(model.state.position - ending.end) < 0.1
}
}
print("PASS: native opening/ending autoskip, paused/late metadata and deliberate relative/slider seek protection")
}
} }
+49 -2
View File
@@ -169,6 +169,7 @@ struct SystemPlaybackChecks {
styleMask: [.titled, .resizable], backing: .buffered, defer: false) styleMask: [.titled, .resizable], backing: .buffered, defer: false)
window.isReleasedWhenClosed = false window.isReleasedWhenClosed = false
defer { window.close() } defer { window.close() }
model.attachWindow(window)
func event(_ code: UInt16, _ text: String, _ flags: NSEvent.ModifierFlags = []) -> NSEvent { func event(_ code: UInt16, _ text: String, _ flags: NSEvent.ModifierFlags = []) -> NSEvent {
NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: flags, timestamp: 0, NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: flags, timestamp: 0,
windowNumber: window.windowNumber, context: nil, characters: text, windowNumber: window.windowNumber, context: nil, characters: text,
@@ -252,6 +253,33 @@ struct SystemPlaybackChecks {
let modal = NSApp.beginModalSession(for: window) let modal = NSApp.beginModalSession(for: window)
require(!model.handleKeyEvent(shortcuts[0], in: window), "modal session blocks player shortcuts") require(!model.handleKeyEvent(shortcuts[0], in: window), "modal session blocks player shortcuts")
NSApp.endModalSession(modal) NSApp.endModalSession(modal)
window.orderOut(nil)
model.setVolume(0)
model.open(URL(fileURLWithPath: CommandLine.arguments[1]).appendingPathComponent("cache.mp4"))
until("keyboard consumer has actual video playback") { model.canTogglePlayback && model.state.has_video }
model.setPlayback(playing: false)
until("keyboard consumer playback pauses") { model.state.status == PLAYER_PAUSED }
require(model.perform(.enterCompact), "video playback accepts compact entry")
until("keyboard consumer compact entry settles") { !model.compactWindow.isTransitioning }
guard let panel = model.compactWindow.presentationWindow as? NSPanel else {
fatalError("FAIL: keyboard consumer requires the compact presentation panel")
}
model.setShowsBufferInfo(false)
require(!model.handleKeyEvent(shortcuts[4], in: window) && !model.showsBufferInfo,
"hidden normal window cannot dispatch compact playback shortcuts")
let compactShortcut = NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: .command,
timestamp: 0, windowNumber: panel.windowNumber, context: nil, characters: "b",
charactersIgnoringModifiers: "b", isARepeat: false, keyCode: 11)!
require(model.handleKeyEvent(compactShortcut, in: panel) && model.showsBufferInfo,
"active compact panel dispatches the configured playback shortcut")
require(!window.isVisible && !window.isKeyWindow && !panel.isVisible && !panel.isKeyWindow,
"keyboard consumer checks never display or key either presentation")
model.compactWindow.restore(resumeFullScreen: false, animated: false)
model.setShowsBufferInfo(false)
focus(lists[0])
require(model.handleKeyEvent(shortcuts[4], in: window) && model.showsBufferInfo
&& model.compactWindow.presentationWindow === window,
"restored normal window resumes dispatching playback shortcuts")
} }
static func main() { static func main() {
@@ -296,7 +324,12 @@ struct SystemPlaybackChecks {
backing: .buffered, defer: false) backing: .buffered, defer: false)
window.isReleasedWhenClosed = false window.isReleasedWhenClosed = false
defer { window.close() } defer { window.close() }
let surface = PlaybackSurface.DragView(model: model)
surface.frame = window.contentView!.bounds
window.contentView!.addSubview(surface)
model.attachWindow(window) model.attachWindow(window)
model.open(directory.appendingPathComponent("cache.mp4"))
until("queue geometry fixture has actual video playback") { model.canTogglePlayback && model.state.has_video }
let videoFrame = window.frame let videoFrame = window.frame
model.showsQueue = true model.showsQueue = true
until("queue opening settles") { !model.isQueueTransitioning } until("queue opening settles") { !model.isQueueTransitioning }
@@ -308,16 +341,30 @@ struct SystemPlaybackChecks {
require(abs(window.frame.width - videoFrame.width) < 0.01 && require(abs(window.frame.width - videoFrame.width) < 0.01 &&
abs(window.frame.height - videoFrame.height) < 0.01, abs(window.frame.height - videoFrame.height) < 0.01,
"closing queue restores video-only window size") "closing queue restores video-only window size")
guard let renderer = model.videoView else { fatalError("FAIL: native renderer is unavailable") }
let content = window.contentView
let activationPolicy = NSApp.activationPolicy()
model.open(directory.appendingPathComponent("anamorphic.mp4")) model.open(directory.appendingPathComponent("anamorphic.mp4"))
require(model.isBusy && model.perform(.enterCompact) && model.isCompact, require(model.isBusy && model.perform(.enterCompact) && model.isCompact,
"compact entry is available before native media opening acknowledges") "compact entry is available before native media opening acknowledges")
until("decoded anamorphic metadata replaces provisional compact geometry") { until("decoded anamorphic metadata replaces provisional compact geometry") {
model.state.status == PLAYER_PLAYING && model.isCompact && !model.compactWindow.isTransitioning && guard let presentation = model.compactWindow.presentationWindow else { return false }
abs(window.frame.width / window.frame.height - 8.0 / 3.0) < 0.01 return model.state.status == PLAYER_PLAYING && model.isCompact && !model.compactWindow.isTransitioning &&
abs(presentation.frame.width / presentation.frame.height - 8.0 / 3.0) < 0.01
} }
let panel = model.compactWindow.presentationWindow!
require(panel !== window && panel is NSPanel && panel.styleMask.contains(.nonactivatingPanel)
&& panel.contentView === content && model.videoView === renderer && renderer.window === panel
&& !window.isVisible && !window.isKeyWindow && !panel.isVisible && !panel.isKeyWindow
&& NSApp.activationPolicy() == activationPolicy,
"loading metadata resizes the compact panel without replacing renderer or app policy")
model.closeFile() model.closeFile()
require(!model.isCompact && !model.compactWindow.isTransitioning, require(!model.isCompact && !model.compactWindow.isTransitioning,
"closing loading/loaded media restores immediately") "closing loading/loaded media restores immediately")
require(model.compactWindow.presentationWindow === window && window.contentView === content
&& model.videoView === renderer && renderer.window === window && !window.isVisible && !window.isKeyWindow
&& panel.contentView !== content && !panel.isVisible && !panel.isKeyWindow,
"urgent media close returns retained content to the hidden normal window and retires the panel")
until("anamorphic source closes before the ordinary playback checks") { model.state.status == PLAYER_EMPTY } until("anamorphic source closes before the ordinary playback checks") { model.state.status == PLAYER_EMPTY }
require(!model.canPerform(.enterCompact), "empty playback does not enable compact entry") require(!model.canPerform(.enterCompact), "empty playback does not enable compact entry")
+107
View File
@@ -167,6 +167,112 @@ struct YouTubeResolverChecks {
try require(try decode(emptyHeaders).video.headers.isEmpty, "absent headers serialize to an empty block") try require(try decode(emptyHeaders).video.headers.isEmpty, "absent headers serialize to an empty block")
} }
static func subtitles() throws {
func vtt(_ url: String, name: Any = "English", headers: Any? = nil) -> [String: Any] {
var row: [String: Any] = ["ext": "vtt", "url": url, "name": name]
if let headers { row["http_headers"] = headers }
return row
}
let original = "https://www.youtube.com/api/timedtext?lang=en&kind=asr&fmt=vtt"
var object = try fixture(combinedJSON)
try require(try decode(object).subtitles.isEmpty, "absent captions default to Off with no optional tracks")
for payload in [NSNull(), [], true, 17, "captions"] as [Any] {
object["subtitles"] = payload
object["automatic_captions"] = payload
try require(try decode(object).subtitles.isEmpty,
"malformed optional caption containers never block playback")
}
object["subtitles"] = [
"broken": true,
"en": [NSNull(), 7, ["ext": false], ["ext": "vtt", "url": 9],
["ext": "srt", "url": "https://subs.example/convert-me"],
vtt("file:///unsafe.vtt"),
vtt("https://subs.example/unsafe.vtt", headers: ["X-Unsafe": "bad\nvalue"]),
vtt("https://subs.example/bad-headers.vtt", headers: ["X-Bad": 3]),
vtt("https://subs.example/en.vtt?token=first", headers: ["X-Track": "manual"]),
vtt("https://subs.example/duplicate.vtt")],
"fr": [vtt("https://subs.example/fr.vtt", name: ["malformed"])],
"srt-only": [["ext": "srt", "url": "https://subs.example/only.srt"]]
] as [String: Any]
object["automatic_captions"] = [
"en-orig": [vtt(original, headers: ["X-Track": "auto"])],
"en": [vtt(original)],
"fr": [vtt(original + "&tlang=fr", name: "French")],
"de": [vtt("https://subs.example/de.vtt", name: "German (Original)")],
"bad-orig": [NSNull(), vtt("ftp://subs.example/bad.vtt")]
] as [String: Any]
let tracks = try decode(object).subtitles
try require(tracks.count == 3 && tracks.map(\.language) == ["en", "fr", "en"],
"manual VTT and one original auto survive malformed siblings; translations and aliases do not")
try require(tracks.map(\.selectionID) == ["youtube:manual:en", "youtube:manual:fr", "youtube:auto:en"],
"manual and auto identities depend on language metadata, not signed URLs or labels")
try require(tracks[0].url == "https://subs.example/en.vtt?token=first"
&& tracks[0].label == "English" && tracks[1].label == "fr"
&& tracks[2].label == "English (auto-generated)",
"one valid supplied VTT per language preserves name and query; malformed name falls back to language")
try require(tracks.allSatisfy { !$0.isDefault } && ResolvedSource.validSubtitles(tracks),
"optional captions preserve transport safeguards and never enable themselves")
try require(tracks[0].headers == "X-Track: manual\r\n"
&& tracks[1].headers == "User-Agent: combined-agent\r\n"
&& tracks[2].headers == "X-Track: auto\r\n",
"variant headers override the complete root map; absent variant headers inherit root")
var changed = object
changed["automatic_captions"] = ["en": [vtt(original + "&token=renewed", name: "Renamed")]]
let alias = try decode(changed).subtitles.last!
try require(alias.selectionID == tracks.last?.selectionID && alias.url != tracks.last?.url,
"explicit untranslated ASR alias retains original identity after signed URL and label refresh")
changed["automatic_captions"] = [
"en-orig": [vtt(original)],
"en": [vtt(original + "&token=alias")],
"other-orig": [vtt(original)]
]
try require(try decode(changed).subtitles.filter { $0.selectionID?.hasPrefix("youtube:auto:") == true }.count == 1,
"original auto aliases deduplicate by language and by URL")
changed["automatic_captions"] = ["en-orig": [vtt(original)]]
changed["subtitles"] = ["en": [vtt("https://subs.example/empty.vtt", headers: [:])]]
try require(try decode(changed).subtitles.first?.headers == "",
"an explicitly empty caption header map does not inherit root headers")
for unsafe in ["https://user:pass@subs.example/c.vtt", "https://subs.example/c.vtt#fragment",
"https://subs.example:0/c.vtt", "https://subs.example/c vtt"] {
changed["subtitles"] = ["en": [vtt(unsafe)]]
try require(try decode(changed).subtitles.count == 1,
"unsafe optional subtitle URL is skipped without discarding a good auto sibling")
}
var fragmented = vtt("https://subs.example/chunks")
fragmented["protocol"] = "m3u8_native"
changed["subtitles"] = ["en": [fragmented]]
try require(try decode(changed).subtitles.count == 1,
"caption playlists are not treated as immutable VTT documents")
var many: [String: Any] = [:]
for index in 0..<40 {
let key = String(format: "lang-%02d", index)
many[key] = [vtt("https://subs.example/\(key).vtt", name: key)]
}
changed["subtitles"] = many
let capped = try decode(changed).subtitles
try require(capped.count == 32 && capped.last?.selectionID == "youtube:auto:en"
&& capped.first?.language == "lang-00" && capped[30].language == "lang-30",
"deterministic 32-track cap reserves original auto instead of crowding it out with manual languages")
changed["automatic_captions"] = [:]
try require(try decode(changed).subtitles.count == 32,
"manual-only metadata uses the entire track allowance")
changed["automatic_captions"] = ["en-orig": [vtt(original + "&tlang=fr")]]
let translated = try decode(changed).subtitles
try require(translated.count == 32
&& translated.allSatisfy { $0.selectionID?.hasPrefix("youtube:manual:") == true },
"translation query metadata is excluded even under an original-looking key")
for (key, value) in [("is_live", true), ("live_status", "is_live")] as [(String, Any)] {
var broadcast = object
broadcast[key] = value
try require(try decode(broadcast).subtitles.isEmpty,
"live captions are disabled instead of freezing a partial live transcript")
}
changed["url"] = "file:///unsafe-video"
try reject("optional caption tolerance does not weaken video validation") { _ = try decode(changed) }
}
static func chapters() throws { static func chapters() throws {
var object = try fixture(combinedJSON) var object = try fixture(combinedJSON)
try require(try decode(object).chapters.isEmpty, "absent chapters remain playable") try require(try decode(object).chapters.isEmpty, "absent chapters remain playable")
@@ -683,6 +789,7 @@ struct YouTubeResolverChecks {
try videoURLs() try videoURLs()
try metadata() try metadata()
try chapters() try chapters()
try subtitles()
try malformedMetadata() try malformedMetadata()
try qualities() try qualities()
try bundledHelpers(root: root) try bundledHelpers(root: root)
+62 -3
View File
@@ -15,7 +15,10 @@ struct ZokoCheck {
throw Failure(message: "Incorrect provider error category") throw Failure(message: "Incorrect provider error category")
} }
static func page(_ config: [String: Any]) throws -> String { static func page(_ config: [String: Any]) throws -> String {
var data = Array(try JSONSerialization.data(withJSONObject: config)) try page(data: JSONSerialization.data(withJSONObject: config))
}
static func page(data: Data) -> String {
var data = Array(data)
let key = Array("otaku-embed-v1".utf8) let key = Array("otaku-embed-v1".utf8)
for i in data.indices { data[i] ^= key[i % key.count] } for i in data.indices { data[i] ^= key[i % key.count] }
return "<script>window.__P = '\(Data(data).base64EncodedString())';</script>" return "<script>window.__P = '\(Data(data).base64EncodedString())';</script>"
@@ -35,7 +38,60 @@ struct ZokoCheck {
} }
var config: [String: Any] = ["src": "https://hls.dramahot.top/master.m3u8?token=fixture", var config: [String: Any] = ["src": "https://hls.dramahot.top/master.m3u8?token=fixture",
"subtitles": [["src": "https://hls.dramahot.top/en.vtt", "lang": "en", "label": "English", "default": true]]] "subtitles": [["src": "https://hls.dramahot.top/en.vtt", "lang": "en", "label": "English", "default": true]]]
let intro = ProviderInterval(kind: .intro, start: 49.25, end: 144.75)
let outro = ProviderInterval(kind: .outro, start: 1357.125, end: 1451.5)
config["skip"] = ["intro": ["start": intro.start, "end": intro.end],
"outro": ["start": outro.start, "end": outro.end]]
let source = try ZokoResolver.parse(page: page(config), mediaID: "1", episode: 1) let source = try ZokoResolver.parse(page: page(config), mediaID: "1", episode: 1)
try require(source.intervals == [intro, outro], "fractional provider boundaries retain seconds and kinds")
let replaced = source.replacingSubtitles([])
try require(replaced.intervals == source.intervals && replaced.subtitles.isEmpty &&
replaced.identity == source.identity && replaced.requirements.zoko,
"subtitle replacement preserves provider intervals and playback requirements")
let malformedMetadata: [Any] = [NSNull(), true, "bad", [], [:], ["intro": NSNull(), "outro": "bad"]]
for metadata in malformedMetadata {
var malformed = config
malformed["skip"] = metadata
let parsed = try ZokoResolver.parse(page: page(malformed), mediaID: "1", episode: 1)
try require(parsed.intervals.isEmpty && parsed.url == source.url && parsed.subtitles.count == 1,
"missing or malformed skip metadata does not block valid playback")
}
var absent = config
absent.removeValue(forKey: "skip")
let withoutTimings = try ZokoResolver.parse(page: page(absent), mediaID: "1", episode: 1)
try require(withoutTimings.intervals.isEmpty && withoutTimings.url == source.url,
"provider timings are optional")
let invalidTimings: [Any] = [
NSNull(), true, "bad", [], [:], ["start": 1], ["end": 2],
["start": true, "end": 144], ["start": 49, "end": false],
["start": "49", "end": 144], ["start": 49, "end": "144"],
["start": -1, "end": 144], ["start": 49, "end": -1],
["start": 49, "end": 49], ["start": 144, "end": 49]
]
for invalid in invalidTimings {
for badKind in ["intro", "outro"] {
var malformed = config
var skip = config["skip"] as! [String: Any]
skip[badKind] = invalid
malformed["skip"] = skip
let parsed = try ZokoResolver.parse(page: page(malformed), mediaID: "1", episode: 1)
try require(parsed.intervals == (badKind == "intro" ? [outro] : [intro]) && parsed.url == source.url,
"each malformed interval is discarded independently without blocking playback")
}
}
for timing in [#"{"start":1e999,"end":144}"#, #"{"start":49,"end":1e999}"#] {
let data = Data("""
{"src":"https://hls.dramahot.top/master.m3u8","subtitles":[],
"skip":{"intro":\(timing),"outro":{"start":1357.125,"end":1451.5}}}
""".utf8)
let parsed = try ZokoResolver.parse(page: page(data: data), mediaID: "1", episode: 1)
try require(parsed.intervals == [outro], "nonfinite numeric interval never blocks a valid sibling or playback")
}
var zeroStart = config
zeroStart["skip"] = ["intro": ["start": 0, "end": 1.5]]
let zeroSource = try ZokoResolver.parse(page: page(zeroStart), mediaID: "1", episode: 1)
try require(zeroSource.intervals == [ProviderInterval(kind: .intro, start: 0, end: 1.5)],
"zero-based intervals remain valid")
let mapped = try await ZokoResolver.resolve(mediaID: "97940", episode: 1, fetchResponse: { request in let mapped = try await ZokoResolver.resolve(mediaID: "97940", episode: 1, fetchResponse: { request in
let data: Data let data: Data
let mime: String let mime: String
@@ -46,13 +102,16 @@ struct ZokoCheck {
mime = "application/json" mime = "application/json"
} else { } else {
try require(request.url?.absoluteString == "https://zokoanime.video/stream/mal/34572/1/sub", "provider lookup uses the exact mapped MAL ID, not AniList ID") try require(request.url?.absoluteString == "https://zokoanime.video/stream/mal/34572/1/sub", "provider lookup uses the exact mapped MAL ID, not AniList ID")
data = Data(try page(["src": "https://hls.dramahot.top/master.m3u8", "subtitles": []]).utf8) data = Data(try page(["src": "https://hls.dramahot.top/master.m3u8", "subtitles": [],
"skip": ["intro": ["start": 49.25, "end": 144.75]]]).utf8)
mime = "text/html" mime = "text/html"
} }
return (data, HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: ["Content-Type": mime])!) return (data, HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: ["Content-Type": mime])!)
}) })
try require(mapped.identity == "anilist:97940:1" && mapped.title == "Black Clover · Episode 1 (sub)", try require(mapped.identity == "anilist:97940:1" && mapped.title == "Black Clover · Episode 1 (sub)",
"mapped playback preserves AniList identity and catalog title") "mapped playback preserves AniList identity and catalog title")
try require(mapped.intervals == [intro] && mapped.requirements.zoko,
"catalog title enrichment preserves provider intervals and transport requirements")
for bad in [ for bad in [
#"{"data":{"Media":{"id":34572,"idMal":97940}}}"#, #"{"data":{"Media":{"id":34572,"idMal":97940}}}"#,
#"{"data":{"Media":{"id":97940,"idMal":0}}}"#, #"{"data":{"Media":{"id":97940,"idMal":0}}}"#,
@@ -116,7 +175,7 @@ struct ZokoCheck {
progress = PlaybackProgress(defaults: defaults) progress = PlaybackProgress(defaults: defaults)
try require(progress.position(for: identity) == 123 && progress.position(for: URL(string: "miruro:1:1")!) == 456, try require(progress.position(for: identity) == 123 && progress.position(for: URL(string: "miruro:1:1")!) == 456,
"AniList resume persists separately from Miruro") "AniList resume persists separately from Miruro")
print("PASS: AniList URL/identity, static codec, unavailable/protection/malformed categories, bounded resources/subtitles, narrow CDN policy and isolated resume") print("PASS: AniList URL/identity, static codec, optional independent fractional skip metadata, source transformations, unavailable/protection/malformed categories, bounded resources/subtitles, narrow CDN policy and isolated resume")
} }
// No NSApplication, window or audio playback. The production player opens and // No NSApplication, window or audio playback. The production player opens and
+12 -3
View File
@@ -100,11 +100,15 @@ $(APP)/Contents/Info.plist: Resources/Info.plist
mkdir -p "$(APP)/Contents" mkdir -p "$(APP)/Contents"
cp "$<" "$@" cp "$<" "$@"
$(APP)/Contents/Resources/AppIcon.icns: Resources/AppIcon.icns
mkdir -p "$(APP)/Contents/Resources"
cp "$<" "$@"
$(CLI): $(CLI_SOURCES) $(CONFIG_LIBRARY) Makefile $(CLI): $(CLI_SOURCES) $(CONFIG_LIBRARY) Makefile
mkdir -p "$(APP)/Contents/MacOS" mkdir -p "$(APP)/Contents/MacOS"
$(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -O $(DEBUG_FLAGS) $(CLI_SOURCES) $(CONFIG_FLAGS) -framework AppKit -o $@ $(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -O $(DEBUG_FLAGS) $(CLI_SOURCES) $(CONFIG_FLAGS) -framework AppKit -o $@
$(BUNDLE_STAMP): $(PLAYER) $(BOOTSTRAP) $(CLI) $(APP)/Contents/Info.plist $(APP)/Contents/Resources/TOMLDecoder-LICENSE.txt $(NATIVE_STAMP) $(HELPER_STAMP) Scripts/bundle.py $(BUNDLE_STAMP): $(PLAYER) $(BOOTSTRAP) $(CLI) $(APP)/Contents/Info.plist $(APP)/Contents/Resources/AppIcon.icns $(APP)/Contents/Resources/TOMLDecoder-LICENSE.txt $(NATIVE_STAMP) $(HELPER_STAMP) Scripts/bundle.py
python3 Scripts/bundle.py --app "$(APP)" --dependencies "$(DEPENDENCY_ROOT)" --signing-identity "$(SIGNING_IDENTITY)" python3 Scripts/bundle.py --app "$(APP)" --dependencies "$(DEPENDENCY_ROOT)" --signing-identity "$(SIGNING_IDENTITY)"
touch "$@" touch "$@"
@@ -212,8 +216,8 @@ $(BUILD_DIR)/configuration-check: Checks/configuration.swift $(CONFIG_LIBRARY) M
check-config: $(BUILD_DIR)/configuration-check check-config: $(BUILD_DIR)/configuration-check
$(BUILD_DIR)/configuration-check $(BUILD_DIR)/configuration-check
$(BUILD_DIR)/youtube-resolver-check: Checks/youtube-resolver.swift Sources/App/YouTubeResolver.swift Makefile | $(BUILD_DIR) check-dependencies $(BUILD_DIR)/youtube-resolver-check: Checks/youtube-resolver.swift Sources/App/YouTubeResolver.swift Sources/App/ResolvedSource.swift Sources/App/StreamResolver.swift Makefile | $(BUILD_DIR) check-dependencies
$(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -O $(DEBUG_FLAGS) Checks/youtube-resolver.swift Sources/App/YouTubeResolver.swift -framework Foundation -o $@ $(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -O $(DEBUG_FLAGS) Checks/youtube-resolver.swift Sources/App/YouTubeResolver.swift Sources/App/ResolvedSource.swift Sources/App/StreamResolver.swift -framework Foundation -o $@
check-youtube: $(BUILD_DIR)/youtube-resolver-check $(BUNDLE_STAMP) check-youtube: $(BUILD_DIR)/youtube-resolver-check $(BUNDLE_STAMP)
$(BUILD_DIR)/youtube-resolver-check "$(abspath $(APP))" $(BUILD_DIR)/youtube-resolver-check "$(abspath $(APP))"
@@ -321,6 +325,10 @@ $(BUILD_DIR)/resolved-playback-check: Checks/resolved-playback.swift $(SWIFT_SOU
check-resolved: $(BUILD_DIR)/resolved-playback-check check-fixtures check-resolved: $(BUILD_DIR)/resolved-playback-check check-fixtures
python3 Checks/resolved-playback.py $(BUILD_DIR)/resolved-playback-check $(BUILD_DIR)/fixtures python3 Checks/resolved-playback.py $(BUILD_DIR)/resolved-playback-check $(BUILD_DIR)/fixtures
.PHONY: check-zoko-intervals
check-zoko-intervals: $(BUILD_DIR)/resolved-playback-check check-fixtures
python3 Checks/resolved-playback.py $(BUILD_DIR)/resolved-playback-check $(BUILD_DIR)/fixtures --zoko-intervals
$(BUILD_DIR)/ffmpeg-runtime-check: Checks/ffmpeg-runtime.swift $(RUNTIME_SOURCE) Makefile | $(BUILD_DIR) $(BUILD_DIR)/ffmpeg-runtime-check: Checks/ffmpeg-runtime.swift $(RUNTIME_SOURCE) Makefile | $(BUILD_DIR)
$(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -O $(DEBUG_FLAGS) Checks/ffmpeg-runtime.swift $(RUNTIME_SOURCE) -framework Foundation -o $@ $(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -O $(DEBUG_FLAGS) Checks/ffmpeg-runtime.swift $(RUNTIME_SOURCE) -framework Foundation -o $@
@@ -352,6 +360,7 @@ check-cast: $(BUILD_DIR)/cast-session-check $(BUILD_DIR)/cast-media-check $(BUIL
$(BUILD_DIR)/cast-media-check --remux-work $(BUILD_DIR)/cast-media-check --remux-work
$(BUILD_DIR)/cast-media-check --codec-work $(BUILD_DIR)/cast-media-check --codec-work
$(BUILD_DIR)/cast-session-check --playback $(BUILD_DIR)/cast-playback-check $(BUILD_DIR)/fixtures/cache.mp4 $(BUILD_DIR)/cast-session-check --playback $(BUILD_DIR)/cast-playback-check $(BUILD_DIR)/fixtures/cache.mp4
python3 Checks/automatic-quality.py $(BUILD_DIR)/cast-playback-check $(BUILD_DIR)/fixtures "$(APP)" --cast-session $(BUILD_DIR)/cast-session-check
$(BUILD_DIR)/app-startup-check.dylib: Checks/app-startup.swift $(SWIFT_SOURCES) $(OBJECTS) $(CONFIG_LIBRARY) Makefile | check-dependencies $(BUILD_DIR)/app-startup-check.dylib: Checks/app-startup.swift $(SWIFT_SOURCES) $(OBJECTS) $(CONFIG_LIBRARY) Makefile | check-dependencies
$(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -emit-library -DPLAYER_CHECK -O $(DEBUG_FLAGS) -import-objc-header Sources/Core/player_macos.h Checks/app-startup.swift $(SWIFT_SOURCES) $(CONFIG_FLAGS) $(OBJECTS) $(FF_LIBS) $(FRAMEWORKS) -lc++ -o $@ $(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -emit-library -DPLAYER_CHECK -O $(DEBUG_FLAGS) -import-objc-header Sources/Core/player_macos.h Checks/app-startup.swift $(SWIFT_SOURCES) $(CONFIG_FLAGS) $(OBJECTS) $(FF_LIBS) $(FRAMEWORKS) -lc++ -o $@
+29 -9
View File
@@ -19,6 +19,8 @@ The first build downloads dependencies. Quit Terebi before updating, then run `m
To run without installing, use `make run`. The built `.build/terebi.app` is self-contained; no separate FFmpeg, Python or yt-dlp installation is needed at runtime. Builds are ad-hoc signed, not notarized. Bundled licenses and source archives are in the app's Resources; public binary redistribution needs a separate license audit. To run without installing, use `make run`. The built `.build/terebi.app` is self-contained; no separate FFmpeg, Python or yt-dlp installation is needed at runtime. Builds are ad-hoc signed, not notarized. Bundled licenses and source archives are in the app's Resources; public binary redistribution needs a separate license audit.
The app icon uses layered glass screens and the amber, ember, cream and dark-brown colors from the application's `SunsetPalette`, with a large play symbol for small Dock sizes. It is bundled from `Resources/AppIcon.icns`; its editable vector source is `Resources/AppIcon.svg`. Rasterize the SVG with AppKit to preserve its gradients and transparency.
## Usage ## Usage
- **Local files:** use **⌘O**, drag files onto the player, or choose Finder's **Open With**. Multiple files form a queue in natural filename order, with automatic advance. Use **View → Show Queue** to play, reorder or remove items; **+ → file icon** or dropping files onto the sidebar appends them. The queue lasts only for the session; opening other media replaces it. - **Local files:** use **⌘O**, drag files onto the player, or choose Finder's **Open With**. Multiple files form a queue in natural filename order, with automatic advance. Use **View → Show Queue** to play, reorder or remove items; **+ → file icon** or dropping files onto the sidebar appends them. The queue lasts only for the session; opening other media replaces it.
@@ -26,10 +28,11 @@ To run without installing, use `make run`. The built `.build/terebi.app` is self
- **Stream queues:** integrations can supply an ordered yt-dlp/Miruro queue with `--new-instance --queue-file /absolute/queue.json`, or append without interrupting the running player using `--enqueue --queue-file /absolute/queue.json` (also for a single item). Enqueue starts playback if the player is empty or not running. The same sidebar supports play, reorder, remove, previous/next and automatic advance. Unresolved titles, including the selected row, load serially at lower priority without waiting for playback loading, buffering or quality extraction. YouTube uses a title-only oEmbed request; other sites use yt-dlp metadata and Miruro uses optional episode metadata. Arrival still depends on the site/network; failed lookups retain the supplied label, and full playback metadata takes precedence. Playback sources still resolve fresh when selected or revisited; local-file batches still use natural filename order. yt-dlp items use the original page URL for resume and version-1 progress reports; recognized YouTube video URLs still canonicalize. - **Stream queues:** integrations can supply an ordered yt-dlp/Miruro queue with `--new-instance --queue-file /absolute/queue.json`, or append without interrupting the running player using `--enqueue --queue-file /absolute/queue.json` (also for a single item). Enqueue starts playback if the player is empty or not running. The same sidebar supports play, reorder, remove, previous/next and automatic advance. Unresolved titles, including the selected row, load serially at lower priority without waiting for playback loading, buffering or quality extraction. YouTube uses a title-only oEmbed request; other sites use yt-dlp metadata and Miruro uses optional episode metadata. Arrival still depends on the site/network; failed lookups retain the supplied label, and full playback metadata takes precedence. Playback sources still resolve fresh when selected or revisited; local-file batches still use natural filename order. yt-dlp items use the original page URL for resume and version-1 progress reports; recognized YouTube video URLs still canonicalize.
- **Seeking:** click or drag the progress track or thumb to seek. The thumb centers on the cursor, clamped to the track endpoints. While mouse-seeking, the segment under the thumb smoothly thickens and returns to normal on release. Hovering a named segment fades its title in above the segment's center. Reduce Motion disables these transitions. - **Seeking:** click or drag the progress track or thumb to seek. The thumb centers on the cursor, clamped to the track endpoints. While mouse-seeking, the segment under the thumb smoothly thickens and returns to normal on release. Hovering a named segment fades its title in above the segment's center. Reduce Motion disables these transitions.
- **YouTube chapters:** when yt-dlp provides usable chapters, small rounded gaps mark their boundaries in the playback track. Boundaries use the current player's duration; missing or malformed chapter data leaves ordinary playback unchanged. Chapters do not add opening/ending skip buttons. Availability varies, and description-derived timestamps may differ from YouTube's displayed chapters. - **YouTube chapters:** when yt-dlp provides usable chapters, small rounded gaps mark their boundaries in the playback track. Boundaries use the current player's duration; missing or malformed chapter data leaves ordinary playback unchanged. Chapters do not add opening/ending skip buttons. Availability varies, and description-derived timestamps may differ from YouTube's displayed chapters.
- **Sponsored ranges:** optional [SponsorBlock](https://sponsor.ajay.app/) sponsor/skip ranges paint the existing track crimson without adding chapter boundaries or changing chapter gaps. Hover shows **Sponsor** alongside any chapter title; accessibility also identifies sponsored ranges. This is visual information only: it never seeks or skips. - **YouTube subtitles:** the existing subtitle menu offers manual WebVTT tracks and original-language automatic captions (marked **auto-generated**), up to 32 tracks. Captions default **Off** and fetch only when selected. A track—or Off—survives manual and automatic quality changes; queue changes reset to Off. Caption errors do not stop video playback. Machine-translated automatic tracks and live captions are not offered; captions requiring additional YouTube access may be unavailable.
- **Miruro metadata:** titles and opening/ending intervals load independently of playback when available. Small rounded gaps mark interval boundaries in the slider. Inside a segment, **Skip Opening** or **Skip Ending** appears at the top-right below the titlebar for up to ten seconds or until the segment ends; clicking seeks to its end. A light background fill grows as the offer expires, with a short entrance/exit transition; Reduce Motion uses stepped countdown updates without the transition. Ignored offers stay dismissed until the media is closed or replaced. Missing/invalid metadata leaves playback usable, and timings may differ between cuts. Nothing skips automatically. - **Sponsored ranges:** optional [SponsorBlock](https://sponsor.ajay.app/) sponsor/skip ranges paint the existing track crimson without adding chapter boundaries or changing chapter gaps. Hover shows **Sponsor** alongside any chapter title, centered over the sponsored range itself even when it crosses chapter boundaries; accessibility also identifies sponsored ranges. Inside a range, **Skip Sponsor** uses the opening/ending button without a timeout or countdown fill. Clicking seeks to the range's end; automatic skipping is a separate opt-in.
- **Miruro metadata:** titles and opening/ending intervals load independently of playback when available. Small rounded gaps mark interval boundaries in the slider. Inside a segment, **Skip Opening** or **Skip Ending** appears at the top-right below the titlebar for up to ten seconds or until the segment ends; clicking seeks to its end. A light background fill grows as the offer expires, with a short entrance/exit transition; Reduce Motion uses stepped countdown updates without the transition. Ignored offers stay dismissed until the media is closed or replaced. Missing/invalid metadata leaves playback usable, and timings may differ between cuts. Automatic skipping is configurable below.
- **Resume:** videos resume their saved position; finished videos restart from the beginning. Live and audio-only media do not save progress. - **Resume:** videos resume their saved position; finished videos restart from the beginning. Live and audio-only media do not save progress.
- **Compact mode:** press **P** for a floating player; double-click it to restore. Drag noninteractive areas to move the window. The queue starts hidden each time you enter compact mode; **⇧⌘L** or **gear → Show Queue** expands it below the video. Its top fade has a 12-point solid section and a 20-point transition, with the first row starting below both. The same shortcut toggles the normal sidebar, whose visibility is preserved when leaving compact mode. Use the titlebar pin to keep the normal window on top. - **Compact mode:** press **P** for a floating player; double-click it to restore. It follows you across desktops and other apps' native fullscreen Spaces while keeping Terebi in the Dock and **⌘Tab**. Moving between compact and normal mode preserves the running player and restores the original window. Drag noninteractive areas to move the window. The queue starts hidden each time you enter compact mode; **⇧⌘L** or **gear → Show Queue** expands it below the video. Its top fade has a 12-point solid section and a 20-point transition, with the first row starting below both. The same shortcut toggles the normal sidebar, whose visibility is preserved when leaving compact mode. Use the titlebar pin to keep the normal window on top.
- **Quality:** yt-dlp defaults to SDR up to 1080p when resolution is reported. Missing resolution/codec hints are left to native probing. Use **gear → Quality** for other reported resolutions or automatic quality. - **Quality:** yt-dlp defaults to SDR up to 1080p when resolution is reported. Missing resolution/codec hints are left to native probing. Use **gear → Quality** for other reported resolutions or automatic quality.
### Keyboard shortcuts ### Keyboard shortcuts
@@ -55,7 +58,7 @@ Open media, select the **Cast** icon or **Playback → Cast to Device**, and cho
Playback controls and queue navigation work on the receiver. **Play on This Mac** returns to local playback at the receiver's position. Keep the Mac connected: it relays the media and prevents idle system sleep during playback, while allowing display sleep when casting. Closing the lid or explicitly sleeping the Mac can interrupt playback. Playback controls and queue navigation work on the receiver. **Play on This Mac** returns to local playback at the receiver's position. Keep the Mac connected: it relays the media and prevents idle system sleep during playback, while allowing display sleep when casting. Closing the lid or explicitly sleeping the Mac can interrupt playback.
Cast output is limited to 1080p/30 fps; frame stepping and stream quality changes are local-only. Use Cast only on a trusted network. AirPlay is not supported. Cast output is limited to 1080p/30 fps; frame stepping and stream quality changes are local-only. YouTube captions are also local-only: their selector is disabled while casting, no caption tracks are fetched for the receiver, and returning to this Mac restores the local selection. Use Cast only on a trusted network. AirPlay is not supported.
## Command line ## Command line
@@ -81,6 +84,8 @@ The canonical handoff still uses `/stream/ani/<AniList ID>/<episode>/sub`, not t
Resolution uses bounded ordinary HTTPS with certificate verification, no cookies/credentials, no redirect following, and no browser or advertising/challenge script execution. AniList's returned catalog ID must match the requested identity; invalid mappings and GraphQL errors are rejected. The same metadata response can supply the playback title, with `AniList <ID> · Episode <number> (sub)` used when the title is absent. Only the provider's static `window.__P` Base64/XOR configuration is decoded with the first-party `otaku-embed-v1` codec. Poster, download, chapter, view/telemetry and player-script fields are ignored and never fetched. Resolution uses bounded ordinary HTTPS with certificate verification, no cookies/credentials, no redirect following, and no browser or advertising/challenge script execution. AniList's returned catalog ID must match the requested identity; invalid mappings and GraphQL errors are rejected. The same metadata response can supply the playback title, with `AniList <ID> · Episode <number> (sub)` used when the title is absent. Only the provider's static `window.__P` Base64/XOR configuration is decoded with the first-party `otaku-embed-v1` codec. Poster, download, chapter, view/telemetry and player-script fields are ignored and never fetched.
ZokoAnime's optional `skip.intro` / `skip.outro` timestamps feed the existing opening/ending bands and skip controls, including queued episodes and saved-episode reopening. Fractional seconds are preserved. Each interval must have finite, nonnegative, increasing boundaries within the actual player-reported duration; missing or malformed timing is ignored without blocking playback. No Miruro lookup or external timing service is used.
The existing process-local loopback HLS relay normalizes playlists for the native decoder. This provider alone permits HTTPS playlists/VTT on `hls.dramahot.top` and HTTPS media resources on that host or the demonstrated `hls1.drama1.cfd` segment origin (default port only). Every manifest URI is checked before registration and fetching, upstream redirects are refused, and only the fixed public `Referer: https://zokoanime.video/` is supplied to those admitted origins. Upstream URLs/tokens remain in memory and are not progress identities. Native global same-origin policy and TLS verification are unchanged; new CDN hosts fail closed until explicitly reviewed. Subtitle redirects retain the existing same-origin-only restriction. The existing process-local loopback HLS relay normalizes playlists for the native decoder. This provider alone permits HTTPS playlists/VTT on `hls.dramahot.top` and HTTPS media resources on that host or the demonstrated `hls1.drama1.cfd` segment origin (default port only). Every manifest URI is checked before registration and fetching, upstream redirects are refused, and only the fixed public `Referer: https://zokoanime.video/` is supplied to those admitted origins. Upstream URLs/tokens remain in memory and are not progress identities. Native global same-origin policy and TLS verification are unchanged; new CDN hosts fail closed until explicitly reviewed. Subtitle redirects retain the existing same-origin-only restriction.
Resume, queue deduplication and per-item reports use **`anilist:<ID>:<episode>`**, separately from existing `miruro:` and YouTube identities. Queues use the existing version-1 queue document, with the watch URL above as `url`; progress JSON uses the existing version-2 fields with `media_identity: {"provider":"anilist","media_id":"1","episode":1}`. Old Miruro history is neither migrated nor reinterpreted. Resolved-source files also accept `identity.provider = "anilist"` with canonical decimal IDs; as with Miruro files, the caller owns the explicitly supplied source and headers. Resume, queue deduplication and per-item reports use **`anilist:<ID>:<episode>`**, separately from existing `miruro:` and YouTube identities. Queues use the existing version-1 queue document, with the watch URL above as `url`; progress JSON uses the existing version-2 fields with `media_identity: {"provider":"anilist","media_id":"1","episode":1}`. Old Miruro history is neither migrated nor reinterpreted. Resolved-source files also accept `identity.provider = "anilist"` with canonical decimal IDs; as with Miruro files, the caller owns the explicitly supplied source and headers.
@@ -106,25 +111,39 @@ The optional config file is `~/.config/terebi/config.toml`, or `$XDG_CONFIG_HOME
terebi --default-config terebi --default-config
``` ```
Copy the settings you want to change into the config file; omitted settings keep their defaults. Settings load at startup, so restart the player after editing. Menu changes are session-only except SponsorBlock, whose saved preference overrides its startup config setting. Copy the settings you want to change into the config file; omitted settings keep their defaults. Settings load at startup, so restart the player after editing. Menu changes are session-only except the SponsorBlock and opening/ending autoskip toggles, whose saved preferences override their startup config settings.
The queue shortcut can be rebound with `show_queue` and `hide_queue` under `[keys]`; give both actions the same chord to keep a single toggle. The queue shortcut can be rebound with `show_queue` and `hide_queue` under `[keys]`; give both actions the same chord to keep a single toggle.
### Opening/ending autoskip
**Playback → Automatically Skip Openings and Endings**, also available in the player's gear menu, skips valid opening/ending intervals to their exact ends. It is **off by default**, independent of SponsorBlock, and saved across launches. Without a saved preference, this startup setting applies:
```toml
[playback]
opening_ending_autoskip = true
```
Use `false` or omit the setting to keep automatic skipping disabled; non-boolean values are rejected. Autoskip runs only during playing, nonbuffering video playback, never while paused or a seek is pending. Seeking into an interval or up to **five seconds before its start** keeps it manual-only for that visit, including when metadata arrives later. Quality changes preserve that intent; saved-position restoration gets the same protection. The existing timed manual buttons remain available when autoskip is suppressed. No interval metadata means no automatic skip.
### SponsorBlock ### SponsorBlock
SponsorBlock is **off by default**. **Playback → SponsorBlock → Show Sponsored Ranges** saves your choice across launches. A saved choice overrides the config setting; without one, the startup setting applies: SponsorBlock and automatic sponsor skipping are **off by default**. **Playback → SponsorBlock → Show Sponsored Ranges** enables lookups, markers and manual skipping; **Automatically Skip Sponsors** separately opts into automatic skipping. Both choices are saved across launches. Saved choices override their respective config settings; without them, the startup settings apply:
```toml ```toml
[playback] [playback]
sponsorblock = true sponsorblock = true
sponsorblock_autoskip = false
``` ```
Use `false` (the generated default) or omit the setting to default to disabled when no preference is saved; non-boolean values are rejected. The optional lookup runs independently of playback, only for canonical YouTube videos. Failure leaves playback unchanged with no sponsored markers. Use `false` (the generated defaults) or omit these settings to default to disabled when no preference is saved; non-boolean values are rejected. Autoskip alone does not enable SponsorBlock lookups. The optional lookup runs independently of playback, only for canonical YouTube videos. Failure leaves playback unchanged with no sponsored markers.
Enabling it adds a connection to `sponsor.ajay.app`. Requests use a bucket identified by the first four hexadecimal characters of the video ID's SHA-256 hash, reducing disclosure compared with sending the full ID; this is **not anonymous**. Terebi sends no cookies, account credentials or API key and makes no submissions, votes or view reports. Enabling it adds a connection to `sponsor.ajay.app`. Requests use a bucket identified by the first four hexadecimal characters of the video ID's SHA-256 hash, reducing disclosure compared with sending the full ID; this is **not anonymous**. Terebi sends no cookies, account credentials or API key and makes no submissions, votes or view reports.
Ranges require finite, strictly valid bounds inside the actual player duration. A known reference duration that differs from playback by **one second or more** is rejected. Ranges with an unknown reference duration are retained until the player reports an actual finite duration, then validated before display. Ranges require finite, strictly valid bounds inside the actual player duration. A known reference duration that differs from playback by **one second or more** is rejected. Ranges with an unknown reference duration are retained until the player reports an actual finite duration, then validated before display.
Autoskip seeks to the end of a sponsored range during playing, nonbuffering video playback, not while paused or a seek is pending. Seeking into a range or up to **five seconds before its start** leaves that range manual-only for that visit, including when playback resumes or metadata arrives later. A later seek replaces that intent; quality changes preserve it. Saved-position restoration gets the same protection. The manual button remains available throughout the range.
### Miruro providers ### Miruro providers
Choose **Playback → Miruro → Video Host** or **Caption Provider** in Terebi's menu bar, or use **gear → Miruro**, to change providers for the session. Choices retain both provider and track: `ssub` (soft subtitles), `sub` (hard subtitles), or `dub` (dubbed audio). Video and caption providers are independent; available choices come from the episode response. These menus do not write the config file. Choose **Playback → Miruro → Video Host** or **Caption Provider** in Terebi's menu bar, or use **gear → Miruro**, to change providers for the session. Choices retain both provider and track: `ssub` (soft subtitles), `sub` (hard subtitles), or `dub` (dubbed audio). Video and caption providers are independent; available choices come from the episode response. These menus do not write the config file.
@@ -146,7 +165,7 @@ Native playback supports the returned proxy configuration and HLS query/segment-
## Limitations ## Limitations
- SDR output only; HDR is not supported. - SDR output only; HDR is not supported.
- No embedded or YouTube captions, manual audio-track selection, rotation or deinterlacing. External WebVTT subtitles are supported for resolved streams. - No embedded captions, manual audio-track selection, rotation or deinterlacing. External WebVTT subtitles are supported for resolved streams and YouTube videos.
- yt-dlp support does not guarantee playback: extraction must yield HTTP(S)/HLS media without download-only fragment assembly. Audio-only results with explicit codec metadata are unsupported. Private, age-restricted, region-blocked or sign-in-required videos may fail; browser cookies are not used. Rebuild and reinstall for bundled extractor updates. - yt-dlp support does not guarantee playback: extraction must yield HTTP(S)/HLS media without download-only fragment assembly. Audio-only results with explicit codec metadata are unsupported. Private, age-restricted, region-blocked or sign-in-required videos may fail; browser cookies are not used. Rebuild and reinstall for bundled extractor updates.
- YouTube extraction uses IPv4 to avoid anti-bot failures on affected IPv6 routes. This does not bypass account or regional restrictions; other video sites retain their default network routing. - YouTube extraction uses IPv4 to avoid anti-bot failures on affected IPv6 routes. This does not bypass account or regional restrictions; other video sites retain their default network routing.
- Seeking can be slow on long-GOP or unindexed media. Live streams cannot be sought, and expired stream URLs are not automatically refreshed. - Seeking can be slow on long-GOP or unindexed media. Live streams cannot be sought, and expired stream URLs are not automatically refreshed.
@@ -158,7 +177,8 @@ Install FFmpeg for test fixtures (`brew install ffmpeg`), then run `make check`.
For the focused native compact-queue smoke (transparent, noninteractive app window), run `TEREBI_CHECK_COMPACT_QUEUE_ONLY=1 make check-startup`. `make check-window check-config` covers dropdown geometry, resizing, metadata aspect changes, restoration and configurable queue shortcuts without displaying windows. For the focused native compact-queue smoke (transparent, noninteractive app window), run `TEREBI_CHECK_COMPACT_QUEUE_ONLY=1 make check-startup`. `make check-window check-config` covers dropdown geometry, resizing, metadata aspect changes, restoration and configurable queue shortcuts without displaying windows.
`make check-sponsorblock` exercises the bounded native lookup and duration validation with local fixtures; `.build/sponsorblock-check --live` performs read-only public API probes. `make check-resolved` includes SponsorBlock opt-in/failure isolation, same-video cancellation, direct/saved/queue/enqueue launches, quality changes and Miruro separation. The native slider regression (`.build/cast-playback-check --slider-regression`, after building that target) checks crimson painting across chapter gaps, buffering, active emphasis, hover and seeking. `make check-sponsorblock` exercises the bounded native lookup and duration validation with local fixtures; `.build/sponsorblock-check --live` performs read-only public API probes. `make check-resolved` includes persistent manual sponsor skips, native sponsor/opening/ending autoskip, deliberate relative/slider seek protection, paused/late metadata, saved preferences, opt-in/failure isolation, same-video cancellation, direct/saved/queue/enqueue launches, quality changes and Miruro separation. The native slider regression (`.build/cast-playback-check --slider-regression`, after building that target) checks crimson painting across chapter gaps, sponsor-centered hover anchors, buffering, active emphasis and seeking.
`make check-youtube check-subtitles check-resolved check-adaptive` covers caption metadata filtering, native HTTP fetching, Off/On, seeking, stable manual/auto selection through quality changes, queue resets and optional failures. `make check-cast` also checks YouTube caption isolation during Cast handoff and local selection restoration; it uses a simulated TLS receiver, not physical Chromecast hardware.
`make check-zoko check-reporting check-cli check-stream check-progress` covers provider admission/codec/error categories, bounded resources, scoped CDN policy, isolated resume and report identity without launching a GUI. Optional `make check-zoko-live` resolves current AniList 1 episode 1, loads VTT, and uses the **actual native player** while paused to decode/step video, seek/decode at 600 seconds, restore position and assert version-2 AniList progress. It creates no windows and does not start audio playback, but production Core Audio still initializes an output device. No tokenized addresses are printed or retained after the check. This is not a full-episode, audio-output, pixel/subtitle-rendering or UI smoke test, nor proof of availability for other catalog entries. Full `make check` still launches GUI/audio checks. `make check-zoko check-reporting check-cli check-stream check-progress` covers provider admission/codec/error categories, bounded resources, scoped CDN policy, isolated resume and report identity without launching a GUI. Optional `make check-zoko-live` resolves current AniList 1 episode 1, loads VTT, and uses the **actual native player** while paused to decode/step video, seek/decode at 600 seconds, restore position and assert version-2 AniList progress. It creates no windows and does not start audio playback, but production Core Audio still initializes an output device. No tokenized addresses are printed or retained after the check. This is not a full-episode, audio-output, pixel/subtitle-rendering or UI smoke test, nor proof of availability for other catalog entries. Full `make check` still launches GUI/audio checks.
`make check-source` exercises the public parser/result contract, error redaction, shared manifest validation, total deadline/cancellation and actual CLI rejection paths without GUI/audio or native-player linking. Optional `make check-source-live` runs the **actual CLI**, copied outside its app bundle with an intentionally invalid configuration and isolated state directories: Cowboy Bebop, Black Clover episodes 1/170 and Black Clover Season 2 episode 1 must resolve through their exact MAL mappings, while Cowboy Bebop episode 27 remains `unavailable`, with no state writes. These source-only probes fetch no segments and are distinct from `check-zoko-live`'s native playback checks. `.build/zoko-check --live-black-clover` exercises native decoding, ten-minute seek, subtitles, resume and original AniList progress identity for Black Clover. `make check-source` exercises the public parser/result contract, error redaction, shared manifest validation, total deadline/cancellation and actual CLI rejection paths without GUI/audio or native-player linking. Optional `make check-source-live` runs the **actual CLI**, copied outside its app bundle with an intentionally invalid configuration and isolated state directories: Cowboy Bebop, Black Clover episodes 1/170 and Black Clover Season 2 episode 1 must resolve through their exact MAL mappings, while Cowboy Bebop episode 27 remains `unavailable`, with no state writes. These source-only probes fetch no segments and are distinct from `check-zoko-live`'s native playback checks. `.build/zoko-check --live-black-clover` exercises native decoding, ten-minute seek, subtitles, resume and original AniList progress identity for Black Clover.
Binary file not shown.
+42
View File
@@ -0,0 +1,42 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 320" width="1024" height="1024">
<defs>
<linearGradient id="shell" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#49352b"/>
<stop offset="48%" stop-color="#2c211c"/>
<stop offset="100%" stop-color="#1f1714"/>
</linearGradient>
<linearGradient id="ember" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#f2943b"/>
<stop offset="100%" stop-color="#c2452e"/>
</linearGradient>
<linearGradient id="rim" x1="0%" y1="0%" x2="75%" y2="100%">
<stop offset="0%" stop-color="#ffe3b5"/>
<stop offset="40%" stop-color="#f2943b"/>
<stop offset="100%" stop-color="#c2452e"/>
</linearGradient>
<linearGradient id="glass" x1="0%" y1="0%" x2="80%" y2="100%">
<stop offset="0%" stop-color="#68432e"/>
<stop offset="50%" stop-color="#38241b"/>
<stop offset="100%" stop-color="#1f1714"/>
</linearGradient>
<linearGradient id="play" x1="0%" y1="0%" x2="60%" y2="100%">
<stop offset="0%" stop-color="#ffe3b5"/>
<stop offset="100%" stop-color="#f2943b"/>
</linearGradient>
<linearGradient id="shine" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#ffe3b5" stop-opacity=".22"/>
<stop offset="100%" stop-color="#ffe3b5" stop-opacity="0"/>
</linearGradient>
</defs>
<rect x="24" y="29" width="272" height="272" rx="62" fill="#1f1714" fill-opacity=".22"/>
<rect x="24" y="24" width="272" height="272" rx="62" fill="url(#shell)"/>
<rect x="25" y="25" width="270" height="270" rx="61" fill="none" stroke="#d9c7a6" stroke-opacity=".25" stroke-width="2"/>
<rect x="63" y="77" width="194" height="160" rx="31" transform="rotate(-12 160 157)" fill="url(#ember)"/>
<rect x="64" y="78" width="192" height="158" rx="30" transform="rotate(-12 160 157)" fill="none" stroke="#ffe3b5" stroke-opacity=".35" stroke-width="2"/>
<rect x="46" y="98" width="228" height="170" rx="37" fill="#1f1714" fill-opacity=".5"/>
<rect x="46" y="90" width="228" height="170" rx="37" fill="url(#rim)"/>
<rect x="54" y="98" width="212" height="154" rx="29" fill="url(#glass)"/>
<path d="M83 101H237Q263 101 263 127V143Q165 125 57 173V127Q57 101 83 101Z" fill="url(#shine)"/>
<path d="M136 137Q136 129 144 134L207 175Q215 180 207 185L144 226Q136 231 136 223Z" fill="#1f1714" fill-opacity=".65"/>
<path d="M136 130Q136 122 144 127L207 168Q215 173 207 178L144 219Q136 224 136 216Z" fill="url(#play)"/>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

+1
View File
@@ -6,6 +6,7 @@
<key>CFBundleIdentifier</key><string>local.personal.terebi</string> <key>CFBundleIdentifier</key><string>local.personal.terebi</string>
<key>CFBundleName</key><string>Terebi</string> <key>CFBundleName</key><string>Terebi</string>
<key>CFBundleDisplayName</key><string>Terebi</string> <key>CFBundleDisplayName</key><string>Terebi</string>
<key>CFBundleIconFile</key><string>AppIcon</string>
<key>CFBundlePackageType</key><string>APPL</string> <key>CFBundlePackageType</key><string>APPL</string>
<key>CFBundleShortVersionString</key><string>0.1</string> <key>CFBundleShortVersionString</key><string>0.1</string>
<key>LSMinimumSystemVersion</key><string>15.0</string> <key>LSMinimumSystemVersion</key><string>15.0</string>
+109 -33
View File
@@ -7,6 +7,7 @@ final class CompactWindowController: NSObject {
var normalVideoLayout: (() -> (aspect: Double, sidebarWidth: CGFloat)?)? var normalVideoLayout: (() -> (aspect: Double, sidebarWidth: CGFloat)?)?
private(set) var isCompact = false private(set) var isCompact = false
private(set) var isTransitioning = false private(set) var isTransitioning = false
var presentationWindow: NSWindow? { compactPanel ?? window }
private(set) var queueHeight: CGFloat = 0 private(set) var queueHeight: CGFloat = 0
static let transitionDuration: TimeInterval = 0.25 static let transitionDuration: TimeInterval = 0.25
@@ -30,9 +31,13 @@ final class CompactWindowController: NSObject {
let level: NSWindow.Level let level: NSWindow.Level
let hidesOnDeactivate: Bool let hidesOnDeactivate: Bool
let isMovableByWindowBackground: Bool let isMovableByWindowBackground: Bool
let isVisible: Bool
let firstResponder: NSResponder?
let contentSuperview: NSView?
let contentFrame: NSRect
@MainActor @MainActor
init(_ window: NSWindow) { init(_ window: NSWindow, contentView: NSView) {
frame = window.frame frame = window.frame
minSize = window.minSize minSize = window.minSize
contentMinSize = window.contentMinSize contentMinSize = window.contentMinSize
@@ -45,12 +50,19 @@ final class CompactWindowController: NSObject {
level = window.level level = window.level
hidesOnDeactivate = window.hidesOnDeactivate hidesOnDeactivate = window.hidesOnDeactivate
isMovableByWindowBackground = window.isMovableByWindowBackground isMovableByWindowBackground = window.isMovableByWindowBackground
isVisible = window.isVisible
firstResponder = window.firstResponder
contentSuperview = contentView === window.contentView ? nil : contentView.superview
contentFrame = contentView.frame
} }
} }
private enum FullScreenTransition { case entering, exiting } private enum FullScreenTransition { case entering, exiting }
private weak var window: NSWindow? private weak var window: NSWindow?
private weak var contentView: NSView?
private var compactPanel: NSPanel?
private var panelDelegate: FullScreenDelegate?
private var snapshot: Snapshot? private var snapshot: Snapshot?
private var fullScreenTransition: FullScreenTransition? private var fullScreenTransition: FullScreenTransition?
private var delegateProxy: FullScreenDelegate? private var delegateProxy: FullScreenDelegate?
@@ -77,7 +89,7 @@ final class CompactWindowController: NSObject {
deinit { NotificationCenter.default.removeObserver(self) } deinit { NotificationCenter.default.removeObserver(self) }
func attach(_ window: NSWindow) { func attach(_ window: NSWindow, contentView: NSView? = nil) {
guard window !== self.window else { return } guard window !== self.window else { return }
stopFrameAnimation() stopFrameAnimation()
if let oldWindow = self.window, fullScreenTransition == nil, if let oldWindow = self.window, fullScreenTransition == nil,
@@ -87,6 +99,7 @@ final class CompactWindowController: NSObject {
restoreDelegate() restoreDelegate()
NotificationCenter.default.removeObserver(self) NotificationCenter.default.removeObserver(self)
self.window = window self.window = window
self.contentView = contentView ?? window.contentView
snapshot = nil snapshot = nil
fullScreenTransition = nil fullScreenTransition = nil
sessionActive = false sessionActive = false
@@ -133,13 +146,13 @@ final class CompactWindowController: NSObject {
func updateAspect(_ aspect: Double) { func updateAspect(_ aspect: Double) {
guard aspect.isFinite, aspect > 0, aspect != videoAspect else { return } guard aspect.isFinite, aspect > 0, aspect != videoAspect else { return }
videoAspect = aspect videoAspect = aspect
guard isCompact, !isTransitioning, let window else { return } guard isCompact, !isTransitioning, let window = presentationWindow else { return }
fitCompactWindow(window, initial: false) fitCompactWindow(window, initial: false)
} }
// The reveal and native frame share the model's animation clock. // The reveal and native frame share the model's animation clock.
func queueLayout(visible: Bool) -> (height: CGFloat, frame: NSRect)? { func queueLayout(visible: Bool) -> (height: CGFloat, frame: NSRect)? {
guard isCompact, !isTransitioning, let window else { return nil } guard isCompact, !isTransitioning, let window = presentationWindow else { return nil }
let usable = Self.usableFrame(visibleFrame(for: window)) let usable = Self.usableFrame(visibleFrame(for: window))
let height = visible ? min(240, max(0, usable.height - 90)) : 0 let height = visible ? min(240, max(0, usable.height - 90)) : 0
let video = Self.compactSize(aspect: videoAspect, width: window.frame.width, let video = Self.compactSize(aspect: videoAspect, width: window.frame.width,
@@ -152,7 +165,7 @@ final class CompactWindowController: NSObject {
} }
func updateQueue(height: CGFloat, frame: NSRect) { func updateQueue(height: CGFloat, frame: NSRect) {
guard isCompact, let window else { return } guard isCompact, let window = presentationWindow else { return }
window.resizeIncrements = NSSize(width: 1, height: 1) window.resizeIncrements = NSSize(width: 1, height: 1)
queueHeight = height queueHeight = height
fittedVideoAspect = frame.width / (frame.height - height) fittedVideoAspect = frame.width / (frame.height - height)
@@ -166,7 +179,7 @@ final class CompactWindowController: NSObject {
} }
func snapAfterDrag() { func snapAfterDrag() {
guard isCompact, !isTransitioning, snappingEnabled, let window else { return } guard isCompact, !isTransitioning, snappingEnabled, let window = presentationWindow else { return }
let frame = Self.snappedFrame(window.frame, visibleFrame: visibleFrame(for: window)) let frame = Self.snappedFrame(window.frame, visibleFrame: visibleFrame(for: window))
if frame.origin != window.frame.origin { window.setFrameOrigin(frame.origin) } if frame.origin != window.frame.origin { window.setFrameOrigin(frame.origin) }
} }
@@ -174,7 +187,7 @@ final class CompactWindowController: NSObject {
@objc private func windowNotification(_ notification: Notification) { @objc private func windowNotification(_ notification: Notification) {
switch notification.name { switch notification.name {
case NSWindow.didEndLiveResizeNotification: case NSWindow.didEndLiveResizeNotification:
guard isCompact, !isTransitioning, let window, guard isCompact, !isTransitioning, let window = presentationWindow,
window.frame.width.isFinite, window.frame.width > 0 else { return } window.frame.width.isFinite, window.frame.width > 0 else { return }
defaults.set(Double(window.frame.width), forKey: Self.widthKey) defaults.set(Double(window.frame.width), forKey: Self.widthKey)
case NSWindow.willEnterFullScreenNotification: case NSWindow.willEnterFullScreenNotification:
@@ -187,7 +200,12 @@ final class CompactWindowController: NSObject {
fullScreenTransition = nil fullScreenTransition = nil
reconcile() reconcile()
case NSWindow.willCloseNotification: case NSWindow.willCloseNotification:
if notification.object as? NSWindow === compactPanel {
window?.close()
return
}
stopFrameAnimation() stopFrameAnimation()
if let window { removeCompactPanel(returningContentTo: window) }
restoreDelegate() restoreDelegate()
NotificationCenter.default.removeObserver(self) NotificationCenter.default.removeObserver(self)
window = nil window = nil
@@ -318,7 +336,7 @@ final class CompactWindowController: NSObject {
} else if !isCompact { } else if !isCompact {
enterCompact(window, animated: animated) enterCompact(window, animated: animated)
} else { } else {
if fittedAspect != videoAspect { fitCompactWindow(window, initial: false) } if fittedAspect != videoAspect, let panel = compactPanel { fitCompactWindow(panel, initial: false) }
setState(compact: true, transitioning: false) setState(compact: true, transitioning: false)
} }
return return
@@ -333,11 +351,12 @@ final class CompactWindowController: NSObject {
} }
if let snapshot { if let snapshot {
queueHeight = 0 queueHeight = 0
setState(compact: false, transitioning: animated) setState(compact: false, transitioning: true)
window.aspectRatio = .zero let restoringWindow = presentationWindow ?? window
window.minSize = .zero restoringWindow.aspectRatio = .zero
window.contentMinSize = .zero restoringWindow.minSize = .zero
animateFrame(snapshot.frame, of: window, animated: animated) { [weak self, weak window] in restoringWindow.contentMinSize = .zero
animateFrame(snapshot.frame, of: restoringWindow, animated: animated) { [weak self, weak window] in
guard let self, let window else { return } guard let self, let window else { return }
self.restoreSnapshot(to: window) self.restoreSnapshot(to: window)
self.reconcile() self.reconcile()
@@ -357,33 +376,84 @@ final class CompactWindowController: NSObject {
window.toggleFullScreen(nil) window.toggleFullScreen(nil)
} }
private func enterCompact(_ window: NSWindow, animated: Bool) { private func enterCompact(_ normalWindow: NSWindow, animated: Bool) {
// After didExitFullScreen, this is the normal frame, not the fullscreen frame. // A regular NSWindow cannot join another app's native fullscreen Space.
snapshot = Snapshot(window) // Keep the scene and renderer alive, but present their content in a nonactivating panel.
queueHeight = 0 guard let content = contentView else {
setState(compact: true, transitioning: animated) finishSession()
window.aspectRatio = .zero return
window.minSize = .zero }
window.contentMinSize = .zero let snapshot = Snapshot(normalWindow, contentView: content)
// A borderless NSWindow cannot become key; retain native keyboard and resize behavior. self.snapshot = snapshot
window.styleMask.formUnion([.titled, .fullSizeContentView, .resizable]) let window = NSPanel(contentRect: .zero,
styleMask: [.titled, .closable, .resizable, .fullSizeContentView, .nonactivatingPanel],
backing: .buffered, defer: false)
window.isReleasedWhenClosed = false
window.isFloatingPanel = true
window.hidesOnDeactivate = false
window.title = normalWindow.title
window.representedURL = normalWindow.representedURL
window.titleVisibility = .hidden window.titleVisibility = .hidden
window.titlebarAppearsTransparent = true window.titlebarAppearsTransparent = true
window.minSize = NSSize(width: 160, height: 90) window.titlebarSeparatorStyle = .none
window.contentMinSize = NSSize(width: 160, height: 90) window.appearance = normalWindow.appearance
var behavior = window.collectionBehavior window.isOpaque = normalWindow.isOpaque
behavior.subtract([.primary, .auxiliary, .fullScreenPrimary, .fullScreenNone, .moveToActiveSpace]) window.backgroundColor = normalWindow.backgroundColor
behavior.formUnion([.canJoinAllSpaces, .canJoinAllApplications, .fullScreenAuxiliary]) window.hasShadow = normalWindow.hasShadow
window.collectionBehavior = behavior window.alphaValue = normalWindow.alphaValue
window.ignoresMouseEvents = normalWindow.ignoresMouseEvents
window.acceptsMouseMovedEvents = normalWindow.acceptsMouseMovedEvents
window.level = .floating window.level = .floating
window.hidesOnDeactivate = false window.collectionBehavior = [.canJoinAllSpaces, .canJoinAllApplications, .fullScreenAuxiliary]
// Explicit drag surfaces preserve controls' mouse tracking in compact mode too.
window.isMovableByWindowBackground = false window.isMovableByWindowBackground = false
window.setFrame(normalWindow.frame, display: false)
let proxy = FullScreenDelegate(owner: self, original: nil)
panelDelegate = proxy
window.delegate = proxy
compactPanel = window
for name in [NSWindow.willCloseNotification, NSWindow.didEndLiveResizeNotification] {
NotificationCenter.default.addObserver(self, selector: #selector(windowNotification(_:)),
name: name, object: window)
}
queueHeight = 0
setState(compact: true, transitioning: animated)
normalWindow.orderOut(nil)
if content === normalWindow.contentView { normalWindow.contentView = nil }
else { content.removeFromSuperview() }
window.contentView = content
window.minSize = NSSize(width: 160, height: 90)
window.contentMinSize = window.minSize
if snapshot.isVisible {
window.orderFrontRegardless()
if NSApp.isActive { window.makeKey() }
window.makeFirstResponder(snapshot.firstResponder)
}
fitCompactWindow(window, initial: true, animated: animated) fitCompactWindow(window, initial: true, animated: animated)
} }
private func removeCompactPanel(returningContentTo window: NSWindow) {
guard let panel = compactPanel else { return }
NotificationCenter.default.removeObserver(self, name: nil, object: panel)
panel.delegate = nil
panelDelegate = nil
compactPanel = nil
window.title = panel.title
window.representedURL = panel.representedURL
let content = panel.contentView
panel.contentView = nil
if let content, let snapshot, let superview = snapshot.contentSuperview {
superview.addSubview(content)
content.frame = snapshot.contentFrame
} else {
window.contentView = content
}
panel.close()
}
private func restoreSnapshot(to window: NSWindow) { private func restoreSnapshot(to window: NSWindow) {
guard let snapshot else { return } guard let snapshot else { return }
let wasKey = compactPanel?.isKeyWindow ?? false
removeCompactPanel(returningContentTo: window)
window.aspectRatio = .zero window.aspectRatio = .zero
window.minSize = .zero window.minSize = .zero
window.contentMinSize = .zero window.contentMinSize = .zero
@@ -402,7 +472,13 @@ final class CompactWindowController: NSObject {
} else { } else {
window.aspectRatio = snapshot.aspectRatio window.aspectRatio = snapshot.aspectRatio
} }
if snapshot.isVisible {
if wasKey { window.makeKeyAndOrderFront(nil) }
else { window.orderFront(nil) }
window.makeFirstResponder(snapshot.firstResponder)
}
self.snapshot = nil self.snapshot = nil
onChange?()
} }
private func finishSession() { private func finishSession() {
@@ -429,7 +505,7 @@ final class CompactWindowController: NSObject {
window.animator().setFrame(frame, display: true) window.animator().setFrame(frame, display: true)
} completionHandler: { [weak self, weak window] in } completionHandler: { [weak self, weak window] in
MainActor.assumeIsolated { MainActor.assumeIsolated {
guard let self, let window, self.window === window, self.frameAnimating, guard let self, let window, self.presentationWindow === window, self.frameAnimating,
self.frameAnimationGeneration == generation else { return } self.frameAnimationGeneration == generation else { return }
self.frameAnimating = false self.frameAnimating = false
completion() completion()
@@ -441,7 +517,7 @@ final class CompactWindowController: NSObject {
guard frameAnimating else { return } guard frameAnimating else { return }
frameAnimating = false frameAnimating = false
frameAnimationGeneration &+= 1 frameAnimationGeneration &+= 1
guard let window else { return } guard let window = presentationWindow else { return }
NSAnimationContext.runAnimationGroup { context in NSAnimationContext.runAnimationGroup { context in
context.duration = 0 context.duration = 0
window.animator().setFrame(window.frame, display: true) window.animator().setFrame(window.frame, display: true)
+1
View File
@@ -182,6 +182,7 @@ final class MiruroTransport: @unchecked Sendable {
var prepared = ResolvedSource(url: url.absoluteString, headers: "", identity: source.identity, var prepared = ResolvedSource(url: url.absoluteString, headers: "", identity: source.identity,
title: source.title, subtitles: source.subtitles) title: source.title, subtitles: source.subtitles)
prepared.playbackLifetime = owner prepared.playbackLifetime = owner
prepared.intervals = source.intervals
return prepared return prepared
} catch { } catch {
if Task.isCancelled || error is CancellationError { throw CancellationError() } if Task.isCancelled || error is CancellationError { throw CancellationError() }
+25 -8
View File
@@ -93,7 +93,8 @@ extension PlayerModel {
} }
private func handleKeyEvent(_ event: NSEvent, in window: NSWindow, chord: KeyChord) -> Bool { private func handleKeyEvent(_ event: NSEvent, in window: NSWindow, chord: KeyChord) -> Bool {
guard event.window === window, NSApp.modalWindow == nil, window.attachedSheet == nil else { return false } guard self.window === window, event.window === window,
NSApp.modalWindow == nil, window.attachedSheet == nil else { return false }
if chord.modifiers.contains(.command), keyBindings.shortcuts(for: .openStream).contains(chord) { if chord.modifiers.contains(.command), keyBindings.shortcuts(for: .openStream).contains(chord) {
return perform(.openStream) return perform(.openStream)
} }
@@ -127,7 +128,7 @@ extension PlayerModel {
perform(action) perform(action)
return return
} }
if let window = event.window ?? NSApp.keyWindow, if let window = window ?? event.window ?? NSApp.keyWindow,
!handleKeyEvent(event, in: window, chord: chord), !handleKeyEvent(event, in: window, chord: chord),
let responder = window.firstResponder as? NSView { let responder = window.firstResponder as? NSView {
if !responder.performKeyEquivalent(with: event) { responder.keyDown(with: event) } if !responder.performKeyEquivalent(with: event) { responder.keyDown(with: event) }
@@ -206,6 +207,17 @@ struct PlayerMiruroMenu: View {
} }
} }
@MainActor
struct PlayerOpeningEndingSkipToggle: View {
@ObservedObject var model: PlayerModel
var body: some View {
Toggle("Automatically Skip Openings and Endings", isOn: Binding(
get: { model.openingEndingAutoSkip },
set: { model.setOpeningEndingAutoSkip($0) }))
}
}
@MainActor @MainActor
struct PlayerSponsorBlockMenu: View { struct PlayerSponsorBlockMenu: View {
@ObservedObject var model: PlayerModel @ObservedObject var model: PlayerModel
@@ -215,8 +227,12 @@ struct PlayerSponsorBlockMenu: View {
Toggle("Show Sponsored Ranges", isOn: Binding( Toggle("Show Sponsored Ranges", isOn: Binding(
get: { model.sponsorBlockEnabled }, get: { model.sponsorBlockEnabled },
set: { model.setSponsorBlockEnabled($0) })) set: { model.setSponsorBlockEnabled($0) }))
Toggle("Automatically Skip Sponsors", isOn: Binding(
get: { model.sponsorBlockAutoSkip },
set: { model.setSponsorBlockAutoSkip($0) }))
.disabled(!model.sponsorBlockEnabled)
Text("Connects to sponsor.ajay.app using a video-ID hash prefix.") Text("Connects to sponsor.ajay.app using a video-ID hash prefix.")
Text("Reduces disclosure; not anonymous. Display only, never skips.") Text("Reduces disclosure; not anonymous.")
Link("(Using SponsorBlock)", destination: URL(string: "https://sponsor.ajay.app/")!) Link("(Using SponsorBlock)", destination: URL(string: "https://sponsor.ajay.app/")!)
Menu("Acknowledgements") { Menu("Acknowledgements") {
Text("Uses SponsorBlock data under CC BY-NC-SA 4.0") Text("Uses SponsorBlock data under CC BY-NC-SA 4.0")
@@ -230,12 +246,12 @@ struct PlayerSponsorBlockMenu: View {
@MainActor @MainActor
struct PlayerCommands: Commands { struct PlayerCommands: Commands {
@ObservedObject var model: PlayerModel
@FocusedObject private var focusedModel: PlayerModel? @FocusedObject private var focusedModel: PlayerModel?
var body: some Commands { var body: some Commands {
// The player has one scene; app-level Open/Quit still work before it becomes focused. // Nonactivating compact panels need not provide SwiftUI's scene focus.
let model = focusedModel ?? PlayerModel.shared let hasFocusedScene = focusedModel != nil || model.window?.isKeyWindow == true
let hasFocusedScene = focusedModel != nil
let playback = model.playbackAction let playback = model.playbackAction
let mute = model.muteAction let mute = model.muteAction
let fullscreen = model.fullscreenAction let fullscreen = model.fullscreenAction
@@ -265,8 +281,8 @@ struct PlayerCommands: Commands {
.disabled(!hasFocusedScene || !model.canPerform(.closeFile)) .disabled(!hasFocusedScene || !model.canPerform(.closeFile))
} }
CommandGroup(replacing: .saveItem) { CommandGroup(replacing: .saveItem) {
Button("Close Window") { NSApp.keyWindow?.performClose(nil) } Button("Close Window") { model.window?.performClose(nil) }
.disabled(focusedModel == nil) .disabled(!hasFocusedScene)
} }
CommandMenu("Playback") { CommandMenu("Playback") {
CastDeviceMenu(model: model, icon: false) CastDeviceMenu(model: model, icon: false)
@@ -333,6 +349,7 @@ struct PlayerCommands: Commands {
} }
} }
PlayerMiruroMenu(model: model).disabled(!hasFocusedScene) PlayerMiruroMenu(model: model).disabled(!hasFocusedScene)
PlayerOpeningEndingSkipToggle(model: model)
PlayerSponsorBlockMenu(model: model) PlayerSponsorBlockMenu(model: model)
Button(model.showsBufferInfo ? "Hide Buffer Info" : "Show Buffer Info") { Button(model.showsBufferInfo ? "Hide Buffer Info" : "Show Buffer Info") {
model.performMenuAction(bufferInfo) model.performMenuAction(bufferInfo)
+14 -2
View File
@@ -7,6 +7,8 @@ struct ResolvedSubtitle: Decodable, Sendable {
let language: String let language: String
let label: String let label: String
let isDefault: Bool let isDefault: Bool
// Extractor identity survives expiring URLs and distinguishes manual from automatic captions.
var selectionID: String? = nil
private let headerFields: [String: String] private let headerFields: [String: String]
var proxy: MiruroProxy? = nil var proxy: MiruroProxy? = nil
var headers: String { var headers: String {
@@ -15,7 +17,7 @@ struct ResolvedSubtitle: Decodable, Sendable {
fileprivate var validatedHeaders: [String: String] { headerFields } fileprivate var validatedHeaders: [String: String] { headerFields }
init(url: String, format: String, language: String, label: String, isDefault: Bool, init(url: String, format: String, language: String, label: String, isDefault: Bool,
headers: [String: String], proxy: MiruroProxy? = nil) { headers: [String: String], proxy: MiruroProxy? = nil, selectionID: String? = nil) {
self.url = url self.url = url
self.format = format self.format = format
self.language = language self.language = language
@@ -23,6 +25,7 @@ struct ResolvedSubtitle: Decodable, Sendable {
self.isDefault = isDefault self.isDefault = isDefault
self.headerFields = headers self.headerFields = headers
self.proxy = proxy self.proxy = proxy
self.selectionID = selectionID
} }
private enum CodingKeys: String, CodingKey { private enum CodingKeys: String, CodingKey {
case url, format, language, label case url, format, language, label
@@ -31,18 +34,27 @@ struct ResolvedSubtitle: Decodable, Sendable {
} }
} }
// Optional episode-relative provider timings; playback validates against media duration.
struct ProviderInterval: Sendable, Equatable {
enum Kind: Sendable { case intro, outro }
let kind: Kind
let start: Double
let end: Double
}
struct ResolvedSource: Sendable { struct ResolvedSource: Sendable {
let url: String let url: String
let headers: String let headers: String
let identity: String let identity: String
let title: String let title: String
let subtitles: [ResolvedSubtitle] let subtitles: [ResolvedSubtitle]
var intervals: [ProviderInterval] = []
var requirements: MiruroPlaybackRequirements = .init() var requirements: MiruroPlaybackRequirements = .init()
var playbackLifetime: (any Sendable)? = nil var playbackLifetime: (any Sendable)? = nil
func replacingSubtitles(_ subtitles: [ResolvedSubtitle]) -> ResolvedSource { func replacingSubtitles(_ subtitles: [ResolvedSubtitle]) -> ResolvedSource {
ResolvedSource(url: url, headers: headers, identity: identity, title: title, subtitles: subtitles, ResolvedSource(url: url, headers: headers, identity: identity, title: title, subtitles: subtitles,
requirements: requirements, playbackLifetime: playbackLifetime) intervals: intervals, requirements: requirements, playbackLifetime: playbackLifetime)
} }
struct Document: Decodable { struct Document: Decodable {
+200 -52
View File
@@ -76,6 +76,9 @@ final class PlayerModel: ObservableObject {
private var metadataLifecycle = MetadataLifecycle() private var metadataLifecycle = MetadataLifecycle()
private var metadataTask: Task<Void, Never>? private var metadataTask: Task<Void, Never>?
@Published private(set) var sponsorBlockEnabled: Bool @Published private(set) var sponsorBlockEnabled: Bool
@Published private(set) var sponsorBlockAutoSkip: Bool
@Published private(set) var openingEndingAutoSkip: Bool
private var segmentSeekPosition: Double?
private let preferenceDefaults: UserDefaults private let preferenceDefaults: UserDefaults
private var sponsorVideoID: String? private var sponsorVideoID: String?
private var sponsorGeneration: UInt64 = 0 private var sponsorGeneration: UInt64 = 0
@@ -113,11 +116,22 @@ final class PlayerModel: ObservableObject {
@Published private var skipOffer: (interval: MiruroInterval, deadline: Double)? @Published private var skipOffer: (interval: MiruroInterval, deadline: Double)?
private var offeredSegments: Set<MiruroInterval.Kind> = [] private var offeredSegments: Set<MiruroInterval.Kind> = []
private let skipClock: () -> Double private let skipClock: () -> Double
private var canSkipSegment: Bool {
canSeek && state.has_video && !isSeeking && !transition.seekPending && !state.seek_pending &&
(state.status == PLAYER_PLAYING || state.status == PLAYER_PAUSED)
}
private var currentSkipInterval: MiruroInterval? { private var currentSkipInterval: MiruroInterval? {
guard canSeek, state.has_video, !isSeeking, !transition.seekPending, !state.seek_pending, guard canSkipSegment else { return nil }
state.status == PLAYER_PLAYING || state.status == PLAYER_PAUSED else { return nil }
return visibleIntervals.first { state.position >= $0.start && state.position < $0.end } return visibleIntervals.first { state.position >= $0.start && state.position < $0.end }
} }
private var sponsorToSkip: ClosedRange<Double>? {
guard canSkipSegment else { return nil }
return sponsoredRanges.first { state.position >= $0.lowerBound && state.position < $0.upperBound }
}
var skipButtonTitle: String? {
if sponsorToSkip != nil { return "Skip Sponsor" }
return segmentToSkip.map { $0.kind == .intro ? "Skip Opening" : "Skip Ending" }
}
var segmentToSkip: MiruroInterval? { var segmentToSkip: MiruroInterval? {
guard let skipOffer, skipClock() < skipOffer.deadline, guard let skipOffer, skipClock() < skipOffer.deadline,
currentSkipInterval == skipOffer.interval else { return nil } currentSkipInterval == skipOffer.interval else { return nil }
@@ -129,6 +143,14 @@ final class PlayerModel: ObservableObject {
} }
private func updateSegmentSkip() { private func updateSegmentSkip() {
var automaticRange = sponsorBlockAutoSkip ? sponsorToSkip : nil
if automaticRange == nil, openingEndingAutoSkip, let interval = currentSkipInterval {
automaticRange = interval.start...interval.end
}
if let range = automaticRange, state.status == PLAYER_PLAYING, effectivePlaying, !state.buffering,
segmentSeekPosition.map({ $0 >= range.lowerBound - 5 && $0 < range.upperBound }) != true {
_ = seek(to: range.upperBound, userInitiated: false)
}
guard let interval = currentSkipInterval else { guard let interval = currentSkipInterval else {
if skipOffer != nil { skipOffer = nil } if skipOffer != nil { skipOffer = nil }
return return
@@ -142,7 +164,8 @@ final class PlayerModel: ObservableObject {
@discardableResult @discardableResult
func skipSegment() -> Bool { func skipSegment() -> Bool {
guard let interval = segmentToSkip, seekRelative(interval.end - state.position) else { return false } guard let end = sponsorToSkip?.upperBound ?? segmentToSkip?.end,
seek(to: end, userInitiated: false) else { return false }
skipOffer = nil skipOffer = nil
return true return true
} }
@@ -178,6 +201,10 @@ final class PlayerModel: ObservableObject {
@Published private(set) var subtitleError: String? @Published private(set) var subtitleError: String?
private var subtitleDocument: WebVTTDocument? private var subtitleDocument: WebVTTDocument?
private var subtitleTask: Task<Void, Never>? private var subtitleTask: Task<Void, Never>?
var canSelectSubtitle: Bool {
if case .youtube? = currentMedia, castDevice != nil { return false }
return !subtitleTracks.isEmpty
}
@Published var seekPosition = 0.0 @Published var seekPosition = 0.0
@Published var isSeeking = false @Published var isSeeking = false
@Published private(set) var volume = 1.0 @Published private(set) var volume = 1.0
@@ -218,7 +245,9 @@ final class PlayerModel: ObservableObject {
let videoView: NSView? let videoView: NSView?
private var handle: OpaquePointer? private var handle: OpaquePointer?
private var timer: Timer? private var timer: Timer?
private weak var window: NSWindow? private weak var attachedWindow: NSWindow?
weak var hostedPlayerView: NSView?
var window: NSWindow? { compactWindow.presentationWindow ?? attachedWindow }
private var hideDeadline: ContinuousClock.Instant? private var hideDeadline: ContinuousClock.Instant?
private var pointerInsideWindow = true private var pointerInsideWindow = true
private var keyboardNavigation = false private var keyboardNavigation = false
@@ -271,6 +300,8 @@ final class PlayerModel: ObservableObject {
showsBufferInfo = configuration.showBufferInfo showsBufferInfo = configuration.showBufferInfo
preferenceDefaults = defaults preferenceDefaults = defaults
sponsorBlockEnabled = (defaults.object(forKey: "sponsorBlockEnabled") as? Bool) ?? configuration.sponsorBlock sponsorBlockEnabled = (defaults.object(forKey: "sponsorBlockEnabled") as? Bool) ?? configuration.sponsorBlock
sponsorBlockAutoSkip = (defaults.object(forKey: "sponsorBlockAutoSkip") as? Bool) ?? configuration.sponsorBlockAutoSkip
openingEndingAutoSkip = (defaults.object(forKey: "openingEndingAutoSkip") as? Bool) ?? configuration.openingEndingAutoSkip
miruroVideoProvider = configuration.miruroVideoProvider miruroVideoProvider = configuration.miruroVideoProvider
miruroCaptionProvider = configuration.miruroCaptionProvider miruroCaptionProvider = configuration.miruroCaptionProvider
compactWindow = CompactWindowController(snappingEnabled: configuration.snapToEdges, defaults: defaults) compactWindow = CompactWindowController(snappingEnabled: configuration.snapToEdges, defaults: defaults)
@@ -304,6 +335,7 @@ final class PlayerModel: ObservableObject {
self.compactIntent = nil self.compactIntent = nil
self.fullScreenIntent = nil self.fullScreenIntent = nil
} }
self.updateWindowState()
self.objectWillChange.send() self.objectWillChange.send()
} }
timer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { [weak self] _ in timer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { [weak self] _ in
@@ -352,7 +384,9 @@ final class PlayerModel: ObservableObject {
guard canCast, let source = currentMedia, let handle else { return } guard canCast, let source = currentMedia, let handle else { return }
cancelStreamResolution() cancelStreamResolution()
commandError = nil commandError = nil
casting.start(to: device, source: source, title: fileName ?? "Terebi", subtitle: selectedSubtitle, // YouTube captions remain local-only: never prefetch optional tracks during Cast handoff.
let subtitle: Int? = if case .youtube = source { nil } else { selectedSubtitle }
casting.start(to: device, source: source, title: fileName ?? "Terebi", subtitle: subtitle,
handle: handle, snapshot: state, playing: playing ?? effectivePlaying, handle: handle, snapshot: state, playing: playing ?? effectivePlaying,
volume: volume, audibleVolume: lastAudibleVolume) volume: volume, audibleVolume: lastAudibleVolume)
revealControls() revealControls()
@@ -376,7 +410,7 @@ final class PlayerModel: ObservableObject {
state = projection.state state = projection.state
volume = projection.volume volume = projection.volume
if volume > 0 { lastAudibleVolume = volume } if volume > 0 { lastAudibleVolume = volume }
selectedSubtitle = projection.subtitle if case .resolved? = currentMedia { selectedSubtitle = projection.subtitle }
if !isSeeking && !state.seek_pending { seekPosition = state.position } if !isSeeking && !state.seek_pending { seekPosition = state.position }
if let handle { _ = player_report_remote_state(handle, &state) } if let handle { _ = player_report_remote_state(handle, &state) }
saveProgress() saveProgress()
@@ -569,6 +603,11 @@ final class PlayerModel: ObservableObject {
private func resolveStream(_ stream: StreamURL, change: MediaChange) { private func resolveStream(_ stream: StreamURL, change: MediaChange) {
replaceMetadata(for: nil) replaceMetadata(for: nil)
if !change.isQuality {
if castDevice != nil { stopCasting(resume: false) }
selectSubtitle(nil)
subtitleTracks = []
}
switch stream { switch stream {
case .youtube(let url): resolveYouTube(url, height: nil, change: change) case .youtube(let url): resolveYouTube(url, height: nil, change: change)
case .miruro(let mediaID, let episode): resolveMiruro(mediaID: mediaID, episode: episode, change: change) case .miruro(let mediaID, let episode): resolveMiruro(mediaID: mediaID, episode: episode, change: change)
@@ -592,6 +631,7 @@ final class PlayerModel: ObservableObject {
func selectSubtitle(_ index: Int?) { func selectSubtitle(_ index: Int?) {
if casting.active { if casting.active {
guard canSelectSubtitle else { return }
if !casting.setSubtitle(index) { commandError = "Could not change Cast subtitles." } if !casting.setSubtitle(index) { commandError = "Could not change Cast subtitles." }
return return
} }
@@ -895,6 +935,16 @@ final class PlayerModel: ObservableObject {
replaceSponsorMetadata(videoID: sponsorVideoID) replaceSponsorMetadata(videoID: sponsorVideoID)
} }
func setSponsorBlockAutoSkip(_ enabled: Bool) {
sponsorBlockAutoSkip = enabled
preferenceDefaults.set(enabled, forKey: "sponsorBlockAutoSkip")
}
func setOpeningEndingAutoSkip(_ enabled: Bool) {
openingEndingAutoSkip = enabled
preferenceDefaults.set(enabled, forKey: "openingEndingAutoSkip")
}
private func updateSponsorRanges() { private func updateSponsorRanges() {
sponsorRanges = SponsorSegment.visibleRanges(sponsorSegments, duration: state.duration) sponsorRanges = SponsorSegment.visibleRanges(sponsorSegments, duration: state.duration)
} }
@@ -936,9 +986,16 @@ final class PlayerModel: ObservableObject {
skipOffer = nil skipOffer = nil
} }
let parts = source?.url.absoluteString.split(separator: ":") ?? [] let parts = source?.url.absoluteString.split(separator: ":") ?? []
let episode = parts.count == 3 && parts[0] == "miruro" ? Int(parts[2]) : nil let episode = parts.count == 3 && (parts[0] == "miruro" || parts[0] == "anilist") ? Int(parts[2]) : nil
let generation = metadataLifecycle.replace(identity: source?.url.absoluteString, episode: episode) let providerIntervals: [ProviderInterval]
guard let source, let episode else { return } if case .resolved(let resolved, _) = source, parts.first == "anilist" {
providerIntervals = resolved.intervals
} else {
providerIntervals = []
}
let generation = metadataLifecycle.replace(identity: source?.url.absoluteString, episode: episode,
providerIntervals: providerIntervals)
guard let source, let episode, parts[0] == "miruro" else { return }
let identity = source.url.absoluteString let identity = source.url.absoluteString
let mediaID = String(parts[1]) let mediaID = String(parts[1])
let resolver = miruroMetadataResolver let resolver = miruroMetadataResolver
@@ -1058,16 +1115,22 @@ final class PlayerModel: ObservableObject {
if let fileName, !source.url.isFileURL, let id = queue.currentID { if let fileName, !source.url.isFileURL, let id = queue.currentID {
queue.updateLabel(fileName, for: id) queue.updateLabel(fileName, for: id)
} }
let previousSubtitleLanguage = selectedSubtitle.flatMap { let previousSubtitle = selectedSubtitle.flatMap {
subtitleTracks.indices.contains($0) ? subtitleTracks[$0].language : nil subtitleTracks.indices.contains($0) ? subtitleTracks[$0] : nil
} }
selectSubtitle(nil) selectSubtitle(nil)
if case .resolved(let resolved, _) = source { if case .resolved(let resolved, _) = source {
subtitleTracks = resolved.subtitles subtitleTracks = resolved.subtitles
let defaultIndex = subtitleTracks.firstIndex(where: \.isDefault) let defaultIndex = subtitleTracks.firstIndex(where: \.isDefault)
selectSubtitle(change.isQuality ? previousSubtitleLanguage.flatMap { language in selectSubtitle(change.isQuality ? previousSubtitle.flatMap { previous in
subtitleTracks.firstIndex { $0.language == language } ?? defaultIndex subtitleTracks.firstIndex { $0.language == previous.language } ?? defaultIndex
} : defaultIndex) } : defaultIndex)
} else if case .youtube(let video, _) = source {
subtitleTracks = video.subtitles
// Off and a missing track both stay Off; never substitute manual/auto by language.
if change.preservesPosition, let id = previousSubtitle?.selectionID {
selectSubtitle(subtitleTracks.firstIndex { $0.selectionID == id })
}
} else { } else {
subtitleTracks = [] subtitleTracks = []
} }
@@ -1080,6 +1143,7 @@ final class PlayerModel: ObservableObject {
window?.title = fileName ?? "Terebi" window?.title = fileName ?? "Terebi"
revealControls() revealControls()
replaceMetadata(for: source) replaceMetadata(for: source)
if !change.isQuality { segmentSeekPosition = savedPosition }
systemPlayback?.update() systemPlayback?.update()
} }
@@ -1203,14 +1267,7 @@ final class PlayerModel: ObservableObject {
if transition.resolvingAutomaticQuality { cancelStreamResolution() } if transition.resolvingAutomaticQuality { cancelStreamResolution() }
automaticQuality.resetHealth() automaticQuality.resetHealth()
isSeeking = false isSeeking = false
guard let handle, canSeek else { return } _ = seek(to: min(max(0, seekPosition), duration), userInitiated: true)
if casting.active {
seekCast(to: min(max(0, seekPosition), duration))
return
}
let position = min(max(0, seekPosition), duration)
let accepted = transition.seek(position, handle: handle)
commandError = accepted ? nil : "Could not queue the seek. Please try again."
revealControls() revealControls()
} }
@@ -1257,12 +1314,21 @@ final class PlayerModel: ObservableObject {
@discardableResult @discardableResult
func seekRelative(_ seconds: Double) -> Bool { func seekRelative(_ seconds: Double) -> Bool {
guard canSeek, let handle, seconds.isFinite else { return false } guard seconds.isFinite else { return false }
let target = min(max(0, ((transition.seekPending || state.seek_pending) ? seekPosition : state.position) + seconds), duration)
return seek(to: target, userInitiated: true)
}
private func seek(to target: Double, userInitiated: Bool) -> Bool {
guard canSeek, let handle, target.isFinite else { return false }
if transition.resolvingAutomaticQuality { cancelStreamResolution() } if transition.resolvingAutomaticQuality { cancelStreamResolution() }
automaticQuality.resetHealth() automaticQuality.resetHealth()
let target = min(max(0, ((transition.seekPending || state.seek_pending) ? seekPosition : state.position) + seconds), duration) let previousSeek = segmentSeekPosition
// Record intent before Cast can synchronously publish a new state.
if userInitiated { segmentSeekPosition = target }
let accepted = casting.active ? seekCast(to: target) : transition.seek(target, handle: handle) let accepted = casting.active ? seekCast(to: target) : transition.seek(target, handle: handle)
if accepted { seekPosition = target } if accepted { seekPosition = target }
else { segmentSeekPosition = previousSeek }
commandError = accepted ? nil : "Could not queue the seek. Please try again." commandError = accepted ? nil : "Could not queue the seek. Please try again."
if !accepted { revealControls() } if !accepted { revealControls() }
return accepted return accepted
@@ -1272,6 +1338,7 @@ final class PlayerModel: ObservableObject {
func stepFrame(_ direction: Int32) -> Bool { func stepFrame(_ direction: Int32) -> Bool {
guard canStepFrame, let handle else { return false } guard canStepFrame, let handle else { return false }
let accepted = transition.stepFrame(direction, handle: handle) let accepted = transition.stepFrame(direction, handle: handle)
if accepted { segmentSeekPosition = state.position }
commandError = accepted ? nil : "Could not queue the frame step. Please try again." commandError = accepted ? nil : "Could not queue the frame step. Please try again."
revealControls() revealControls()
return accepted return accepted
@@ -1384,17 +1451,17 @@ final class PlayerModel: ObservableObject {
} }
func attachWindow(_ window: NSWindow) { func attachWindow(_ window: NSWindow) {
guard self.window !== window else { return } guard !window.styleMask.contains(.nonactivatingPanel), attachedWindow !== window else { return }
startContentHeight = 400 - (window.frame.height - window.contentLayoutRect.height) startContentHeight = 400 - (window.frame.height - window.contentLayoutRect.height)
finishQueueTransition() finishQueueTransition()
self.window = window attachedWindow = window
window.title = fileName ?? "Terebi" window.title = fileName ?? "Terebi"
window.contentMinSize = NSSize(width: 400, height: 240) window.contentMinSize = NSSize(width: 400, height: 240)
compactWindow.normalVideoLayout = { [weak self] in compactWindow.normalVideoLayout = { [weak self] in
guard let self, self.showsQueue, self.state.has_video else { return nil } guard let self, self.showsQueue, self.state.has_video else { return nil }
return (self.state.video_aspect_ratio, 260) return (self.state.video_aspect_ratio, 260)
} }
compactWindow.attach(window) compactWindow.attach(window, contentView: hostedPlayerView)
updateWindowState() updateWindowState()
if fileName == nil { configureStartWindow(showing: true) } if fileName == nil { configureStartWindow(showing: true) }
fitVideoWindow() fitVideoWindow()
@@ -2100,14 +2167,20 @@ final class PlayerModel: ObservableObject {
private struct MetadataLifecycle { private struct MetadataLifecycle {
private(set) var identity: String? private(set) var identity: String?
private(set) var metadata: MiruroMetadata? private(set) var metadata: MiruroMetadata?
private var providerIntervals: [MiruroInterval] = []
private var episode: Int? private var episode: Int?
private var generation: UInt64 = 0 private var generation: UInt64 = 0
mutating func replace(identity: String?, episode: Int?) -> UInt64 { mutating func replace(identity: String?, episode: Int?, providerIntervals: [ProviderInterval] = []) -> UInt64 {
generation &+= 1 generation &+= 1
self.identity = identity self.identity = identity
self.episode = episode self.episode = episode
metadata = nil metadata = nil
self.providerIntervals = episode.map { episode in
providerIntervals.map {
MiruroInterval(kind: $0.kind == .intro ? .intro : .outro, start: $0.start, end: $0.end, episode: episode)
}
} ?? []
return generation return generation
} }
@@ -2118,8 +2191,14 @@ final class PlayerModel: ObservableObject {
} }
func visibleIntervals(duration: Double) -> [MiruroInterval] { func visibleIntervals(duration: Double) -> [MiruroInterval] {
guard let episode, let metadata else { return [] } guard let episode else { return [] }
return metadata.visibleIntervals(episode: episode, duration: duration) if !providerIntervals.isEmpty {
guard duration.isFinite, duration > 0 else { return [] }
return providerIntervals.filter {
$0.start.isFinite && $0.end.isFinite && $0.start >= 0 && $0.end > $0.start && $0.end <= duration
}
}
return metadata?.visibleIntervals(episode: episode, duration: duration) ?? []
} }
} }
@@ -2166,6 +2245,32 @@ final class PlayerModel: ObservableObject {
precondition(!lifecycle.accept(metadata, identity: a, generation: second), "YouTube rejects earlier metadata") precondition(!lifecycle.accept(metadata, identity: a, generation: second), "YouTube rejects earlier metadata")
_ = lifecycle.replace(identity: nil, episode: nil) _ = lifecycle.replace(identity: nil, episode: nil)
precondition(lifecycle.metadata == nil, "Close clears metadata") precondition(lifecycle.metadata == nil, "Close clears metadata")
let providerRows: [ProviderInterval] = [
.init(kind: .intro, start: 0.25, end: 1.75),
.init(kind: .outro, start: 90.5, end: 100),
.init(kind: .intro, start: -1, end: 1),
.init(kind: .outro, start: 10, end: 10),
.init(kind: .intro, start: .nan, end: 3),
.init(kind: .outro, start: 2, end: .infinity),
]
let providerGeneration = lifecycle.replace(identity: "anilist:1:1", episode: 1, providerIntervals: providerRows)
let expected = [
MiruroInterval(kind: .intro, start: 0.25, end: 1.75, episode: 1),
MiruroInterval(kind: .outro, start: 90.5, end: 100, episode: 1),
]
for duration in [0.0, -1, .nan, .infinity] {
precondition(lifecycle.visibleIntervals(duration: duration).isEmpty, "Unknown/nonfinite provider duration hides bands")
}
precondition(lifecycle.visibleIntervals(duration: 100) == expected, "Provider fractions and exact-duration end survive mapping")
precondition(lifecycle.visibleIntervals(duration: 95) == [expected[0]], "Provider ranges outside actual duration are rejected, not clipped")
precondition(lifecycle.visibleIntervals(duration: 100) == expected, "Provider raw rows survive duration changes")
_ = lifecycle.replace(identity: "anilist:1:2", episode: 2)
precondition(lifecycle.visibleIntervals(duration: 100).isEmpty, "Missing provider timing clears preceding episode")
_ = lifecycle.replace(identity: "anilist:1:1", episode: 1)
precondition(!lifecycle.accept(metadata, identity: "anilist:1:1", generation: providerGeneration),
"Provider replacement retains same-identity generation protection")
_ = lifecycle.replace(identity: nil, episode: nil)
precondition(lifecycle.visibleIntervals(duration: 100).isEmpty, "Close clears provider bands")
} }
#endif #endif
@@ -2582,8 +2687,10 @@ private struct PlaybackSlider: NSViewRepresentable {
let lower = index == 0 ? minValue : cell.boundaries[index - 1] let lower = index == 0 ? minValue : cell.boundaries[index - 1]
let upper = index == cell.boundaries.count ? maxValue : cell.boundaries[index] let upper = index == cell.boundaries.count ? maxValue : cell.boundaries[index]
let middle = (lower + upper) / 2 let middle = (lower + upper) / 2
setHover(Hover(time: middle, x: start + (middle - minValue) / (maxValue - minValue) * width, let sponsor = cell.sponsoredRanges.first { $0.lowerBound <= time && time < $0.upperBound }
sponsored: cell.sponsoredRanges.contains { $0.lowerBound <= time && time < $0.upperBound })) let anchor = sponsor.map { ($0.lowerBound + $0.upperBound) / 2 } ?? middle
setHover(Hover(time: middle, x: start + (anchor - minValue) / (maxValue - minValue) * width,
sponsored: sponsor != nil))
} }
func refreshHover() { func refreshHover() {
@@ -2813,14 +2920,18 @@ func playbackSliderHitCheck() -> Bool {
pixel(50).redComponent != pixel(30).redComponent, pixel(50).redComponent != pixel(30).redComponent,
pixel(10).alphaComponent > 0, pixel(40).alphaComponent > 0 else { return false } pixel(10).alphaComponent > 0, pixel(40).alphaComponent > 0 else { return false }
cell.emphasis = [] cell.emphasis = []
for (time, middle) in [(0.0, 12.5), (25.0, 50.0), (75.0, 87.5), (100.0, 87.5)] { for (time, middle, anchor, sponsored) in [
(0.0, 12.5, 12.5, false), (20.0, 12.5, 25.0, true),
(25.0, 50.0, 25.0, true), (30.0, 50.0, 25.0, true),
(40.0, 50.0, 50.0, false), (75.0, 87.5, 87.5, false), (100.0, 87.5, 87.5, false)
] {
let point = slider.convert(NSPoint(x: left + time / 100 * (right - left), y: 7), to: nil) let point = slider.convert(NSPoint(x: left + time / 100 * (right - left), y: 7), to: nil)
let event = NSEvent.mouseEvent(with: .mouseMoved, location: point, modifierFlags: [], let event = NSEvent.mouseEvent(with: .mouseMoved, location: point, modifierFlags: [],
timestamp: 0, windowNumber: window.windowNumber, context: nil, eventNumber: 0, clickCount: 0, pressure: 0)! timestamp: 0, windowNumber: window.windowNumber, context: nil, eventNumber: 0, clickCount: 0, pressure: 0)!
slider.mouseMoved(with: event) slider.mouseMoved(with: event)
guard hovered?.time == middle, guard hovered?.time == middle,
hovered?.sponsored == (time == 25), hovered?.sponsored == sponsored,
abs(hovered!.x - (left + middle / 100 * (right - left))) < 0.5 else { return false } abs(hovered!.x - (left + anchor / 100 * (right - left))) < 0.5 else { return false }
} }
let exit = NSEvent.enterExitEvent(with: .mouseExited, location: .zero, modifierFlags: [], let exit = NSEvent.enterExitEvent(with: .mouseExited, location: .zero, modifierFlags: [],
timestamp: 0, windowNumber: window.windowNumber, context: nil, eventNumber: 0, trackingNumber: 0, userData: nil)! timestamp: 0, windowNumber: window.windowNumber, context: nil, eventNumber: 0, trackingNumber: 0, userData: nil)!
@@ -2894,6 +3005,7 @@ private struct CaptionSelector: NSViewRepresentable {
required init?(coder: NSCoder) { nil } required init?(coder: NSCoder) { nil }
func updateTracks() { func updateTracks() {
isEnabled = model.canSelectSubtitle
guard menu == nil || displayedSelection != model.selectedSubtitle || guard menu == nil || displayedSelection != model.selectedSubtitle ||
displayedTracks.count != model.subtitleTracks.count || displayedTracks.count != model.subtitleTracks.count ||
zip(displayedTracks, model.subtitleTracks).contains(where: { zip(displayedTracks, model.subtitleTracks).contains(where: {
@@ -2920,7 +3032,6 @@ private struct CaptionSelector: NSViewRepresentable {
} }
menu = tracks menu = tracks
(cell as? NSPopUpButtonCell)?.menuItem = captionItem (cell as? NSPopUpButtonCell)?.menuItem = captionItem
isEnabled = !model.subtitleTracks.isEmpty
} }
@objc private func selectTrack(_ item: NSMenuItem) { @objc private func selectTrack(_ item: NSMenuItem) {
@@ -3349,6 +3460,7 @@ private struct PlayerContent: View {
Divider() Divider()
PlayerQualityMenu(model: model) PlayerQualityMenu(model: model)
PlayerMiruroMenu(model: model) PlayerMiruroMenu(model: model)
PlayerOpeningEndingSkipToggle(model: model)
PlayerSponsorBlockMenu(model: model) PlayerSponsorBlockMenu(model: model)
Divider() Divider()
if model.isCompact { if model.isCompact {
@@ -3363,7 +3475,7 @@ private struct PlayerContent: View {
} }
} }
} }
.disabled(model.subtitleTracks.isEmpty) .disabled(!model.canSelectSubtitle)
Divider() Divider()
} }
PlayerActionButton(model: model, action: model.bufferInfoAction, PlayerActionButton(model: model, action: model.bufferInfoAction,
@@ -4085,12 +4197,11 @@ private struct PlayerContent: View {
.accessibilityHidden(model.isCompact || !model.controlsVisible) .accessibilityHidden(model.isCompact || !model.controlsVisible)
} }
ZStack(alignment: .topTrailing) { ZStack(alignment: .topTrailing) {
if let interval = model.segmentToSkip { if let title = model.skipButtonTitle {
Button { Button {
model.skipSegment() model.skipSegment()
} label: { } label: {
Label(interval.kind == .intro ? "Skip Opening" : "Skip Ending", Label(title, systemImage: "forward.end.fill")
systemImage: "forward.end.fill")
.font(.system(size: model.isCompact ? 11 : 13, weight: .semibold)) .font(.system(size: model.isCompact ? 11 : 13, weight: .semibold))
.padding(.horizontal, 12) .padding(.horizontal, 12)
.padding(.vertical, 8) .padding(.vertical, 8)
@@ -4101,12 +4212,14 @@ private struct PlayerContent: View {
.background { .background {
ZStack(alignment: .leading) { ZStack(alignment: .leading) {
PlayerGlass(cornerRadius: 8) PlayerGlass(cornerRadius: 8)
TimelineView(.animation(minimumInterval: reduceMotion ? 1 : 1.0 / 30)) { _ in if model.segmentToSkip != nil {
GeometryReader { geometry in TimelineView(.animation(minimumInterval: reduceMotion ? 1 : 1.0 / 30)) { _ in
let elapsed = 1 - model.skipOfferRemainingFraction GeometryReader { geometry in
Color(SunsetPalette.text).opacity(0.25) let elapsed = 1 - model.skipOfferRemainingFraction
.frame(width: geometry.size.width * Color(SunsetPalette.text).opacity(0.25)
(reduceMotion ? floor(elapsed * 10) / 10 : elapsed)) .frame(width: geometry.size.width *
(reduceMotion ? floor(elapsed * 10) / 10 : elapsed))
}
} }
} }
} }
@@ -4120,8 +4233,8 @@ private struct PlayerContent: View {
.padding(.trailing, model.isCompact ? 8 : 16) .padding(.trailing, model.isCompact ? 8 : 16)
.padding(.top, 44) .padding(.top, 44)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing)
.animation(reduceMotion ? nil : .easeInOut(duration: 0.2), value: model.segmentToSkip?.kind) .animation(reduceMotion ? nil : .easeInOut(duration: 0.2), value: model.skipButtonTitle)
.allowsHitTesting(model.segmentToSkip != nil) .allowsHitTesting(model.skipButtonTitle != nil)
if dropTargeted { if dropTargeted {
RoundedRectangle(cornerRadius: 10) RoundedRectangle(cornerRadius: 10)
.strokeBorder(Color(SunsetPalette.accent), lineWidth: 2) .strokeBorder(Color(SunsetPalette.accent), lineWidth: 2)
@@ -4186,7 +4299,9 @@ private struct WindowChrome: NSViewRepresentable {
window.isOpaque = false window.isOpaque = false
window.backgroundColor = .clear window.backgroundColor = .clear
window.acceptsMouseMovedEvents = true window.acceptsMouseMovedEvents = true
window.collectionBehavior.insert(.fullScreenPrimary) if !window.styleMask.contains(.nonactivatingPanel) {
window.collectionBehavior.insert(.fullScreenPrimary)
}
window.appearance = NSAppearance(named: .darkAqua) window.appearance = NSAppearance(named: .darkAqua)
for name in [NSWindow.willEnterFullScreenNotification, NSWindow.willStartLiveResizeNotification, for name in [NSWindow.willEnterFullScreenNotification, NSWindow.willStartLiveResizeNotification,
NSWindow.willCloseNotification] { NSWindow.willCloseNotification] {
@@ -4233,8 +4348,9 @@ private struct WindowChrome: NSViewRepresentable {
return consumed ? nil : event return consumed ? nil : event
} }
Task { @MainActor [weak self, weak window] in Task { @MainActor [weak self, weak window] in
guard let self, let window else { return } guard let self, let window, self.configuredWindow === window, self.window === window else { return }
self.model.attachWindow(window) self.model.attachWindow(window)
self.model.updateWindowState()
self.updateButtons() self.updateButtons()
window.makeFirstResponder(nil) window.makeFirstResponder(nil)
} }
@@ -4312,6 +4428,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
} }
} }
func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool {
let model = PlayerModel.shared
guard model.isCompact || model.compactWindow.isTransitioning,
let window = model.window else { return true }
window.makeKeyAndOrderFront(nil)
return false
}
// Let SwiftUI create the initial window for a Finder/document launch. // Let SwiftUI create the initial window for a Finder/document launch.
func application(_ application: NSApplication, open urls: [URL]) { func application(_ application: NSApplication, open urls: [URL]) {
if urls.contains(where: { $0.scheme == "terebi-enqueue" }) { if urls.contains(where: { $0.scheme == "terebi-enqueue" }) {
@@ -4321,13 +4445,36 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
else if let url = urls.first { PlayerModel.shared.open(url) } else if let url = urls.first { PlayerModel.shared.open(url) }
} }
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { true } func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
// AppKit doesn't count the compact panel as a normal application window.
let compact = PlayerModel.shared.compactWindow
return !compact.isCompact && !compact.isTransitioning
}
func applicationWillTerminate(_ notification: Notification) { func applicationWillTerminate(_ notification: Notification) {
PlayerModel.shared.shutdown() PlayerModel.shared.shutdown()
} }
} }
@MainActor
private struct PlayerSceneContent: NSViewRepresentable {
let model: PlayerModel
func makeNSView(context: Context) -> NSView {
// Keep SwiftUI's scene host in place; only this app-owned player host moves.
let container = NSView()
let player = NSHostingView(rootView: PlayerContent(model: model))
player.sizingOptions = []
player.frame = container.bounds
player.autoresizingMask = [.width, .height]
container.addSubview(player)
model.hostedPlayerView = player
return container
}
func updateNSView(_ view: NSView, context: Context) {}
}
@MainActor @MainActor
struct TerebiApp: App { struct TerebiApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) private var delegate @NSApplicationDelegateAdaptor(AppDelegate.self) private var delegate
@@ -4335,13 +4482,14 @@ struct TerebiApp: App {
var body: some Scene { var body: some Scene {
Window("\(model.fileName ?? "Terebi")", id: "terebi") { Window("\(model.fileName ?? "Terebi")", id: "terebi") {
PlayerContent(model: model) PlayerSceneContent(model: model)
.ignoresSafeArea()
.focusedSceneObject(model) .focusedSceneObject(model)
} }
.defaultSize(width: 640, height: 400) .defaultSize(width: 640, height: 400)
.windowResizability(.contentSize) .windowResizability(.contentSize)
.windowStyle(.hiddenTitleBar) .windowStyle(.hiddenTitleBar)
.commands { PlayerCommands() } .commands { PlayerCommands(model: model) }
} }
} }
+107 -2
View File
@@ -23,6 +23,7 @@ struct YouTubeVideo: Sendable {
let height: Int? let height: Int?
let availableHeights: [Int] let availableHeights: [Int]
let chapters: [YouTubeChapter] let chapters: [YouTubeChapter]
var subtitles: [ResolvedSubtitle] = []
func chapterBoundaries(duration: Double) -> [Double] { func chapterBoundaries(duration: Double) -> [Double] {
guard duration.isFinite, duration > 0 else { return [] } guard duration.isFinite, duration > 0 else { return [] }
@@ -157,13 +158,15 @@ enum YouTubeResolver {
let video = try stream(videoFormat, fallbackHeaders: metadata.http_headers) let video = try stream(videoFormat, fallbackHeaders: metadata.http_headers)
let audio = try audioFormat.map { try stream($0, fallbackHeaders: metadata.http_headers) } let audio = try audioFormat.map { try stream($0, fallbackHeaders: metadata.http_headers) }
let title = metadata.title?.trimmingCharacters(in: .whitespacesAndNewlines) let title = metadata.title?.trimmingCharacters(in: .whitespacesAndNewlines)
let isLive = metadata.is_live == true || metadata.live_status == "is_live"
return YouTubeVideo( return YouTubeVideo(
title: title?.isEmpty == false ? title! : "Video", title: title?.isEmpty == false ? title! : "Video",
video: video, audio: audio, duration: duration, video: video, audio: audio, duration: duration,
isLive: metadata.is_live == true || metadata.live_status == "is_live", isLive: isLive,
webpageURL: try validateURL(webpageURL.absoluteString), webpageURL: try validateURL(webpageURL.absoluteString),
height: videoFormat.height, availableHeights: availableHeights, height: videoFormat.height, availableHeights: availableHeights,
chapters: metadata.chapters?.values ?? [] chapters: metadata.chapters?.values ?? [],
subtitles: isLive ? [] : subtitles(metadata)
) )
} }
@@ -297,6 +300,58 @@ enum YouTubeResolver {
return video return video
} }
private static func subtitles(_ metadata: YouTubeMetadata) -> [ResolvedSubtitle] {
func track(_ variants: [YouTubeCaption], key: String, automatic: Bool) -> ResolvedSubtitle? {
let language = automatic && key.hasSuffix("-orig") ? String(key.dropLast(5)) : key
for variant in variants where variant.ext == "vtt" {
guard variant.transport == nil || ["http", "https"].contains(variant.transport!) else { continue }
if automatic,
URLComponents(string: variant.url)?.queryItems?.contains(where: { $0.name == "tlang" }) == true {
continue
}
if automatic && !key.hasSuffix("-orig") {
// Nontranslatable ASR may have no -orig alias. Never infer it from a label.
guard let url = URLComponents(string: variant.url),
let host = url.host, ["www.youtube.com", "youtube.com"].contains(host),
url.path == "/api/timedtext",
url.queryItems?.contains(where: { $0.name == "kind" && $0.value == "asr" }) == true,
url.queryItems?.contains(where: { $0.name == "lang" && $0.value == language }) == true else { continue }
}
let name = variant.name?.trimmingCharacters(in: .whitespacesAndNewlines)
let label = name?.isEmpty == false ? name! : language
let subtitle = ResolvedSubtitle(url: variant.url, format: "vtt", language: language,
label: automatic ? "\(label) (auto-generated)" : label, isDefault: false,
headers: variant.headers ?? metadata.http_headers ?? [:],
selectionID: "youtube:\(automatic ? "auto" : "manual"):\(language)")
if ResolvedSource.validSubtitles([subtitle]) { return subtitle }
}
return nil
}
let captions = metadata.automatic_captions?.values ?? [:]
// Explicit originals win over compatibility aliases, even when their signed URLs differ.
let autoKeys = captions.keys.sorted {
if $0.hasSuffix("-orig") != $1.hasSuffix("-orig") { return $0.hasSuffix("-orig") }
return $0 < $1
}
var originals: [ResolvedSubtitle] = []
var languages = Set<String>()
var urls = Set<String>()
for key in autoKeys {
guard let subtitle = track(captions[key]!, key: key, automatic: true),
languages.insert(subtitle.language).inserted,
urls.insert(subtitle.url).inserted else { continue }
originals.append(subtitle)
if originals.count == 32 { break }
}
var manual: [ResolvedSubtitle] = []
let supplied = metadata.subtitles?.values ?? [:]
for key in supplied.keys.sorted() {
if manual.count == 32 - originals.count { break }
if let subtitle = track(supplied[key]!, key: key, automatic: false) { manual.append(subtitle) }
}
return manual + originals
}
private static func stream(_ format: YouTubeMetadata, fallbackHeaders: [String: String]?) throws -> YouTubeStream { private static func stream(_ format: YouTubeMetadata, fallbackHeaders: [String: String]?) throws -> YouTubeStream {
guard format.protocol == nil || ["http", "https", "m3u8", "m3u8_native"].contains(format.protocol!) else { guard format.protocol == nil || ["http", "https", "m3u8", "m3u8_native"].contains(format.protocol!) else {
throw YouTubeResolutionError(message: "yt-dlp selected a format that requires downloading and assembling fragments instead of native streaming.") throw YouTubeResolutionError(message: "yt-dlp selected a format that requires downloading and assembling fragments instead of native streaming.")
@@ -415,12 +470,62 @@ private struct YouTubeMetadata: Decodable {
let http_headers: [String: String]? let http_headers: [String: String]?
let requested_formats: [YouTubeMetadata]? let requested_formats: [YouTubeMetadata]?
let chapters: YouTubeChapters? let chapters: YouTubeChapters?
let subtitles: YouTubeCaptions?
let automatic_captions: YouTubeCaptions?
// Generic extractors may omit codec hints; FFmpeg probes the actual streams. // Generic extractors may omit codec hints; FFmpeg probes the actual streams.
var hasVideo: Bool { vcodec.map { !$0.isEmpty && $0 != "none" } ?? true } var hasVideo: Bool { vcodec.map { !$0.isEmpty && $0 != "none" } ?? true }
var hasAudio: Bool { acodec.map { !$0.isEmpty && $0 != "none" } ?? true } var hasAudio: Bool { acodec.map { !$0.isEmpty && $0 != "none" } ?? true }
} }
private struct YouTubeCaption: Decodable {
let ext: String
let url: String
let name: String?
let headers: [String: String]?
let transport: String?
private enum CodingKeys: String, CodingKey { case ext, url, name, http_headers, `protocol` }
init(from decoder: Decoder) throws {
let row = try decoder.container(keyedBy: CodingKeys.self)
ext = try row.decode(String.self, forKey: .ext)
url = try row.decode(String.self, forKey: .url)
name = try? row.decode(String.self, forKey: .name)
headers = try row.decodeIfPresent([String: String].self, forKey: .http_headers)
transport = try row.decodeIfPresent(String.self, forKey: .protocol)
}
}
private struct YouTubeCaptions: Decodable {
let values: [String: [YouTubeCaption]]
private struct LanguageKey: CodingKey {
let stringValue: String
var intValue: Int? { nil }
init?(stringValue: String) { self.stringValue = stringValue }
init?(intValue: Int) { return nil }
}
init(from decoder: Decoder) {
guard let languages = try? decoder.container(keyedBy: LanguageKey.self) else {
values = [:]
return
}
var captions: [String: [YouTubeCaption]] = [:]
for key in languages.allKeys {
guard var rows = try? languages.nestedUnkeyedContainer(forKey: key) else { continue }
var variants: [YouTubeCaption] = []
while !rows.isAtEnd {
guard let row = try? rows.superDecoder() else { break }
if let caption = try? YouTubeCaption(from: row) { variants.append(caption) }
}
captions[key.stringValue] = variants
}
values = captions
}
}
private struct YouTubeChapters: Decodable { private struct YouTubeChapters: Decodable {
let values: [YouTubeChapter] let values: [YouTubeChapter]
+39 -2
View File
@@ -82,7 +82,8 @@ enum ZokoResolver {
guard enrichTitle, let name = metadata.name else { return source } guard enrichTitle, let name = metadata.name else { return source }
try Task.checkCancellation() try Task.checkCancellation()
return ResolvedSource(url: source.url, headers: source.headers, identity: source.identity, return ResolvedSource(url: source.url, headers: source.headers, identity: source.identity,
title: "\(name) · Episode \(episode) (sub)", subtitles: source.subtitles, requirements: source.requirements) title: "\(name) · Episode \(episode) (sub)", subtitles: source.subtitles,
intervals: source.intervals, requirements: source.requirements, playbackLifetime: source.playbackLifetime)
} }
static func parse(page: String, mediaID: String, episode: Int) throws -> ResolvedSource { static func parse(page: String, mediaID: String, episode: Int) throws -> ResolvedSource {
@@ -106,8 +107,37 @@ enum ZokoResolver {
} }
struct Config: Decodable { struct Config: Decodable {
struct Subtitle: Decodable { let src: String; let lang: String; let label: String; let `default`: Bool? } struct Subtitle: Decodable { let src: String; let lang: String; let label: String; let `default`: Bool? }
struct Timing: Decodable {
let start: Double
let end: Double
enum CodingKeys: CodingKey { case start, end }
init(from decoder: any Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
start = try values.decode(Double.self, forKey: .start)
end = try values.decode(Double.self, forKey: .end)
guard start.isFinite, end.isFinite, start >= 0, end > start else { throw ZokoError.malformed }
}
}
struct Skip: Decodable {
let intro: Timing?
let outro: Timing?
enum CodingKeys: CodingKey { case intro, outro }
init(from decoder: any Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
intro = try? values.decode(Timing.self, forKey: .intro)
outro = try? values.decode(Timing.self, forKey: .outro)
}
}
let src: String let src: String
let subtitles: [Subtitle] let subtitles: [Subtitle]
let skip: Skip?
enum CodingKeys: CodingKey { case src, subtitles, skip }
init(from decoder: any Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
src = try values.decode(String.self, forKey: .src)
subtitles = try values.decode([Subtitle].self, forKey: .subtitles)
skip = try? values.decode(Skip.self, forKey: .skip)
}
} }
let config: Config let config: Config
do { config = try JSONDecoder().decode(Config.self, from: decoded) } do { config = try JSONDecoder().decode(Config.self, from: decoded) }
@@ -119,9 +149,16 @@ enum ZokoResolver {
isDefault: row.default ?? false, headers: ["Referer": "https://zokoanime.video/"]) isDefault: row.default ?? false, headers: ["Referer": "https://zokoanime.video/"])
} }
guard ResolvedSource.validSubtitles(subtitles) else { throw ZokoError.malformed } guard ResolvedSource.validSubtitles(subtitles) else { throw ZokoError.malformed }
var intervals: [ProviderInterval] = []
if let intro = config.skip?.intro {
intervals.append(ProviderInterval(kind: .intro, start: intro.start, end: intro.end))
}
if let outro = config.skip?.outro {
intervals.append(ProviderInterval(kind: .outro, start: outro.start, end: outro.end))
}
return ResolvedSource(url: config.src, headers: referer, identity: "anilist:\(mediaID):\(episode)", return ResolvedSource(url: config.src, headers: referer, identity: "anilist:\(mediaID):\(episode)",
title: "AniList \(mediaID) · Episode \(episode) (sub)", subtitles: subtitles, title: "AniList \(mediaID) · Episode \(episode) (sub)", subtitles: subtitles,
requirements: .init(zoko: true)) intervals: intervals, requirements: .init(zoko: true))
} }
struct Catalog: Decodable { struct Catalog: Decodable {
@@ -9,6 +9,8 @@ public struct PlayerConfiguration: Equatable, Sendable {
public var showBufferInfo: Bool public var showBufferInfo: Bool
public var automaticQuality: Bool public var automaticQuality: Bool
public var sponsorBlock: Bool public var sponsorBlock: Bool
public var sponsorBlockAutoSkip: Bool
public var openingEndingAutoSkip: Bool
public var snapToEdges: Bool public var snapToEdges: Bool
public var compactTitleProgress: CompactTitleProgress public var compactTitleProgress: CompactTitleProgress
public var miruroVideoProvider: String public var miruroVideoProvider: String
@@ -16,12 +18,14 @@ public struct PlayerConfiguration: Equatable, Sendable {
public var keys: KeyBindings public var keys: KeyBindings
public init(showBufferInfo: Bool = false, automaticQuality: Bool = false, sponsorBlock: Bool = false, public init(showBufferInfo: Bool = false, automaticQuality: Bool = false, sponsorBlock: Bool = false,
snapToEdges: Bool = true, sponsorBlockAutoSkip: Bool = false, openingEndingAutoSkip: Bool = false, snapToEdges: Bool = true,
compactTitleProgress: CompactTitleProgress = .time, miruroVideoProvider: String = "", compactTitleProgress: CompactTitleProgress = .time, miruroVideoProvider: String = "",
miruroCaptionProvider: String = "", keys: KeyBindings = KeyBindings()) { miruroCaptionProvider: String = "", keys: KeyBindings = KeyBindings()) {
self.showBufferInfo = showBufferInfo self.showBufferInfo = showBufferInfo
self.automaticQuality = automaticQuality self.automaticQuality = automaticQuality
self.sponsorBlock = sponsorBlock self.sponsorBlock = sponsorBlock
self.sponsorBlockAutoSkip = sponsorBlockAutoSkip
self.openingEndingAutoSkip = openingEndingAutoSkip
self.snapToEdges = snapToEdges self.snapToEdges = snapToEdges
self.compactTitleProgress = compactTitleProgress self.compactTitleProgress = compactTitleProgress
self.miruroVideoProvider = miruroVideoProvider self.miruroVideoProvider = miruroVideoProvider
@@ -37,6 +41,8 @@ public struct PlayerConfiguration: Equatable, Sendable {
"[playback]", "[playback]",
"automatic_quality = \(defaults.automaticQuality)", "automatic_quality = \(defaults.automaticQuality)",
"sponsorblock = \(defaults.sponsorBlock)", "sponsorblock = \(defaults.sponsorBlock)",
"sponsorblock_autoskip = \(defaults.sponsorBlockAutoSkip)",
"opening_ending_autoskip = \(defaults.openingEndingAutoSkip)",
"", "[miruro]", "", "[miruro]",
"video_provider = \"\(defaults.miruroVideoProvider)\"", "video_provider = \"\(defaults.miruroVideoProvider)\"",
"caption_provider = \"\(defaults.miruroCaptionProvider)\"", "caption_provider = \"\(defaults.miruroCaptionProvider)\"",
@@ -96,6 +102,8 @@ public struct PlayerConfiguration: Equatable, Sendable {
return Self(showBufferInfo: values.ui?.bufferInfo ?? false, return Self(showBufferInfo: values.ui?.bufferInfo ?? false,
automaticQuality: values.playback?.automaticQuality ?? false, automaticQuality: values.playback?.automaticQuality ?? false,
sponsorBlock: values.playback?.sponsorBlock ?? false, sponsorBlock: values.playback?.sponsorBlock ?? false,
sponsorBlockAutoSkip: values.playback?.sponsorBlockAutoSkip ?? false,
openingEndingAutoSkip: values.playback?.openingEndingAutoSkip ?? false,
snapToEdges: values.ui?.compactSnapToEdges ?? true, snapToEdges: values.ui?.compactSnapToEdges ?? true,
compactTitleProgress: values.ui?.compactTitleProgress ?? .time, compactTitleProgress: values.ui?.compactTitleProgress ?? .time,
miruroVideoProvider: values.miruro?.videoProvider ?? "", miruroVideoProvider: values.miruro?.videoProvider ?? "",
@@ -181,11 +189,16 @@ public struct PlayerConfiguration: Equatable, Sendable {
private struct PlaybackConfiguration { private struct PlaybackConfiguration {
let automaticQuality: Bool? let automaticQuality: Bool?
let sponsorBlock: Bool? let sponsorBlock: Bool?
let sponsorBlockAutoSkip: Bool?
let openingEndingAutoSkip: Bool?
init(container: KeyedDecodingContainer<SettingKey>) throws { init(container: KeyedDecodingContainer<SettingKey>) throws {
try validate(container, allowed: ["automatic_quality", "sponsorblock"]) try validate(container, allowed: ["automatic_quality", "sponsorblock", "sponsorblock_autoskip",
"opening_ending_autoskip"])
automaticQuality = try container.decodeIfPresent(Bool.self, forKey: SettingKey("automatic_quality")) automaticQuality = try container.decodeIfPresent(Bool.self, forKey: SettingKey("automatic_quality"))
sponsorBlock = try container.decodeIfPresent(Bool.self, forKey: SettingKey("sponsorblock")) sponsorBlock = try container.decodeIfPresent(Bool.self, forKey: SettingKey("sponsorblock"))
sponsorBlockAutoSkip = try container.decodeIfPresent(Bool.self, forKey: SettingKey("sponsorblock_autoskip"))
openingEndingAutoSkip = try container.decodeIfPresent(Bool.self, forKey: SettingKey("opening_ending_autoskip"))
} }
} }
-3
View File
@@ -1,3 +0,0 @@
- [ ] make sponsored blocks skippable:
- display the skip button used for openings and endings but for sponsored blocks it shouldn't disappear after a timeout
- add a setting for autoskipping (shouldn't activate if seeking onto the sponsored segment (or just before it) to avoid fighting user intention)
+4
View File
@@ -24,6 +24,10 @@ The Miruro contract below remains intact for old collections. New AniList catalo
Terebi resolves bounded static `window.__P` data over ordinary verified HTTPS, then uses the existing scoped native HLS relay with a Zoko-only exact-origin policy. The optional title lookup is bounded AniList GraphQL metadata; unavailable metadata yields an explicit catalog-ID/episode fallback. There is no dub fallback, browser/challenge execution, telemetry fetch or persistent source URL. See [direct playback and security limits](../README.md#anilist--zokoanime-direct-playback). Terebi resolves bounded static `window.__P` data over ordinary verified HTTPS, then uses the existing scoped native HLS relay with a Zoko-only exact-origin policy. The optional title lookup is bounded AniList GraphQL metadata; unavailable metadata yields an explicit catalog-ID/episode fallback. There is no dub fallback, browser/challenge execution, telemetry fetch or persistent source URL. See [direct playback and security limits](../README.md#anilist--zokoanime-direct-playback).
Optional `skip.intro` and `skip.outro` from that same configuration remain attached to the resolved source through title enrichment, subtitle replacement and relay preparation. For `anilist:` playback they feed the existing metadata lifecycle and opening/ending display/skip policy without a Miruro request or external timing service. Each interval independently requires numeric, finite, nonnegative, increasing boundaries wholly within actual positive player duration; fractional seconds remain unchanged. Missing/malformed timing never blocks playback. Media replacement clears timings and retains generation/cancellation guards.
`make check-zoko check-zoko-intervals check-source check-miruro-metadata all` and `.build/resolved-playback-check --metadata-lifecycle` passed after this integration. The localhost native playback smoke covers direct/sidebar launches, manual skip, fresh-model saved reopening, queue/revisit, missing/out-of-duration timing and stale source/metadata rejection. Visual rendering and live-provider timestamp alignment remain unverified.
Use stable `anilist:<ID>:<episode>` for resume and report registration. Queue/enqueue documents keep their existing version-1 shape and use the watch URL. Version-2 reports keep exactly the existing event/position/duration/eligibility fields, substituting `media_identity: {"provider":"anilist","media_id":"1","episode":1}`. Validate against the exact expected provider/ID/episode; never reinterpret an old `miruro` identity as AniList. Explicit external source documents also accept the `anilist` identity namespace with decimal IDs, retaining the existing strict native same-origin resource policy. Use stable `anilist:<ID>:<episode>` for resume and report registration. Queue/enqueue documents keep their existing version-1 shape and use the watch URL. Version-2 reports keep exactly the existing event/position/duration/eligibility fields, substituting `media_identity: {"provider":"anilist","media_id":"1","episode":1}`. Validate against the exact expected provider/ID/episode; never reinterpret an old `miruro` identity as AniList. Explicit external source documents also accept the `anilist` identity namespace with decimal IDs, retaining the existing strict native same-origin resource policy.
Browsing clients may invoke `terebi --check-source URL` through their configured player executable. This isolated command accepts only the canonical AniList sub URL and no other flags. It reads fresh static configuration plus the validated root HLS manifest using the playback resolver/transport, with a 15-second total deadline and cancellation; it skips title enrichment, nested manifests, subtitles and segments. It launches no app/player/audio/relay, reads no config and writes no resume/progress/config state. Browsing clients may invoke `terebi --check-source URL` through their configured player executable. This isolated command accepts only the canonical AniList sub URL and no other flags. It reads fresh static configuration plus the validated root HLS manifest using the playback resolver/transport, with a 15-second total deadline and cancellation; it skips title enrichment, nested manifests, subtitles and segments. It launches no app/player/audio/relay, reads no config and writes no resume/progress/config state.