Compare commits
10 Commits
b95f7d251e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 0641859d95 | |||
| a2c8cc8e4d | |||
| 491252e35f | |||
| 98dccd7593 | |||
| 82d47741eb | |||
| 41aa0abd56 | |||
| d8fb648c90 | |||
| 599e2f548a | |||
| a604810319 | |||
| 7052fe29af |
+48
-15
@@ -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 {
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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", ""),
|
||||||
|
|||||||
@@ -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
|
||||||
|
[binary, str(source), str(progress)], check=True, timeout=100)
|
||||||
|
if not zoko_only:
|
||||||
records = [json.loads(line) for line in progress.read_text().splitlines()]
|
records = [json.loads(line) for line in progress.read_text().splitlines()]
|
||||||
assert records and all(record["version"] == 2 and record["media_identity"] ==
|
assert records and all(record["version"] == 2 and record["media_identity"] ==
|
||||||
{"provider": "miruro", "media_id": "fixture-ID", "episode": 1} for record in records), "stable episode progress identity"
|
{"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 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 "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
@@ -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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,15 +62,19 @@ def main():
|
|||||||
require(result.returncode != 0 and not result.stdout, "invalid syntax/unsupported URL must fail without JSON or launch")
|
require(result.returncode != 0 and not result.stdout, "invalid syntax/unsupported URL must fail without JSON or launch")
|
||||||
require(not (root / secret).exists(), "invalid source check must not create progress file")
|
require(not (root / secret).exists(), "invalid source check must not create progress file")
|
||||||
if live:
|
if live:
|
||||||
for episode, expected in [(1, "available"), (27, "unavailable")]:
|
for media_id, episode, expected in [
|
||||||
result = invoke(["--check-source", f"https://zokoanime.video/stream/ani/1/{episode}/sub"])
|
(1, 1, "available"), (1, 27, "unavailable"),
|
||||||
|
(97940, 1, "available"), (97940, 170, "available"),
|
||||||
|
(195604, 1, "available"),
|
||||||
|
]:
|
||||||
|
result = invoke(["--check-source", f"https://zokoanime.video/stream/ani/{media_id}/{episode}/sub"])
|
||||||
require(result.returncode == 0, "completed provider checks exit zero")
|
require(result.returncode == 0, "completed provider checks exit zero")
|
||||||
require(result.stdout.endswith(b"\n") and result.stdout.count(b"\n") == 1, "exactly one JSON line")
|
require(result.stdout.endswith(b"\n") and result.stdout.count(b"\n") == 1, "exactly one JSON line")
|
||||||
row = json.loads(result.stdout)
|
row = json.loads(result.stdout)
|
||||||
require(row == {"version": 1, "provider": "zokoanime", "track": "sub", "status": expected},
|
require(row == {"version": 1, "provider": "zokoanime", "track": "sub", "status": expected},
|
||||||
f"live episode {episode} expected {expected}; result is redacted")
|
f"live AniList {media_id} episode {episode} expected {expected}; result is redacted")
|
||||||
require(not result.stderr, "successful source checks have no error logs")
|
require(not result.stderr, "successful source checks have no error logs")
|
||||||
print("PASS: actual CLI resolves episode 1/root manifest and explicit missing episode 27 headlessly, without state writes")
|
print("PASS: actual CLI resolves exact MAL mappings for Cowboy Bebop and both Black Clover entries; explicit missing remains unavailable; no state writes")
|
||||||
else:
|
else:
|
||||||
print("PASS: actual CLI rejects hostile URLs/flags privately, without configuration reads, state writes or bundle launch")
|
print("PASS: actual CLI rejects hostile URLs/flags privately, without configuration reads, state writes or bundle launch")
|
||||||
|
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ struct SourceCheckChecks {
|
|||||||
throw Failure()
|
throw Failure()
|
||||||
}
|
}
|
||||||
// Exercise error classification, not fake provider availability. Live proof uses the actual CLI below.
|
// Exercise error classification, not fake provider availability. Live proof uses the actual CLI below.
|
||||||
for category in [ZokoError.unavailable, .transport, .protection, .malformed] {
|
for category in [ZokoError.unavailable, .transport, .protection, .malformed, .mapping] {
|
||||||
let result = await SourceCheck.run(mediaID: "1", episode: 1, resolve: { _, _ in throw category },
|
let result = await SourceCheck.run(mediaID: "1", episode: 1, resolve: { _, _ in throw category },
|
||||||
validate: { _ in throw Failure() })
|
validate: { _ in throw Failure() })
|
||||||
try require(result == (category == .unavailable ? .unavailable : .failed), "only explicit missing episode is unavailable")
|
try require(result == (category == .unavailable ? .unavailable : .failed), "only explicit missing episode is unavailable")
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
+102
-7
@@ -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,98 @@ 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 data: Data
|
||||||
|
let mime: String
|
||||||
|
if request.url?.absoluteString == "https://graphql.anilist.co/" {
|
||||||
|
let body = try JSONSerialization.jsonObject(with: request.httpBody!) as! [String: Any]
|
||||||
|
try require((body["variables"] as? [String: Int])?["id"] == 97940, "mapping uses the original AniList ID")
|
||||||
|
data = Data(#"{"data":{"Media":{"id":97940,"idMal":34572,"title":{"english":"Black Clover"}}}}"#.utf8)
|
||||||
|
mime = "application/json"
|
||||||
|
} 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")
|
||||||
|
data = Data(try page(["src": "https://hls.dramahot.top/master.m3u8", "subtitles": [],
|
||||||
|
"skip": ["intro": ["start": 49.25, "end": 144.75]]]).utf8)
|
||||||
|
mime = "text/html"
|
||||||
|
}
|
||||||
|
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)",
|
||||||
|
"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 [
|
||||||
|
#"{"data":{"Media":{"id":34572,"idMal":97940}}}"#,
|
||||||
|
#"{"data":{"Media":{"id":97940,"idMal":0}}}"#,
|
||||||
|
#"{"data":{"Media":{"id":97940,"idMal":-1}}}"#,
|
||||||
|
#"{"data":{"Media":{"id":97940,"idMal":2147483648}}}"#,
|
||||||
|
#"{"data":{"Media":{"id":97940,"idMal":true}}}"#,
|
||||||
|
#"{"data":{"Media":{"id":97940,"idMal":34572}},"errors":[{"message":"partial failure"}]}"#
|
||||||
|
] {
|
||||||
|
try expect(.malformed) { _ = try ZokoResolver.decodeCatalog(Data(bad.utf8), mediaID: "97940") }
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
_ = try await ZokoResolver.resolve(mediaID: "97940", episode: 1, enrichTitle: false, fetchResponse: { request in
|
||||||
|
try require(request.url?.host == "graphql.anilist.co", "missing mapping must not guess a provider ID")
|
||||||
|
return (Data(#"{"data":{"Media":{"id":97940,"idMal":null}}}"#.utf8),
|
||||||
|
HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: ["Content-Type": "application/json"])!)
|
||||||
|
})
|
||||||
|
throw Failure(message: "missing mapping accepted")
|
||||||
|
} catch ZokoError.mapping {}
|
||||||
try require(source.identity == "anilist:1:1" && source.requirements.zoko, "native scoped transport selected")
|
try require(source.identity == "anilist:1:1" && source.requirements.zoko, "native scoped transport selected")
|
||||||
try require(source.title == "AniList 1 · Episode 1 (sub)" && source.subtitles.count == 1 && source.subtitles[0].isDefault,
|
try require(source.title == "AniList 1 · Episode 1 (sub)" && source.subtitles.count == 1 && source.subtitles[0].isDefault,
|
||||||
"honest fallback title and VTT metadata")
|
"honest fallback title and VTT metadata")
|
||||||
@@ -81,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
|
||||||
@@ -97,10 +191,10 @@ struct ZokoCheck {
|
|||||||
throw Failure(message: "Native player acknowledgement timed out")
|
throw Failure(message: "Native player acknowledgement timed out")
|
||||||
}
|
}
|
||||||
|
|
||||||
static func live() async throws {
|
static func live(mediaID: String = "1", episode: Int = 1) async throws {
|
||||||
let source = try await MiruroTransport.prepare(ZokoResolver.resolve(mediaID: "1", episode: 1))
|
let source = try await MiruroTransport.prepare(ZokoResolver.resolve(mediaID: mediaID, episode: episode))
|
||||||
defer { _fixLifetime(source) }
|
defer { _fixLifetime(source) }
|
||||||
try require(source.identity == "anilist:1:1" && source.headers.isEmpty, "native relay preserves identity without upstream headers")
|
try require(source.identity == "anilist:\(mediaID):\(episode)" && source.headers.isEmpty, "native relay preserves identity without upstream headers")
|
||||||
guard let track = source.subtitles.first(where: \.isDefault) else { throw Failure(message: "Live source has no default subtitle") }
|
guard let track = source.subtitles.first(where: \.isDefault) else { throw Failure(message: "Live source has no default subtitle") }
|
||||||
let document = try await WebVTTDocument.load(url: track.url, headers: track.headers)
|
let document = try await WebVTTDocument.load(url: track.url, headers: track.headers)
|
||||||
guard let cue = document.cues.first(where: { !$0.text.isEmpty && $0.end > $0.start }) else { throw Failure(message: "Live VTT contains no active cues") }
|
guard let cue = document.cues.first(where: { !$0.text.isEmpty && $0.end > $0.start }) else { throw Failure(message: "Live VTT contains no active cues") }
|
||||||
@@ -152,16 +246,17 @@ struct ZokoCheck {
|
|||||||
try require(!rows.isEmpty, "Native progress records emitted")
|
try require(!rows.isEmpty, "Native progress records emitted")
|
||||||
for row in rows {
|
for row in rows {
|
||||||
let identity = row["media_identity"] as? [String: Any]
|
let identity = row["media_identity"] as? [String: Any]
|
||||||
try require(row["version"] as? Int == 2 && row["media_url"] == nil && identity?["provider"] as? String == "anilist" && identity?["media_id"] as? String == "1" && identity?["episode"] as? Int == 1,
|
try require(row["version"] as? Int == 2 && row["media_url"] == nil && identity?["provider"] as? String == "anilist" && identity?["media_id"] as? String == mediaID && identity?["episode"] as? Int == episode,
|
||||||
"Native progress uses AniList v2 identity with no media URL")
|
"Native progress uses AniList v2 identity with no media URL")
|
||||||
}
|
}
|
||||||
try require(rows.contains { ($0["position_seconds"] as? Double ?? 0) >= 600 }, "Acknowledged seek/resume position reported")
|
try require(rows.contains { ($0["position_seconds"] as? Double ?? 0) >= 600 }, "Acknowledged seek/resume position reported")
|
||||||
print("PASS: fresh AniList 1 episode 1 resolves through scoped relay; actual native video decode/frame step, tracks, 600s seek/decode, VTT text, restored position and v2 progress (no GUI/audio playback)")
|
print("PASS: fresh AniList \(mediaID) episode \(episode) mapped via MAL; actual native decode/frame step, 600s seek/decode, VTT text, restored position and AniList v2 progress (no GUI/audio playback)")
|
||||||
}
|
}
|
||||||
static func main() async {
|
static func main() async {
|
||||||
do {
|
do {
|
||||||
try await checks()
|
try await checks()
|
||||||
if CommandLine.arguments.contains("--live") { try await live() }
|
if CommandLine.arguments.contains("--live") { try await live() }
|
||||||
|
if CommandLine.arguments.contains("--live-black-clover") { try await live(mediaID: "97940") }
|
||||||
} catch {
|
} catch {
|
||||||
FileHandle.standardError.write(Data("FAIL: Zoko check: \(error.localizedDescription)\n".utf8))
|
FileHandle.standardError.write(Data("FAIL: Zoko check: \(error.localizedDescription)\n".utf8))
|
||||||
exit(1)
|
exit(1)
|
||||||
|
|||||||
@@ -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 $@
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
@@ -77,9 +80,11 @@ For native episode-URL playback, ordered stream queues, external resolved source
|
|||||||
terebi --url 'https://zokoanime.video/stream/ani/1/1/sub'
|
terebi --url 'https://zokoanime.video/stream/ani/1/1/sub'
|
||||||
```
|
```
|
||||||
|
|
||||||
The exact `ani` route is supported, not the site's broken documented `anilist` route. IDs and episode numbers must be canonical positive decimal integers within `Int32.max`. Only `sub` is admitted; there is no silent dub fallback. Catalog episode numbers are not a provider inventory: Terebi resolves the selected episode on demand and distinguishes an unavailable episode from network, access/protection, and malformed configuration failures.
|
The canonical handoff still uses `/stream/ani/<AniList ID>/<episode>/sub`, not the site's broken documented `anilist` route. IDs and episode numbers must be canonical positive decimal integers within `Int32.max`. Before fetching a provider source, Terebi queries AniList's exact `idMal` and requests ZokoAnime's `/stream/mal/<MAL ID>/<episode>/sub` route. It never substitutes an AniList number into the MAL namespace or guesses a match by title. A missing mapping is a lookup failure, not evidence of a missing episode. Only `sub` is admitted; there is no silent dub fallback.
|
||||||
|
|
||||||
Resolution uses bounded ordinary HTTPS with certificate verification, no cookies/credentials, no redirect following, and no browser or advertising/challenge script execution. Only the static `window.__P` Base64/XOR configuration is decoded with the first-party `otaku-embed-v1` codec. Optional bounded AniList metadata supplies the title; failure uses `AniList <ID> · Episode <number> (sub)` rather than guessing a show title. 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.
|
||||||
|
|
||||||
@@ -92,7 +97,7 @@ terebi --check-source 'https://zokoanime.video/stream/ani/1/1/sub'
|
|||||||
# {"version":1,"provider":"zokoanime","track":"sub","status":"available"}
|
# {"version":1,"provider":"zokoanime","track":"sub","status":"available"}
|
||||||
```
|
```
|
||||||
|
|
||||||
It fetches a fresh embed configuration and the bounded root HLS manifest, using the same resolver, origin policy, HTTP transport and manifest validation as playback. It skips AniList title enrichment and does not start the app, native decoder, loopback relay or audio; read configuration; download subtitles, nested playlists or segments; or write progress/resume/configuration. The entire check has a 15-second deadline and supports cancellation. No retries, source URLs, tokens or raw resource errors are emitted or persisted.
|
It fetches the exact AniList-to-MAL mapping, a fresh MAL-route embed configuration and the bounded root HLS manifest, using the same resolver, origin policy, HTTP transport and manifest validation as playback. It omits title fields and does not start the app, native decoder, loopback relay or audio; read configuration; download subtitles, nested playlists or segments; or write progress/resume/configuration. The entire check has a 15-second deadline and supports cancellation. No retries, source URLs, tokens or raw resource errors are emitted or persisted.
|
||||||
|
|
||||||
Exactly one bounded version-1 JSON line is written to stdout. `available` means fresh source configuration plus an accessible valid root manifest, **not** guaranteed complete playback. `unavailable` means only the provider's explicit missing-episode page. Network, access/protection, malformed configuration, manifest, cancellation and deadline failures return `failed`, never `unavailable`. All three completed statuses exit zero; bad syntax, unsupported URLs and combinations with other flags exit nonzero without JSON. Only the canonical AniList `sub` watch URL is supported, not arbitrary sources or legacy Miruro URLs.
|
Exactly one bounded version-1 JSON line is written to stdout. `available` means fresh source configuration plus an accessible valid root manifest, **not** guaranteed complete playback. `unavailable` means only the provider's explicit missing-episode page. Network, access/protection, malformed configuration, manifest, cancellation and deadline failures return `failed`, never `unavailable`. All three completed statuses exit zero; bad syntax, unsupported URLs and combinations with other flags exit nonzero without JSON. Only the canonical AniList `sub` watch URL is supported, not arbitrary sources or legacy Miruro URLs.
|
||||||
|
|
||||||
@@ -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,10 +177,11 @@ 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: AniList 1 episode 1 must return `available`, and episode 27 must return `unavailable`, with no state writes. These source-only probes fetch no segments and are distinct from `check-zoko-live`'s native playback 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.
|
||||||
|
|
||||||
|
|
||||||
## Acknowledgements
|
## Acknowledgements
|
||||||
|
|||||||
Binary file not shown.
@@ -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 |
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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() }
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
+193
-45
@@ -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,6 +4212,7 @@ private struct PlayerContent: View {
|
|||||||
.background {
|
.background {
|
||||||
ZStack(alignment: .leading) {
|
ZStack(alignment: .leading) {
|
||||||
PlayerGlass(cornerRadius: 8)
|
PlayerGlass(cornerRadius: 8)
|
||||||
|
if model.segmentToSkip != nil {
|
||||||
TimelineView(.animation(minimumInterval: reduceMotion ? 1 : 1.0 / 30)) { _ in
|
TimelineView(.animation(minimumInterval: reduceMotion ? 1 : 1.0 / 30)) { _ in
|
||||||
GeometryReader { geometry in
|
GeometryReader { geometry in
|
||||||
let elapsed = 1 - model.skipOfferRemainingFraction
|
let elapsed = 1 - model.skipOfferRemainingFraction
|
||||||
@@ -4110,6 +4222,7 @@ private struct PlayerContent: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
.allowsHitTesting(false)
|
.allowsHitTesting(false)
|
||||||
.accessibilityHidden(true)
|
.accessibilityHidden(true)
|
||||||
@@ -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
|
||||||
|
if !window.styleMask.contains(.nonactivatingPanel) {
|
||||||
window.collectionBehavior.insert(.fullScreenPrimary)
|
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) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|
||||||
|
|||||||
+102
-29
@@ -1,13 +1,14 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
enum ZokoError: String, LocalizedError {
|
enum ZokoError: String, LocalizedError {
|
||||||
case unavailable, transport, protection, malformed
|
case unavailable, transport, protection, malformed, mapping
|
||||||
var errorDescription: String? {
|
var errorDescription: String? {
|
||||||
switch self {
|
switch self {
|
||||||
case .unavailable: "ZokoAnime has no sub episode available for this catalog number. Catalog numbering does not confirm availability."
|
case .unavailable: "ZokoAnime has no sub episode available for this catalog number. Catalog numbering does not confirm availability."
|
||||||
case .transport: "ZokoAnime HTTPS request failed or timed out. No retry was attempted."
|
case .transport: "ZokoAnime HTTPS request failed or timed out. No retry was attempted."
|
||||||
case .protection: "ZokoAnime returned an access/protection page or refused the request."
|
case .protection: "ZokoAnime returned an access/protection page or refused the request."
|
||||||
case .malformed: "ZokoAnime returned unsupported or malformed source data. Private source details are redacted."
|
case .malformed: "ZokoAnime returned unsupported or malformed source data. Private source details are redacted."
|
||||||
|
case .mapping: "AniList has no usable MAL mapping for this title. Source availability could not be checked."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -62,20 +63,27 @@ enum ZokoResolver {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static func resolve(mediaID: String, episode: Int, enrichTitle: Bool = true) async throws -> ResolvedSource {
|
static func resolve(
|
||||||
|
mediaID: String, episode: Int, enrichTitle: Bool = true,
|
||||||
|
fetchResponse: @Sendable (URLRequest) async throws -> (Data, HTTPURLResponse) = {
|
||||||
|
try await fetch($0, allowMissingPage: $0.url?.host == "zokoanime.video")
|
||||||
|
}
|
||||||
|
) async throws -> ResolvedSource {
|
||||||
guard ResolvedSource.validAniListIdentity(mediaID: mediaID, episode: episode) else { throw ZokoError.malformed }
|
guard ResolvedSource.validAniListIdentity(mediaID: mediaID, episode: episode) else { throw ZokoError.malformed }
|
||||||
var request = URLRequest(url: URL(string: "https://zokoanime.video/stream/ani/\(mediaID)/\(episode)/sub")!)
|
let metadata = try await catalog(mediaID: mediaID, includeTitle: enrichTitle, fetchResponse: fetchResponse)
|
||||||
|
guard let malID = metadata.idMal else { throw ZokoError.mapping }
|
||||||
|
var request = URLRequest(url: URL(string: "https://zokoanime.video/stream/mal/\(malID)/\(episode)/sub")!)
|
||||||
request.timeoutInterval = 20
|
request.timeoutInterval = 20
|
||||||
request.setValue("text/html", forHTTPHeaderField: "Accept")
|
request.setValue("text/html", forHTTPHeaderField: "Accept")
|
||||||
let (data, response) = try await fetch(request, allowMissingPage: true)
|
let (data, response) = try await fetchResponse(request)
|
||||||
guard response.mimeType == "text/html", let page = String(data: data, encoding: .utf8) else { throw ZokoError.malformed }
|
guard response.mimeType == "text/html", let page = String(data: data, encoding: .utf8) else { throw ZokoError.malformed }
|
||||||
let source = try parse(page: page, mediaID: mediaID, episode: episode)
|
let source = try parse(page: page, mediaID: mediaID, episode: episode)
|
||||||
guard response.statusCode == 200 else { throw ZokoError.transport }
|
guard response.statusCode == 200 else { throw ZokoError.transport }
|
||||||
guard enrichTitle else { return source }
|
guard enrichTitle, let name = metadata.name else { return source }
|
||||||
let title = (try? await Self.title(mediaID: mediaID, episode: episode)) ?? source.title
|
|
||||||
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: title, 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 {
|
||||||
@@ -99,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) }
|
||||||
@@ -112,34 +149,70 @@ 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 Title: Decodable { let english: String?; let romaji: String?; let native: String? }
|
||||||
|
let id: Int
|
||||||
|
let idMal: Int?
|
||||||
|
let title: Title?
|
||||||
|
var name: String? {
|
||||||
|
guard let title else { return nil }
|
||||||
|
func usable(_ name: String?) -> String? {
|
||||||
|
guard let name, !name.isEmpty, ResolvedSource.validText(name, limit: 2048) else { return nil }
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
return usable(title.english) ?? usable(title.romaji) ?? usable(title.native)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func decodeCatalog(_ data: Data, mediaID: String) throws -> Catalog {
|
||||||
|
struct Response: Decodable {
|
||||||
|
struct Body: Decodable {
|
||||||
|
let media: Catalog?
|
||||||
|
enum CodingKeys: String, CodingKey { case media = "Media" }
|
||||||
|
}
|
||||||
|
struct Problem: Decodable {}
|
||||||
|
let data: Body?
|
||||||
|
let errors: [Problem]?
|
||||||
|
}
|
||||||
|
let response: Response
|
||||||
|
do { response = try JSONDecoder().decode(Response.self, from: data) }
|
||||||
|
catch { throw ZokoError.malformed }
|
||||||
|
guard response.errors?.isEmpty != false, let metadata = response.data?.media,
|
||||||
|
String(metadata.id) == mediaID,
|
||||||
|
metadata.idMal.map({ $0 > 0 && $0 <= Int(Int32.max) }) ?? true else { throw ZokoError.malformed }
|
||||||
|
return metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func catalog(
|
||||||
|
mediaID: String, includeTitle: Bool,
|
||||||
|
fetchResponse: @Sendable (URLRequest) async throws -> (Data, HTTPURLResponse)
|
||||||
|
) async throws -> Catalog {
|
||||||
|
var request = URLRequest(url: URL(string: "https://graphql.anilist.co/")!)
|
||||||
|
request.httpMethod = "POST"
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
let fields = includeTitle ? "id idMal title{english romaji native}" : "id idMal"
|
||||||
|
request.httpBody = try JSONSerialization.data(withJSONObject: ["query": "query($id:Int!){Media(id:$id,type:ANIME){\(fields)}}", "variables": ["id": Int(mediaID)!]])
|
||||||
|
let (data, response) = try await fetchResponse(request)
|
||||||
|
guard response.statusCode == 200, response.mimeType == "application/json" else { throw ZokoError.malformed }
|
||||||
|
return try decodeCatalog(data, mediaID: mediaID)
|
||||||
}
|
}
|
||||||
|
|
||||||
static func title(mediaID: String, episode: Int) async throws -> String {
|
static func title(mediaID: String, episode: Int) async throws -> String {
|
||||||
guard ResolvedSource.validAniListIdentity(mediaID: mediaID, episode: episode) else { throw ZokoError.malformed }
|
guard ResolvedSource.validAniListIdentity(mediaID: mediaID, episode: episode) else { throw ZokoError.malformed }
|
||||||
var request = URLRequest(url: URL(string: "https://graphql.anilist.co")!)
|
let metadata = try await catalog(mediaID: mediaID, includeTitle: true, fetchResponse: { try await fetch($0) })
|
||||||
request.httpMethod = "POST"
|
guard let name = metadata.name else { throw ZokoError.malformed }
|
||||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
||||||
request.httpBody = try JSONSerialization.data(withJSONObject: ["query": "query($id:Int!){Media(id:$id,type:ANIME){id title{english romaji native}}}", "variables": ["id": Int(mediaID)!]])
|
|
||||||
let (data, response) = try await fetch(request)
|
|
||||||
guard response.mimeType == "application/json" else { throw ZokoError.malformed }
|
|
||||||
struct Metadata: Decodable {
|
|
||||||
struct Body: Decodable {
|
|
||||||
struct Media: Decodable {
|
|
||||||
struct Title: Decodable { let english: String?; let romaji: String?; let native: String? }
|
|
||||||
let id: Int; let title: Title
|
|
||||||
}
|
|
||||||
let media: Media
|
|
||||||
enum CodingKeys: String, CodingKey { case media = "Media" }
|
|
||||||
}
|
|
||||||
let data: Body
|
|
||||||
}
|
|
||||||
let metadata = try JSONDecoder().decode(Metadata.self, from: data).data.media
|
|
||||||
guard String(metadata.id) == mediaID,
|
|
||||||
let name = [metadata.title.english, metadata.title.romaji, metadata.title.native].compactMap({ $0 }).first(where: { !$0.isEmpty }),
|
|
||||||
ResolvedSource.validText(name, limit: 2048) else { throw ZokoError.malformed }
|
|
||||||
return "\(name) · Episode \(episode) (sub)"
|
return "\(name) · Episode \(episode) (sub)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
|
||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user