Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| edb17f76e3 | |||
| 119fe2e737 |
@@ -113,12 +113,116 @@ struct ResolvedPlaybackCheck {
|
|||||||
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)
|
||||||
|
try await chapterLifecycle(source: source, file: file)
|
||||||
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static var fixtureChapters: [YouTubeChapter] {
|
||||||
|
[.init(start: 0, end: 1.25, title: "Opening"),
|
||||||
|
.init(start: 1.25, end: 2.5, title: "Ending")]
|
||||||
|
}
|
||||||
|
|
||||||
|
static func requireYouTubeChapters(_ model: PlayerModel, _ expected: [Double], _ context: String) {
|
||||||
|
require(!model.isBusy && model.state.has_video && model.state.has_audio && model.state.duration > 2.5,
|
||||||
|
"\(context): boundaries belong to decoded native media with measured duration")
|
||||||
|
require(model.sliderBoundaries == expected, "\(context): exact fractional chapter boundaries")
|
||||||
|
require(model.visibleIntervals.isEmpty && model.segmentToSkip == nil,
|
||||||
|
"\(context): YouTube chapters never become Miruro bands or skip offers")
|
||||||
|
}
|
||||||
|
|
||||||
|
static func chapterLifecycle(source: ResolvedSource, file: URL) async throws {
|
||||||
|
let directory = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("terebi.chapter-check." + UUID().uuidString)
|
||||||
|
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||||
|
defer { try? FileManager.default.removeItem(at: directory) }
|
||||||
|
let report = directory.appendingPathComponent("single-launch.jsonl")
|
||||||
|
let suite = "terebi.chapter-check." + UUID().uuidString
|
||||||
|
let defaults = UserDefaults(suiteName: suite)!
|
||||||
|
defer { defaults.removePersistentDomain(forName: suite) }
|
||||||
|
let chaptered = URL(string: "https://www.youtube.com/watch?v=AAAAAAAAAAA")!
|
||||||
|
let chapterless = URL(string: "https://www.youtube.com/watch?v=BBBBBBBBBBB")!
|
||||||
|
var requests: [URL] = []
|
||||||
|
// The single-video Tubular launch uses this progress-file initialization plus openStream.
|
||||||
|
let model = PlayerModel(defaults: defaults, progressFile: report.path, youtubeResolver: { url, height in
|
||||||
|
requests.append(url)
|
||||||
|
return YouTubeVideo(title: url == chaptered ? "Chaptered launch" : "Chapterless launch",
|
||||||
|
video: .init(url: source.url, headers: source.headers), audio: nil,
|
||||||
|
duration: 0, isLive: false, webpageURL: url, height: height ?? 1080, availableHeights: [1080, 720],
|
||||||
|
chapters: url == chaptered ? fixtureChapters + [.init(start: 3, end: 500, title: nil)] : [])
|
||||||
|
}, youtubeTitleResolver: { _ in nil },
|
||||||
|
miruroMetadataResolver: { _, _ in MiruroMetadata(title: nil, intervals: []) })
|
||||||
|
defer { model.shutdown() }
|
||||||
|
require(model.progressInitializationError == nil, "single Tubular launch initializes progress reporting")
|
||||||
|
for (url, expected) in [(chaptered, [1.25, 2.5]), (chapterless, [])] {
|
||||||
|
model.openStream(url.absoluteString)
|
||||||
|
require(model.sliderBoundaries.isEmpty, "direct open hides prior chapters while resolving")
|
||||||
|
try await until("direct YouTube stream decodes", model: model) {
|
||||||
|
!model.isBusy && model.state.status == PLAYER_PLAYING && model.state.position > 0.2
|
||||||
|
}
|
||||||
|
requireYouTubeChapters(model, expected, "direct openStream")
|
||||||
|
require(model.sliderSegmentTitle(at: 0.5) == (url == chaptered ? "Opening" : nil) &&
|
||||||
|
model.sliderSegmentTitle(at: 1.25) == (url == chaptered ? "Ending" : nil) &&
|
||||||
|
model.sliderSegmentTitle(at: 2.5) == nil,
|
||||||
|
"hover names match the chapter under the pointer, with no invented gap names")
|
||||||
|
require(model.state.duration < 500, "native duration rejects chapter edges beyond measured media")
|
||||||
|
require(model.setPlayback(playing: false), "pause before saving chapter playback")
|
||||||
|
try await until("chapter playback paused", model: model) { model.state.status == PLAYER_PAUSED }
|
||||||
|
require(model.seekRelative(0.8 - model.state.position), "save chapter playback bookmark")
|
||||||
|
try await until("chapter bookmark acknowledged", model: model) {
|
||||||
|
!model.state.seek_pending && abs(model.state.position - 0.8) < 0.1
|
||||||
|
}
|
||||||
|
model.closeFile()
|
||||||
|
require(model.sliderBoundaries.isEmpty, "close immediately removes chapter boundaries")
|
||||||
|
try await until("chapter playback closed", model: model) { model.state.status == PLAYER_EMPTY }
|
||||||
|
require(model.savedPosition(for: url).map { abs($0 - 0.8) < 0.1 } == true,
|
||||||
|
"acknowledged bookmark persists for canonical YouTube URL")
|
||||||
|
require(model.recentMedia.first?.url == url, "recent entry retains original YouTube URL")
|
||||||
|
model.open(model.recentMedia.first!.url)
|
||||||
|
try await until("recent YouTube reopen restores native bookmark", model: model) {
|
||||||
|
!model.isBusy && model.state.status == PLAYER_PLAYING && model.state.position >= 0.7
|
||||||
|
}
|
||||||
|
requireYouTubeChapters(model, expected, "saved/recent open(url)")
|
||||||
|
require(model.setPlayback(playing: false), "pause before chapter quality replacement")
|
||||||
|
try await until("chapter quality pause acknowledged", model: model) { model.state.status == PLAYER_PAUSED }
|
||||||
|
let position = model.state.position
|
||||||
|
model.setYouTubeQuality(720)
|
||||||
|
require(model.sliderBoundaries.isEmpty, "quality extraction hides prior chapter boundaries")
|
||||||
|
try await until("chapter quality replacement preserves pause and position", model: model) {
|
||||||
|
!model.isBusy && model.state.status == PLAYER_PAUSED && model.youtubeHeight == 720 &&
|
||||||
|
abs(model.state.position - position) < 0.2
|
||||||
|
}
|
||||||
|
requireYouTubeChapters(model, expected, "chaptered/chapterless quality replacement")
|
||||||
|
}
|
||||||
|
model.open(chaptered)
|
||||||
|
try await until("chaptered media replaces chapterless media", model: model) {
|
||||||
|
!model.isBusy && model.state.status == PLAYER_PLAYING && model.fileName == "Chaptered launch"
|
||||||
|
}
|
||||||
|
requireYouTubeChapters(model, [1.25, 2.5], "chaptered replacement")
|
||||||
|
model.openResolvedSource(file)
|
||||||
|
require(model.sliderBoundaries.isEmpty, "non-YouTube replacement hides prior chapters while opening")
|
||||||
|
try await until("resolved replacement decodes without YouTube chapters", model: model) {
|
||||||
|
!model.isBusy && model.state.status == PLAYER_PLAYING && model.state.has_video && model.state.has_audio
|
||||||
|
}
|
||||||
|
require(model.sliderBoundaries.isEmpty, "resolved media does not inherit YouTube chapters")
|
||||||
|
model.closeFile()
|
||||||
|
try await until("resolved replacement closes", model: model) { model.state.status == PLAYER_EMPTY }
|
||||||
|
require(model.sliderBoundaries.isEmpty, "empty player has no chapter boundaries")
|
||||||
|
require(requests == [chaptered, chaptered, chaptered, chapterless, chapterless, chapterless, chaptered],
|
||||||
|
"direct, recent, saved and quality opens each resolve fresh chapter metadata")
|
||||||
|
model.shutdown()
|
||||||
|
let rows = try String(contentsOf: report, encoding: .utf8).split(separator: "\n").map {
|
||||||
|
try JSONSerialization.jsonObject(with: Data($0.utf8)) as! [String: Any]
|
||||||
|
}
|
||||||
|
require(rows.contains {
|
||||||
|
$0["version"] as? Int == 1 && $0["media_url"] as? String == chaptered.absoluteString &&
|
||||||
|
$0["event"] as? String == "progress"
|
||||||
|
}, "single Tubular launch reports real playback under its canonical YouTube identity")
|
||||||
|
print("PASS: chaptered/chapterless direct and single Tubular launch, saved/recent reopen, quality, measured duration, replacement and close")
|
||||||
|
}
|
||||||
|
|
||||||
static func transportProtocol(source: ResolvedSource) async throws {
|
static func transportProtocol(source: ResolvedSource) async throws {
|
||||||
let original = URLComponents(string: source.url)!
|
let original = URLComponents(string: source.url)!
|
||||||
let base = "\(original.scheme!)://\(original.host!):\(original.port!)"
|
let base = "\(original.scheme!)://\(original.host!):\(original.port!)"
|
||||||
@@ -298,7 +402,8 @@ struct ResolvedPlaybackCheck {
|
|||||||
var titleRequests: [URL] = []
|
var titleRequests: [URL] = []
|
||||||
func video(_ url: URL, title: String, height: Int = 1080) -> YouTubeVideo {
|
func video(_ url: URL, title: String, height: Int = 1080) -> YouTubeVideo {
|
||||||
YouTubeVideo(title: title, video: .init(url: source.url, headers: source.headers), audio: nil,
|
YouTubeVideo(title: title, video: .init(url: source.url, headers: source.headers), audio: nil,
|
||||||
duration: 0, isLive: false, webpageURL: url, height: height, availableHeights: [1080, 720])
|
duration: 0, isLive: false, webpageURL: url, height: height, availableHeights: [1080, 720],
|
||||||
|
chapters: height == 720 ? [.init(start: 0.75, end: 1.75, title: nil)] : fixtureChapters)
|
||||||
}
|
}
|
||||||
let model = PlayerModel(defaults: defaults, youtubeResolver: { url, height in
|
let model = PlayerModel(defaults: defaults, youtubeResolver: { url, height in
|
||||||
if holdFirst || height != nil {
|
if holdFirst || height != nil {
|
||||||
@@ -352,6 +457,7 @@ struct ResolvedPlaybackCheck {
|
|||||||
try await until("foreground title takes precedence and native playback starts", model: model) {
|
try await until("foreground title takes precedence and native playback starts", model: model) {
|
||||||
model.state.status == PLAYER_PLAYING && model.queue.currentItem?.displayName == "Playing title"
|
model.state.status == PLAYER_PLAYING && model.queue.currentItem?.displayName == "Playing title"
|
||||||
}
|
}
|
||||||
|
requireYouTubeChapters(model, [1.25, 2.5], "foreground queued metadata")
|
||||||
require(model.setPlayback(playing: false), "pause for metadata preservation")
|
require(model.setPlayback(playing: false), "pause for metadata preservation")
|
||||||
try await until("metadata smoke pause acknowledged", model: model) { model.state.status == PLAYER_PAUSED }
|
try await until("metadata smoke pause acknowledged", model: model) { model.state.status == PLAYER_PAUSED }
|
||||||
let position = model.state.position
|
let position = model.state.position
|
||||||
@@ -383,10 +489,14 @@ struct ResolvedPlaybackCheck {
|
|||||||
for _ in 0..<10 { await Task.yield() }
|
for _ in 0..<10 { await Task.yield() }
|
||||||
require(model.queue.items[1].displayName == urls[1].absoluteString,
|
require(model.queue.items[1].displayName == urls[1].absoluteString,
|
||||||
"removed/replaced item cannot rename a new row for the same URL")
|
"removed/replaced item cannot rename a new row for the same URL")
|
||||||
|
try await until("replacement native media ready for quality change", model: model) {
|
||||||
|
!model.isBusy && model.state.status == PLAYER_PLAYING && model.fileName == "Playing title"
|
||||||
|
}
|
||||||
require(model.setPlayback(playing: false), "pause before quality extraction")
|
require(model.setPlayback(playing: false), "pause before quality extraction")
|
||||||
try await until("quality smoke pause acknowledged", model: model) { model.state.status == PLAYER_PAUSED }
|
try await until("quality smoke pause acknowledged", model: model) { model.state.status == PLAYER_PAUSED }
|
||||||
model.setYouTubeQuality(720)
|
model.setYouTubeQuality(720)
|
||||||
try await until("quality extraction is held", model: model) { foreground != nil }
|
try await until("quality extraction is held", model: model) { foreground != nil }
|
||||||
|
require(model.sliderBoundaries.isEmpty, "held quality replacement hides previous chapter boundaries")
|
||||||
background!.resume(returning: "Replacement title")
|
background!.resume(returning: "Replacement title")
|
||||||
background = nil
|
background = nil
|
||||||
try await until("title arrives while quality extraction is held", model: model) {
|
try await until("title arrives while quality extraction is held", model: model) {
|
||||||
@@ -399,6 +509,7 @@ struct ResolvedPlaybackCheck {
|
|||||||
try await until("quality extraction resumes paused native playback", model: model) {
|
try await until("quality extraction resumes paused native playback", model: model) {
|
||||||
!model.isBusy && model.state.status == PLAYER_PAUSED && model.fileName == "Quality title"
|
!model.isBusy && model.state.status == PLAYER_PAUSED && model.fileName == "Quality title"
|
||||||
}
|
}
|
||||||
|
requireYouTubeChapters(model, [0.75, 1.75], "quality replacement accepts fresh chapters and preserves pause")
|
||||||
print("PASS: early current/queued titles during held playback and quality extraction; serial utility lookup, generic/Miruro titles, reorder, failure and stale replacement/foreground precedence")
|
print("PASS: early current/queued titles during held playback and quality extraction; serial utility lookup, generic/Miruro titles, reorder, failure and stale replacement/foreground precedence")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -424,7 +535,8 @@ struct ResolvedPlaybackCheck {
|
|||||||
var stale: CheckedContinuation<YouTubeVideo, Never>?
|
var stale: CheckedContinuation<YouTubeVideo, Never>?
|
||||||
let video = YouTubeVideo(title: "Queue YouTube fixture",
|
let video = YouTubeVideo(title: "Queue YouTube fixture",
|
||||||
video: .init(url: source.url, headers: source.headers), audio: nil,
|
video: .init(url: source.url, headers: source.headers), audio: nil,
|
||||||
duration: 0, isLive: false, webpageURL: youtubeURL, height: nil, availableHeights: [])
|
duration: 0, isLive: false, webpageURL: youtubeURL, height: nil, availableHeights: [],
|
||||||
|
chapters: fixtureChapters)
|
||||||
let model = PlayerModel(defaults: defaults, streamQueue: queue, youtubeResolver: { _, _ in
|
let model = PlayerModel(defaults: defaults, streamQueue: queue, youtubeResolver: { _, _ in
|
||||||
youtubeRequests += 1
|
youtubeRequests += 1
|
||||||
if youtubeRequests == 1 {
|
if youtubeRequests == 1 {
|
||||||
@@ -447,17 +559,20 @@ struct ResolvedPlaybackCheck {
|
|||||||
try await until("manual stream selection decodes native media", model: model) {
|
try await until("manual stream selection decodes native media", model: model) {
|
||||||
!model.isBusy && model.state.status == PLAYER_PLAYING && model.state.position > 0.2
|
!model.isBusy && model.state.status == PLAYER_PLAYING && model.state.position > 0.2
|
||||||
}
|
}
|
||||||
|
require(model.sliderBoundaries.isEmpty, "selected Miruro media has no stale YouTube chapters")
|
||||||
stale!.resume(returning: video)
|
stale!.resume(returning: video)
|
||||||
stale = nil
|
stale = nil
|
||||||
// Yield to the canceled resolver; it must not open or redirect its report.
|
// Yield to the canceled resolver; it must not open or redirect its report.
|
||||||
for _ in 0..<10 { await Task.yield() }
|
for _ in 0..<10 { await Task.yield() }
|
||||||
require(model.queue.currentID == secondID && !model.miruroVideoProviders.isEmpty &&
|
require(model.queue.currentID == secondID && !model.miruroVideoProviders.isEmpty &&
|
||||||
!FileManager.default.fileExists(atPath: firstReport.path), "stale extraction cannot replace selected media or report")
|
!FileManager.default.fileExists(atPath: firstReport.path), "stale extraction cannot replace selected media or report")
|
||||||
|
require(model.sliderBoundaries.isEmpty, "canceled chaptered extraction cannot add boundaries to Miruro media")
|
||||||
model.advanceQueue(-1)
|
model.advanceQueue(-1)
|
||||||
try await until("previous resolves YouTube afresh", model: model) {
|
try await until("previous resolves YouTube afresh", model: model) {
|
||||||
!model.isBusy && model.state.status == PLAYER_PLAYING && model.fileName == video.title
|
!model.isBusy && model.state.status == PLAYER_PLAYING && model.fileName == video.title
|
||||||
}
|
}
|
||||||
require(youtubeRequests == 2 && model.queue.currentID == firstID, "manual previous preserves queue and resolves current item")
|
require(youtubeRequests == 2 && model.queue.currentID == firstID, "manual previous preserves queue and resolves current item")
|
||||||
|
requireYouTubeChapters(model, [1.25, 2.5], "batch queue previous")
|
||||||
require(model.setPlayback(playing: false), "pause first queued stream")
|
require(model.setPlayback(playing: false), "pause first queued stream")
|
||||||
try await until("queue pause acknowledged", model: model) { model.state.status == PLAYER_PAUSED }
|
try await until("queue pause acknowledged", model: model) { model.state.status == PLAYER_PAUSED }
|
||||||
require(model.seekRelative(max(0, model.duration - 0.3) - model.state.position), "seek first stream near natural end")
|
require(model.seekRelative(max(0, model.duration - 0.3) - model.state.position), "seek first stream near natural end")
|
||||||
@@ -469,6 +584,7 @@ struct ResolvedPlaybackCheck {
|
|||||||
model.queue.currentID == secondID && !model.isBusy && model.state.status == PLAYER_PLAYING &&
|
model.queue.currentID == secondID && !model.isBusy && model.state.status == PLAYER_PLAYING &&
|
||||||
!model.miruroVideoProviders.isEmpty && episodeRequests == 2
|
!model.miruroVideoProviders.isEmpty && episodeRequests == 2
|
||||||
}
|
}
|
||||||
|
require(model.sliderBoundaries.isEmpty, "natural queue advance clears YouTube chapters for Miruro")
|
||||||
require(model.setPlayback(playing: false), "pause second stream for restoration")
|
require(model.setPlayback(playing: false), "pause second stream for restoration")
|
||||||
try await until("second paused", model: model) { model.state.status == PLAYER_PAUSED }
|
try await until("second paused", model: model) { model.state.status == PLAYER_PAUSED }
|
||||||
require(model.seekRelative(0.8 - model.state.position), "save an acknowledged second-stream bookmark")
|
require(model.seekRelative(0.8 - model.state.position), "save an acknowledged second-stream bookmark")
|
||||||
@@ -479,6 +595,7 @@ struct ResolvedPlaybackCheck {
|
|||||||
try await until("revisit first report session", model: model) {
|
try await until("revisit first report session", model: model) {
|
||||||
!model.isBusy && model.fileName == video.title && model.state.status == PLAYER_PLAYING
|
!model.isBusy && model.fileName == video.title && model.state.status == PLAYER_PLAYING
|
||||||
}
|
}
|
||||||
|
requireYouTubeChapters(model, [1.25, 2.5], "batch queue revisit")
|
||||||
model.advanceQueue(1)
|
model.advanceQueue(1)
|
||||||
try await until("second revisit resumes acknowledged bookmark", model: model) {
|
try await until("second revisit resumes acknowledged bookmark", model: model) {
|
||||||
!model.isBusy && model.queue.currentID == secondID && model.state.status == PLAYER_PLAYING &&
|
!model.isBusy && model.queue.currentID == secondID && model.state.status == PLAYER_PLAYING &&
|
||||||
@@ -530,7 +647,8 @@ struct ResolvedPlaybackCheck {
|
|||||||
requests += 1
|
requests += 1
|
||||||
return YouTubeVideo(title: url.absoluteString,
|
return YouTubeVideo(title: url.absoluteString,
|
||||||
video: .init(url: source.url, headers: source.headers), audio: nil,
|
video: .init(url: source.url, headers: source.headers), audio: nil,
|
||||||
duration: 0, isLive: false, webpageURL: url, height: nil, availableHeights: [])
|
duration: 0, isLive: false, webpageURL: url, height: nil, availableHeights: [],
|
||||||
|
chapters: url == first ? fixtureChapters : [])
|
||||||
}, youtubeTitleResolver: { _ in nil }, miruroResolver: { _, _ in episodeRequests += 1; return try providerSources(source) },
|
}, youtubeTitleResolver: { _ in nil }, miruroResolver: { _, _ in episodeRequests += 1; return try providerSources(source) },
|
||||||
miruroMetadataResolver: { _, _ in MiruroMetadata(title: nil, intervals: []) })
|
miruroMetadataResolver: { _, _ in MiruroMetadata(title: nil, intervals: []) })
|
||||||
defer { model.shutdown() }
|
defer { model.shutdown() }
|
||||||
@@ -545,6 +663,7 @@ struct ResolvedPlaybackCheck {
|
|||||||
try await until("standalone stream begins before enqueue", model: model) {
|
try await until("standalone stream begins before enqueue", model: model) {
|
||||||
model.state.status == PLAYER_PLAYING && !model.isBusy && model.state.position > 0.2
|
model.state.status == PLAYER_PLAYING && !model.isBusy && model.state.position > 0.2
|
||||||
}
|
}
|
||||||
|
requireYouTubeChapters(model, [1.25, 2.5], "single stream before enqueue")
|
||||||
require(model.setPlayback(playing: false), "pause original single-video playback")
|
require(model.setPlayback(playing: false), "pause original single-video playback")
|
||||||
try await until("standalone pause acknowledged", model: model) { model.state.status == PLAYER_PAUSED }
|
try await until("standalone pause acknowledged", model: model) { model.state.status == PLAYER_PAUSED }
|
||||||
let selected = model.queue.currentID
|
let selected = model.queue.currentID
|
||||||
@@ -556,6 +675,7 @@ struct ResolvedPlaybackCheck {
|
|||||||
model.state.position == position && model.state.revision == revision &&
|
model.state.position == position && model.state.revision == revision &&
|
||||||
model.fileName == first.absoluteString && requests == 1 && episodeRequests == 0,
|
model.fileName == first.absoluteString && requests == 1 && episodeRequests == 0,
|
||||||
"paused enqueue preserves original media, selection and decoder timeline; new items resolve lazily in supplied order")
|
"paused enqueue preserves original media, selection and decoder timeline; new items resolve lazily in supplied order")
|
||||||
|
requireYouTubeChapters(model, [1.25, 2.5], "paused enqueue preserves current chapters")
|
||||||
require(model.setPlayback(playing: true), "resume original playback")
|
require(model.setPlayback(playing: true), "resume original playback")
|
||||||
try await until("original playing again", model: model) { model.state.status == PLAYER_PLAYING }
|
try await until("original playing again", model: model) { model.state.status == PLAYER_PLAYING }
|
||||||
let playingRevision = model.state.revision
|
let playingRevision = model.state.revision
|
||||||
@@ -564,6 +684,7 @@ struct ResolvedPlaybackCheck {
|
|||||||
model.queue.currentID == selected && model.state.revision == playingRevision &&
|
model.queue.currentID == selected && model.state.revision == playingRevision &&
|
||||||
model.state.status == PLAYER_PLAYING,
|
model.state.status == PLAYER_PLAYING,
|
||||||
"repeated native delivery appends while playing without reopening current media")
|
"repeated native delivery appends while playing without reopening current media")
|
||||||
|
requireYouTubeChapters(model, [1.25, 2.5], "playing enqueue preserves current chapters")
|
||||||
try deliver([(third, directory.appendingPathComponent("conflict.jsonl"))])
|
try deliver([(third, directory.appendingPathComponent("conflict.jsonl"))])
|
||||||
require(model.commandError != nil && model.queue.items.count == 4 && model.queue.currentID == selected,
|
require(model.commandError != nil && model.queue.items.count == 4 && model.queue.currentID == selected,
|
||||||
"conflicting identity rejects complete enqueue without changing playback")
|
"conflicting identity rejects complete enqueue without changing playback")
|
||||||
@@ -588,10 +709,12 @@ struct ResolvedPlaybackCheck {
|
|||||||
!model.isBusy && model.state.status == PLAYER_PLAYING && !model.miruroVideoProviders.isEmpty &&
|
!model.isBusy && model.state.status == PLAYER_PLAYING && !model.miruroVideoProviders.isEmpty &&
|
||||||
model.queue.currentURL == second
|
model.queue.currentURL == second
|
||||||
}
|
}
|
||||||
|
require(model.sliderBoundaries.isEmpty, "enqueued Miruro advance clears current chapters")
|
||||||
model.advanceQueue(1)
|
model.advanceQueue(1)
|
||||||
try await until("enqueued YouTube item decodes", model: model) {
|
try await until("enqueued YouTube item decodes", model: model) {
|
||||||
!model.isBusy && model.state.status == PLAYER_PLAYING && model.fileName == third.absoluteString
|
!model.isBusy && model.state.status == PLAYER_PLAYING && model.fileName == third.absoluteString
|
||||||
}
|
}
|
||||||
|
requireYouTubeChapters(model, [], "chapterless enqueued YouTube advance")
|
||||||
model.advanceQueue(-1)
|
model.advanceQueue(-1)
|
||||||
try await until("enqueued Miruro report revisits", model: model) {
|
try await until("enqueued Miruro report revisits", model: model) {
|
||||||
!model.isBusy && model.state.status == PLAYER_PLAYING && model.queue.currentURL == second
|
!model.isBusy && model.state.status == PLAYER_PLAYING && model.queue.currentURL == second
|
||||||
@@ -600,6 +723,7 @@ struct ResolvedPlaybackCheck {
|
|||||||
try await until("original single video remains navigable", model: model) {
|
try await until("original single video remains navigable", model: model) {
|
||||||
!model.isBusy && model.state.status == PLAYER_PLAYING && model.fileName == first.absoluteString
|
!model.isBusy && model.state.status == PLAYER_PLAYING && model.fileName == first.absoluteString
|
||||||
}
|
}
|
||||||
|
requireYouTubeChapters(model, [1.25, 2.5], "enqueued revisit restores chaptered YouTube")
|
||||||
var retiredReports: [(URL, Data, URL, URL)] = []
|
var retiredReports: [(URL, Data, URL, URL)] = []
|
||||||
for (url, oldReport) in [(first, originalReport), (third, thirdReport)] {
|
for (url, oldReport) in [(first, originalReport), (third, thirdReport)] {
|
||||||
let oldID = model.queue.items.first(where: { $0.url == url })!.id
|
let oldID = model.queue.items.first(where: { $0.url == url })!.id
|
||||||
@@ -649,7 +773,7 @@ struct ResolvedPlaybackCheck {
|
|||||||
require(!FileManager.default.fileExists(atPath: fourthReport.path), "unselected enqueued item never owns a writer")
|
require(!FileManager.default.fileExists(atPath: fourthReport.path), "unselected enqueued item never owns a writer")
|
||||||
let empty = PlayerModel(defaults: defaults, youtubeResolver: { url, _ in
|
let empty = PlayerModel(defaults: defaults, youtubeResolver: { url, _ in
|
||||||
YouTubeVideo(title: "Cold enqueue", video: .init(url: source.url, headers: source.headers), audio: nil,
|
YouTubeVideo(title: "Cold enqueue", video: .init(url: source.url, headers: source.headers), audio: nil,
|
||||||
duration: 0, isLive: false, webpageURL: url, height: nil, availableHeights: [])
|
duration: 0, isLive: false, webpageURL: url, height: nil, availableHeights: [], chapters: [])
|
||||||
}, youtubeTitleResolver: { _ in nil })
|
}, youtubeTitleResolver: { _ in nil })
|
||||||
defer { empty.shutdown() }
|
defer { empty.shutdown() }
|
||||||
let coldReport = directory.appendingPathComponent("cold.jsonl")
|
let coldReport = directory.appendingPathComponent("cold.jsonl")
|
||||||
@@ -658,6 +782,7 @@ struct ResolvedPlaybackCheck {
|
|||||||
try await until("empty player starts first enqueued item", model: empty) {
|
try await until("empty player starts first enqueued item", model: empty) {
|
||||||
!empty.isBusy && empty.state.status == PLAYER_PLAYING && empty.queue.currentURL == fourth
|
!empty.isBusy && empty.state.status == PLAYER_PLAYING && empty.queue.currentURL == fourth
|
||||||
}
|
}
|
||||||
|
requireYouTubeChapters(empty, [], "cold chapterless enqueue")
|
||||||
print("PASS: repeated enqueue while paused/playing, standalone navigation, atomic conflicts, independent reports and empty-player start")
|
print("PASS: repeated enqueue while paused/playing, standalone navigation, atomic conflicts, independent reports and empty-player start")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -677,7 +802,8 @@ struct ResolvedPlaybackCheck {
|
|||||||
if url == failedURL { throw CocoaError(.fileReadCorruptFile) }
|
if url == failedURL { throw CocoaError(.fileReadCorruptFile) }
|
||||||
let video = YouTubeVideo(title: "Sidebar \(url == first ? "first" : "second")",
|
let video = YouTubeVideo(title: "Sidebar \(url == first ? "first" : "second")",
|
||||||
video: .init(url: source.url, headers: source.headers), audio: nil,
|
video: .init(url: source.url, headers: source.headers), audio: nil,
|
||||||
duration: 0, isLive: false, webpageURL: url, height: nil, availableHeights: [])
|
duration: 0, isLive: false, webpageURL: url, height: nil, availableHeights: [],
|
||||||
|
chapters: url == first ? fixtureChapters : [])
|
||||||
if url == cancelledURL { return await withCheckedContinuation { pending = $0 } }
|
if url == cancelledURL { return await withCheckedContinuation { pending = $0 } }
|
||||||
return video
|
return video
|
||||||
}, youtubeTitleResolver: { _ in nil }, miruroResolver: { _, _ in try providerSources(source) },
|
}, youtubeTitleResolver: { _ in nil }, miruroResolver: { _, _ in try providerSources(source) },
|
||||||
@@ -688,6 +814,7 @@ struct ResolvedPlaybackCheck {
|
|||||||
!model.isBusy && model.state.status == PLAYER_PLAYING && model.state.position > 0.2
|
!model.isBusy && model.state.status == PLAYER_PLAYING && model.state.position > 0.2
|
||||||
}
|
}
|
||||||
require(requests == 1 && model.queue.currentURL == first, "empty addition uses fetched source without re-extracting")
|
require(requests == 1 && model.queue.currentURL == first, "empty addition uses fetched source without re-extracting")
|
||||||
|
requireYouTubeChapters(model, [1.25, 2.5], "sidebar first native playback")
|
||||||
require(model.setPlayback(playing: false), "pause before sidebar addition")
|
require(model.setPlayback(playing: false), "pause before sidebar addition")
|
||||||
try await until("sidebar pause acknowledged", model: model) { model.state.status == PLAYER_PAUSED }
|
try await until("sidebar pause acknowledged", model: model) { model.state.status == PLAYER_PAUSED }
|
||||||
let selected = model.queue.currentID
|
let selected = model.queue.currentID
|
||||||
@@ -722,18 +849,21 @@ struct ResolvedPlaybackCheck {
|
|||||||
while pending == nil { await Task.yield() }
|
while pending == nil { await Task.yield() }
|
||||||
cancelled.cancel()
|
cancelled.cancel()
|
||||||
pending!.resume(returning: YouTubeVideo(title: "Cancelled", video: .init(url: source.url, headers: source.headers),
|
pending!.resume(returning: YouTubeVideo(title: "Cancelled", video: .init(url: source.url, headers: source.headers),
|
||||||
audio: nil, duration: 0, isLive: false, webpageURL: cancelledURL, height: nil, availableHeights: []))
|
audio: nil, duration: 0, isLive: false, webpageURL: cancelledURL, height: nil, availableHeights: [],
|
||||||
|
chapters: [.init(start: 0.75, end: 1.75, title: "Canceled metadata")]))
|
||||||
do {
|
do {
|
||||||
try await cancelled.value
|
try await cancelled.value
|
||||||
fatalError("FAIL: cancelled sidebar addition accepted")
|
fatalError("FAIL: cancelled sidebar addition accepted")
|
||||||
} catch is CancellationError {}
|
} catch is CancellationError {}
|
||||||
require(model.queue.items.count == 4 && model.queue.currentID == selected,
|
require(model.queue.items.count == 4 && model.queue.currentID == selected,
|
||||||
"dismissed in-flight addition cannot append a late result")
|
"dismissed in-flight addition cannot append a late result")
|
||||||
|
requireYouTubeChapters(model, [1.25, 2.5], "canceled sidebar metadata leaves accepted chapters unchanged")
|
||||||
model.selectQueueItem(model.queue.items[1].id)
|
model.selectQueueItem(model.queue.items[1].id)
|
||||||
try await until("sidebar-added item plays through native queue selection", model: model) {
|
try await until("sidebar-added item plays through native queue selection", model: model) {
|
||||||
!model.isBusy && model.state.status == PLAYER_PLAYING && model.queue.currentURL == second &&
|
!model.isBusy && model.state.status == PLAYER_PLAYING && model.queue.currentURL == second &&
|
||||||
model.fileName == "Sidebar second"
|
model.fileName == "Sidebar second"
|
||||||
}
|
}
|
||||||
|
requireYouTubeChapters(model, [], "sidebar chapterless native selection")
|
||||||
print("PASS: sidebar URL fetch/add, empty-player decode, paused/playing preservation, validation, failure, cancellation and selection")
|
print("PASS: sidebar URL fetch/add, empty-player decode, paused/playing preservation, validation, failure, cancellation and selection")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -742,12 +872,15 @@ struct ResolvedPlaybackCheck {
|
|||||||
let ending = MiruroInterval(kind: .outro, start: 2, end: model.duration, episode: 1)
|
let ending = MiruroInterval(kind: .outro, start: 2, end: model.duration, episode: 1)
|
||||||
let metadata = MiruroMetadata(title: nil, intervals: [opening, ending])
|
let metadata = MiruroMetadata(title: nil, intervals: [opening, ending])
|
||||||
require(model.segmentToSkip == nil && !model.skipSegment(), "absent metadata cannot skip")
|
require(model.segmentToSkip == nil && !model.skipSegment(), "absent metadata cannot skip")
|
||||||
|
require(model.sliderBoundaries.isEmpty, "absent Miruro metadata leaves slider unsegmented")
|
||||||
require(model.seekRelative(0.75 - model.state.position), "enter opening")
|
require(model.seekRelative(0.75 - model.state.position), "enter opening")
|
||||||
try await until("opening position acknowledged", model: model) {
|
try await until("opening position acknowledged", 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
|
||||||
}
|
}
|
||||||
model.supplyMetadataForCheck(metadata, episode: 1)
|
model.supplyMetadataForCheck(metadata, episode: 1)
|
||||||
require(model.segmentToSkip == opening, "late metadata offers opening at acknowledged paused position")
|
require(model.segmentToSkip == opening, "late metadata offers opening at acknowledged paused position")
|
||||||
|
require(model.visibleIntervals == [opening, ending] && model.sliderBoundaries == [0.5, 1.5, 2],
|
||||||
|
"Miruro retains intro/outro bands and interior slider boundaries without the media endpoint")
|
||||||
require(model.skipOfferRemainingFraction == 1, "late entry starts with a full countdown background")
|
require(model.skipOfferRemainingFraction == 1, "late entry starts with a full countdown background")
|
||||||
clock.now = 105
|
clock.now = 105
|
||||||
require(model.skipOfferRemainingFraction == 0.5, "paused countdown drains by elapsed offer time, not media position")
|
require(model.skipOfferRemainingFraction == 0.5, "paused countdown drains by elapsed offer time, not media position")
|
||||||
@@ -777,6 +910,7 @@ struct ResolvedPlaybackCheck {
|
|||||||
require(model.segmentToSkip == nil, "ignored opening stays dismissed when revisited")
|
require(model.segmentToSkip == nil, "ignored opening stays dismissed when revisited")
|
||||||
model.closeFile()
|
model.closeFile()
|
||||||
require(model.segmentToSkip == nil, "close removes offer")
|
require(model.segmentToSkip == nil, "close removes offer")
|
||||||
|
require(model.sliderBoundaries.isEmpty, "closing Miruro media removes slider boundaries")
|
||||||
model.openResolvedSource(file)
|
model.openResolvedSource(file)
|
||||||
try await until("reopened media playing", model: model) {
|
try await until("reopened media playing", model: model) {
|
||||||
model.state.status == PLAYER_PLAYING && !model.state.seek_pending && !model.isBusy
|
model.state.status == PLAYER_PLAYING && !model.state.seek_pending && !model.isBusy
|
||||||
@@ -787,6 +921,7 @@ struct ResolvedPlaybackCheck {
|
|||||||
try await until("reopened opening acknowledged", model: model) {
|
try await until("reopened opening acknowledged", 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.sliderBoundaries.isEmpty, "reopened Miruro media waits for fresh metadata")
|
||||||
model.supplyMetadataForCheck(metadata, episode: 1)
|
model.supplyMetadataForCheck(metadata, episode: 1)
|
||||||
try await until("new open resets dismissal after seek settles", model: model) {
|
try await until("new open resets dismissal after seek settles", model: model) {
|
||||||
model.segmentToSkip == opening
|
model.segmentToSkip == opening
|
||||||
|
|||||||
@@ -167,6 +167,79 @@ 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 chapters() throws {
|
||||||
|
var object = try fixture(combinedJSON)
|
||||||
|
try require(try decode(object).chapters.isEmpty, "absent chapters remain playable")
|
||||||
|
for payload in [NSNull(), [], true, 17, "chapters", [:],
|
||||||
|
[NSNull(), 17, "row", [:], ["start_time": 0],
|
||||||
|
["start_time": "0", "end_time": 2], ["start_time": 0, "end_time": false]]] as [Any] {
|
||||||
|
object["chapters"] = payload
|
||||||
|
let video = try decode(object)
|
||||||
|
try require(video.chapters.isEmpty && video.video.url == "https://media.example/combined",
|
||||||
|
"null, empty and malformed optional chapters never prevent playback")
|
||||||
|
}
|
||||||
|
object["chapters"] = [
|
||||||
|
NSNull(),
|
||||||
|
["start_time": 0, "end_time": 2.25, "title": "Opening"],
|
||||||
|
["start_time": 2.25, "end_time": 8.75],
|
||||||
|
["start_time": 8.75, "end_time": 12, "title": ["malformed"]],
|
||||||
|
["start_time": 2.25, "end_time": 8.75, "title": NSNull()]
|
||||||
|
] as [Any]
|
||||||
|
let decoded = try decode(object)
|
||||||
|
try require(decoded.chapters.count == 4 && decoded.chapters[0].title == "Opening"
|
||||||
|
&& decoded.chapters[0].end == 2.25 && decoded.chapters[1].start == 2.25,
|
||||||
|
"fractional chapters and titles survive malformed sibling rows")
|
||||||
|
try require(decoded.chapters.dropFirst().allSatisfy { $0.title == nil },
|
||||||
|
"missing, null and malformed optional titles do not discard chapters")
|
||||||
|
try require(decoded.chapterBoundaries(duration: 12) == [2.25, 8.75],
|
||||||
|
"chapter edges are sorted, deduplicated and strictly interior")
|
||||||
|
try require(decoded.chapterTitle(at: 0, duration: 12) == "Opening" &&
|
||||||
|
decoded.chapterTitle(at: 2.249, duration: 12) == "Opening" &&
|
||||||
|
decoded.chapterTitle(at: 2.25, duration: 12) == nil &&
|
||||||
|
decoded.chapterTitle(at: 12, duration: 12) == nil,
|
||||||
|
"hover titles include chapter start, exclude its end and never invent missing titles")
|
||||||
|
for duration in [0, -1, 1, Double.nan, .infinity] {
|
||||||
|
try require(decoded.chapterTitle(at: 0.5, duration: duration) == nil,
|
||||||
|
"unknown durations and chapters extending beyond media have no hover title")
|
||||||
|
}
|
||||||
|
|
||||||
|
object["duration"] = 0
|
||||||
|
object["chapters"] = [["start_time": 0, "end_time": 2.25],
|
||||||
|
["start_time": 2.25, "end_time": 20.5]]
|
||||||
|
let unknownDuration = try decode(object)
|
||||||
|
try require(unknownDuration.chapterBoundaries(duration: 20.5) == [2.25],
|
||||||
|
"actual playback duration, not extractor duration, validates chapters")
|
||||||
|
for duration in [0, -1, Double.nan, .infinity, -.infinity] {
|
||||||
|
try require(unknownDuration.chapterBoundaries(duration: duration).isEmpty,
|
||||||
|
"unknown and invalid playback durations never produce boundaries")
|
||||||
|
}
|
||||||
|
|
||||||
|
let video = YouTubeVideo(title: "Chapters",
|
||||||
|
video: .init(url: "https://media.example/combined", headers: ""), audio: nil,
|
||||||
|
duration: 0, isLive: false, webpageURL: page, height: nil, availableHeights: [],
|
||||||
|
chapters: [
|
||||||
|
.init(start: 8.75, end: 12, title: nil),
|
||||||
|
.init(start: 0, end: 2.25, title: nil),
|
||||||
|
.init(start: 2.25, end: 8.75, title: nil),
|
||||||
|
.init(start: 2.25, end: 8.75, title: nil),
|
||||||
|
.init(start: -1, end: 4, title: nil),
|
||||||
|
.init(start: 3, end: 13, title: nil),
|
||||||
|
.init(start: 6, end: 6, title: nil),
|
||||||
|
.init(start: 7, end: 5, title: nil),
|
||||||
|
.init(start: .nan, end: 4, title: nil),
|
||||||
|
.init(start: .infinity, end: 4, title: nil),
|
||||||
|
.init(start: -.infinity, end: 4, title: nil),
|
||||||
|
.init(start: 3, end: .nan, title: nil),
|
||||||
|
.init(start: 3, end: .infinity, title: nil),
|
||||||
|
.init(start: 3, end: -.infinity, title: nil)
|
||||||
|
])
|
||||||
|
try require(video.chapterBoundaries(duration: 12) == [2.25, 8.75],
|
||||||
|
"invalid, zero-length, reversed and over-range rows contribute neither edge")
|
||||||
|
object["chapters"] = "malformed optional data"
|
||||||
|
object["url"] = 17
|
||||||
|
try reject("chapter tolerance must not weaken required stream validation") { _ = try decode(object) }
|
||||||
|
}
|
||||||
|
|
||||||
static func qualities() throws {
|
static func qualities() throws {
|
||||||
func video(_ height: Int, audio: Bool = false, range: String = "SDR", transport: String = "https") -> [String: Any] {
|
func video(_ height: Int, audio: Bool = false, range: String = "SDR", transport: String = "https") -> [String: Any] {
|
||||||
["height": height, "vcodec": "h264", "acodec": audio ? "aac" : "none",
|
["height": height, "vcodec": "h264", "acodec": audio ? "aac" : "none",
|
||||||
@@ -609,6 +682,7 @@ struct YouTubeResolverChecks {
|
|||||||
try FileManager.default.createSymbolicLink(at: runtime, withDestinationURL: URL(fileURLWithPath: "/usr/bin/true"))
|
try FileManager.default.createSymbolicLink(at: runtime, withDestinationURL: URL(fileURLWithPath: "/usr/bin/true"))
|
||||||
try videoURLs()
|
try videoURLs()
|
||||||
try metadata()
|
try metadata()
|
||||||
|
try chapters()
|
||||||
try malformedMetadata()
|
try malformedMetadata()
|
||||||
try qualities()
|
try qualities()
|
||||||
try bundledHelpers(root: root)
|
try bundledHelpers(root: root)
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ To run without installing, use `make run`. The built `.build/terebi.app` is self
|
|||||||
- **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.
|
||||||
- **Streams:** use **Open Stream…** (**⇧⌘O**) or drop an HTTP(S) video link. To append without replacing playback, press the sidebar's **+**, then the **link icon**: the URL field opens upwards; **Return** fetches and adds the video. The link icon toggles the field; **Esc** dismisses it and cancels a pending fetch. Fetch errors leave the queue unchanged. Links are passed to the bundled yt-dlp without a site allowlist; extraction must return a single streamable video, not a playlist or channel. Miruro keeps its dedicated resolver: links must use `https://barelystarted.miruro.tv/watch/<opaque-id>/<slug>?ep=<episode>` with an explicit positive episode number.
|
- **Streams:** use **Open Stream…** (**⇧⌘O**) or drop an HTTP(S) video link. To append without replacing playback, press the sidebar's **+**, then the **link icon**: the URL field opens upwards; **Return** fetches and adds the video. The link icon toggles the field; **Esc** dismisses it and cancels a pending fetch. Fetch errors leave the queue unchanged. Links are passed to the bundled yt-dlp without a site allowlist; extraction must return a single streamable video, not a playlist or channel. Miruro keeps its dedicated resolver: links must use `https://barelystarted.miruro.tv/watch/<opaque-id>/<slug>?ep=<episode>` with an explicit positive episode number.
|
||||||
- **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.
|
- **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.
|
||||||
- **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.
|
- **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.
|
||||||
- **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. 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.
|
||||||
|
|||||||
+192
-18
@@ -76,6 +76,23 @@ final class PlayerModel: ObservableObject {
|
|||||||
currentMedia?.url.absoluteString == metadataLifecycle.identity else { return [] }
|
currentMedia?.url.absoluteString == metadataLifecycle.identity else { return [] }
|
||||||
return metadataLifecycle.visibleIntervals(duration: state.duration)
|
return metadataLifecycle.visibleIntervals(duration: state.duration)
|
||||||
}
|
}
|
||||||
|
var sliderBoundaries: [Double] {
|
||||||
|
guard !isBusy, state.status != PLAYER_EMPTY, state.status != PLAYER_ERROR,
|
||||||
|
state.duration.isFinite, state.duration > 0 else { return [] }
|
||||||
|
if case .youtube(let video, _) = currentMedia {
|
||||||
|
return video.chapterBoundaries(duration: state.duration)
|
||||||
|
}
|
||||||
|
return Array(Set(visibleIntervals.flatMap { [$0.start, $0.end] }
|
||||||
|
.filter { $0 > 0 && $0 < state.duration })).sorted()
|
||||||
|
}
|
||||||
|
func sliderSegmentTitle(at time: Double) -> String? {
|
||||||
|
guard !isBusy, state.duration.isFinite, state.duration > 0 else { return nil }
|
||||||
|
if case .youtube(let video, _) = currentMedia {
|
||||||
|
return video.chapterTitle(at: time, duration: state.duration)
|
||||||
|
}
|
||||||
|
return visibleIntervals.first { $0.start <= time && time < $0.end }
|
||||||
|
.map { $0.kind == .intro ? "Opening" : "Ending" }
|
||||||
|
}
|
||||||
@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
|
||||||
@@ -2336,7 +2353,12 @@ private struct PlaybackSlider: NSViewRepresentable {
|
|||||||
var volume = false
|
var volume = false
|
||||||
var enabled = true
|
var enabled = true
|
||||||
var bufferedRange: ClosedRange<Double>?
|
var bufferedRange: ClosedRange<Double>?
|
||||||
var intervals: [MiruroInterval] = []
|
var boundaries: [Double] = []
|
||||||
|
struct Hover: Equatable {
|
||||||
|
let time: Double
|
||||||
|
let x: CGFloat
|
||||||
|
}
|
||||||
|
var onHover: (Hover?) -> Void = { _ in }
|
||||||
var onEditingChanged: (Bool) -> Void = { _ in }
|
var onEditingChanged: (Bool) -> Void = { _ in }
|
||||||
|
|
||||||
func makeCoordinator() -> Coordinator { Coordinator(self) }
|
func makeCoordinator() -> Coordinator { Coordinator(self) }
|
||||||
@@ -2357,14 +2379,22 @@ private struct PlaybackSlider: NSViewRepresentable {
|
|||||||
slider.minValue = range.lowerBound
|
slider.minValue = range.lowerBound
|
||||||
slider.maxValue = range.upperBound
|
slider.maxValue = range.upperBound
|
||||||
slider.setModelValue(value)
|
slider.setModelValue(value)
|
||||||
|
if slider.isEnabled != enabled {
|
||||||
slider.isEnabled = enabled
|
slider.isEnabled = enabled
|
||||||
|
if !enabled { slider.resetSegments() }
|
||||||
|
}
|
||||||
slider.editingChanged = onEditingChanged
|
slider.editingChanged = onEditingChanged
|
||||||
if let cell = slider.cell as? Cell,
|
slider.hoverChanged = onHover
|
||||||
cell.bufferedRange != bufferedRange || cell.intervals != intervals {
|
if let cell = slider.cell as? Cell {
|
||||||
|
if cell.boundaries != boundaries {
|
||||||
|
cell.boundaries = boundaries
|
||||||
|
slider.resetSegments()
|
||||||
|
}
|
||||||
|
if cell.bufferedRange != bufferedRange {
|
||||||
cell.bufferedRange = bufferedRange
|
cell.bufferedRange = bufferedRange
|
||||||
cell.intervals = intervals
|
|
||||||
slider.needsDisplay = true
|
slider.needsDisplay = true
|
||||||
}
|
}
|
||||||
|
}
|
||||||
slider.setAccessibilityLabel(volume ? "Volume" : "Playback Position")
|
slider.setAccessibilityLabel(volume ? "Volume" : "Playback Position")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2382,6 +2412,95 @@ private struct PlaybackSlider: NSViewRepresentable {
|
|||||||
final class Slider: NSSlider {
|
final class Slider: NSSlider {
|
||||||
var editingChanged: (Bool) -> Void = { _ in }
|
var editingChanged: (Bool) -> Void = { _ in }
|
||||||
private(set) var tracking = false
|
private(set) var tracking = false
|
||||||
|
var hoverChanged: (Hover?) -> Void = { _ in }
|
||||||
|
private var hover: Hover?
|
||||||
|
private var hoverArea: NSTrackingArea?
|
||||||
|
private var segmentAnimation: Timer?
|
||||||
|
private var activeSegment: Int?
|
||||||
|
|
||||||
|
override func updateTrackingAreas() {
|
||||||
|
super.updateTrackingAreas()
|
||||||
|
if let hoverArea { removeTrackingArea(hoverArea) }
|
||||||
|
let area = NSTrackingArea(rect: .zero,
|
||||||
|
options: [.mouseEnteredAndExited, .mouseMoved, .activeInKeyWindow, .inVisibleRect],
|
||||||
|
owner: self)
|
||||||
|
addTrackingArea(area)
|
||||||
|
hoverArea = area
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidMoveToWindow() {
|
||||||
|
super.viewDidMoveToWindow()
|
||||||
|
if window == nil { resetSegments() }
|
||||||
|
}
|
||||||
|
|
||||||
|
func resetSegments() {
|
||||||
|
segmentAnimation?.invalidate()
|
||||||
|
segmentAnimation = nil
|
||||||
|
activeSegment = nil
|
||||||
|
if let cell = cell as? Cell { cell.emphasis = Array(repeating: 0, count: cell.boundaries.count + 1) }
|
||||||
|
hover = nil
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
guard let self, self.hover == nil else { return }
|
||||||
|
self.hoverChanged(nil)
|
||||||
|
}
|
||||||
|
needsDisplay = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setHover(_ value: Hover?) {
|
||||||
|
guard hover != value else { return }
|
||||||
|
hover = value
|
||||||
|
hoverChanged(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateHover(at point: NSPoint) {
|
||||||
|
guard isEnabled, bounds.contains(point), let cell = cell as? Cell, !cell.volume,
|
||||||
|
maxValue > minValue else { setHover(nil); return }
|
||||||
|
let bar = cell.barRect(flipped: isFlipped)
|
||||||
|
let start = bar.minX + cell.knobThickness / 2
|
||||||
|
let width = bar.width - cell.knobThickness
|
||||||
|
guard width > 0 else { setHover(nil); return }
|
||||||
|
let time = minValue + min(1, max(0, (point.x - start) / width)) * (maxValue - minValue)
|
||||||
|
let index = cell.segment(at: time)
|
||||||
|
let lower = index == 0 ? minValue : cell.boundaries[index - 1]
|
||||||
|
let upper = index == cell.boundaries.count ? maxValue : cell.boundaries[index]
|
||||||
|
let middle = (lower + upper) / 2
|
||||||
|
setHover(Hover(time: middle, x: start + (middle - minValue) / (maxValue - minValue) * width))
|
||||||
|
}
|
||||||
|
|
||||||
|
override func mouseEntered(with event: NSEvent) { updateHover(at: convert(event.locationInWindow, from: nil)) }
|
||||||
|
override func mouseMoved(with event: NSEvent) { updateHover(at: convert(event.locationInWindow, from: nil)) }
|
||||||
|
override func mouseExited(with event: NSEvent) { setHover(nil) }
|
||||||
|
|
||||||
|
private func emphasizeSegment(_ index: Int?) {
|
||||||
|
guard activeSegment != index, let cell = cell as? Cell else { return }
|
||||||
|
activeSegment = index
|
||||||
|
segmentAnimation?.invalidate()
|
||||||
|
let count = cell.boundaries.count + 1
|
||||||
|
if cell.emphasis.count != count { cell.emphasis = Array(repeating: 0, count: count) }
|
||||||
|
let initial = cell.emphasis
|
||||||
|
let update: @MainActor (Double) -> Void = { [weak self] progress in
|
||||||
|
for segment in 0..<count {
|
||||||
|
let target = segment == index ? 1.0 : 0.0
|
||||||
|
cell.emphasis[segment] = initial[segment] + (target - initial[segment]) * progress
|
||||||
|
}
|
||||||
|
self?.needsDisplay = true
|
||||||
|
}
|
||||||
|
guard !NSWorkspace.shared.accessibilityDisplayShouldReduceMotion else { update(1); return }
|
||||||
|
let start = ContinuousClock.now
|
||||||
|
let timer = Timer(timeInterval: 1.0 / 60, repeats: true) { [weak self] timer in
|
||||||
|
guard self != nil else { timer.invalidate(); return }
|
||||||
|
MainActor.assumeIsolated {
|
||||||
|
let progress = min(1, start.duration(to: .now) / .milliseconds(160))
|
||||||
|
update(progress * progress * (3 - 2 * progress))
|
||||||
|
if progress >= 1 {
|
||||||
|
self?.segmentAnimation?.invalidate()
|
||||||
|
self?.segmentAnimation = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
segmentAnimation = timer
|
||||||
|
RunLoop.main.add(timer, forMode: .common)
|
||||||
|
}
|
||||||
func setModelValue(_ value: Double) { if !tracking { doubleValue = value } }
|
func setModelValue(_ value: Double) { if !tracking { doubleValue = value } }
|
||||||
#if PLAYER_CHECK
|
#if PLAYER_CHECK
|
||||||
static func feedbackCheck() -> Bool {
|
static func feedbackCheck() -> Bool {
|
||||||
@@ -2403,6 +2522,8 @@ private struct PlaybackSlider: NSViewRepresentable {
|
|||||||
editingChanged(true)
|
editingChanged(true)
|
||||||
defer {
|
defer {
|
||||||
tracking = false
|
tracking = false
|
||||||
|
emphasizeSegment(nil)
|
||||||
|
updateHover(at: convert(window?.mouseLocationOutsideOfEventStream ?? .zero, from: nil))
|
||||||
editingChanged(false)
|
editingChanged(false)
|
||||||
}
|
}
|
||||||
guard let cell = cell as? Cell, !cell.volume else {
|
guard let cell = cell as? Cell, !cell.volume else {
|
||||||
@@ -2417,6 +2538,8 @@ private struct PlaybackSlider: NSViewRepresentable {
|
|||||||
func seek(_ x: CGFloat) {
|
func seek(_ x: CGFloat) {
|
||||||
let fraction = min(1, max(0, (x - start) / width))
|
let fraction = min(1, max(0, (x - start) / width))
|
||||||
let value = minValue + fraction * (maxValue - minValue)
|
let value = minValue + fraction * (maxValue - minValue)
|
||||||
|
emphasizeSegment(cell.segment(at: value))
|
||||||
|
updateHover(at: NSPoint(x: x, y: point.y))
|
||||||
guard value != doubleValue else { return }
|
guard value != doubleValue else { return }
|
||||||
doubleValue = value
|
doubleValue = value
|
||||||
if let action { sendAction(action, to: target) }
|
if let action { sendAction(action, to: target) }
|
||||||
@@ -2433,7 +2556,11 @@ private struct PlaybackSlider: NSViewRepresentable {
|
|||||||
final class Cell: NSSliderCell {
|
final class Cell: NSSliderCell {
|
||||||
var volume = false
|
var volume = false
|
||||||
var bufferedRange: ClosedRange<Double>?
|
var bufferedRange: ClosedRange<Double>?
|
||||||
var intervals: [MiruroInterval] = []
|
var boundaries: [Double] = []
|
||||||
|
var emphasis: [Double] = []
|
||||||
|
func segment(at time: Double) -> Int {
|
||||||
|
boundaries.firstIndex(where: { time < $0 }) ?? boundaries.count
|
||||||
|
}
|
||||||
#if PLAYER_CHECK
|
#if PLAYER_CHECK
|
||||||
var renderedKnob = NSRect.zero
|
var renderedKnob = NSRect.zero
|
||||||
#endif
|
#endif
|
||||||
@@ -2450,24 +2577,24 @@ private struct PlaybackSlider: NSViewRepresentable {
|
|||||||
defer { NSGraphicsContext.restoreGraphicsState() }
|
defer { NSGraphicsContext.restoreGraphicsState() }
|
||||||
if !volume, maxValue > minValue, bar.width > 0 {
|
if !volume, maxValue > minValue, bar.width > 0 {
|
||||||
let scale = bar.width / (maxValue - minValue)
|
let scale = bar.width / (maxValue - minValue)
|
||||||
let boundaries = Array(Set([minValue, maxValue] + intervals.flatMap {
|
|
||||||
[$0.start, $0.end]
|
|
||||||
}.filter { $0 > minValue && $0 < maxValue })).sorted()
|
|
||||||
let track = NSBezierPath()
|
let track = NSBezierPath()
|
||||||
for index in 0..<(boundaries.count - 1) {
|
for index in 0...boundaries.count {
|
||||||
let start = bar.minX + (boundaries[index] - minValue) * scale
|
let startTime = index == 0 ? minValue : boundaries[index - 1]
|
||||||
let end = bar.minX + (boundaries[index + 1] - minValue) * scale
|
let endTime = index == boundaries.count ? maxValue : boundaries[index]
|
||||||
|
let start = bar.minX + (startTime - minValue) * scale
|
||||||
|
let end = bar.minX + (endTime - minValue) * scale
|
||||||
let inset = min(1.5, (end - start) / 4)
|
let inset = min(1.5, (end - start) / 4)
|
||||||
let left = index == 0 ? start : start + inset
|
let left = index == 0 ? start : start + inset
|
||||||
let right = index == boundaries.count - 2 ? end : end - inset
|
let right = index == boundaries.count ? end : end - inset
|
||||||
|
let height = 3 + 3 * (emphasis.indices.contains(index) ? emphasis[index] : 0)
|
||||||
track.append(NSBezierPath(roundedRect:
|
track.append(NSBezierPath(roundedRect:
|
||||||
NSRect(x: left, y: bar.minY, width: right - left, height: bar.height),
|
NSRect(x: left, y: bar.midY - height / 2, width: right - left, height: height),
|
||||||
xRadius: 1.5, yRadius: 1.5))
|
xRadius: 1.5, yRadius: 1.5))
|
||||||
}
|
}
|
||||||
track.addClip()
|
track.addClip()
|
||||||
}
|
}
|
||||||
SunsetPalette.track.setFill()
|
SunsetPalette.track.setFill()
|
||||||
NSBezierPath(roundedRect: bar, xRadius: 1.5, yRadius: 1.5).fill()
|
NSBezierPath(roundedRect: volume ? bar : bar.insetBy(dx: 0, dy: -1.5), xRadius: 1.5, yRadius: 1.5).fill()
|
||||||
if volume {
|
if volume {
|
||||||
let fraction = (doubleValue - minValue) / (maxValue - minValue)
|
let fraction = (doubleValue - minValue) / (maxValue - minValue)
|
||||||
let fill = NSRect(x: bar.minX, y: bar.minY, width: bar.width * fraction, height: bar.height)
|
let fill = NSRect(x: bar.minX, y: bar.minY, width: bar.width * fraction, height: bar.height)
|
||||||
@@ -2477,8 +2604,8 @@ private struct PlaybackSlider: NSViewRepresentable {
|
|||||||
let lower = min(maxValue, max(minValue, bufferedRange.lowerBound))
|
let lower = min(maxValue, max(minValue, bufferedRange.lowerBound))
|
||||||
let upper = min(maxValue, max(minValue, bufferedRange.upperBound))
|
let upper = min(maxValue, max(minValue, bufferedRange.upperBound))
|
||||||
let scale = bar.width / (maxValue - minValue)
|
let scale = bar.width / (maxValue - minValue)
|
||||||
let fill = NSRect(x: bar.minX + (lower - minValue) * scale, y: bar.minY,
|
let fill = NSRect(x: bar.minX + (lower - minValue) * scale, y: bar.minY - 1.5,
|
||||||
width: (upper - lower) * scale, height: bar.height)
|
width: (upper - lower) * scale, height: 6)
|
||||||
SunsetPalette.bufferedTrack.setFill()
|
SunsetPalette.bufferedTrack.setFill()
|
||||||
NSBezierPath(roundedRect: fill, xRadius: 1.5, yRadius: 1.5).fill()
|
NSBezierPath(roundedRect: fill, xRadius: 1.5, yRadius: 1.5).fill()
|
||||||
}
|
}
|
||||||
@@ -2515,11 +2642,39 @@ func playbackSliderHitCheck() -> Bool {
|
|||||||
let left = cell.knobRect(flipped: slider.isFlipped).midX
|
let left = cell.knobRect(flipped: slider.isFlipped).midX
|
||||||
slider.doubleValue = slider.maxValue
|
slider.doubleValue = slider.maxValue
|
||||||
let right = cell.knobRect(flipped: slider.isFlipped).midX
|
let right = cell.knobRect(flipped: slider.isFlipped).midX
|
||||||
|
var hovered: PlaybackSlider.Hover?
|
||||||
|
slider.hoverChanged = { hovered = $0 }
|
||||||
|
cell.boundaries = [25, 75]
|
||||||
|
cell.emphasis = [1, 0, 0]
|
||||||
|
let bitmap = NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: 280, pixelsHigh: 14,
|
||||||
|
bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false,
|
||||||
|
colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0)!
|
||||||
|
NSGraphicsContext.saveGraphicsState()
|
||||||
|
NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: bitmap)
|
||||||
|
NSGraphicsContext.current!.cgContext.clear(NSRect(x: 0, y: 0, width: 280, height: 14))
|
||||||
|
cell.drawBar(inside: NSRect(x: 0, y: 0, width: 280, height: 14), flipped: false)
|
||||||
|
NSGraphicsContext.restoreGraphicsState()
|
||||||
|
guard bitmap.colorAt(x: 35, y: 4)!.alphaComponent > 0,
|
||||||
|
bitmap.colorAt(x: 140, y: 4)!.alphaComponent == 0 else { return false }
|
||||||
|
cell.emphasis = []
|
||||||
|
for (time, middle) in [(0.0, 12.5), (25.0, 50.0), (75.0, 87.5), (100.0, 87.5)] {
|
||||||
|
let point = slider.convert(NSPoint(x: left + time / 100 * (right - left), y: 7), to: nil)
|
||||||
|
let event = NSEvent.mouseEvent(with: .mouseMoved, location: point, modifierFlags: [],
|
||||||
|
timestamp: 0, windowNumber: window.windowNumber, context: nil, eventNumber: 0, clickCount: 0, pressure: 0)!
|
||||||
|
slider.mouseMoved(with: event)
|
||||||
|
guard hovered?.time == middle,
|
||||||
|
abs(hovered!.x - (left + middle / 100 * (right - left))) < 0.5 else { return false }
|
||||||
|
}
|
||||||
|
let exit = NSEvent.enterExitEvent(with: .mouseExited, location: .zero, modifierFlags: [],
|
||||||
|
timestamp: 0, windowNumber: window.windowNumber, context: nil, eventNumber: 0, trackingNumber: 0, userData: nil)!
|
||||||
|
slider.mouseExited(with: exit)
|
||||||
|
guard hovered == nil else { return false }
|
||||||
for (initial, offset, movement): (Double, CGFloat, CGFloat) in [
|
for (initial, offset, movement): (Double, CGFloat, CGFloat) in [
|
||||||
(50, -4, 0), (50, -2, 0), (50, -1, 0), (50, 0, 0), (50, 1, 0), (50, 2, 0), (50, 4, 0),
|
(50, -4, 0), (50, -2, 0), (50, -1, 0), (50, 0, 0), (50, 1, 0), (50, 2, 0), (50, 4, 0),
|
||||||
(20, -1, -20), (80, 1, 20), (20, -4, -20), (80, 4, 20),
|
(20, -1, -20), (80, 1, 20), (20, -4, -20), (80, 4, 20),
|
||||||
(20, 0, -500), (80, 0, 500)
|
(20, 0, -500), (80, 0, 500), (25, 0, 0), (25, 0, 50), (75, 0, -50)
|
||||||
] {
|
] {
|
||||||
|
cell.boundaries = initial == 25 || initial == 75 ? [25, 75] : []
|
||||||
slider.doubleValue = initial
|
slider.doubleValue = initial
|
||||||
let knob = cell.knobRect(flipped: slider.isFlipped)
|
let knob = cell.knobRect(flipped: slider.isFlipped)
|
||||||
let point = slider.convert(NSPoint(x: knob.midX + offset, y: knob.midY), to: nil)
|
let point = slider.convert(NSPoint(x: knob.midX + offset, y: knob.midY), to: nil)
|
||||||
@@ -2726,6 +2881,7 @@ private struct PlayerContent: View {
|
|||||||
@ObservedObject var model: PlayerModel
|
@ObservedObject var model: PlayerModel
|
||||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||||
@ViewState private var showsRemainingTime = false
|
@ViewState private var showsRemainingTime = false
|
||||||
|
@ViewState private var hoveredSliderSegment: PlaybackSlider.Hover?
|
||||||
@ViewState private var dropTargeted = false
|
@ViewState private var dropTargeted = false
|
||||||
private var showsYouTubeInput: Bool { model.showsYouTubeInput }
|
private var showsYouTubeInput: Bool { model.showsYouTubeInput }
|
||||||
@ViewState private var youTubeURL = ""
|
@ViewState private var youTubeURL = ""
|
||||||
@@ -3154,10 +3310,28 @@ private struct PlayerContent: View {
|
|||||||
HStack(spacing: 10) {
|
HStack(spacing: 10) {
|
||||||
Text(time(model.seekPosition)).frame(minWidth: 32, alignment: .trailing)
|
Text(time(model.seekPosition)).frame(minWidth: 32, alignment: .trailing)
|
||||||
PlaybackSlider(value: $model.seekPosition, range: 0...max(model.duration, 0.001),
|
PlaybackSlider(value: $model.seekPosition, range: 0...max(model.duration, 0.001),
|
||||||
enabled: model.canSeek, bufferedRange: buffered, intervals: intervals, onEditingChanged: { editing in
|
enabled: model.canSeek, bufferedRange: buffered, boundaries: model.sliderBoundaries,
|
||||||
|
onHover: { hoveredSliderSegment = $0 }, onEditingChanged: { editing in
|
||||||
if editing { model.beginSeeking() } else { model.finishSeeking() }
|
if editing { model.beginSeeking() } else { model.finishSeeking() }
|
||||||
})
|
})
|
||||||
.frame(height: 14)
|
.frame(height: 14)
|
||||||
|
.overlay(alignment: .topLeading) {
|
||||||
|
if let hover = hoveredSliderSegment, let title = model.sliderSegmentTitle(at: hover.time) {
|
||||||
|
Text(title)
|
||||||
|
.font(.system(size: 11, weight: .medium))
|
||||||
|
.foregroundStyle(Color(SunsetPalette.text))
|
||||||
|
.lineLimit(1)
|
||||||
|
.padding(.horizontal, 8).padding(.vertical, 5)
|
||||||
|
.background { PlayerGlass(cornerRadius: 6) }
|
||||||
|
.fixedSize()
|
||||||
|
.position(x: hover.x, y: -16)
|
||||||
|
.transition(.opacity)
|
||||||
|
.id(hover.time)
|
||||||
|
.allowsHitTesting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.animation(reduceMotion ? nil : .easeInOut(duration: 0.16), value: hoveredSliderSegment)
|
||||||
|
.allowsHitTesting(model.canSeek)
|
||||||
.accessibilityLabel("Playback Position")
|
.accessibilityLabel("Playback Position")
|
||||||
.accessibilityValue(time(model.seekPosition) + (timelineStatus.isEmpty ? "" : ", " + timelineStatus))
|
.accessibilityValue(time(model.seekPosition) + (timelineStatus.isEmpty ? "" : ", " + timelineStatus))
|
||||||
.help(timelineStatus.isEmpty ? "Playback Position" : timelineStatus)
|
.help(timelineStatus.isEmpty ? "Playback Position" : timelineStatus)
|
||||||
|
|||||||
@@ -7,6 +7,12 @@ struct YouTubeStream: Sendable {
|
|||||||
let headers: String
|
let headers: String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct YouTubeChapter: Sendable {
|
||||||
|
let start: Double
|
||||||
|
let end: Double
|
||||||
|
let title: String?
|
||||||
|
}
|
||||||
|
|
||||||
struct YouTubeVideo: Sendable {
|
struct YouTubeVideo: Sendable {
|
||||||
let title: String
|
let title: String
|
||||||
let video: YouTubeStream
|
let video: YouTubeStream
|
||||||
@@ -16,6 +22,27 @@ struct YouTubeVideo: Sendable {
|
|||||||
let webpageURL: URL
|
let webpageURL: URL
|
||||||
let height: Int?
|
let height: Int?
|
||||||
let availableHeights: [Int]
|
let availableHeights: [Int]
|
||||||
|
let chapters: [YouTubeChapter]
|
||||||
|
|
||||||
|
func chapterBoundaries(duration: Double) -> [Double] {
|
||||||
|
guard duration.isFinite, duration > 0 else { return [] }
|
||||||
|
var boundaries = Set<Double>()
|
||||||
|
for chapter in chapters {
|
||||||
|
guard chapter.start.isFinite, chapter.end.isFinite,
|
||||||
|
chapter.start >= 0, chapter.end > chapter.start, chapter.end <= duration else { continue }
|
||||||
|
if chapter.start > 0 { boundaries.insert(chapter.start) }
|
||||||
|
if chapter.end < duration { boundaries.insert(chapter.end) }
|
||||||
|
}
|
||||||
|
return boundaries.sorted()
|
||||||
|
}
|
||||||
|
|
||||||
|
func chapterTitle(at time: Double, duration: Double) -> String? {
|
||||||
|
guard duration.isFinite, duration > 0 else { return nil }
|
||||||
|
let title = chapters.first {
|
||||||
|
$0.start >= 0 && $0.end <= duration && $0.start <= time && time < $0.end
|
||||||
|
}?.title?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return title?.isEmpty == false ? title : nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct YouTubeResolutionError: LocalizedError {
|
private struct YouTubeResolutionError: LocalizedError {
|
||||||
@@ -135,7 +162,8 @@ enum YouTubeResolver {
|
|||||||
video: video, audio: audio, duration: duration,
|
video: video, audio: audio, duration: duration,
|
||||||
isLive: metadata.is_live == true || metadata.live_status == "is_live",
|
isLive: metadata.is_live == true || metadata.live_status == "is_live",
|
||||||
webpageURL: try validateURL(webpageURL.absoluteString),
|
webpageURL: try validateURL(webpageURL.absoluteString),
|
||||||
height: videoFormat.height, availableHeights: availableHeights
|
height: videoFormat.height, availableHeights: availableHeights,
|
||||||
|
chapters: metadata.chapters?.values ?? []
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -386,12 +414,38 @@ private struct YouTubeMetadata: Decodable {
|
|||||||
let formats: [YouTubeMetadata]?
|
let formats: [YouTubeMetadata]?
|
||||||
let http_headers: [String: String]?
|
let http_headers: [String: String]?
|
||||||
let requested_formats: [YouTubeMetadata]?
|
let requested_formats: [YouTubeMetadata]?
|
||||||
|
let chapters: YouTubeChapters?
|
||||||
|
|
||||||
// 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 YouTubeChapters: Decodable {
|
||||||
|
let values: [YouTubeChapter]
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case start_time, end_time, title
|
||||||
|
}
|
||||||
|
|
||||||
|
init(from decoder: Decoder) {
|
||||||
|
guard var rows = try? decoder.unkeyedContainer() else {
|
||||||
|
values = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var chapters: [YouTubeChapter] = []
|
||||||
|
while !rows.isAtEnd {
|
||||||
|
guard let rowDecoder = try? rows.superDecoder() else { break }
|
||||||
|
guard let row = try? rowDecoder.container(keyedBy: CodingKeys.self),
|
||||||
|
let start = try? row.decode(Double.self, forKey: .start_time),
|
||||||
|
let end = try? row.decode(Double.self, forKey: .end_time) else { continue }
|
||||||
|
chapters.append(YouTubeChapter(start: start, end: end,
|
||||||
|
title: try? row.decode(String.self, forKey: .title)))
|
||||||
|
}
|
||||||
|
values = chapters
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private struct YouTubeExtractionOutput: Sendable {
|
private struct YouTubeExtractionOutput: Sendable {
|
||||||
let stdout: Data
|
let stdout: Data
|
||||||
let stderr: Data
|
let stderr: Data
|
||||||
|
|||||||
Reference in New Issue
Block a user