feat: show optional SponsorBlock ranges on playback timeline
This commit is contained in:
@@ -206,6 +206,28 @@ struct PlayerMiruroMenu: View {
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
struct PlayerSponsorBlockMenu: View {
|
||||
@ObservedObject var model: PlayerModel
|
||||
|
||||
var body: some View {
|
||||
Menu("SponsorBlock") {
|
||||
Toggle("Show Sponsored Ranges", isOn: Binding(
|
||||
get: { model.sponsorBlockEnabled },
|
||||
set: { model.setSponsorBlockEnabled($0) }))
|
||||
Text("Connects to sponsor.ajay.app using a video-ID hash prefix.")
|
||||
Text("Reduces disclosure; not anonymous. Display only, never skips.")
|
||||
Link("(Using SponsorBlock)", destination: URL(string: "https://sponsor.ajay.app/")!)
|
||||
Menu("Acknowledgements") {
|
||||
Text("Uses SponsorBlock data under CC BY-NC-SA 4.0")
|
||||
Link("CC BY-NC-SA 4.0 License",
|
||||
destination: URL(string: "https://creativecommons.org/licenses/by-nc-sa/4.0/")!)
|
||||
Text("Display filters and merges ranges; does not modify source timings.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
struct PlayerCommands: Commands {
|
||||
@FocusedObject private var focusedModel: PlayerModel?
|
||||
@@ -311,6 +333,7 @@ struct PlayerCommands: Commands {
|
||||
}
|
||||
}
|
||||
PlayerMiruroMenu(model: model).disabled(!hasFocusedScene)
|
||||
PlayerSponsorBlockMenu(model: model)
|
||||
Button(model.showsBufferInfo ? "Hide Buffer Info" : "Show Buffer Info") {
|
||||
model.performMenuAction(bufferInfo)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
struct SponsorSegment: Sendable {
|
||||
let start: Double
|
||||
let end: Double
|
||||
let videoDuration: Double
|
||||
|
||||
static func visibleRanges(_ segments: [SponsorSegment], duration: Double) -> [ClosedRange<Double>] {
|
||||
guard duration.isFinite, duration > 0 else { return [] }
|
||||
let ranges = segments.compactMap { segment -> ClosedRange<Double>? in
|
||||
guard segment.start.isFinite, segment.end.isFinite,
|
||||
segment.start >= 0, segment.end > segment.start, segment.end <= duration,
|
||||
segment.videoDuration.isFinite, segment.videoDuration >= 0,
|
||||
segment.videoDuration == 0 || abs(segment.videoDuration - duration) < 1 else { return nil }
|
||||
return segment.start...segment.end
|
||||
}.sorted { $0.lowerBound < $1.lowerBound }
|
||||
var merged: [ClosedRange<Double>] = []
|
||||
for range in ranges {
|
||||
if let previous = merged.last, range.lowerBound <= previous.upperBound {
|
||||
merged[merged.count - 1] = previous.lowerBound...max(previous.upperBound, range.upperBound)
|
||||
} else {
|
||||
merged.append(range)
|
||||
}
|
||||
}
|
||||
return merged
|
||||
}
|
||||
}
|
||||
|
||||
private enum SponsorBlockError: LocalizedError {
|
||||
case invalidVideoID, invalidResponse, status(Int), tooLarge, malformedJSON
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidVideoID: return "SponsorBlock requires a canonical YouTube video ID."
|
||||
case .invalidResponse: return "SponsorBlock returned an invalid response."
|
||||
case .status(let status): return "SponsorBlock returned HTTP \(status)."
|
||||
case .tooLarge: return "SponsorBlock returned too much data."
|
||||
case .malformedJSON: return "SponsorBlock returned malformed segment data."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private final class SponsorBlockRedirectPolicy: NSObject, URLSessionTaskDelegate, Sendable {
|
||||
func urlSession(_ session: URLSession, task: URLSessionTask,
|
||||
willPerformHTTPRedirection response: HTTPURLResponse,
|
||||
newRequest request: URLRequest,
|
||||
completionHandler: @escaping (URLRequest?) -> Void) {
|
||||
completionHandler(nil)
|
||||
}
|
||||
}
|
||||
|
||||
enum SponsorBlockResolver {
|
||||
static let responseLimit = 2 * 1024 * 1024
|
||||
|
||||
private static func validID(_ id: String) -> Bool {
|
||||
id.utf8.count == 11 && id.utf8.allSatisfy {
|
||||
(65...90).contains($0) || (97...122).contains($0) || (48...57).contains($0) || $0 == 45 || $0 == 95
|
||||
}
|
||||
}
|
||||
|
||||
static func videoID(for url: URL) -> String? {
|
||||
let prefix = "https://www.youtube.com/watch?v="
|
||||
let text = url.absoluteString
|
||||
guard text.hasPrefix(prefix) else { return nil }
|
||||
let id = String(text.dropFirst(prefix.count))
|
||||
return validID(id) ? id : nil
|
||||
}
|
||||
|
||||
static func request(videoID: String) throws -> URLRequest {
|
||||
guard validID(videoID) else { throw SponsorBlockError.invalidVideoID }
|
||||
let prefix = SHA256.hash(data: Data(videoID.utf8)).prefix(2).map { String(format: "%02x", $0) }.joined()
|
||||
let url = URL(string: "https://sponsor.ajay.app/api/skipSegments/\(prefix)?category=sponsor&actionType=skip&service=YouTube")!
|
||||
var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalAndRemoteCacheData, timeoutInterval: 10)
|
||||
request.httpMethod = "GET"
|
||||
request.httpShouldHandleCookies = false
|
||||
return request
|
||||
}
|
||||
|
||||
static func resolve(videoID: String, configuration: URLSessionConfiguration? = nil) async throws -> [SponsorSegment] {
|
||||
let request = try request(videoID: videoID)
|
||||
let configuration = (configuration?.copy() as? URLSessionConfiguration) ?? .ephemeral
|
||||
configuration.httpCookieStorage = nil
|
||||
configuration.httpShouldSetCookies = false
|
||||
configuration.urlCredentialStorage = nil
|
||||
configuration.urlCache = nil
|
||||
configuration.requestCachePolicy = .reloadIgnoringLocalAndRemoteCacheData
|
||||
configuration.httpAdditionalHeaders = nil
|
||||
configuration.timeoutIntervalForRequest = 10
|
||||
configuration.timeoutIntervalForResource = 15
|
||||
let session = URLSession(configuration: configuration)
|
||||
defer { session.invalidateAndCancel() }
|
||||
try Task.checkCancellation()
|
||||
let (bytes, response) = try await session.bytes(for: request, delegate: SponsorBlockRedirectPolicy())
|
||||
try Task.checkCancellation()
|
||||
guard let response = response as? HTTPURLResponse else { throw SponsorBlockError.invalidResponse }
|
||||
if response.statusCode == 404 { return [] }
|
||||
guard response.statusCode == 200 else { throw SponsorBlockError.status(response.statusCode) }
|
||||
guard response.expectedContentLength <= Int64(responseLimit) else { throw SponsorBlockError.tooLarge }
|
||||
var data = Data()
|
||||
data.reserveCapacity(min(responseLimit, max(0, Int(response.expectedContentLength))))
|
||||
for try await byte in bytes {
|
||||
try Task.checkCancellation()
|
||||
guard data.count < responseLimit else { throw SponsorBlockError.tooLarge }
|
||||
data.append(byte)
|
||||
}
|
||||
try Task.checkCancellation()
|
||||
return try decode(data, videoID: videoID)
|
||||
}
|
||||
|
||||
static func decode(_ data: Data, videoID: String) throws -> [SponsorSegment] {
|
||||
guard data.count <= responseLimit else { throw SponsorBlockError.tooLarge }
|
||||
let buckets: [Lossy<Bucket>]
|
||||
do { buckets = try JSONDecoder().decode([Lossy<Bucket>].self, from: data) }
|
||||
catch { throw SponsorBlockError.malformedJSON }
|
||||
return buckets.compactMap(\.value).filter { $0.videoID == videoID }.flatMap { bucket in
|
||||
bucket.segments.compactMap { row in
|
||||
guard let row = row.value, row.category == "sponsor", row.actionType == "skip",
|
||||
row.segment.count == 2,
|
||||
row.segment[0].isFinite, row.segment[1].isFinite,
|
||||
row.segment[0] >= 0, row.segment[1] > row.segment[0],
|
||||
row.videoDuration.isFinite, row.videoDuration >= 0 else { return nil }
|
||||
return SponsorSegment(start: row.segment[0], end: row.segment[1], videoDuration: row.videoDuration)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct Lossy<Value: Decodable>: Decodable {
|
||||
let value: Value?
|
||||
init(from decoder: Decoder) throws { value = try? Value(from: decoder) }
|
||||
}
|
||||
|
||||
private struct Bucket: Decodable {
|
||||
let videoID: String
|
||||
let segments: [Lossy<Row>]
|
||||
}
|
||||
|
||||
private struct Row: Decodable {
|
||||
let category: String
|
||||
let actionType: String
|
||||
let segment: [Double]
|
||||
let videoDuration: Double
|
||||
}
|
||||
}
|
||||
+132
-15
@@ -29,7 +29,11 @@ final class PlayerModel: ObservableObject {
|
||||
static let shared = PlayerModel(configuration: startupConfiguration, progressFile: startupProgressFile,
|
||||
streamQueue: startupStreamQueue)
|
||||
|
||||
@Published private(set) var state = PlayerState()
|
||||
@Published private(set) var state = PlayerState() {
|
||||
didSet {
|
||||
if state.duration != oldValue.duration { updateSponsorRanges() }
|
||||
}
|
||||
}
|
||||
@Published private(set) var fileName: String?
|
||||
@Published var showsYouTubeInput = false
|
||||
@Published private(set) var fileIcon: NSImage?
|
||||
@@ -71,6 +75,18 @@ final class PlayerModel: ObservableObject {
|
||||
private var shouldRecordRecentMedia = false
|
||||
private var metadataLifecycle = MetadataLifecycle()
|
||||
private var metadataTask: Task<Void, Never>?
|
||||
@Published private(set) var sponsorBlockEnabled: Bool
|
||||
private var sponsorVideoID: String?
|
||||
private var sponsorGeneration: UInt64 = 0
|
||||
private var sponsorTask: Task<Void, Never>?
|
||||
private var sponsorSegments: [SponsorSegment] = []
|
||||
private var sponsorRanges: [ClosedRange<Double>] = []
|
||||
var sponsoredRanges: [ClosedRange<Double>] {
|
||||
guard sponsorBlockEnabled, !isBusy, state.status != PLAYER_EMPTY, state.status != PLAYER_ERROR,
|
||||
case .youtube(let video, _) = currentMedia, !video.isLive,
|
||||
SponsorBlockResolver.videoID(for: video.webpageURL) == sponsorVideoID else { return [] }
|
||||
return sponsorRanges
|
||||
}
|
||||
var visibleIntervals: [MiruroInterval] {
|
||||
guard !isBusy, state.status != PLAYER_EMPTY, state.status != PLAYER_ERROR,
|
||||
currentMedia?.url.absoluteString == metadataLifecycle.identity else { return [] }
|
||||
@@ -222,6 +238,7 @@ final class PlayerModel: ObservableObject {
|
||||
private var reportingErrorBuffer = [CChar]()
|
||||
private let youtubeResolver: @MainActor (URL, Int?) async throws -> YouTubeVideo
|
||||
private let youtubeTitleResolver: @MainActor (URL) async throws -> String?
|
||||
private let sponsorResolver: @MainActor (String) async throws -> [SponsorSegment]
|
||||
private let miruroResolver: @MainActor (String, Int) async throws -> MiruroSources
|
||||
private let miruroMetadataResolver: @MainActor (String, Int) async throws -> MiruroMetadata
|
||||
private var reportingErrorMessage: String {
|
||||
@@ -232,6 +249,7 @@ final class PlayerModel: ObservableObject {
|
||||
progressFile: String? = nil, streamQueue: StreamQueue? = nil,
|
||||
youtubeResolver: @escaping @MainActor (URL, Int?) async throws -> YouTubeVideo = { try await YouTubeResolver.resolve($0, height: $1) },
|
||||
youtubeTitleResolver: @escaping @MainActor (URL) async throws -> String? = { try await YouTubeResolver.resolveTitle($0) },
|
||||
sponsorResolver: @escaping @MainActor (String) async throws -> [SponsorSegment] = { try await SponsorBlockResolver.resolve(videoID: $0) },
|
||||
miruroResolver: @escaping @MainActor (String, Int) async throws -> MiruroSources = { try await MiruroResolver.resolve(mediaID: $0, episode: $1) },
|
||||
miruroMetadataResolver: @escaping @MainActor (String, Int) async throws -> MiruroMetadata = { try await MiruroMetadataResolver.resolve(mediaID: $0, episode: $1) },
|
||||
qualityClock: @escaping () -> Double = { ProcessInfo.processInfo.systemUptime },
|
||||
@@ -240,12 +258,14 @@ final class PlayerModel: ObservableObject {
|
||||
self.skipClock = skipClock
|
||||
self.youtubeResolver = youtubeResolver
|
||||
self.youtubeTitleResolver = youtubeTitleResolver
|
||||
self.sponsorResolver = sponsorResolver
|
||||
self.miruroResolver = miruroResolver
|
||||
self.miruroMetadataResolver = miruroMetadataResolver
|
||||
keyBindings = configuration.keys
|
||||
compactTitleProgress = configuration.compactTitleProgress
|
||||
automaticYouTubeQuality = configuration.automaticQuality
|
||||
showsBufferInfo = configuration.showBufferInfo
|
||||
sponsorBlockEnabled = configuration.sponsorBlock
|
||||
miruroVideoProvider = configuration.miruroVideoProvider
|
||||
miruroCaptionProvider = configuration.miruroCaptionProvider
|
||||
compactWindow = CompactWindowController(snappingEnabled: configuration.snapToEdges, defaults: defaults)
|
||||
@@ -716,6 +736,7 @@ final class PlayerModel: ObservableObject {
|
||||
return
|
||||
}
|
||||
objectWillChange.send()
|
||||
replaceSponsorMetadata(videoID: SponsorBlockResolver.videoID(for: url), reusing: change.isQuality)
|
||||
let request = transition.beginResolution(change)
|
||||
systemPlayback?.update()
|
||||
commandError = nil
|
||||
@@ -818,7 +839,46 @@ final class PlayerModel: ObservableObject {
|
||||
queueMetadataTask = (item.id, task)
|
||||
}
|
||||
|
||||
func setSponsorBlockEnabled(_ enabled: Bool) {
|
||||
guard sponsorBlockEnabled != enabled else { return }
|
||||
sponsorBlockEnabled = enabled
|
||||
replaceSponsorMetadata(videoID: sponsorVideoID)
|
||||
}
|
||||
|
||||
private func updateSponsorRanges() {
|
||||
sponsorRanges = SponsorSegment.visibleRanges(sponsorSegments, duration: state.duration)
|
||||
}
|
||||
|
||||
private func replaceSponsorMetadata(videoID: String?, reusing: Bool = false) {
|
||||
if reusing, sponsorVideoID == videoID { return }
|
||||
sponsorTask?.cancel()
|
||||
sponsorTask = nil
|
||||
sponsorGeneration &+= 1
|
||||
sponsorVideoID = videoID
|
||||
sponsorSegments = []
|
||||
updateSponsorRanges()
|
||||
guard sponsorBlockEnabled, let videoID else { return }
|
||||
let generation = sponsorGeneration
|
||||
let resolver = sponsorResolver
|
||||
sponsorTask = Task { [weak self] in
|
||||
// Optional enrichment never changes the playback resolution/error state.
|
||||
let segments = (try? await resolver(videoID)) ?? []
|
||||
guard !Task.isCancelled, let self,
|
||||
self.sponsorVideoID == videoID, self.sponsorGeneration == generation else { return }
|
||||
self.objectWillChange.send()
|
||||
self.sponsorSegments = segments
|
||||
self.updateSponsorRanges()
|
||||
self.sponsorTask = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func replaceMetadata(for source: MediaSource?) {
|
||||
if case .youtube(let video, _) = source {
|
||||
replaceSponsorMetadata(videoID: video.isLive ? nil : SponsorBlockResolver.videoID(for: video.webpageURL),
|
||||
reusing: true)
|
||||
} else {
|
||||
replaceSponsorMetadata(videoID: nil)
|
||||
}
|
||||
metadataTask?.cancel()
|
||||
metadataTask = nil
|
||||
if source?.url.absoluteString != metadataLifecycle.identity {
|
||||
@@ -2271,6 +2331,7 @@ private enum SunsetPalette {
|
||||
static let actionFill = accent.withAlphaComponent(0.08)
|
||||
static let track = secondaryText.withAlphaComponent(0.18)
|
||||
static let bufferedTrack = secondaryText.withAlphaComponent(0.4)
|
||||
static let sponsor = NSColor(srgbRed: 220.0 / 255, green: 20.0 / 255, blue: 60.0 / 255, alpha: 1)
|
||||
static let disabledThumb = secondaryText.withAlphaComponent(0.25)
|
||||
}
|
||||
|
||||
@@ -2354,9 +2415,11 @@ private struct PlaybackSlider: NSViewRepresentable {
|
||||
var enabled = true
|
||||
var bufferedRange: ClosedRange<Double>?
|
||||
var boundaries: [Double] = []
|
||||
var sponsoredRanges: [ClosedRange<Double>] = []
|
||||
struct Hover: Equatable {
|
||||
let time: Double
|
||||
let x: CGFloat
|
||||
var sponsored = false
|
||||
}
|
||||
var onHover: (Hover?) -> Void = { _ in }
|
||||
var onEditingChanged: (Bool) -> Void = { _ in }
|
||||
@@ -2394,6 +2457,11 @@ private struct PlaybackSlider: NSViewRepresentable {
|
||||
cell.bufferedRange = bufferedRange
|
||||
slider.needsDisplay = true
|
||||
}
|
||||
if cell.sponsoredRanges != sponsoredRanges {
|
||||
cell.sponsoredRanges = sponsoredRanges
|
||||
slider.needsDisplay = true
|
||||
DispatchQueue.main.async { [weak slider] in slider?.refreshHover() }
|
||||
}
|
||||
}
|
||||
slider.setAccessibilityLabel(volume ? "Volume" : "Playback Position")
|
||||
}
|
||||
@@ -2464,7 +2532,13 @@ private struct PlaybackSlider: NSViewRepresentable {
|
||||
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))
|
||||
setHover(Hover(time: middle, x: start + (middle - minValue) / (maxValue - minValue) * width,
|
||||
sponsored: cell.sponsoredRanges.contains { $0.lowerBound <= time && time < $0.upperBound }))
|
||||
}
|
||||
|
||||
func refreshHover() {
|
||||
guard hover != nil, let window else { return }
|
||||
updateHover(at: convert(window.mouseLocationOutsideOfEventStream, from: nil))
|
||||
}
|
||||
|
||||
override func mouseEntered(with event: NSEvent) { updateHover(at: convert(event.locationInWindow, from: nil)) }
|
||||
@@ -2557,6 +2631,7 @@ private struct PlaybackSlider: NSViewRepresentable {
|
||||
var volume = false
|
||||
var bufferedRange: ClosedRange<Double>?
|
||||
var boundaries: [Double] = []
|
||||
var sponsoredRanges: [ClosedRange<Double>] = []
|
||||
var emphasis: [Double] = []
|
||||
func segment(at time: Double) -> Int {
|
||||
boundaries.firstIndex(where: { time < $0 }) ?? boundaries.count
|
||||
@@ -2609,6 +2684,15 @@ private struct PlaybackSlider: NSViewRepresentable {
|
||||
SunsetPalette.bufferedTrack.setFill()
|
||||
NSBezierPath(roundedRect: fill, xRadius: 1.5, yRadius: 1.5).fill()
|
||||
}
|
||||
if !volume, maxValue > minValue {
|
||||
// Reuse the chapter-shaped clip: sponsorship paints the track, never adds gaps.
|
||||
SunsetPalette.sponsor.setFill()
|
||||
let scale = bar.width / (maxValue - minValue)
|
||||
for range in sponsoredRanges {
|
||||
NSRect(x: bar.minX + (range.lowerBound - minValue) * scale, y: bar.minY - 1.5,
|
||||
width: (range.upperBound - range.lowerBound) * scale, height: 6).fill()
|
||||
}
|
||||
}
|
||||
}
|
||||
override func drawKnob(_ rect: NSRect) {
|
||||
let knob = visibleKnobRect(rect)
|
||||
@@ -2656,6 +2740,28 @@ func playbackSliderHitCheck() -> Bool {
|
||||
NSGraphicsContext.restoreGraphicsState()
|
||||
guard bitmap.colorAt(x: 35, y: 4)!.alphaComponent > 0,
|
||||
bitmap.colorAt(x: 140, y: 4)!.alphaComponent == 0 else { return false }
|
||||
cell.sponsoredRanges = [10...40]
|
||||
cell.bufferedRange = 0...100
|
||||
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()
|
||||
func pixel(_ time: Double, y: Int = 7) -> NSColor {
|
||||
let x = Int((cell.knobThickness / 2 + time / 100 * (280 - cell.knobThickness)).rounded())
|
||||
return bitmap.colorAt(x: x, y: y)!.usingColorSpace(.sRGB)!
|
||||
}
|
||||
for time in [20.0, 30.0] {
|
||||
let color = pixel(time)
|
||||
guard abs(color.redComponent * 255 - 220) < 2,
|
||||
abs(color.greenComponent * 255 - 20) < 2,
|
||||
abs(color.blueComponent * 255 - 60) < 2 else { return false }
|
||||
}
|
||||
guard pixel(25).alphaComponent == 0, // A sponsor crossing a chapter still preserves its gap.
|
||||
pixel(20, y: 4).alphaComponent > 0, pixel(30, y: 4).alphaComponent == 0,
|
||||
pixel(5).redComponent != pixel(20).redComponent,
|
||||
pixel(50).redComponent != pixel(30).redComponent,
|
||||
pixel(10).alphaComponent > 0, pixel(40).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)
|
||||
@@ -2663,6 +2769,7 @@ func playbackSliderHitCheck() -> Bool {
|
||||
timestamp: 0, windowNumber: window.windowNumber, context: nil, eventNumber: 0, clickCount: 0, pressure: 0)!
|
||||
slider.mouseMoved(with: event)
|
||||
guard hovered?.time == middle,
|
||||
hovered?.sponsored == (time == 25),
|
||||
abs(hovered!.x - (left + middle / 100 * (right - left))) < 0.5 else { return false }
|
||||
}
|
||||
let exit = NSEvent.enterExitEvent(with: .mouseExited, location: .zero, modifierFlags: [],
|
||||
@@ -3192,6 +3299,7 @@ private struct PlayerContent: View {
|
||||
Divider()
|
||||
PlayerQualityMenu(model: model)
|
||||
PlayerMiruroMenu(model: model)
|
||||
PlayerSponsorBlockMenu(model: model)
|
||||
Divider()
|
||||
if model.isCompact {
|
||||
Menu("Subtitles") {
|
||||
@@ -3261,7 +3369,11 @@ private struct PlayerContent: View {
|
||||
let intervalStatus = intervals.map {
|
||||
"\($0.kind == .intro ? "Opening (OP)" : "Ending (ED)") \(time($0.start))–\(time($0.end))"
|
||||
}.joined(separator: ", ")
|
||||
let timelineStatus = [bufferedStatus, intervalStatus.isEmpty ? nil : intervalStatus].compactMap { $0 }
|
||||
let sponsors = model.sponsoredRanges
|
||||
let sponsorStatus = sponsors.map { "Sponsor \(time($0.lowerBound))–\(time($0.upperBound))" }
|
||||
.joined(separator: ", ")
|
||||
let timelineStatus = [bufferedStatus, intervalStatus.isEmpty ? nil : intervalStatus,
|
||||
sponsorStatus.isEmpty ? nil : sponsorStatus].compactMap { $0 }
|
||||
.joined(separator: ", ")
|
||||
return VStack(spacing: 8) {
|
||||
HStack(spacing: 0) {
|
||||
@@ -3311,23 +3423,28 @@ private struct PlayerContent: View {
|
||||
Text(time(model.seekPosition)).frame(minWidth: 32, alignment: .trailing)
|
||||
PlaybackSlider(value: $model.seekPosition, range: 0...max(model.duration, 0.001),
|
||||
enabled: model.canSeek, bufferedRange: buffered, boundaries: model.sliderBoundaries,
|
||||
sponsoredRanges: sponsors,
|
||||
onHover: { hoveredSliderSegment = $0 }, onEditingChanged: { editing in
|
||||
if editing { model.beginSeeking() } else { model.finishSeeking() }
|
||||
})
|
||||
.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)
|
||||
if let hover = hoveredSliderSegment {
|
||||
let chapter = model.sliderSegmentTitle(at: hover.time)
|
||||
let sponsored = hover.sponsored && !sponsors.isEmpty
|
||||
if let title = sponsored ? chapter.map({ "Sponsor · " + $0 }) ?? "Sponsor" : chapter {
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user