feat: show skip offer countdown and transitions

This commit is contained in:
2026-10-22 14:09:53 +02:00
parent 70672321a1
commit 53369424e6
13 changed files with 453 additions and 40 deletions
+9 -5
View File
@@ -35,6 +35,7 @@ struct StreamQueue: Sendable {
var identities = Set<String>()
var reports = Set<String>()
let entries = try document.items.map { item -> Entry in
guard item.url == item.url.trimmingCharacters(in: .whitespacesAndNewlines) else { throw InvalidQueue() }
let stream = try StreamURL.parse(item.url)
let url: URL
let identity: String
@@ -56,12 +57,15 @@ struct StreamQueue: Sendable {
var directoryInfo = stat()
guard lstat(parent.path, &directoryInfo) == 0,
(directoryInfo.st_mode & S_IFMT) == S_IFDIR,
directoryInfo.st_uid == geteuid(), (directoryInfo.st_mode & 0o777) == 0o700,
parent.resolvingSymlinksInPath().path == parent.path,
reports.insert(report.path).inserted else { throw InvalidQueue() }
directoryInfo.st_uid == geteuid(), (directoryInfo.st_mode & 0o7777) == 0o700,
let resolvedParent = realpath(parent.path, nil) else { throw InvalidQueue() }
defer { free(resolvedParent) }
let canonicalReport = URL(fileURLWithPath: String(cString: resolvedParent), isDirectory: true)
.appendingPathComponent(report.lastPathComponent)
guard reports.insert(canonicalReport.path).inserted else { throw InvalidQueue() }
var existing = stat()
guard lstat(report.path, &existing) != 0, errno == ENOENT else { throw InvalidQueue() }
return Entry(url: url, identity: identity, progressFile: report, label: label)
guard lstat(canonicalReport.path, &existing) != 0, errno == ENOENT else { throw InvalidQueue() }
return Entry(url: url, identity: identity, progressFile: canonicalReport, label: label)
}
return StreamQueue(entries: entries)
} catch { throw InvalidQueue() }
+129 -29
View File
@@ -25,7 +25,9 @@ func compactProgressText(position: Double, duration: Double, format: CompactTitl
final class PlayerModel: ObservableObject {
static var startupConfiguration = PlayerConfiguration()
static var startupProgressFile: String?
static let shared = PlayerModel(configuration: startupConfiguration, progressFile: startupProgressFile)
static var startupStreamQueue: StreamQueue?
static let shared = PlayerModel(configuration: startupConfiguration, progressFile: startupProgressFile,
streamQueue: startupStreamQueue)
@Published private(set) var state = PlayerState()
@Published private(set) var fileName: String?
@@ -77,6 +79,10 @@ final class PlayerModel: ObservableObject {
currentSkipInterval == skipOffer.interval else { return nil }
return skipOffer.interval
}
var skipOfferRemainingFraction: Double {
guard segmentToSkip != nil, let skipOffer else { return 0 }
return min(1, max(0, (skipOffer.deadline - skipClock()) / 10))
}
private func updateSegmentSkip() {
guard let interval = currentSkipInterval else {
@@ -170,16 +176,22 @@ final class PlayerModel: ObservableObject {
private var reportingEnabled = false
private var reportingErrorShown = false
private var reportingErrorBuffer = [CChar]()
private let youtubeResolver: @MainActor (URL, Int?) async throws -> YouTubeVideo
private let miruroResolver: @MainActor (String, Int) async throws -> ResolvedSource
private var reportingErrorMessage: String {
reportingErrorBuffer.withUnsafeBufferPointer { String(cString: $0.baseAddress!) }
}
init(defaults: UserDefaults = .standard, configuration: PlayerConfiguration = PlayerConfiguration(),
progressFile: String? = nil,
progressFile: String? = nil, streamQueue: StreamQueue? = nil,
youtubeResolver: @escaping @MainActor (URL, Int?) async throws -> YouTubeVideo = { try await YouTubeResolver.resolve($0, height: $1) },
miruroResolver: @escaping @MainActor (String, Int) async throws -> ResolvedSource = { try await MiruroResolver.resolve(mediaID: $0, episode: $1) },
qualityClock: @escaping () -> Double = { ProcessInfo.processInfo.systemUptime },
skipClock: @escaping () -> Double = { ProcessInfo.processInfo.systemUptime }) {
self.qualityClock = qualityClock
self.skipClock = skipClock
self.youtubeResolver = youtubeResolver
self.miruroResolver = miruroResolver
keyBindings = configuration.keys
compactTitleProgress = configuration.compactTitleProgress
automaticYouTubeQuality = configuration.automaticQuality
@@ -206,6 +218,25 @@ final class PlayerModel: ObservableObject {
progressInitializationError = "Could not create the media player to initialize progress reporting."
}
}
if let streamQueue {
reportingErrorBuffer = [CChar](repeating: 0, count: 512)
for entry in streamQueue.entries {
guard let player else {
progressInitializationError = "Could not create the media player to initialize queue reports."
break
}
let registered = entry.identity.withCString { identity in
entry.progressFile.path.withCString { path in
player_register_progress_file(player, identity, path, &reportingErrorBuffer)
}
}
if !registered {
progressInitializationError = reportingErrorMessage
break
}
reportingEnabled = true
}
}
compactWindow.onChange = { [weak self] in
guard let self else { return }
if !self.compactWindow.isTransitioning {
@@ -349,9 +380,23 @@ final class PlayerModel: ObservableObject {
}
showsYouTubeInput = false
clearQueue()
resolveStream(stream, change: .open)
}
func openStreamQueue(_ streamQueue: StreamQueue) {
guard canOpen, progressInitializationError == nil else { return }
queueCastDevice = nil
queue.replaceStreams(streamQueue.entries.map { (url: $0.url, label: $0.label) })
showsQueue = queue.items.count > 1
showsYouTubeInput = false
openQueueSelection(keepingReceiver: false)
}
private func resolveStream(_ stream: StreamURL, change: MediaChange) {
replaceMetadata(for: nil)
switch stream {
case .youtube(let url): resolveYouTube(url, height: nil)
case .miruro(let mediaID, let episode): resolveMiruro(mediaID: mediaID, episode: episode)
case .youtube(let url): resolveYouTube(url, height: nil, change: change)
case .miruro(let mediaID, let episode): resolveMiruro(mediaID: mediaID, episode: episode, change: change)
}
}
@@ -427,7 +472,7 @@ final class PlayerModel: ObservableObject {
resolveYouTube(url, height: height, change: change)
}
private func resolveMiruro(mediaID: String, episode: Int) {
private func resolveMiruro(mediaID: String, episode: Int, change: MediaChange = .open) {
guard let handle else { return }
if castDevice != nil { stopCasting(resume: false) }
cancelStreamResolution()
@@ -438,13 +483,17 @@ final class PlayerModel: ObservableObject {
return
}
objectWillChange.send()
let request = transition.beginResolution(.open)
let request = transition.beginResolution(change)
systemPlayback?.update()
commandError = nil
revealControls()
let resolver = miruroResolver
let task = Task { [weak self] in
do {
let source = try await MiruroResolver.resolve(mediaID: mediaID, episode: episode)
let source = try await resolver(mediaID, episode)
guard source.identity == "miruro:\(mediaID):\(episode)" else {
throw CocoaError(.fileReadCorruptFile)
}
guard !Task.isCancelled, let self else { return }
self.objectWillChange.send()
guard let change = self.transition.finishResolution(request) else { return }
@@ -475,9 +524,11 @@ final class PlayerModel: ObservableObject {
systemPlayback?.update()
commandError = nil
revealControls()
let resolver = youtubeResolver
let task = Task { [weak self] in
do {
let video = try await YouTubeResolver.resolve(url, height: height)
let video = try await resolver(url, height)
guard video.webpageURL == url else { throw CocoaError(.fileReadCorruptFile) }
guard !Task.isCancelled, let self, let handle = self.handle else { return }
self.objectWillChange.send()
guard var change = self.transition.finishResolution(request) else { return }
@@ -544,6 +595,7 @@ final class PlayerModel: ObservableObject {
self.fileName = title
self.window?.title = title
self.casting.updateTitle(title)
if self.queue.currentID != nil { self.queue.updateCurrentLabel(title) }
self.systemPlayback?.update()
}
self.metadataTask = nil
@@ -635,6 +687,7 @@ final class PlayerModel: ObservableObject {
youtubeHeight = nil
youtubeQuality = nil
}
if let fileName, !source.url.isFileURL { queue.updateCurrentLabel(fileName) }
selectSubtitle(nil)
if case .resolved(let resolved) = source {
subtitleTracks = resolved.subtitles
@@ -706,7 +759,13 @@ final class PlayerModel: ObservableObject {
queuePlaybackRevision = nil
queueCompletionHandled = true
cancelStreamResolution()
openMedia(.file(url), change: .queue(playing: receiver == nil))
let change = MediaChange.queue(playing: receiver == nil)
if url.isFileURL {
openMedia(.file(url), change: change)
} else {
do { resolveStream(try StreamURL.parse(url.absoluteString), change: change) }
catch { commandError = error.localizedDescription }
}
queueCastDevice = receiver
}
@@ -2905,15 +2964,15 @@ private struct PlayerContent: View {
}
.buttonStyle(.plain)
.help("Remove from Queue")
.accessibilityLabel("Remove \(item.url.lastPathComponent) from Queue")
.accessibilityLabel("Remove \(item.displayName) from Queue")
}
VStack(alignment: .leading, spacing: 3) {
Text(item.url.deletingPathExtension().lastPathComponent)
Text(item.url.isFileURL ? item.url.deletingPathExtension().lastPathComponent : item.displayName)
.font(.system(size: 13, weight: .medium))
.lineLimit(2)
.lineSpacing(2)
.truncationMode(.middle)
if !item.url.pathExtension.isEmpty {
if item.url.isFileURL && !item.url.pathExtension.isEmpty {
Text(item.url.pathExtension.uppercased())
.font(.system(size: 10, weight: .medium))
.tracking(0.6)
@@ -2933,7 +2992,7 @@ private struct PlayerContent: View {
}
.contentShape(Rectangle())
.onTapGesture { model.selectQueueItem(item.id) }
.help(item.url.path + "\nClick to play; drag to reorder.")
.help((item.url.isFileURL ? item.url.path : item.displayName) + "\nClick to play; drag to reorder.")
.contextMenu {
Button("Play") { model.selectQueueItem(item.id) }
Button("Remove from Queue") { model.removeQueueItem(item.id) }
@@ -3276,24 +3335,44 @@ 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))
ZStack(alignment: .topTrailing) {
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 {
ZStack(alignment: .leading) {
PlayerGlass(cornerRadius: 8)
TimelineView(.animation(minimumInterval: reduceMotion ? 1 : 1.0 / 30)) { _ in
GeometryReader { geometry in
let remaining = model.skipOfferRemainingFraction
Color(SunsetPalette.accent).opacity(0.25)
.frame(width: geometry.size.width *
(reduceMotion ? ceil(remaining * 10) / 10 : remaining))
}
}
}
.clipShape(RoundedRectangle(cornerRadius: 8))
.allowsHitTesting(false)
.accessibilityHidden(true)
}
.transition(reduceMotion ? .identity : .opacity.combined(with: .move(edge: .top)))
}
.buttonStyle(.plain)
.foregroundStyle(Color(SunsetPalette.text))
.background(PlayerGlass(cornerRadius: 8).clipShape(RoundedRectangle(cornerRadius: 8)))
.padding(.trailing, model.isCompact ? 8 : 16)
.padding(.top, 44)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing)
}
.padding(.trailing, model.isCompact ? 8 : 16)
.padding(.top, 44)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing)
.animation(reduceMotion ? nil : .easeInOut(duration: 0.2), value: model.segmentToSkip?.kind)
.allowsHitTesting(model.segmentToSkip != nil)
if dropTargeted {
RoundedRectangle(cornerRadius: 10)
.strokeBorder(Color(SunsetPalette.accent), lineWidth: 2)
@@ -3467,6 +3546,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
if let index = arguments.firstIndex(of: "--source-file"), index + 1 < arguments.count {
PlayerModel.shared.openResolvedSource(URL(fileURLWithPath: arguments[index + 1]))
}
if let streamQueue = PlayerModel.startupStreamQueue {
PlayerModel.shared.openStreamQueue(streamQueue)
}
}
// Let SwiftUI create the initial window for a Finder/document launch.
@@ -3521,6 +3603,24 @@ public func terebiAppMain() {
exit(78)
}
let arguments = Array(CommandLine.arguments.dropFirst())
if let index = arguments.firstIndex(of: "--queue-file") {
do {
guard index + 1 < arguments.count, arguments[index + 1].hasPrefix("/"),
arguments.filter({ $0 == "--queue-file" }).count == 1,
!arguments.contains("--progress-file"), !arguments.contains("--source-file"),
!arguments.contains("--open-media") else {
throw CocoaError(.fileReadCorruptFile)
}
PlayerModel.startupStreamQueue = try StreamQueue.load(from: URL(fileURLWithPath: arguments[index + 1]))
_ = NSApplication.shared
if let error = PlayerModel.shared.progressInitializationError {
throw NSError(domain: "Terebi.Queue", code: 73, userInfo: [NSLocalizedDescriptionKey: error])
}
} catch {
FileHandle.standardError.write(Data("terebi: \(error.localizedDescription)\n".utf8))
exit(73)
}
}
if let index = arguments.firstIndex(of: "--progress-file") {
guard index + 1 < arguments.count, arguments[index + 1].hasPrefix("/"),
arguments.filter({ $0 == "--progress-file" }).count == 1 else {
+2 -2
View File
@@ -293,12 +293,12 @@ ProgressReports::ProgressReports(const std::string &path)
: single(std::make_unique<ProgressReporter>(path)), active(single.get()) {}
void ProgressReports::registerFile(const std::string &identity, const std::string &path) {
std::lock_guard lock(mutex);
if (single || !ProgressReporter::validMediaIdentity(identity) || entries.size() >= 4096 ||
path.empty() || path.front() != '/' || path.find('\0') != std::string::npos ||
entries.contains(identity))
throw std::runtime_error("Invalid or duplicate queue progress registration");
for (const auto &[key, entry] : entries)
if (entry.path == path) throw std::runtime_error("Duplicate queue progress report path");
if (!paths.insert(path).second) throw std::runtime_error("Duplicate queue progress report path");
entries.emplace(identity, Entry{path, nullptr});
}
+2
View File
@@ -9,6 +9,7 @@
#include <string>
#include <map>
#include <memory>
#include <set>
#include <sys/types.h>
#include <string_view>
#include <thread>
@@ -82,6 +83,7 @@ private:
struct Entry { std::string path; std::unique_ptr<ProgressReporter> reporter; };
std::unique_ptr<ProgressReporter> single;
std::map<std::string, Entry> entries;
std::set<std::string> paths;
ProgressReporter *active = nullptr;
mutable std::mutex mutex;
std::string failure;