Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 885dea74f2 | |||
| 9f1dca3081 |
@@ -191,7 +191,7 @@ private enum WindowDragCheck {
|
||||
model.showsQueue = true
|
||||
case 13:
|
||||
model.showsQueue = false
|
||||
model.openYouTube("not a video URL")
|
||||
model.openStream("not a video URL")
|
||||
case 15: model.open(fixtures.appendingPathComponent("audio.wav"))
|
||||
case 16: model.open(fixtures.appendingPathComponent("cache.mp4"))
|
||||
case 19: model.hideControls(hideCursor: false)
|
||||
@@ -787,7 +787,7 @@ private enum CLIInstanceCheck {
|
||||
if let action = command["action"] as? String {
|
||||
switch action {
|
||||
case "youtube":
|
||||
model.openYouTube(command["url"] as! String)
|
||||
model.openStream(command["url"] as! String)
|
||||
case "queue":
|
||||
model.showsQueue = command["visible"] as! Bool
|
||||
model.revealControls()
|
||||
|
||||
@@ -59,9 +59,9 @@ struct AutomaticQualityPlaybackChecks {
|
||||
let normalFrame = window.frame
|
||||
let compactKey = try! KeyChord("p")
|
||||
mark("gate-open")
|
||||
model.openYouTube("https://www.youtube.com/watch?v=aqz-KE-bpKQ")
|
||||
model.openStream("https://www.youtube.com/watch?v=aqz-KE-bpKQ")
|
||||
until(model, "initial YouTube resolution reaches the controlled extraction gate") {
|
||||
model.resolvingYouTube && marked("entered-open")
|
||||
model.resolvingStream && marked("entered-open")
|
||||
}
|
||||
model.handleKey(compactKey)
|
||||
require(model.isCompact, "compact shortcut enters while the YouTube URL is unresolved")
|
||||
@@ -74,7 +74,7 @@ struct AutomaticQualityPlaybackChecks {
|
||||
until(model, "compact shortcut restores while YouTube resolution is still pending") {
|
||||
!model.isCompact && !model.compactWindow.isTransitioning
|
||||
}
|
||||
require(window.frame == normalFrame && model.resolvingYouTube,
|
||||
require(window.frame == normalFrame && model.resolvingStream,
|
||||
"resolution continues while restoring the original normal window")
|
||||
require(model.perform(.enterCompact), "compact control action is available during resolution")
|
||||
mark("release-open")
|
||||
@@ -98,7 +98,7 @@ struct AutomaticQualityPlaybackChecks {
|
||||
}
|
||||
clock.now = 2000
|
||||
polled(model)
|
||||
require(model.youtubeHeight == 1080 && !model.resolvingYouTube,
|
||||
require(model.youtubeHeight == 1080 && !model.resolvingStream,
|
||||
"disabled automatic quality leaves a stalled manual/default stream alone")
|
||||
model.setAutomaticYouTubeQuality(true)
|
||||
polled(model)
|
||||
@@ -146,12 +146,12 @@ struct AutomaticQualityPlaybackChecks {
|
||||
}
|
||||
clock.now = 3060
|
||||
until(model, "automatic upshift reaches the selected extraction gate") {
|
||||
marked("entered-1080") && model.resolvingYouTube
|
||||
marked("entered-1080") && model.resolvingStream
|
||||
}
|
||||
require(model.setPlayback(playing: false), "pause is accepted during automatic extraction")
|
||||
mark("release-1080")
|
||||
until(model, "cancelled extraction finishes and native pause is acknowledged") {
|
||||
marked("finished-1080") && !model.resolvingYouTube && model.canTogglePlayback &&
|
||||
marked("finished-1080") && !model.resolvingStream && model.canTogglePlayback &&
|
||||
model.state.status == PLAYER_PAUSED && !model.state.seek_pending
|
||||
}
|
||||
let cancelledPosition = model.state.position
|
||||
@@ -179,7 +179,7 @@ struct AutomaticQualityPlaybackChecks {
|
||||
let originalPosition = model.state.position
|
||||
model.setYouTubeQuality(1080)
|
||||
until(model, "failed manual quality switch restores the original native playback state") {
|
||||
model.errorText != nil && !model.resolvingYouTube && model.canTogglePlayback &&
|
||||
model.errorText != nil && !model.resolvingStream && model.canTogglePlayback &&
|
||||
model.state.status == (playing ? PLAYER_PLAYING : PLAYER_PAUSED) &&
|
||||
(!playing || model.state.position > originalPosition + 0.1)
|
||||
}
|
||||
|
||||
@@ -38,6 +38,12 @@ struct ConfigurationChecks {
|
||||
|
||||
static func checkBindings() throws {
|
||||
let bindings = KeyBindings()
|
||||
try require(PlayerAction(name: "open_stream") == .openStream && PlayerAction.openStream.name == "open_stream",
|
||||
"stream action uses the general config name")
|
||||
try require(PlayerAction(name: "open_youtube") == nil, "obsolete YouTube action has no alias")
|
||||
try require(bindings.shortcuts(for: .openStream) == [try KeyChord("cmd+shift+o")],
|
||||
"stream action preserves its shortcut")
|
||||
try requireError(["open_youtube"]) { _ = try KeyBindings(overrides: ["open_youtube": []]) }
|
||||
let inversePairs: [(PlayerAction, PlayerAction)] = [
|
||||
(.play, .pause), (.mute, .unmute), (.enterFullscreen, .exitFullscreen),
|
||||
(.enterCompact, .exitCompact), (.enableAlwaysOnTop, .disableAlwaysOnTop),
|
||||
|
||||
@@ -33,6 +33,9 @@ struct PlayerCommandChecks {
|
||||
require(try opened([colonFile.path]) == colonFile, "colons in filesystem paths are not URL schemes")
|
||||
let canonical = URL(string: "https://www.youtube.com/watch?v=jNQXAC9IVRw")!
|
||||
require(try opened(["https://youtu.be/jNQXAC9IVRw"]) == canonical, "YouTube positional URLs canonicalize for resume identity")
|
||||
let miruro = URL(string: "https://barelystarted.miruro.tv/watch/OERe_a0YS4h-DlsBDOeXwX7HOO6316tw/the-apothecary-diaries-season-3?ep=1")!
|
||||
require(try opened([miruro.absoluteString]) == miruro, "Miruro positional URLs retain episode page identity")
|
||||
require(try opened(["--url", miruro.absoluteString]) == miruro, "explicit Miruro URLs forward without network resolution")
|
||||
require(try opened(["--new-instance"], newInstance: true) == nil, "an independent launch can omit media")
|
||||
require(try opened(["--new-instance", movie.lastPathComponent], newInstance: true) == movie,
|
||||
"independent local launches preserve relative paths and spaces")
|
||||
|
||||
@@ -6,13 +6,13 @@ struct ResolvedPlaybackCheck {
|
||||
static func require(_ condition: @autoclosure () -> Bool, _ message: String) {
|
||||
if !condition() { fatalError("FAIL: " + message) }
|
||||
}
|
||||
static func until(_ message: String, model: PlayerModel, _ predicate: () -> Bool) {
|
||||
static func until(_ message: String, model: PlayerModel, _ predicate: () -> Bool) async throws {
|
||||
let deadline = ContinuousClock.now.advanced(by: .seconds(45))
|
||||
while !predicate(), ContinuousClock.now < deadline {
|
||||
if model.state.status == PLAYER_ERROR || model.commandError != nil {
|
||||
fatalError("FAIL: " + (model.errorText ?? "Playback failed"))
|
||||
}
|
||||
RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.01))
|
||||
try await Task.sleep(for: .milliseconds(10))
|
||||
}
|
||||
require(predicate(), message)
|
||||
}
|
||||
@@ -24,21 +24,31 @@ struct ResolvedPlaybackCheck {
|
||||
static func run() async throws {
|
||||
_ = NSApplication.shared
|
||||
NSApp.setActivationPolicy(.prohibited)
|
||||
let file = URL(fileURLWithPath: CommandLine.arguments[1])
|
||||
let source = try ResolvedSource.load(from: file)
|
||||
let input = CommandLine.arguments[1]
|
||||
let file = URL(fileURLWithPath: input)
|
||||
let source: ResolvedSource
|
||||
if input.hasPrefix("https://") {
|
||||
guard case .miruro(let mediaID, let episode) = try StreamURL.parse(input) else {
|
||||
fatalError("FAIL: URL smoke requires a Miruro episode")
|
||||
}
|
||||
source = try await MiruroResolver.resolve(mediaID: mediaID, episode: episode)
|
||||
} else {
|
||||
source = try ResolvedSource.load(from: file)
|
||||
}
|
||||
let suite = "terebi.resolved-check." + UUID().uuidString
|
||||
let defaults = UserDefaults(suiteName: suite)!
|
||||
defer { defaults.removePersistentDomain(forName: suite) }
|
||||
let model = PlayerModel(defaults: defaults, progressFile: CommandLine.arguments.count > 2 ? CommandLine.arguments[2] : nil)
|
||||
defer { model.shutdown() }
|
||||
model.openResolvedSource(file)
|
||||
until("video/audio decode and advancing playback", model: model) {
|
||||
if input.hasPrefix("https://") { model.openStream(input) }
|
||||
else { model.openResolvedSource(file) }
|
||||
try await until("video/audio decode and advancing playback", model: model) {
|
||||
model.state.status == PLAYER_PLAYING && model.state.has_video && model.state.has_audio && model.state.position > 0.2
|
||||
}
|
||||
print("PASS: resolved HLS decodes video/audio and advances")
|
||||
fflush(stdout)
|
||||
require(model.setPlayback(playing: false), "pause accepted")
|
||||
until("pause acknowledged", model: model) { model.state.status == PLAYER_PAUSED }
|
||||
try await until("pause acknowledged", model: model) { model.state.status == PLAYER_PAUSED }
|
||||
require(model.state.can_seek && model.state.duration > 0, "seekable measured duration")
|
||||
guard let index = source.subtitles.firstIndex(where: \.isDefault) else {
|
||||
fatalError("FAIL: smoke requires a default subtitle")
|
||||
@@ -50,14 +60,14 @@ struct ResolvedPlaybackCheck {
|
||||
let target = playableCues[1].start + min(0.1, (playableCues[1].end - playableCues[1].start) / 2)
|
||||
require(!document.text(at: target).isEmpty, "forward seek targets a visible English cue")
|
||||
require(model.seekRelative(target - model.state.position), "seek accepted")
|
||||
until("seek acknowledged and subtitle synchronized", model: model) {
|
||||
try await until("seek acknowledged and subtitle synchronized", model: model) {
|
||||
!model.state.seek_pending && abs(model.state.position - target) < 0.2 &&
|
||||
model.subtitleText == document.text(at: model.state.position) && model.subtitleError == nil
|
||||
}
|
||||
require(model.selectedSubtitle == index && model.subtitleTracks[index].label == track.label, "default selection and label retained")
|
||||
let backward = playableCues[0].start + min(0.1, (playableCues[0].end - playableCues[0].start) / 2)
|
||||
require(model.seekRelative(backward - model.state.position), "backward seek accepted")
|
||||
until("backward seek subtitle synchronized", model: model) {
|
||||
try await until("backward seek subtitle synchronized", model: model) {
|
||||
!model.state.seek_pending && abs(model.state.position - backward) < 0.2 &&
|
||||
model.subtitleText == document.text(at: model.state.position)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import Foundation
|
||||
|
||||
@main
|
||||
struct StreamResolverChecks {
|
||||
struct Failure: Error { let message: String }
|
||||
static let mediaID = "OERe_a0YS4h-DlsBDOeXwX7HOO6316tw"
|
||||
static let page = "https://barelystarted.miruro.tv/watch/\(mediaID)/the-apothecary-diaries-season-3?ep=1"
|
||||
// Sanitized shape from Tubular's observed source response and projection fixture.
|
||||
static let fixture = #"""
|
||||
{
|
||||
"episodeNumber": 1,
|
||||
"config": {"sun": {"player": "native", "proxy": false,
|
||||
"hls": {"segments": {"enabled": false}, "query": {"enabled": false}}}},
|
||||
"providerOrder": ["sun", "bun"],
|
||||
"tracks": [{"track": "ssub", "providers": [{
|
||||
"provider": "sun",
|
||||
"servers": [
|
||||
{"server": "iframe", "headers": null, "streams": [], "embed": {"url": "https://embed.example/"}},
|
||||
{"server": "icarus-2-1", "headers": {"Origin": "https://ultracloud.cc", "Referer": "https://ultracloud.cc/"},
|
||||
"streams": [{"url": "https://vault.example/master.m3u8?token=secret", "format": "hls", "embed": null}]}
|
||||
],
|
||||
"subtitles": [
|
||||
{"file": "https://vault.example/english.jpeg", "format": "vtt", "language": "en", "label": "English", "default": true},
|
||||
{"file": "https://vault.example/spanish.jpeg", "format": "vtt", "language": "sp", "label": "Spanish (Latin America)", "default": false},
|
||||
{"file": "https://vault.example/unsupported.srt", "format": "srt", "language": "en", "label": "English SRT", "default": false}
|
||||
]
|
||||
}]}]
|
||||
}
|
||||
"""#
|
||||
|
||||
static func require(_ condition: @autoclosure () -> Bool, _ message: String) throws {
|
||||
if !condition() { throw Failure(message: message) }
|
||||
}
|
||||
static func reject(_ message: String, _ operation: () throws -> Void) throws {
|
||||
do { try operation() }
|
||||
catch {
|
||||
try require(!error.localizedDescription.contains("secret"), "errors must not expose source secrets")
|
||||
return
|
||||
}
|
||||
throw Failure(message: "Accepted \(message)")
|
||||
}
|
||||
static func project(_ json: String) throws -> ResolvedSource {
|
||||
try MiruroResolver.decode(Data(json.utf8), mediaID: mediaID, episode: 1)
|
||||
}
|
||||
|
||||
static func checks() async throws {
|
||||
guard case .miruro(let id, let episode) = try StreamURL.parse(" \n" + page + "\n") else {
|
||||
throw Failure(message: "Miruro URL classification")
|
||||
}
|
||||
try require(id == mediaID && episode == 1, "opaque identity and route episode retained")
|
||||
guard case .youtube(let youtube) = try StreamURL.parse("https://youtu.be/jNQXAC9IVRw") else {
|
||||
throw Failure(message: "YouTube URL classification")
|
||||
}
|
||||
try require(youtube.absoluteString == "https://www.youtube.com/watch?v=jNQXAC9IVRw", "existing YouTube normalization")
|
||||
for invalid in [
|
||||
"https://example.com/video", "https://www.miruro.to/watch?id=21&ep=1",
|
||||
page.replacingOccurrences(of: "barelystarted.miruro.tv", with: "barelystarted.miruro.tv.evil.example"),
|
||||
page.replacingOccurrences(of: "barelystarted.miruro.tv", with: "barelystarted.miruro.tv@evil.example"),
|
||||
page.replacingOccurrences(of: "https://", with: "http://"),
|
||||
page.replacingOccurrences(of: "https://", with: "https://user:secret@"),
|
||||
page.replacingOccurrences(of: "/watch/", with: "/info/"),
|
||||
page.replacingOccurrences(of: mediaID, with: "invalid%2Fid"),
|
||||
page.replacingOccurrences(of: mediaID, with: "%2e%2e"),
|
||||
page.replacingOccurrences(of: "?ep=1", with: ""),
|
||||
page.replacingOccurrences(of: "?ep=1", with: "?ep=0"),
|
||||
page.replacingOccurrences(of: "?ep=1", with: "?ep=01"),
|
||||
page.replacingOccurrences(of: "?ep=1", with: "?ep=-1"),
|
||||
page.replacingOccurrences(of: "?ep=1", with: "?ep=2147483648"),
|
||||
page.replacingOccurrences(of: "?ep=1", with: "?ep=%31"),
|
||||
page + "&ep=2", page + "&provider=bee", page + "#secret",
|
||||
page.replacingOccurrences(of: ".tv/", with: ".tv:444/"),
|
||||
page.replacingOccurrences(of: "?ep", with: "/?ep"),
|
||||
"https://youtu.be/invalid", "file:///secret"
|
||||
] {
|
||||
try reject("unsupported or unsafe URL") { _ = try StreamURL.parse(invalid) }
|
||||
}
|
||||
let source = try project(fixture)
|
||||
try require(source.identity == "miruro:\(mediaID):1", "stable catalog identity")
|
||||
try require(source.url == "https://vault.example/master.m3u8?token=secret", "first direct HLS stream retained")
|
||||
try require(source.headers == "Origin: https://ultracloud.cc\r\nReferer: https://ultracloud.cc/\r\n", "all server headers retained")
|
||||
try require(source.subtitles.count == 2 && source.subtitles[0].isDefault, "only external VTT retained with default")
|
||||
try require(source.subtitles[0].url.hasSuffix(".jpeg") && source.subtitles[1].label == "Spanish (Latin America)", "subtitle format and labels independent of file extension")
|
||||
try require(source.subtitles.allSatisfy { $0.headers == source.headers }, "verified Sun subtitle headers retained")
|
||||
let renewed = try project(fixture.replacingOccurrences(of: "token=secret", with: "token=renewed"))
|
||||
try require(renewed.identity == source.identity && renewed.url != source.url, "token rotation does not change stable identity")
|
||||
for (old, replacement) in [
|
||||
("\"episodeNumber\": 1", "\"episodeNumber\": 49"),
|
||||
("\"proxy\": false", "\"proxy\": true"),
|
||||
("\"enabled\": false", "\"enabled\": true"),
|
||||
("\"player\": \"native\"", "\"player\": \"iframe\""),
|
||||
("\"track\": \"ssub\"", "\"track\": \"dub\""),
|
||||
("\"provider\": \"sun\"", "\"provider\": \"bee\""),
|
||||
("\"format\": \"hls\"", "\"format\": \"mp4\""),
|
||||
("\"embed\": null", "\"embed\": {\"url\": \"https://embed.example/secret\"}"),
|
||||
("https://vault.example/master.m3u8?token=secret", "file:///secret"),
|
||||
("https://vault.example/english.jpeg", "https://user:secret@vault.example/english.jpeg"),
|
||||
("https://ultracloud.cc/\"", "https://ultracloud.cc/\\r\\nX-Injected: secret\""),
|
||||
("\"Origin\":", "\"origin\": \"secret\", \"Origin\":"),
|
||||
("\"default\": false", "\"default\": true")
|
||||
] {
|
||||
try reject("unsafe/unsupported source projection") {
|
||||
_ = try project(fixture.replacingOccurrences(of: old, with: replacement))
|
||||
}
|
||||
}
|
||||
try reject("oversized metadata") {
|
||||
_ = try MiruroResolver.decode(Data(repeating: 32, count: MiruroResolver.bodyLimit + 1), mediaID: mediaID, episode: 1)
|
||||
}
|
||||
let responseURL = URL(string: "https://barelystarted.miruro.tv/api/sources")!
|
||||
for (status, type, length) in [(403, "text/html", "12"), (302, "application/json", "12"), (200, "text/html", "12"), (200, "application/json", "2097153")] {
|
||||
let response = HTTPURLResponse(url: responseURL, statusCode: status, httpVersion: nil,
|
||||
headerFields: ["Content-Type": type, "Content-Length": length])!
|
||||
try reject("protection/redirect/oversized response") { try MiruroResolver.validateResponse(response) }
|
||||
}
|
||||
let cancelled = Task {
|
||||
withUnsafeCurrentTask { $0?.cancel() }
|
||||
return try await MiruroResolver.resolve(mediaID: mediaID, episode: 1)
|
||||
}
|
||||
do {
|
||||
_ = try await cancelled.value
|
||||
throw Failure(message: "Cancelled resolution was accepted")
|
||||
} catch is CancellationError {}
|
||||
print("PASS: streaming URL security, Sun projection, shared validation, bounded responses and cancellation")
|
||||
}
|
||||
|
||||
static func main() async {
|
||||
do {
|
||||
let args = Array(CommandLine.arguments.dropFirst())
|
||||
if args.count == 2, args[0] == "--live" {
|
||||
guard case .miruro(let id, let episode) = try StreamURL.parse(args[1]) else {
|
||||
throw Failure(message: "--live requires a Miruro episode URL")
|
||||
}
|
||||
let source = try await MiruroResolver.resolve(mediaID: id, episode: episode)
|
||||
print("PASS: live \(source.identity); \(source.subtitles.count) VTT tracks")
|
||||
} else if args.count == 2, args[0] == "--fixture" {
|
||||
let data = try Data(contentsOf: URL(fileURLWithPath: args[1]))
|
||||
let source = try MiruroResolver.decode(data, mediaID: mediaID, episode: 1)
|
||||
print("PASS: fixture \(source.identity); \(source.subtitles.count) VTT tracks")
|
||||
} else if args.isEmpty {
|
||||
try await checks()
|
||||
} else {
|
||||
throw Failure(message: "Usage: stream-resolver-check [--live EPISODE_URL | --fixture SOURCES_JSON]")
|
||||
}
|
||||
} catch {
|
||||
let message = (error as? Failure)?.message ?? error.localizedDescription
|
||||
FileHandle.standardError.write(Data("FAIL: \(message)\n".utf8))
|
||||
exit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,8 @@ OBJECTS := $(BUILD_DIR)/player.o $(BUILD_DIR)/progress_reporter.o $(BUILD_DIR)/O
|
||||
SWIFT_SOURCES := Sources/App/TerebiApp.swift Sources/App/PlayerActions.swift Sources/App/CompactWindow.swift Sources/App/YouTubeResolver.swift Sources/App/PlaybackProgress.swift Sources/App/PlaybackQueue.swift Sources/App/SystemPlaybackControls.swift Sources/App/AutomaticQuality.swift Sources/App/ResolvedSource.swift Sources/App/ExternalSubtitles.swift Sources/App/CastSession.swift Sources/App/CastMediaServer.swift Sources/App/CastControls.swift
|
||||
RUNTIME_SOURCE := Sources/Launcher/FFmpegRuntime.swift
|
||||
CLI_SOURCES := Sources/CLI/TerebiCLI.swift Sources/CLI/PlayerCommand.swift Sources/App/YouTubeResolver.swift Sources/App/ResolvedSource.swift $(RUNTIME_SOURCE)
|
||||
SWIFT_SOURCES += Sources/App/StreamResolver.swift
|
||||
CLI_SOURCES += Sources/App/StreamResolver.swift
|
||||
CONFIG_BUILD := .build/configuration
|
||||
CONFIG_BIN := $(shell xcrun swift build --scratch-path $(CONFIG_BUILD) -c release --show-bin-path)
|
||||
CONFIG_LIBRARY := $(CONFIG_BIN)/libTerebiConfiguration.a
|
||||
@@ -154,6 +156,7 @@ $(BUILD_DIR)/fixtures/local-seek.mp4: Makefile | check-dependencies
|
||||
check-local-seek: $(BUILD_DIR)/local-seek-check $(BUILD_DIR)/fixtures/local-seek.mp4 $(DECODE_FIXTURES)
|
||||
$(BUILD_DIR)/local-seek-check $(BUILD_DIR)/fixtures/local-seek.mp4 $(DECODE_FIXTURES)
|
||||
|
||||
check: check-stream
|
||||
check: check-dependencies $(BUILD_DIR)/playback-check check-seek-queue check-local-seek check-video-decoding check-fixtures check-audio check-window check-config check-youtube check-quality check-adaptive check-network check-progress check-reporting check-cli check-runtime check-media check-startup check-bundle check-subtitles check-resolved check-cast check-queue
|
||||
$(BUILD_DIR)/playback-check $(BUILD_DIR)/fixtures
|
||||
|
||||
@@ -208,6 +211,13 @@ $(BUILD_DIR)/youtube-resolver-check: Checks/youtube-resolver.swift Sources/App/Y
|
||||
check-youtube: $(BUILD_DIR)/youtube-resolver-check $(BUNDLE_STAMP)
|
||||
$(BUILD_DIR)/youtube-resolver-check "$(abspath $(APP))"
|
||||
|
||||
$(BUILD_DIR)/stream-resolver-check: Checks/stream-resolver.swift Sources/App/StreamResolver.swift Sources/App/YouTubeResolver.swift Sources/App/ResolvedSource.swift Makefile | $(BUILD_DIR)
|
||||
$(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -O $(DEBUG_FLAGS) Checks/stream-resolver.swift Sources/App/StreamResolver.swift Sources/App/YouTubeResolver.swift Sources/App/ResolvedSource.swift -framework Foundation -o $@
|
||||
|
||||
.PHONY: check-stream
|
||||
check-stream: $(BUILD_DIR)/stream-resolver-check
|
||||
$(BUILD_DIR)/stream-resolver-check
|
||||
|
||||
$(BUILD_DIR)/automatic-quality-check: Checks/automatic-quality.swift Sources/App/AutomaticQuality.swift Makefile | $(BUILD_DIR) check-dependencies
|
||||
$(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -O $(DEBUG_FLAGS) Checks/automatic-quality.swift Sources/App/AutomaticQuality.swift -framework Foundation -o $@
|
||||
|
||||
@@ -258,8 +268,8 @@ $(BUILD_DIR)/progress-reporting-check: Checks/progress-reporting.mm Sources/Core
|
||||
check-reporting: $(BUILD_DIR)/progress-reporting-check
|
||||
$(BUILD_DIR)/progress-reporting-check
|
||||
|
||||
$(BUILD_DIR)/player-command-check: Checks/player-command.swift Sources/CLI/PlayerCommand.swift Sources/App/YouTubeResolver.swift Sources/App/ResolvedSource.swift Makefile | $(BUILD_DIR) check-dependencies
|
||||
$(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -O $(DEBUG_FLAGS) Checks/player-command.swift Sources/CLI/PlayerCommand.swift Sources/App/YouTubeResolver.swift Sources/App/ResolvedSource.swift -framework Foundation -o $@
|
||||
$(BUILD_DIR)/player-command-check: Checks/player-command.swift Sources/CLI/PlayerCommand.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/player-command.swift Sources/CLI/PlayerCommand.swift Sources/App/YouTubeResolver.swift Sources/App/ResolvedSource.swift Sources/App/StreamResolver.swift -framework Foundation -o $@
|
||||
|
||||
check-cli: $(BUILD_DIR)/player-command-check
|
||||
$(BUILD_DIR)/player-command-check
|
||||
|
||||
@@ -35,9 +35,9 @@ Builds are ad-hoc signed for personal use, not notarized. Bundled licenses and s
|
||||
|
||||
## Usage
|
||||
|
||||
Open one or more local files with **⌘O**, drag them onto the video, or use Finder's **Open With**. A selection replaces the session's queue and plays in natural filename order (for example, episode 2 before episode 10). Open YouTube with **⇧⌘O** or drop a video link. Media starts automatically and resumes its saved position; finished videos restart from the beginning. Live and audio-only media do not save progress.
|
||||
Open one or more local files with **⌘O**, drag them onto the video, or use Finder's **Open With**. A selection replaces the session's queue and plays in natural filename order (for example, episode 2 before episode 10). Open a YouTube video or Miruro episode with **⇧⌘O** or drop its link. Media starts automatically and resumes its saved position; finished videos restart from the beginning. Live and audio-only media do not save progress.
|
||||
|
||||
The start screen uses a fixed 640 × 400-point two-pane window: Terebi's identity and version on the left; **Open…**, **Open URL…**, and the six most recently played local files or YouTube videos on the right. Opening media restores normal resizing; closing media returns to the fixed start size. Shortcut hints follow your configured bindings. **Open URL…** reveals a focused inline YouTube field. When the most recent item has saved progress, it appears once as a **Resume** row with its saved timestamp. Recent titles and ordering survive relaunches; failed opens are not added. Resolved streams are excluded because reopening them requires a fresh external handoff. The action pane scrolls, and drag-and-drop remains available.
|
||||
The start screen uses a fixed 640 × 400-point two-pane window: Terebi's identity and version on the left; **Open…**, **Open Stream…**, and at most the three most recently played local files or YouTube videos on the right, including any **Resume** row. Opening media restores normal resizing; closing media returns to the fixed start size. Shortcut hints follow your configured bindings. **Open Stream…** reveals a focused inline URL field. When the most recent item has saved progress, it appears once as a **Resume** row with its saved timestamp. Recent titles and ordering survive relaunches; failed opens are not added. Resolved streams are excluded from recents. The action pane scrolls, and drag-and-drop remains available.
|
||||
|
||||
Start-screen actions use 14-point text and begin 64 points below the top edge. Branding uses a 16-point app name and 12-point version; the drop hint uses 13-point text. Open actions have no idle fill; their background appears on hover or press.
|
||||
|
||||
@@ -93,6 +93,14 @@ Playback defaults to the best available SDR resolution up to **1080p**. Use **ge
|
||||
|
||||
Only individual videos are supported, not playlists or channels. Private, age-restricted, region-blocked, or sign-in-required videos may fail; browser cookies are not read. The extractor is bundled, so YouTube compatibility updates require rebuilding/reinstalling terebi, not updating an external yt-dlp.
|
||||
|
||||
### Miruro episode links
|
||||
|
||||
**Open Stream…**, dropped links and CLI URLs accept `https://barelystarted.miruro.tv/watch/<opaque-id>/<slug>?ep=<episode>`. An explicit positive episode number is required; series pages, other Miruro domains and arbitrary streaming sites are not supported.
|
||||
|
||||
Terebi resolves a fresh direct Sun soft-subtitle source, preserving stream headers, external WebVTT subtitles and stable episode identity. No provider fallback, cookies, protection bypass or proxy/HLS rewriting is used. Resolution errors leave the previous media loaded. Miruro URL playback currently uses the title “Miruro episode N”.
|
||||
|
||||
The configurable URL action is now `open_stream` (formerly `open_youtube`); custom key bindings must use the new name. The default remains **⇧⌘O**.
|
||||
|
||||
### Chromecast
|
||||
|
||||
Open media, select the **Cast** icon or **Playback → Cast to Device → Find Cast Devices…**, and choose a receiver on the same LAN. Allow macOS Local Network access and incoming firewall connections. Terebi keeps the Mac awake during the handoff and active Cast playback while allowing the screensaver and display sleep once local playback stops. Keep the Mac connected: it relays media to the receiver. Closing the lid, explicitly sleeping the Mac or critical-battery sleep can still interrupt playback.
|
||||
@@ -120,16 +128,16 @@ The CLI opens one file or video, reusing the running player by default. With no
|
||||
|
||||
### Resolved sources and progress reporting
|
||||
|
||||
Miruro playback requires a freshly resolved source document from an external caller such as Tubular; terebi does not discover episodes or accept Miruro page URLs directly.
|
||||
External callers such as Tubular can continue supplying freshly resolved Miruro source documents. This path does not re-resolve page URLs or override the caller's selection, headers, title or subtitles; the source-file and progress-report contracts are unchanged.
|
||||
|
||||
```sh
|
||||
terebi --new-instance --source-file '/private/session/source.json' \
|
||||
--progress-file '/private/session/progress.jsonl'
|
||||
```
|
||||
|
||||
Both options require `--new-instance`; `--progress-file` also works with YouTube. Use a private session directory and a nonexistent report destination. Keep the source file present and unchanged during playback; the caller owns cleanup. Reports contain playback facts, not watched-state decisions.
|
||||
Both options require `--new-instance`; `--progress-file` also works with YouTube and Miruro episode URLs. Use a private session directory and a nonexistent report destination. Keep the source file present and unchanged during playback; the caller owns cleanup. Reports contain playback facts, not watched-state decisions.
|
||||
|
||||
See the [integration contract](docs/miruro-playback-handoff.md) for source JSON, resource-header/origin restrictions, and progress schemas. Tubular still needs to adopt Miruro's version-2 progress identity.
|
||||
See the [integration contract](docs/miruro-playback-handoff.md) for source JSON, resource-header/origin restrictions, and progress schemas.
|
||||
|
||||
## Configuration
|
||||
|
||||
|
||||
@@ -94,8 +94,8 @@ extension PlayerModel {
|
||||
|
||||
private func handleKeyEvent(_ event: NSEvent, in window: NSWindow, chord: KeyChord) -> Bool {
|
||||
guard event.window === window, NSApp.modalWindow == nil, window.attachedSheet == nil else { return false }
|
||||
if chord.modifiers.contains(.command), keyBindings.shortcuts(for: .openYoutube).contains(chord) {
|
||||
return perform(.openYoutube)
|
||||
if chord.modifiers.contains(.command), keyBindings.shortcuts(for: .openStream).contains(chord) {
|
||||
return perform(.openStream)
|
||||
}
|
||||
let responder = window.firstResponder
|
||||
if let text = responder as? NSTextView, text.isEditable || text.isSelectable { return false }
|
||||
@@ -200,9 +200,9 @@ struct PlayerCommands: Commands {
|
||||
.disabled(!model.canPerform(.openFile))
|
||||
Button("Add to Queue…", action: model.addQueuePanel)
|
||||
.disabled(!model.canOpen)
|
||||
Button("Open YouTube URL…") { model.performMenuAction(.openYoutube) }
|
||||
.keyboardShortcut(model.keyBindings.shortcuts(for: .openYoutube).first?.keyboardShortcut)
|
||||
.disabled(!model.canPerform(.openYoutube))
|
||||
Button("Open Stream…") { model.performMenuAction(.openStream) }
|
||||
.keyboardShortcut(model.keyBindings.shortcuts(for: .openStream).first?.keyboardShortcut)
|
||||
.disabled(!model.canPerform(.openStream))
|
||||
Button("Close File") { model.performMenuAction(.closeFile) }
|
||||
.keyboardShortcut(model.keyBindings.shortcuts(for: .closeFile).first?.keyboardShortcut)
|
||||
.disabled(!hasFocusedScene || !model.canPerform(.closeFile))
|
||||
|
||||
@@ -12,6 +12,15 @@ struct ResolvedSubtitle: Decodable, Sendable {
|
||||
headerFields.sorted { $0.key < $1.key }.map { "\($0.key): \($0.value)\r\n" }.joined()
|
||||
}
|
||||
fileprivate var validatedHeaders: [String: String] { headerFields }
|
||||
|
||||
init(url: String, format: String, language: String, label: String, isDefault: Bool, headers: [String: String]) {
|
||||
self.url = url
|
||||
self.format = format
|
||||
self.language = language
|
||||
self.label = label
|
||||
self.isDefault = isDefault
|
||||
self.headerFields = headers
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case url, format, language, label
|
||||
case isDefault = "default"
|
||||
@@ -26,7 +35,7 @@ struct ResolvedSource: Sendable {
|
||||
let title: String
|
||||
let subtitles: [ResolvedSubtitle]
|
||||
|
||||
private struct Document: Decodable {
|
||||
struct Document: Decodable {
|
||||
struct Identity: Decodable {
|
||||
let provider: String
|
||||
let media_id: String
|
||||
@@ -62,29 +71,37 @@ struct ResolvedSource: Sendable {
|
||||
let data = try handle.read(upToCount: 1_048_577) ?? Data()
|
||||
guard data.count <= 1_048_576 else { throw InvalidSource() }
|
||||
let document = try JSONDecoder().decode(Document.self, from: data)
|
||||
let id = document.identity
|
||||
guard document.version == 1, id.provider == "miruro",
|
||||
!id.media_id.isEmpty, id.media_id.utf8.count <= 512,
|
||||
id.media_id.utf8.allSatisfy({ isIDByte($0) }),
|
||||
id.episode > 0, id.episode <= Int(Int32.max),
|
||||
validURL(document.url), validHeaders(document.headers),
|
||||
validText(document.title ?? "", limit: 4096),
|
||||
document.subtitles.count <= 32,
|
||||
document.subtitles.filter({ $0.isDefault }).count <= 1 else { throw InvalidSource() }
|
||||
for subtitle in document.subtitles {
|
||||
guard validURL(subtitle.url), subtitle.format == "vtt",
|
||||
validText(subtitle.language, limit: 128), !subtitle.language.isEmpty,
|
||||
validText(subtitle.label, limit: 4096), !subtitle.label.isEmpty,
|
||||
validHeaders(subtitle.validatedHeaders) else { throw InvalidSource() }
|
||||
}
|
||||
let headers = document.headers.sorted { $0.key < $1.key }
|
||||
.map { "\($0.key): \($0.value)\r\n" }.joined()
|
||||
return ResolvedSource(url: document.url, headers: headers,
|
||||
identity: "miruro:\(id.media_id):\(id.episode)", title: document.title ?? "Miruro episode \(id.episode)",
|
||||
subtitles: document.subtitles)
|
||||
return try validated(document)
|
||||
} catch { throw InvalidSource() }
|
||||
}
|
||||
|
||||
static func validIdentity(mediaID: String, episode: Int) -> Bool {
|
||||
!mediaID.isEmpty && mediaID.utf8.count <= 512 &&
|
||||
mediaID.utf8.allSatisfy({ isIDByte($0) }) &&
|
||||
episode > 0 && episode <= Int(Int32.max)
|
||||
}
|
||||
|
||||
static func validated(_ document: Document) throws -> ResolvedSource {
|
||||
let id = document.identity
|
||||
guard document.version == 1, id.provider == "miruro",
|
||||
validIdentity(mediaID: id.media_id, episode: id.episode),
|
||||
validURL(document.url), validHeaders(document.headers),
|
||||
validText(document.title ?? "", limit: 4096),
|
||||
document.subtitles.count <= 32,
|
||||
document.subtitles.filter({ $0.isDefault }).count <= 1 else { throw InvalidSource() }
|
||||
for subtitle in document.subtitles {
|
||||
guard validURL(subtitle.url), subtitle.format == "vtt",
|
||||
validText(subtitle.language, limit: 128), !subtitle.language.isEmpty,
|
||||
validText(subtitle.label, limit: 4096), !subtitle.label.isEmpty,
|
||||
validHeaders(subtitle.validatedHeaders) else { throw InvalidSource() }
|
||||
}
|
||||
let headers = document.headers.sorted { $0.key < $1.key }
|
||||
.map { "\($0.key): \($0.value)\r\n" }.joined()
|
||||
return ResolvedSource(url: document.url, headers: headers,
|
||||
identity: "miruro:\(id.media_id):\(id.episode)", title: document.title ?? "Miruro episode \(id.episode)",
|
||||
subtitles: document.subtitles)
|
||||
}
|
||||
|
||||
private static func isIDByte(_ byte: UInt8) -> Bool {
|
||||
(65...90).contains(byte) || (97...122).contains(byte) ||
|
||||
(48...57).contains(byte) || byte == 45 || byte == 95
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
import Foundation
|
||||
|
||||
private struct StreamResolutionError: LocalizedError {
|
||||
let message: String
|
||||
var errorDescription: String? { message }
|
||||
}
|
||||
|
||||
enum StreamURL: Sendable {
|
||||
case youtube(URL)
|
||||
case miruro(mediaID: String, episode: Int)
|
||||
|
||||
static func parse(_ text: String) throws -> StreamURL {
|
||||
let text = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let url = try? YouTubeResolver.validateURL(text) { return .youtube(url) }
|
||||
let invalid = StreamResolutionError(message: "Enter a YouTube video URL or a Miruro episode URL from barelystarted.miruro.tv.")
|
||||
guard !text.unicodeScalars.contains(where: {
|
||||
CharacterSet.whitespacesAndNewlines.contains($0) || CharacterSet.controlCharacters.contains($0)
|
||||
}), let components = URLComponents(string: text),
|
||||
components.scheme?.lowercased() == "https",
|
||||
components.host?.lowercased() == "barelystarted.miruro.tv",
|
||||
components.user == nil, components.password == nil,
|
||||
components.port == nil || components.port == 443,
|
||||
components.fragment == nil,
|
||||
components.percentEncodedPath == components.path,
|
||||
components.url != nil else { throw invalid }
|
||||
let path = components.path.split(separator: "/", omittingEmptySubsequences: false)
|
||||
guard path.count == 4, path[0].isEmpty, path[1] == "watch",
|
||||
!path[3].isEmpty, path[3].utf8.count <= 512,
|
||||
path[3].utf8.allSatisfy({ isSegmentByte($0) }),
|
||||
let query = components.queryItems, query.count == 1, query[0].name == "ep",
|
||||
let number = query[0].value, let episode = Int(number),
|
||||
components.percentEncodedQuery == "ep=\(episode)",
|
||||
ResolvedSource.validIdentity(mediaID: String(path[2]), episode: episode) else { throw invalid }
|
||||
return .miruro(mediaID: String(path[2]), episode: episode)
|
||||
}
|
||||
|
||||
private static func isSegmentByte(_ byte: UInt8) -> Bool {
|
||||
(65...90).contains(byte) || (97...122).contains(byte) ||
|
||||
(48...57).contains(byte) || byte == 45 || byte == 95
|
||||
}
|
||||
}
|
||||
|
||||
enum MiruroResolver {
|
||||
static let bodyLimit = 2 * 1024 * 1024
|
||||
|
||||
static func resolve(mediaID: String, episode: Int) async throws -> ResolvedSource {
|
||||
try Task.checkCancellation()
|
||||
guard ResolvedSource.validIdentity(mediaID: mediaID, episode: episode) else {
|
||||
throw StreamResolutionError(message: "Invalid Miruro episode identity.")
|
||||
}
|
||||
var components = URLComponents(string: "https://barelystarted.miruro.tv/api/sources")!
|
||||
components.queryItems = [URLQueryItem(name: "id", value: mediaID), URLQueryItem(name: "n", value: String(episode))]
|
||||
var request = URLRequest(url: components.url!)
|
||||
request.timeoutInterval = 25
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
let configuration = URLSessionConfiguration.ephemeral
|
||||
configuration.timeoutIntervalForRequest = 25
|
||||
configuration.timeoutIntervalForResource = 25
|
||||
configuration.httpShouldSetCookies = false
|
||||
configuration.httpCookieStorage = nil
|
||||
configuration.urlCache = nil
|
||||
let session = URLSession(configuration: configuration, delegate: MiruroRedirectPolicy(), delegateQueue: nil)
|
||||
defer { session.invalidateAndCancel() }
|
||||
do {
|
||||
let (bytes, response) = try await session.bytes(for: request)
|
||||
try validateResponse(response)
|
||||
var data = Data()
|
||||
if response.expectedContentLength > 0 { data.reserveCapacity(Int(response.expectedContentLength)) }
|
||||
for try await byte in bytes {
|
||||
try Task.checkCancellation()
|
||||
guard data.count < bodyLimit else {
|
||||
throw StreamResolutionError(message: "Miruro source response exceeded 2 MiB.")
|
||||
}
|
||||
data.append(byte)
|
||||
}
|
||||
try Task.checkCancellation()
|
||||
return try decode(data, mediaID: mediaID, episode: episode)
|
||||
} catch {
|
||||
if Task.isCancelled || error is CancellationError || (error as? URLError)?.code == .cancelled {
|
||||
throw CancellationError()
|
||||
}
|
||||
if let error = error as? StreamResolutionError { throw error }
|
||||
throw StreamResolutionError(message: "Miruro request failed or timed out. No retry was attempted.")
|
||||
}
|
||||
}
|
||||
|
||||
static func validateResponse(_ response: URLResponse) throws {
|
||||
guard let response = response as? HTTPURLResponse else {
|
||||
throw StreamResolutionError(message: "Miruro returned an invalid HTTP response.")
|
||||
}
|
||||
guard response.statusCode == 200 else {
|
||||
throw StreamResolutionError(message: "Miruro HTTP \(response.statusCode). No retry was attempted.")
|
||||
}
|
||||
guard response.mimeType?.lowercased() == "application/json" else {
|
||||
throw StreamResolutionError(message: "Miruro returned an unexpected content type; protection pages are not supported.")
|
||||
}
|
||||
guard response.expectedContentLength <= Int64(bodyLimit) else {
|
||||
throw StreamResolutionError(message: "Miruro source response exceeded 2 MiB.")
|
||||
}
|
||||
}
|
||||
|
||||
// Match Tubular's direct ssub/Sun projection, without provider or server retry.
|
||||
static func decode(_ data: Data, mediaID: String, episode: Int) throws -> ResolvedSource {
|
||||
do {
|
||||
guard data.count <= bodyLimit,
|
||||
ResolvedSource.validIdentity(mediaID: mediaID, episode: episode) else { throw InvalidProjection() }
|
||||
let response = try JSONDecoder().decode(Sources.self, from: data)
|
||||
guard response.episodeNumber == episode else { throw InvalidProjection() }
|
||||
let config = response.config.sun
|
||||
guard config.player == "native", !config.proxy,
|
||||
config.hls?.segments?.enabled != true, config.hls?.query?.enabled != true else {
|
||||
throw StreamResolutionError(message: "Miruro Sun requires unsupported proxy or HLS rewriting.")
|
||||
}
|
||||
guard let provider = response.tracks.first(where: { $0.track == "ssub" })?
|
||||
.providers.first(where: { $0.provider == "sun" }),
|
||||
let server = provider.servers.first(where: { !$0.streams.isEmpty }),
|
||||
let stream = server.streams.first else {
|
||||
throw StreamResolutionError(message: "Direct Miruro Sun soft-subtitle source is unavailable.")
|
||||
}
|
||||
guard ["hls", "m3u8", "application/vnd.apple.mpegurl", "application/x-mpegurl"].contains(stream.format),
|
||||
!stream.hasEmbed else { throw InvalidProjection() }
|
||||
let headers = server.headers ?? [:]
|
||||
var subtitles: [ResolvedSubtitle] = []
|
||||
for subtitle in provider.subtitles where subtitle.format == "vtt" {
|
||||
guard subtitles.count < 32 else { throw InvalidProjection() }
|
||||
subtitles.append(ResolvedSubtitle(url: subtitle.file, format: subtitle.format, language: subtitle.language,
|
||||
label: subtitle.label, isDefault: subtitle.default, headers: headers))
|
||||
}
|
||||
return try ResolvedSource.validated(.init(version: 1, url: stream.url, headers: headers,
|
||||
identity: .init(provider: "miruro", media_id: mediaID, episode: episode),
|
||||
title: nil, subtitles: subtitles))
|
||||
} catch {
|
||||
if let error = error as? StreamResolutionError { throw error }
|
||||
throw StreamResolutionError(message: "Miruro returned invalid or unsupported source metadata.")
|
||||
}
|
||||
}
|
||||
|
||||
private struct InvalidProjection: Error {}
|
||||
private struct Sources: Decodable {
|
||||
let episodeNumber: Int
|
||||
let tracks: [Track]
|
||||
let config: Config
|
||||
}
|
||||
private struct Config: Decodable { let sun: ProviderConfig }
|
||||
private struct ProviderConfig: Decodable {
|
||||
let player: String
|
||||
let proxy: Bool
|
||||
let hls: HLS?
|
||||
}
|
||||
private struct HLS: Decodable {
|
||||
struct Rule: Decodable { let enabled: Bool? }
|
||||
let segments: Rule?
|
||||
let query: Rule?
|
||||
}
|
||||
private struct Track: Decodable {
|
||||
let track: String
|
||||
let providers: [Provider]
|
||||
}
|
||||
private struct Provider: Decodable {
|
||||
let provider: String
|
||||
let servers: [Server]
|
||||
let subtitles: [Subtitle]
|
||||
}
|
||||
private struct Server: Decodable {
|
||||
let headers: [String: String]?
|
||||
let streams: [Stream]
|
||||
}
|
||||
private struct Stream: Decodable {
|
||||
let url: String
|
||||
let format: String
|
||||
let hasEmbed: Bool
|
||||
enum CodingKeys: String, CodingKey { case url, format, embed }
|
||||
init(from decoder: Decoder) throws {
|
||||
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||
url = try values.decode(String.self, forKey: .url)
|
||||
format = try values.decode(String.self, forKey: .format)
|
||||
if values.contains(.embed) {
|
||||
hasEmbed = try !values.decodeNil(forKey: .embed)
|
||||
} else {
|
||||
hasEmbed = false
|
||||
}
|
||||
}
|
||||
}
|
||||
private struct Subtitle: Decodable {
|
||||
let file: String
|
||||
let format: String
|
||||
let language: String
|
||||
let label: String
|
||||
let `default`: Bool
|
||||
}
|
||||
}
|
||||
|
||||
private final class MiruroRedirectPolicy: NSObject, URLSessionTaskDelegate, @unchecked Sendable {
|
||||
func urlSession(_ session: URLSession, task: URLSessionTask,
|
||||
willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest,
|
||||
completionHandler: @escaping (URLRequest?) -> Void) {
|
||||
completionHandler(nil)
|
||||
}
|
||||
}
|
||||
+71
-35
@@ -58,7 +58,7 @@ final class PlayerModel: ObservableObject {
|
||||
private var currentMedia: MediaSource?
|
||||
private var shouldRecordRecentMedia = false
|
||||
var pendingRevision: UInt64? { transition.pendingRevision }
|
||||
var resolvingYouTube: Bool { transition.isResolving }
|
||||
var resolvingStream: Bool { transition.isResolving }
|
||||
var isLiveStream: Bool { transition.isLive }
|
||||
var isNetworkMedia: Bool { transition.isNetwork }
|
||||
@Published private(set) var youtubeHeights: [Int] = []
|
||||
@@ -216,7 +216,7 @@ final class PlayerModel: ObservableObject {
|
||||
|
||||
func cast(to device: CastDevice, playing: Bool? = nil) {
|
||||
guard canCast, let source = currentMedia, let handle else { return }
|
||||
cancelYouTubeResolution()
|
||||
cancelStreamResolution()
|
||||
commandError = nil
|
||||
casting.start(to: device, source: source, title: fileName ?? "Terebi", subtitle: selectedSubtitle,
|
||||
handle: handle, snapshot: state, playing: playing ?? effectivePlaying,
|
||||
@@ -294,11 +294,11 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
|
||||
|
||||
func openYouTube(_ text: String) {
|
||||
func openStream(_ text: String) {
|
||||
guard canOpen else { return }
|
||||
let url: URL
|
||||
let stream: StreamURL
|
||||
do {
|
||||
url = try YouTubeResolver.validateURL(text)
|
||||
stream = try StreamURL.parse(text)
|
||||
} catch {
|
||||
commandError = error.localizedDescription
|
||||
revealControls()
|
||||
@@ -306,14 +306,17 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
showsYouTubeInput = false
|
||||
clearQueue()
|
||||
resolveYouTube(url, height: nil)
|
||||
switch stream {
|
||||
case .youtube(let url): resolveYouTube(url, height: nil)
|
||||
case .miruro(let mediaID, let episode): resolveMiruro(mediaID: mediaID, episode: episode)
|
||||
}
|
||||
}
|
||||
|
||||
func openResolvedSource(_ file: URL) {
|
||||
do {
|
||||
let source = try ResolvedSource.load(from: file)
|
||||
clearQueue()
|
||||
cancelYouTubeResolution()
|
||||
cancelStreamResolution()
|
||||
openMedia(.resolved(source))
|
||||
} catch {
|
||||
commandError = error.localizedDescription
|
||||
@@ -362,7 +365,7 @@ final class PlayerModel: ObservableObject {
|
||||
func setAutomaticYouTubeQuality(_ enabled: Bool) {
|
||||
automaticYouTubeQuality = enabled
|
||||
automaticQuality = AutomaticQuality()
|
||||
if !enabled && transition.resolvingAutomaticQuality { cancelYouTubeResolution() }
|
||||
if !enabled && transition.resolvingAutomaticQuality { cancelStreamResolution() }
|
||||
revealControls()
|
||||
}
|
||||
|
||||
@@ -381,10 +384,43 @@ final class PlayerModel: ObservableObject {
|
||||
resolveYouTube(url, height: height, change: change)
|
||||
}
|
||||
|
||||
private func resolveMiruro(mediaID: String, episode: Int) {
|
||||
guard let handle else { return }
|
||||
if castDevice != nil { stopCasting(resume: false) }
|
||||
cancelStreamResolution()
|
||||
automaticQuality = AutomaticQuality()
|
||||
guard player_pause(handle) else {
|
||||
commandError = "Could not pause the current media to open the stream. Please try again."
|
||||
revealControls()
|
||||
return
|
||||
}
|
||||
objectWillChange.send()
|
||||
let request = transition.beginResolution(.open)
|
||||
systemPlayback?.update()
|
||||
commandError = nil
|
||||
revealControls()
|
||||
let task = Task { [weak self] in
|
||||
do {
|
||||
let source = try await MiruroResolver.resolve(mediaID: mediaID, episode: episode)
|
||||
guard !Task.isCancelled, let self else { return }
|
||||
self.objectWillChange.send()
|
||||
guard let change = self.transition.finishResolution(request) else { return }
|
||||
self.openMedia(.resolved(source), change: change)
|
||||
} catch {
|
||||
guard !Task.isCancelled, let self else { return }
|
||||
self.objectWillChange.send()
|
||||
guard self.transition.finishResolution(request) != nil else { return }
|
||||
self.commandError = error.localizedDescription
|
||||
self.revealControls()
|
||||
}
|
||||
}
|
||||
transition.attachResolution(task, request: request)
|
||||
}
|
||||
|
||||
private func resolveYouTube(_ url: URL, height: Int?, change: MediaChange = .open) {
|
||||
guard let handle else { return }
|
||||
if castDevice != nil { stopCasting(resume: false) }
|
||||
cancelYouTubeResolution()
|
||||
cancelStreamResolution()
|
||||
if !change.automatic { automaticQuality = AutomaticQuality() }
|
||||
if !change.automatic && !player_pause(handle) {
|
||||
commandError = "Could not pause the current media to open YouTube. Please try again."
|
||||
@@ -422,7 +458,7 @@ final class PlayerModel: ObservableObject {
|
||||
transition.attachResolution(task, request: request)
|
||||
}
|
||||
|
||||
private func cancelYouTubeResolution() {
|
||||
private func cancelStreamResolution() {
|
||||
guard transition.isResolving else { return }
|
||||
objectWillChange.send()
|
||||
transition.cancelResolution()
|
||||
@@ -545,7 +581,7 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
|
||||
func open(_ url: URL) {
|
||||
if url.isFileURL { openFiles([url]) } else { openYouTube(url.absoluteString) }
|
||||
if url.isFileURL { openFiles([url]) } else { openStream(url.absoluteString) }
|
||||
}
|
||||
|
||||
func openFiles(_ urls: [URL]) {
|
||||
@@ -595,7 +631,7 @@ final class PlayerModel: ObservableObject {
|
||||
let receiver = keepingReceiver ? (castDevice ?? queueCastDevice) : nil
|
||||
queuePlaybackRevision = nil
|
||||
queueCompletionHandled = true
|
||||
cancelYouTubeResolution()
|
||||
cancelStreamResolution()
|
||||
openMedia(.file(url), change: .queue(playing: receiver == nil))
|
||||
queueCastDevice = receiver
|
||||
}
|
||||
@@ -634,7 +670,7 @@ final class PlayerModel: ObservableObject {
|
||||
return accepted
|
||||
}
|
||||
guard let handle, canTogglePlayback else { return false }
|
||||
if !playing && transition.resolvingAutomaticQuality { cancelYouTubeResolution() }
|
||||
if !playing && transition.resolvingAutomaticQuality { cancelStreamResolution() }
|
||||
if !playing { automaticQuality.resetHealth() }
|
||||
switch transition.setPlayback(playing, status: state.status, handle: handle) {
|
||||
case .unchanged:
|
||||
@@ -651,7 +687,7 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
|
||||
func finishSeeking() {
|
||||
if transition.resolvingAutomaticQuality { cancelYouTubeResolution() }
|
||||
if transition.resolvingAutomaticQuality { cancelStreamResolution() }
|
||||
automaticQuality.resetHealth()
|
||||
isSeeking = false
|
||||
guard let handle, canSeek else { return }
|
||||
@@ -669,7 +705,7 @@ final class PlayerModel: ObservableObject {
|
||||
guard let handle, canClose else { return }
|
||||
saveProgress()
|
||||
if castDevice != nil { stopCasting(resume: false) }
|
||||
cancelYouTubeResolution()
|
||||
cancelStreamResolution()
|
||||
automaticQuality = AutomaticQuality()
|
||||
guard player_close(handle) else {
|
||||
commandError = "Could not queue closing the file. Please try again."
|
||||
@@ -705,7 +741,7 @@ final class PlayerModel: ObservableObject {
|
||||
@discardableResult
|
||||
func seekRelative(_ seconds: Double) -> Bool {
|
||||
guard canSeek, let handle, seconds.isFinite else { return false }
|
||||
if transition.resolvingAutomaticQuality { cancelYouTubeResolution() }
|
||||
if transition.resolvingAutomaticQuality { cancelStreamResolution() }
|
||||
automaticQuality.resetHealth()
|
||||
let target = min(max(0, ((transition.seekPending || state.seek_pending) ? seekPosition : state.position) + seconds), duration)
|
||||
let accepted = casting.active ? seekCast(to: target) : transition.seek(target, handle: handle)
|
||||
@@ -890,7 +926,7 @@ final class PlayerModel: ObservableObject {
|
||||
return window != nil && !isFullScreen && fullScreenIntent == nil &&
|
||||
!isCompact && compactIntent != true && !compactWindow.isTransitioning &&
|
||||
(action == .enableAlwaysOnTop ? !isOnTop : isOnTop)
|
||||
case .openFile, .openYoutube: return canOpen
|
||||
case .openFile, .openStream: return canOpen
|
||||
case .closeFile: return canClose
|
||||
case .quit: return true
|
||||
case .showBufferInfo: return !showsBufferInfo
|
||||
@@ -948,7 +984,7 @@ final class PlayerModel: ObservableObject {
|
||||
window?.level = isOnTop ? .floating : .normal
|
||||
revealControls()
|
||||
case .openFile: openPanel()
|
||||
case .openYoutube: showsYouTubeInput.toggle()
|
||||
case .openStream: showsYouTubeInput.toggle()
|
||||
case .closeFile: closeFile()
|
||||
case .quit: NSApp.terminate(nil)
|
||||
case .showBufferInfo: setShowsBufferInfo(true)
|
||||
@@ -1013,7 +1049,7 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
|
||||
func beginSeeking() {
|
||||
if transition.resolvingAutomaticQuality { cancelYouTubeResolution() }
|
||||
if transition.resolvingAutomaticQuality { cancelStreamResolution() }
|
||||
automaticQuality.resetHealth()
|
||||
isSeeking = true
|
||||
revealControls()
|
||||
@@ -1185,7 +1221,7 @@ final class PlayerModel: ObservableObject {
|
||||
saveProgress()
|
||||
if castDevice != nil { stopCasting(resume: false) }
|
||||
castDiscovery.stop()
|
||||
cancelYouTubeResolution()
|
||||
cancelStreamResolution()
|
||||
selectSubtitle(nil)
|
||||
systemPlayback?.shutdown()
|
||||
systemPlayback = nil
|
||||
@@ -2118,13 +2154,13 @@ private struct PlayerContent: View {
|
||||
}
|
||||
|
||||
private func toggleYouTubeInput() {
|
||||
model.perform(.openYoutube)
|
||||
model.perform(.openStream)
|
||||
}
|
||||
|
||||
private func startScreen(in size: CGSize) -> some View {
|
||||
let sidebarWidth = min(260, max(112, size.width * 0.28))
|
||||
let openKeys = model.keyBindings.shortcuts(for: .openFile)
|
||||
let urlKeys = model.keyBindings.shortcuts(for: .openYoutube)
|
||||
let urlKeys = model.keyBindings.shortcuts(for: .openStream)
|
||||
let latest = model.recentMedia.first
|
||||
let resumePosition = latest.flatMap { model.savedPosition(for: $0.url) }
|
||||
return HStack(spacing: 0) {
|
||||
@@ -2164,14 +2200,14 @@ private struct PlayerContent: View {
|
||||
.accessibilityLabel("Open File…")
|
||||
Button(action: toggleYouTubeInput) {
|
||||
HStack {
|
||||
Text("Open URL…")
|
||||
Text("Open Stream…")
|
||||
Spacer(minLength: 8)
|
||||
Text((urlKeys.first { $0.modifiers.contains(.command) } ?? urlKeys.first)?.displayText ?? "")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.buttonStyle(StartScreenButtonStyle())
|
||||
.accessibilityLabel("Open YouTube URL…")
|
||||
.accessibilityLabel("Open Stream…")
|
||||
if showsYouTubeInput {
|
||||
startURLInput.padding(.horizontal, 14)
|
||||
.transition(.opacity.combined(with: .scale(scale: 0.96, anchor: .top)))
|
||||
@@ -2189,7 +2225,7 @@ private struct PlayerContent: View {
|
||||
.help("Resume \(latest.title) at \(time(resumePosition))")
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
ForEach(model.recentMedia.filter { resumePosition == nil || $0.id != latest?.id }) { item in
|
||||
ForEach(model.recentMedia.prefix(4).filter { resumePosition == nil || $0.id != latest?.id }) { item in
|
||||
Button { model.open(item.url) } label: {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: item.url.isFileURL ? "doc.richtext" : "link")
|
||||
@@ -2226,20 +2262,20 @@ private struct PlayerContent: View {
|
||||
|
||||
private var startURLInput: some View {
|
||||
HStack(spacing: 8) {
|
||||
TextField("YouTube video URL", text: $youTubeURL,
|
||||
prompt: Text("YouTube video URL")
|
||||
TextField("Stream URL", text: $youTubeURL,
|
||||
prompt: Text("Stream URL")
|
||||
.foregroundStyle(Color(SunsetPalette.secondaryText).opacity(0.6)))
|
||||
.textFieldStyle(.plain)
|
||||
.focused($youTubeInputFocused)
|
||||
.accessibilityLabel("YouTube video URL")
|
||||
.accessibilityLabel("Stream URL")
|
||||
.onAppear { youTubeInputFocused = true }
|
||||
.onSubmit { model.openYouTube(youTubeURL) }
|
||||
Button { model.openYouTube(youTubeURL) } label: {
|
||||
.onSubmit { model.openStream(youTubeURL) }
|
||||
Button { model.openStream(youTubeURL) } label: {
|
||||
Image(systemName: "arrow.right").frame(width: 24, height: 24).contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Open YouTube Video")
|
||||
.help("Open YouTube Video")
|
||||
.accessibilityLabel("Open Stream")
|
||||
.help("Open Stream")
|
||||
.disabled(youTubeURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
}
|
||||
.font(.system(size: 12))
|
||||
@@ -2369,7 +2405,7 @@ private struct PlayerContent: View {
|
||||
Button(model.showsQueue ? "Hide Queue" : "Show Queue") { model.showsQueue.toggle() }
|
||||
Button("Previous Queue Item") { model.advanceQueue(-1) }.disabled(!model.canPreviousQueueItem)
|
||||
Button("Next Queue Item") { model.advanceQueue(1) }.disabled(!model.canNextQueueItem)
|
||||
PlayerActionButton(model: model, action: .openYoutube, title: "Open YouTube URL…")
|
||||
PlayerActionButton(model: model, action: .openStream, title: "Open Stream…")
|
||||
PlayerActionButton(model: model, action: .closeFile, title: "Close File")
|
||||
Divider()
|
||||
PlayerQualityMenu(model: model)
|
||||
@@ -2870,7 +2906,7 @@ private struct PlayerContent: View {
|
||||
VStack(spacing: 12) {
|
||||
ProgressView().controlSize(.large)
|
||||
Text(model.castConnecting ? "Connecting to \(model.castDevice?.name ?? "Cast device")…" :
|
||||
(model.resolvingYouTube ? "Resolving YouTube…" : "Opening…"))
|
||||
(model.resolvingStream ? "Resolving stream…" : "Opening…"))
|
||||
.font(.callout).foregroundStyle(Color(SunsetPalette.secondaryText))
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
@@ -3125,7 +3161,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
|
||||
@objc private func openInternetURL(_ event: NSAppleEventDescriptor, withReplyEvent reply: NSAppleEventDescriptor) {
|
||||
guard let text = event.paramDescriptor(forKeyword: keyDirectObject)?.stringValue else { return }
|
||||
PlayerModel.shared.openYouTube(text)
|
||||
PlayerModel.shared.openStream(text)
|
||||
}
|
||||
|
||||
@objc private func openDocuments(_ event: NSAppleEventDescriptor, withReplyEvent reply: NSAppleEventDescriptor) {
|
||||
|
||||
@@ -7,21 +7,21 @@ enum PlayerCommand {
|
||||
case launchSource(sourceFile: URL, newInstance: Bool, noConfig: Bool, progressFile: URL?)
|
||||
|
||||
static let usage = """
|
||||
Usage: terebi [--new-instance] [--no-config] [FILE | YOUTUBE_URL]
|
||||
terebi [--new-instance] [--no-config] --url YOUTUBE_URL
|
||||
terebi --new-instance --url YOUTUBE_URL --progress-file PATH
|
||||
Usage: terebi [--new-instance] [--no-config] [FILE | STREAM_URL]
|
||||
terebi [--new-instance] [--no-config] --url STREAM_URL
|
||||
terebi --new-instance --url STREAM_URL --progress-file PATH
|
||||
terebi --new-instance --source-file PATH [--progress-file PATH]
|
||||
terebi [--new-instance] [--no-config] -- FILE
|
||||
terebi --help
|
||||
terebi --default-config
|
||||
|
||||
Open one local file, YouTube video or resolved source and start playback,
|
||||
Open one local file, YouTube video, Miruro episode or resolved source and start playback,
|
||||
resuming saved video progress. With no arguments, open or activate the player.
|
||||
Quote paths and URLs containing spaces or shell metacharacters.
|
||||
|
||||
--new-instance Launch a separate player, leaving existing playback untouched.
|
||||
--no-config Use built-in settings instead of config.toml on a fresh launch.
|
||||
--url URL Open a YouTube video URL.
|
||||
--url URL Open a YouTube video or Miruro episode URL.
|
||||
--source-file PATH Open a validated resolved HTTP(S) source in a new player.
|
||||
--progress-file PATH Write JSONL playback facts to a new private file; requires
|
||||
--new-instance. The GUI owns reporting after this CLI exits.
|
||||
@@ -71,6 +71,16 @@ enum PlayerCommand {
|
||||
}
|
||||
return .launch(url, newInstance: newInstance, noConfig: noConfig, progressFile: progressFile)
|
||||
}
|
||||
func streamURL(_ text: String) throws -> URL {
|
||||
switch try StreamURL.parse(text) {
|
||||
case .youtube(let url): return url
|
||||
case .miruro:
|
||||
guard let url = URL(string: text.trimmingCharacters(in: .whitespacesAndNewlines)) else {
|
||||
throw ArgumentError("Invalid stream URL.")
|
||||
}
|
||||
return url
|
||||
}
|
||||
}
|
||||
if inputs.isEmpty { return try launch(nil) }
|
||||
if inputs == ["--help"] || inputs == ["-h"] {
|
||||
guard progressFile == nil else { throw ArgumentError("--progress-file cannot be combined with --help.") }
|
||||
@@ -94,8 +104,8 @@ enum PlayerCommand {
|
||||
noConfig: noConfig, progressFile: progressFile)
|
||||
}
|
||||
if inputs.first == "--url" {
|
||||
guard inputs.count == 2 else { throw ArgumentError("--url requires exactly one YouTube URL.") }
|
||||
return try launch(YouTubeResolver.validateURL(inputs[1]))
|
||||
guard inputs.count == 2 else { throw ArgumentError("--url requires exactly one stream URL.") }
|
||||
return try launch(streamURL(inputs[1]))
|
||||
}
|
||||
let filenameOnly = inputs.first == "--"
|
||||
if filenameOnly {
|
||||
@@ -104,7 +114,7 @@ enum PlayerCommand {
|
||||
throw ArgumentError("Unknown option. Use -- before filenames beginning with '-'.")
|
||||
}
|
||||
guard inputs.count == 1, let input = inputs.first, !input.isEmpty else {
|
||||
throw ArgumentError("Expected exactly one file or YouTube URL.")
|
||||
throw ArgumentError("Expected exactly one file or stream URL.")
|
||||
}
|
||||
let file: URL
|
||||
if !filenameOnly && input.contains("://") {
|
||||
@@ -115,7 +125,7 @@ enum PlayerCommand {
|
||||
}
|
||||
file = url
|
||||
} else {
|
||||
return try launch(YouTubeResolver.validateURL(input))
|
||||
return try launch(streamURL(input))
|
||||
}
|
||||
} else {
|
||||
let base = directory.hasDirectoryPath ? directory : URL(fileURLWithPath: directory.path, isDirectory: true)
|
||||
|
||||
@@ -83,7 +83,7 @@ public struct KeyBindings: Equatable, Sendable {
|
||||
.enterFullscreen: ["f", "ctrl+cmd+f"], .exitFullscreen: ["f", "ctrl+cmd+f", "escape"],
|
||||
.enterCompact: ["p", "ctrl+cmd+p"], .exitCompact: ["p", "ctrl+cmd+p"],
|
||||
.enableAlwaysOnTop: ["shift+p"], .disableAlwaysOnTop: ["shift+p"],
|
||||
.openFile: ["o", "cmd+o"], .openYoutube: ["cmd+shift+o"],
|
||||
.openFile: ["o", "cmd+o"], .openStream: ["cmd+shift+o"],
|
||||
.closeFile: ["cmd+shift+w"], .quit: ["q", "cmd+q"]
|
||||
]
|
||||
return texts.mapValues { $0.map { try! KeyChord($0) } }
|
||||
|
||||
@@ -4,7 +4,7 @@ public enum PlayerAction: Hashable, Sendable {
|
||||
case stepFrameForward, stepFrameBackward
|
||||
case enterFullscreen, exitFullscreen, enterCompact, exitCompact
|
||||
case enableAlwaysOnTop, disableAlwaysOnTop
|
||||
case openFile, openYoutube, closeFile, quit
|
||||
case openFile, openStream, closeFile, quit
|
||||
case showBufferInfo, hideBufferInfo
|
||||
case enableCompactSnapping, disableCompactSnapping
|
||||
case enableAutomaticQuality, disableAutomaticQuality
|
||||
@@ -19,7 +19,7 @@ public enum PlayerAction: Hashable, Sendable {
|
||||
("enter_fullscreen", .enterFullscreen), ("exit_fullscreen", .exitFullscreen),
|
||||
("enter_compact", .enterCompact), ("exit_compact", .exitCompact),
|
||||
("enable_always_on_top", .enableAlwaysOnTop), ("disable_always_on_top", .disableAlwaysOnTop),
|
||||
("open_file", .openFile), ("open_youtube", .openYoutube), ("close_file", .closeFile), ("quit", .quit),
|
||||
("open_file", .openFile), ("open_stream", .openStream), ("close_file", .closeFile), ("quit", .quit),
|
||||
("show_buffer_info", .showBufferInfo), ("hide_buffer_info", .hideBufferInfo),
|
||||
("enable_compact_snapping", .enableCompactSnapping), ("disable_compact_snapping", .disableCompactSnapping),
|
||||
("enable_automatic_quality", .enableAutomaticQuality), ("disable_automatic_quality", .disableAutomaticQuality),
|
||||
|
||||
@@ -12,10 +12,10 @@
|
||||
- synchronized 250 ms sidebar/window reveal, reversible mid-transition, with immediate Reduce Motion changes; top-right controls stay anchored to the moving window corner; native checks passed for rendered controls during transitions, stable video size, reversals, screen-edge fitting, fullscreen and compact restoration; animation feel and Reduce Motion still need manual verification
|
||||
- [x] make the window draggable (in all cases/view states) so long as a button/clickable element is not hovered.
|
||||
- scoped native background dragging across start/playback/queue/error/Cast/compact views, with passive loading/audio overlays passing input through; 20 native input-routing probes passed, including controls and row-reorder exclusions; physical pointer dragging and Cast-screen interaction still need manual verification
|
||||
- [ ] on the start screen:
|
||||
- [x] on the start screen:
|
||||
- [x] make the youtube url input field that appears when clicking the youtube button appear with a nice transition
|
||||
- 200 ms fade/scale with animated row reflow; the button and ⇧⌘O toggle the same inline input, including while editing; native focus, draft-preservation and rapid-reversal checks cover mouse/shortcut interoperation
|
||||
- [ ] change the youtube button to general streaming
|
||||
- we can figure out the stream and how to handle it from the url (i.e. if youtube url, handle as youtube stream; if miruro url, handle accordingly)
|
||||
- [x] change the youtube button to general streaming
|
||||
- Open Stream routes YouTube videos and explicit barelystarted.miruro.tv episode links; live Miruro video/audio and VTT seek checks passed, with shared validated source metadata and unchanged Tubular source/progress contracts; CLI handoff emitted v2 playback/end facts, but its GUI-state checker timed out without snapshots; physical UI/drop/Cast verification remains unverified
|
||||
- [ ] check whether we're using appropriately new apple ui APIs / SDKs for the project.
|
||||
- we should strive to use modern and idiomatic SDKs and APIs including SwiftUI among others
|
||||
@@ -4,14 +4,14 @@
|
||||
|
||||
Terebi now supports resolved Miruro HTTP(S) sources with per-source headers, external WebVTT and stable episode progress. Direct **`ssub` / `sun`** playback was exercised with a freshly resolved source: native video/audio advanced, default English subtitles loaded, and the model's cue text matched expected subtitles after forward and backward acknowledged seeks. The user also reported that visible playback appears to work; this is not a comprehensive visual synchronization sign-off.
|
||||
|
||||
Tubular integration is still required. Terebi's source/progress contracts below are implemented, not merely proposed; Tubular has not yet adopted them in this work.
|
||||
Tubular's existing source-file/progress-file handoff remains supported unchanged. Terebi also accepts explicit Miruro episode page URLs through Open Stream, drop, CLI and URL events; that path resolves into the same validated source representation without re-resolving externally supplied source files.
|
||||
|
||||
## Ownership
|
||||
|
||||
- **Tubular:** discovery, episode/provider/language selection, fresh source resolution, source-document creation, session cleanup and watched-state decisions.
|
||||
- **Terebi:** native playback, Cast discovery/control and LAN delivery, request-header propagation, subtitle loading/selection/rendering, stable-identity bookmarks and acknowledged progress reporting.
|
||||
|
||||
No Miruro scraping, `strm.cx` embedding, cookies, impersonation or protection-bypass machinery was added to Terebi.
|
||||
Native episode URL resolution uses Miruro's source endpoint and the same direct ssub/Sun selection as Tubular. No catalog browsing, `strm.cx` embedding, cookies, impersonation or protection-bypass machinery is used.
|
||||
|
||||
## Launch contract
|
||||
|
||||
@@ -101,7 +101,7 @@ YouTube records remain **version 1** with canonical `media_url`. Miruro records
|
||||
|
||||
Reporting includes ready state after restoration, approximately five-second playback intervals, acknowledged pause/seek transitions, the first eligible 95% crossing, native end and final close/quit snapshots. Launch success and window closure do not establish completion. Consumers must tolerate duplicates, partial trailing lines and abrupt termination; a `closed` record is not guaranteed.
|
||||
|
||||
**Required in Tubular `src/video_state.rs`:** retain canonical YouTube/version-1 validation, add version-2 validation against the episode identity expected for that launched session, and key Miruro watched state by `(miruro, opaque media ID, episode)`. Never use stream URL, token, slug or selected playback provider as identity.
|
||||
Tubular integration must retain canonical YouTube/version-1 validation, accept version-2 validation against the episode identity expected for that launched session, and key Miruro watched state by `(miruro, opaque media ID, episode)`. Never use stream URL, token, slug or selected playback provider as identity.
|
||||
|
||||
Retain the current completion decision for matching identities:
|
||||
|
||||
@@ -129,6 +129,8 @@ Handle `null` duration as ineligible. This measures position, not viewing covera
|
||||
|
||||
Earlier startup runs failed held-Space and YouTube end-transition assertions. The user reported possible manual interaction. The full rerun passed without playback-code changes; causality of the earlier failures was not established. No full aggregate `make check` pass is claimed.
|
||||
|
||||
Streaming-input verification (2026-10-19): native URLSession resolved a live Miruro episode with nine VTT tracks; native URL-to-playback exercised video/audio, pause, default captions and forward/backward acknowledged subtitle seeks. Offline stream parsing/projection/security, CLI/config, resolved playback and YouTube resolver/adaptive checks passed. The actual source-file CLI launch emitted version-2 progress and ended records, but the startup check timed out waiting for GUI state snapshots; this run does not establish the complete two-window handoff scenario. The live probe's original synchronous wait blocked async resolution; yielding the actor fixed the probe without playback-code changes.
|
||||
|
||||
### Reproduction commands from Terebi
|
||||
|
||||
```sh
|
||||
|
||||
Reference in New Issue
Block a user