feat: offer timed opening and ending skips
This commit is contained in:
@@ -2,7 +2,7 @@ import Foundation
|
||||
import CoreFoundation
|
||||
|
||||
struct MiruroInterval: Sendable, Equatable {
|
||||
enum Kind: Sendable { case intro, outro }
|
||||
enum Kind: Sendable, Hashable { case intro, outro }
|
||||
let kind: Kind
|
||||
var start: Double
|
||||
var end: Double
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -766,7 +766,9 @@ struct Player {
|
||||
for (auto &input : inputs) {
|
||||
if (!input) continue;
|
||||
input->discardAudio(false);
|
||||
if (input.get() == tracks[0].input && input.get() != tracks[1].input && beyondAudio(target))
|
||||
// EOF is a timeline position, not a demux seek target (HLS rejects it).
|
||||
if ((duration > 0 && target >= duration) ||
|
||||
(input.get() == tracks[0].input && input.get() != tracks[1].input && beyondAudio(target)))
|
||||
input->finish();
|
||||
else
|
||||
input->seek(-1, timestamp);
|
||||
|
||||
Reference in New Issue
Block a user