feat: offer timed opening and ending skips

This commit is contained in:
2026-10-22 13:24:37 +02:00
parent 7b53b00b15
commit 3d949154a2
8 changed files with 165 additions and 10 deletions
+70 -1
View File
@@ -64,6 +64,38 @@ final class PlayerModel: ObservableObject {
currentMedia?.url.absoluteString == metadataLifecycle.identity else { return [] }
return metadataLifecycle.visibleIntervals(duration: state.duration)
}
@Published private var skipOffer: (interval: MiruroInterval, deadline: Double)?
private var offeredSegments: Set<MiruroInterval.Kind> = []
private let skipClock: () -> Double
private var currentSkipInterval: MiruroInterval? {
guard canSeek, state.has_video, !isSeeking, !transition.seekPending, !state.seek_pending,
state.status == PLAYER_PLAYING || state.status == PLAYER_PAUSED else { return nil }
return visibleIntervals.first { state.position >= $0.start && state.position < $0.end }
}
var segmentToSkip: MiruroInterval? {
guard let skipOffer, skipClock() < skipOffer.deadline,
currentSkipInterval == skipOffer.interval else { return nil }
return skipOffer.interval
}
private func updateSegmentSkip() {
guard let interval = currentSkipInterval else {
if skipOffer != nil { skipOffer = nil }
return
}
if offeredSegments.insert(interval.kind).inserted {
skipOffer = (interval, skipClock() + 3)
} else if let skipOffer, skipClock() >= skipOffer.deadline || skipOffer.interval != interval {
self.skipOffer = nil
}
}
@discardableResult
func skipSegment() -> Bool {
guard let interval = segmentToSkip, seekRelative(interval.end - state.position) else { return false }
skipOffer = nil
return true
}
var pendingRevision: UInt64? { transition.pendingRevision }
var resolvingStream: Bool { transition.isResolving }
var isLiveStream: Bool { transition.isLive }
@@ -144,8 +176,10 @@ final class PlayerModel: ObservableObject {
init(defaults: UserDefaults = .standard, configuration: PlayerConfiguration = PlayerConfiguration(),
progressFile: String? = nil,
qualityClock: @escaping () -> Double = { ProcessInfo.processInfo.systemUptime }) {
qualityClock: @escaping () -> Double = { ProcessInfo.processInfo.systemUptime },
skipClock: @escaping () -> Double = { ProcessInfo.processInfo.systemUptime }) {
self.qualityClock = qualityClock
self.skipClock = skipClock
keyBindings = configuration.keys
compactTitleProgress = configuration.compactTitleProgress
automaticYouTubeQuality = configuration.automaticQuality
@@ -273,6 +307,7 @@ final class PlayerModel: ObservableObject {
revealControls()
}
systemPlayback?.update()
updateSegmentSkip()
}
@discardableResult
@@ -488,6 +523,10 @@ final class PlayerModel: ObservableObject {
private func replaceMetadata(for source: MediaSource?) {
metadataTask?.cancel()
metadataTask = nil
if source?.url.absoluteString != metadataLifecycle.identity {
offeredSegments.removeAll()
skipOffer = nil
}
let parts = source?.url.absoluteString.split(separator: ":") ?? []
let episode = parts.count == 3 && parts[0] == "miruro" ? Int(parts[2]) : nil
let generation = metadataLifecycle.replace(identity: source?.url.absoluteString, episode: episode)
@@ -500,6 +539,7 @@ final class PlayerModel: ObservableObject {
self.currentMedia?.url.absoluteString == identity,
self.metadataLifecycle.accept(metadata, identity: identity, generation: generation) else { return }
self.objectWillChange.send()
self.updateSegmentSkip()
if let title = metadata.title {
self.fileName = title
self.window?.title = title
@@ -1171,6 +1211,7 @@ final class PlayerModel: ObservableObject {
}
}
if !casting.active { applyState(player_get_state(handle)) }
else { updateSegmentSkip() }
}
func applyState(_ next: PlayerState) {
@@ -1182,6 +1223,7 @@ final class PlayerModel: ObservableObject {
handle: handle, history: playbackProgress)
guard observation.ready else { return }
defer { systemPlayback?.update() }
defer { updateSegmentSkip() }
state = next
updateSubtitle()
if shouldRecordRecentMedia, !isBusy, next.status == PLAYER_PLAYING || next.status == PLAYER_PAUSED,
@@ -1602,6 +1644,15 @@ final class PlayerModel: ObservableObject {
}
#if PLAYER_CHECK
func supplyMetadataForCheck(_ metadata: MiruroMetadata, episode: Int) {
metadataTask?.cancel()
metadataTask = nil
let identity = currentMedia!.url.absoluteString
let generation = metadataLifecycle.replace(identity: identity, episode: episode)
precondition(metadataLifecycle.accept(metadata, identity: identity, generation: generation))
updateSegmentSkip()
}
static func metadataLifecycleCheck() {
var lifecycle = MetadataLifecycle()
let a = "miruro:A:1"
@@ -3225,6 +3276,24 @@ private struct PlayerContent: View {
.allowsHitTesting(!model.isCompact && model.controlsVisible)
.accessibilityHidden(model.isCompact || !model.controlsVisible)
}
if let interval = model.segmentToSkip {
Button {
model.skipSegment()
} label: {
Label(interval.kind == .intro ? "Skip Opening" : "Skip Ending",
systemImage: "forward.end.fill")
.font(.system(size: model.isCompact ? 11 : 13, weight: .semibold))
.padding(.horizontal, 12)
.padding(.vertical, 8)
.contentShape(RoundedRectangle(cornerRadius: 8))
}
.buttonStyle(.plain)
.foregroundStyle(Color(SunsetPalette.text))
.background(PlayerGlass(cornerRadius: 8).clipShape(RoundedRectangle(cornerRadius: 8)))
.padding(.trailing, model.isCompact ? 8 : 16)
.padding(.bottom, model.isCompact ? compactControlHeight + 20 : controlHeight + 40)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomTrailing)
}
if dropTargeted {
RoundedRectangle(cornerRadius: 10)
.strokeBorder(Color(SunsetPalette.accent), lineWidth: 2)