feat: show skip offer countdown and transitions
This commit is contained in:
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user