feat: enqueue streams in running player
This commit is contained in:
@@ -27,6 +27,10 @@ struct PlaybackQueue {
|
||||
currentID = items.first?.id
|
||||
}
|
||||
|
||||
mutating func appendStreams(_ entries: [(url: URL, label: String)]) {
|
||||
items.append(contentsOf: entries.map { Item(url: $0.url, label: $0.label) })
|
||||
}
|
||||
|
||||
mutating func updateCurrentLabel(_ label: String) {
|
||||
guard let index = currentIndex else { return }
|
||||
items[index].label = label
|
||||
|
||||
@@ -18,6 +18,26 @@ struct StreamQueue: Sendable {
|
||||
private struct InvalidQueue: LocalizedError {
|
||||
var errorDescription: String? { "Invalid stream queue: expected version 1, 1–4096 unique supported videos and new absolute report files in private owned directories." }
|
||||
}
|
||||
// Explicit-app URL delivery reaches both cold launches and already running players.
|
||||
static func enqueueURL(for file: URL) -> URL {
|
||||
var components = URLComponents()
|
||||
components.scheme = "terebi-enqueue"
|
||||
components.host = "queue"
|
||||
components.queryItems = [URLQueryItem(name: "file", value: file.path)]
|
||||
return components.url!
|
||||
}
|
||||
|
||||
static func enqueueFile(from url: URL) throws -> URL {
|
||||
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
|
||||
components.scheme == "terebi-enqueue", components.host == "queue",
|
||||
components.path.isEmpty, components.user == nil, components.password == nil,
|
||||
components.port == nil, components.fragment == nil,
|
||||
let query = components.queryItems, query.count == 1, query[0].name == "file",
|
||||
let path = query[0].value, path.hasPrefix("/"), !path.contains("\0") else {
|
||||
throw InvalidQueue()
|
||||
}
|
||||
return URL(fileURLWithPath: path).standardizedFileURL
|
||||
}
|
||||
|
||||
static func load(from file: URL) throws -> StreamQueue {
|
||||
do {
|
||||
|
||||
+85
-21
@@ -50,6 +50,7 @@ final class PlayerModel: ObservableObject {
|
||||
private var queuePlaybackRevision: UInt64?
|
||||
private var queueCompletionHandled = false
|
||||
private var queueCastDevice: CastDevice?
|
||||
private var resolvedQueueSource: (id: UUID, source: ResolvedSource)?
|
||||
var canPreviousQueueItem: Bool { canOpen && queue.canPrevious }
|
||||
var canNextQueueItem: Bool { canOpen && queue.canNext }
|
||||
private var transition = PlaybackTransition()
|
||||
@@ -218,24 +219,8 @@ 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
|
||||
}
|
||||
if let streamQueue, !registerQueueReports(streamQueue) {
|
||||
progressInitializationError = reportingErrorMessage
|
||||
}
|
||||
compactWindow.onChange = { [weak self] in
|
||||
guard let self else { return }
|
||||
@@ -380,6 +365,12 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
showsYouTubeInput = false
|
||||
clearQueue()
|
||||
let url: URL
|
||||
switch stream {
|
||||
case .youtube(let canonical): url = canonical
|
||||
case .miruro: url = URL(string: text.trimmingCharacters(in: .whitespacesAndNewlines))!
|
||||
}
|
||||
queue.replaceStreams([(url: url, label: url.absoluteString)])
|
||||
resolveStream(stream, change: .open)
|
||||
}
|
||||
|
||||
@@ -393,6 +384,69 @@ final class PlayerModel: ObservableObject {
|
||||
openQueueSelection(keepingReceiver: false)
|
||||
}
|
||||
|
||||
private func registerQueueReports(_ streamQueue: StreamQueue) -> Bool {
|
||||
reportingErrorBuffer = [CChar](repeating: 0, count: 512)
|
||||
guard let handle else { return false }
|
||||
let identities = streamQueue.entries.map { strdup($0.identity) }
|
||||
let paths = streamQueue.entries.map { strdup($0.progressFile.path) }
|
||||
defer {
|
||||
for value in identities { free(value) }
|
||||
for value in paths { free(value) }
|
||||
}
|
||||
let identityPointers = identities.map { $0.map { UnsafePointer($0) } }
|
||||
let pathPointers = paths.map { $0.map { UnsafePointer($0) } }
|
||||
let accepted = player_register_progress_files(handle, identityPointers, pathPointers,
|
||||
Int32(streamQueue.entries.count), &reportingErrorBuffer)
|
||||
if accepted { reportingEnabled = true }
|
||||
return accepted
|
||||
}
|
||||
|
||||
func enqueueStreamQueue(_ streamQueue: StreamQueue) {
|
||||
guard canOpen else {
|
||||
commandError = "Could not create the media player to enqueue streams."
|
||||
return
|
||||
}
|
||||
let existing = Set(queue.items.compactMap { item -> String? in
|
||||
guard !item.url.isFileURL else { return nil }
|
||||
if let stream = try? StreamURL.parse(item.url.absoluteString) {
|
||||
switch stream {
|
||||
case .youtube(let url): return url.absoluteString
|
||||
case .miruro(let id, let episode): return "miruro:\(id):\(episode)"
|
||||
}
|
||||
}
|
||||
return item.url.absoluteString
|
||||
})
|
||||
guard !streamQueue.entries.contains(where: { existing.contains($0.identity) }) else {
|
||||
commandError = "Cannot enqueue a video already present in this queue; reports cannot be rebound."
|
||||
revealControls()
|
||||
return
|
||||
}
|
||||
guard registerQueueReports(streamQueue) else {
|
||||
commandError = reportingErrorMessage
|
||||
revealControls()
|
||||
return
|
||||
}
|
||||
let needsStart = queue.currentID == nil && currentMedia == nil && !resolvingStream
|
||||
queue.appendStreams(streamQueue.entries.map { (url: $0.url, label: $0.label) })
|
||||
commandError = nil
|
||||
showsQueue = queue.items.count > 1
|
||||
if needsStart, let first = queue.items.first {
|
||||
queue.select(first.id)
|
||||
if fileName == nil { configureStartWindow(showing: false) }
|
||||
openQueueSelection(keepingReceiver: false)
|
||||
}
|
||||
}
|
||||
|
||||
func receiveEnqueueURL(_ url: URL) {
|
||||
do {
|
||||
let file = try StreamQueue.enqueueFile(from: url)
|
||||
enqueueStreamQueue(try StreamQueue.load(from: file))
|
||||
} catch {
|
||||
commandError = error.localizedDescription
|
||||
revealControls()
|
||||
}
|
||||
}
|
||||
|
||||
private func resolveStream(_ stream: StreamURL, change: MediaChange) {
|
||||
replaceMetadata(for: nil)
|
||||
switch stream {
|
||||
@@ -405,6 +459,8 @@ final class PlayerModel: ObservableObject {
|
||||
do {
|
||||
let source = try ResolvedSource.load(from: file)
|
||||
clearQueue()
|
||||
queue.replaceStreams([(url: URL(string: source.identity)!, label: source.title)])
|
||||
resolvedQueueSource = (queue.currentID!, source)
|
||||
cancelStreamResolution()
|
||||
openMedia(.resolved(source))
|
||||
} catch {
|
||||
@@ -761,7 +817,9 @@ final class PlayerModel: ObservableObject {
|
||||
queueCompletionHandled = true
|
||||
cancelStreamResolution()
|
||||
let change = MediaChange.queue(playing: receiver == nil)
|
||||
if url.isFileURL {
|
||||
if let original = resolvedQueueSource, queue.currentID == original.id {
|
||||
openMedia(.resolved(original.source), change: change)
|
||||
} else if url.isFileURL {
|
||||
openMedia(.file(url), change: change)
|
||||
} else {
|
||||
do { resolveStream(try StreamURL.parse(url.absoluteString), change: change) }
|
||||
@@ -773,6 +831,7 @@ final class PlayerModel: ObservableObject {
|
||||
private func clearQueue() {
|
||||
queue.replace([])
|
||||
queuePlaybackRevision = nil
|
||||
resolvedQueueSource = nil
|
||||
queueCompletionHandled = false
|
||||
queueCastDevice = nil
|
||||
}
|
||||
@@ -3542,7 +3601,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
|
||||
@objc private func openInternetURL(_ event: NSAppleEventDescriptor, withReplyEvent reply: NSAppleEventDescriptor) {
|
||||
guard let text = event.paramDescriptor(forKeyword: keyDirectObject)?.stringValue else { return }
|
||||
PlayerModel.shared.openStream(text)
|
||||
guard let url = URL(string: text) else { return }
|
||||
if url.scheme == "terebi-enqueue" { PlayerModel.shared.receiveEnqueueURL(url) }
|
||||
else { PlayerModel.shared.openStream(text) }
|
||||
}
|
||||
|
||||
@objc private func openDocuments(_ event: NSAppleEventDescriptor, withReplyEvent reply: NSAppleEventDescriptor) {
|
||||
@@ -3568,7 +3629,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
|
||||
// Let SwiftUI create the initial window for a Finder/document launch.
|
||||
func application(_ application: NSApplication, open urls: [URL]) {
|
||||
if urls.allSatisfy(\.isFileURL) { PlayerModel.shared.openFiles(urls) }
|
||||
if urls.contains(where: { $0.scheme == "terebi-enqueue" }) {
|
||||
guard urls.count == 1, let url = urls.first else { return }
|
||||
PlayerModel.shared.receiveEnqueueURL(url)
|
||||
} else if urls.allSatisfy(\.isFileURL) { PlayerModel.shared.openFiles(urls) }
|
||||
else if let url = urls.first { PlayerModel.shared.open(url) }
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ enum PlayerCommand {
|
||||
case launch(URL?, newInstance: Bool, noConfig: Bool, progressFile: URL?)
|
||||
case launchSource(sourceFile: URL, newInstance: Bool, noConfig: Bool, progressFile: URL?)
|
||||
case launchQueue(queueFile: URL, newInstance: Bool, noConfig: Bool)
|
||||
case enqueue(queueFile: URL, noConfig: Bool)
|
||||
|
||||
static let usage = """
|
||||
Usage: terebi [--new-instance] [--no-config] [FILE | STREAM_URL]
|
||||
@@ -13,6 +14,7 @@ enum PlayerCommand {
|
||||
terebi --new-instance --url STREAM_URL --progress-file PATH
|
||||
terebi --new-instance --source-file PATH [--progress-file PATH]
|
||||
terebi --new-instance [--no-config] --queue-file PATH
|
||||
terebi --enqueue [--no-config] --queue-file PATH
|
||||
terebi [--new-instance] [--no-config] -- FILE
|
||||
terebi --help
|
||||
terebi --default-config
|
||||
@@ -26,6 +28,8 @@ enum PlayerCommand {
|
||||
--url URL Open a YouTube video or Miruro episode URL.
|
||||
--source-file PATH Open a validated resolved HTTP(S) source in a new player.
|
||||
--queue-file PATH Open an ordered version-1 stream queue with per-item reports.
|
||||
--enqueue Append a queue to the running player without interrupting playback;
|
||||
launch and start its first item if no player is running.
|
||||
--progress-file PATH Write JSONL playback facts to a new private file; requires
|
||||
--new-instance. The GUI owns reporting after this CLI exits.
|
||||
-- Treat the following argument as a filename, even with a leading '-'.
|
||||
@@ -58,16 +62,23 @@ enum PlayerCommand {
|
||||
}
|
||||
var newInstance = false
|
||||
var noConfig = false
|
||||
while let option = inputs.first, option == "--new-instance" || option == "--no-config" {
|
||||
var enqueue = false
|
||||
while let option = inputs.first, ["--new-instance", "--no-config", "--enqueue"].contains(option) {
|
||||
if option == "--new-instance" {
|
||||
guard !newInstance else { throw ArgumentError("--new-instance may only be supplied once.") }
|
||||
newInstance = true
|
||||
} else if option == "--enqueue" {
|
||||
guard !enqueue else { throw ArgumentError("--enqueue may only be supplied once.") }
|
||||
enqueue = true
|
||||
} else {
|
||||
guard !noConfig else { throw ArgumentError("--no-config may only be supplied once.") }
|
||||
noConfig = true
|
||||
}
|
||||
inputs.removeFirst()
|
||||
}
|
||||
guard !enqueue || (!newInstance && progressFile == nil && inputs.first == "--queue-file") else {
|
||||
throw ArgumentError("--enqueue requires --queue-file and cannot combine with --new-instance, --progress-file or other media.")
|
||||
}
|
||||
func launch(_ url: URL?) throws -> PlayerCommand {
|
||||
guard progressFile == nil || newInstance else {
|
||||
throw ArgumentError("--progress-file requires --new-instance.")
|
||||
@@ -94,12 +105,13 @@ enum PlayerCommand {
|
||||
return .printDefaultConfig
|
||||
}
|
||||
if inputs.first == "--queue-file" {
|
||||
guard newInstance, progressFile == nil, inputs.count == 2,
|
||||
guard newInstance || enqueue, progressFile == nil, inputs.count == 2,
|
||||
inputs[1].hasPrefix("/"), !inputs[1].contains("\0") else {
|
||||
throw ArgumentError("--queue-file requires --new-instance and one absolute manifest path; it cannot be combined with --progress-file.")
|
||||
throw ArgumentError("--queue-file requires --new-instance or --enqueue and one absolute manifest path; it cannot be combined with --progress-file.")
|
||||
}
|
||||
let queueFile = URL(fileURLWithPath: inputs[1]).standardizedFileURL
|
||||
_ = try StreamQueue.load(from: queueFile)
|
||||
if enqueue { return .enqueue(queueFile: queueFile, noConfig: noConfig) }
|
||||
return .launchQueue(queueFile: queueFile, newInstance: newInstance, noConfig: noConfig)
|
||||
}
|
||||
if inputs.first == "--source-file" {
|
||||
|
||||
@@ -24,6 +24,9 @@ enum TerebiCLI {
|
||||
case .launchQueue(let queueFile, let newInstance, let noConfig):
|
||||
await launch(url: nil, sourceFile: nil, newInstance: newInstance, noConfig: noConfig,
|
||||
progressFile: nil, queueFile: queueFile)
|
||||
case .enqueue(let queueFile, let noConfig):
|
||||
await launch(url: StreamQueue.enqueueURL(for: queueFile), sourceFile: nil,
|
||||
newInstance: false, noConfig: noConfig, progressFile: nil)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+9
-10
@@ -197,7 +197,7 @@ struct Player {
|
||||
PlayerStatus status = PLAYER_EMPTY;
|
||||
bool previewShown = false;
|
||||
std::array<char, 512> error{};
|
||||
std::unique_ptr<playback::ProgressReports> reporter;
|
||||
const std::unique_ptr<playback::ProgressReports> reporter = std::make_unique<playback::ProgressReports>();
|
||||
bool remoteReporting = false;
|
||||
std::thread worker;
|
||||
|
||||
@@ -1429,10 +1429,10 @@ bool player_set_progress_file(Player *player, const char *path, char *error) {
|
||||
try {
|
||||
if (!player || !path || !*path) throw std::runtime_error("A progress file path is required");
|
||||
std::lock_guard<std::mutex> lock(player->mutex);
|
||||
if (player->reporter || player->stopping.load(std::memory_order_relaxed) ||
|
||||
if (player->stopping.load(std::memory_order_relaxed) ||
|
||||
player->state.status != PLAYER_EMPTY || !player->commands.empty())
|
||||
throw std::runtime_error("Progress reporting must be initialized once, before opening media");
|
||||
player->reporter = std::make_unique<playback::ProgressReports>(path);
|
||||
player->reporter->setSingleFile(path);
|
||||
return true;
|
||||
} catch (const std::exception &failure) {
|
||||
if (error) copyError(error, failure.what());
|
||||
@@ -1441,16 +1441,15 @@ bool player_set_progress_file(Player *player, const char *path, char *error) {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
bool player_register_progress_file(Player *player, const char *identity, const char *path, char *error) {
|
||||
bool player_register_progress_files(Player *player, const char *const *identities,
|
||||
const char *const *paths, int count, char *error) {
|
||||
if (error) error[0] = '\0';
|
||||
try {
|
||||
if (!player || !identity || !path) throw std::runtime_error("Queue progress identity and path are required");
|
||||
if (!player) throw std::runtime_error("A player is required for queue progress registration");
|
||||
std::lock_guard<std::mutex> lock(player->mutex);
|
||||
if (player->stopping.load(std::memory_order_relaxed) ||
|
||||
player->state.status != PLAYER_EMPTY || !player->commands.empty())
|
||||
throw std::runtime_error("Queue reports must be registered before opening media");
|
||||
if (!player->reporter) player->reporter = std::make_unique<playback::ProgressReports>();
|
||||
player->reporter->registerFile(identity, path);
|
||||
if (player->stopping.load(std::memory_order_relaxed))
|
||||
throw std::runtime_error("Cannot register queue reports while the player is stopping");
|
||||
player->reporter->registerFiles(identities, paths, count);
|
||||
return true;
|
||||
} catch (const std::exception &failure) {
|
||||
if (error) copyError(error, failure.what());
|
||||
|
||||
@@ -40,9 +40,11 @@ Player *player_create(void);
|
||||
// Opt-in reporting must be initialized before opening media. Creates a new 0600
|
||||
// file exclusively; errors never change playback state. Error buffers hold 512 bytes.
|
||||
bool player_set_progress_file(Player *player, const char *path, char *error);
|
||||
// Register immutable queue destinations before opening any media. Each path is new;
|
||||
// revisits append only to the original owned inode, and unmatched identities are not reported.
|
||||
bool player_register_progress_file(Player *player, const char *identity, const char *path, char *error);
|
||||
// Register immutable queue destinations without changing current playback. Each path is new;
|
||||
// revisits append only to the original owned inode. Conflicting identities/paths are rejected.
|
||||
// Atomic batch: all registrations succeed or none do. Strings are copied before returning.
|
||||
bool player_register_progress_files(Player *player, const char *const *identities,
|
||||
const char *const *paths, int count, char *error);
|
||||
bool player_get_progress_error(Player *player, char *error);
|
||||
// Stops the worker and drains final reporting, allowing errors to be shown before
|
||||
// destroying the handle. Idempotent; do not call from an audio callback.
|
||||
|
||||
@@ -289,32 +289,72 @@ void ProgressReporter::finish() noexcept {
|
||||
if (writer.joinable()) writer.join();
|
||||
}
|
||||
|
||||
ProgressReports::ProgressReports(const std::string &path)
|
||||
: single(std::make_unique<ProgressReporter>(path)), active(single.get()) {}
|
||||
ProgressReports::ProgressReports(const std::string &path) { setSingleFile(path); }
|
||||
|
||||
void ProgressReports::setSingleFile(const std::string &path) {
|
||||
std::lock_guard lock(mutex);
|
||||
if (single || !entries.empty()) throw std::runtime_error("Progress reporting is already initialized");
|
||||
single = std::make_unique<ProgressReporter>(path);
|
||||
singlePath = path;
|
||||
active = single.get();
|
||||
}
|
||||
|
||||
void ProgressReports::registerFile(const std::string &identity, const std::string &path) {
|
||||
const char *identities[] = {identity.c_str()}, *destinations[] = {path.c_str()};
|
||||
registerFiles(identities, destinations, 1);
|
||||
}
|
||||
|
||||
void ProgressReports::registerFiles(const char *const *identities, const char *const *destinations, int count) {
|
||||
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");
|
||||
if (!paths.insert(path).second) throw std::runtime_error("Duplicate queue progress report path");
|
||||
entries.emplace(identity, Entry{path, nullptr});
|
||||
if (!identities || !destinations || count < 1 || count > 4096 ||
|
||||
entries.size() + count > 4096)
|
||||
throw std::runtime_error("Queue reports exceed the 4096 retained registration limit");
|
||||
std::map<std::string, Entry> additions;
|
||||
std::set<std::string> newPaths;
|
||||
for (int index = 0; index < count; ++index) {
|
||||
const auto *identity = identities[index], *path = destinations[index];
|
||||
if (!identity || !path || !ProgressReporter::validMediaIdentity(identity) ||
|
||||
!*path || *path != '/' || singleIdentity == identity || entries.contains(identity) || additions.contains(identity))
|
||||
throw std::runtime_error("Invalid or conflicting queue progress identity");
|
||||
if (singlePath == path || paths.contains(path) || !newPaths.insert(path).second)
|
||||
throw std::runtime_error("Conflicting queue progress report path");
|
||||
additions.emplace(identity, Entry{path, nullptr});
|
||||
}
|
||||
// Node merges allocate nothing: failed validation cannot partially register a batch.
|
||||
entries.merge(additions);
|
||||
paths.merge(newPaths);
|
||||
}
|
||||
|
||||
void ProgressReports::observe(const PlayerState &state) noexcept {
|
||||
std::lock_guard lock(mutex);
|
||||
if (active) active->observe(state);
|
||||
}
|
||||
|
||||
void ProgressReports::closeMedia() noexcept {
|
||||
std::lock_guard lock(mutex);
|
||||
if (active) active->closeMedia();
|
||||
}
|
||||
|
||||
void ProgressReports::begin(const std::string &identity, bool live) noexcept {
|
||||
std::lock_guard lock(mutex);
|
||||
if (single) { active->begin(identity, live); return; }
|
||||
const auto found = entries.find(identity);
|
||||
auto *next = found == entries.end() ? nullptr : found->second.reporter.get();
|
||||
const bool useSingle = found == entries.end() && single &&
|
||||
(entries.empty() || singleIdentity.empty() || singleIdentity == identity);
|
||||
auto *next = found == entries.end() ? (useSingle ? single.get() : nullptr) : found->second.reporter.get();
|
||||
if (active != next && active) active->finish();
|
||||
active = nullptr; // A failed or unregistered identity must never reuse the old report.
|
||||
if (found == entries.end()) return;
|
||||
if (found == entries.end() && !useSingle) return;
|
||||
try {
|
||||
auto &entry = found->second;
|
||||
if (!entry.reporter) entry.reporter = std::make_unique<ProgressReporter>(entry.path);
|
||||
else entry.reporter->resume();
|
||||
active = entry.reporter.get();
|
||||
if (found == entries.end()) {
|
||||
singleIdentity = identity;
|
||||
single->resume();
|
||||
active = single.get();
|
||||
} else {
|
||||
auto &entry = found->second;
|
||||
if (!entry.reporter) entry.reporter = std::make_unique<ProgressReporter>(entry.path);
|
||||
else entry.reporter->resume();
|
||||
active = entry.reporter.get();
|
||||
}
|
||||
active->begin(identity, live);
|
||||
} catch (const std::exception &exception) {
|
||||
try { if (failure.empty()) failure = exception.what(); } catch (...) {}
|
||||
@@ -331,7 +371,10 @@ void ProgressReports::finish() noexcept {
|
||||
std::string ProgressReports::error() const {
|
||||
std::lock_guard lock(mutex);
|
||||
if (!failure.empty()) return failure;
|
||||
if (single) return single->error();
|
||||
if (single) {
|
||||
const auto message = single->error();
|
||||
if (!message.empty()) return message;
|
||||
}
|
||||
for (const auto &[identity, entry] : entries) {
|
||||
if (entry.reporter) {
|
||||
auto message = entry.reporter->error();
|
||||
|
||||
@@ -73,15 +73,19 @@ class ProgressReports {
|
||||
public:
|
||||
explicit ProgressReports(const std::string &path);
|
||||
ProgressReports() = default;
|
||||
void setSingleFile(const std::string &path);
|
||||
void registerFile(const std::string &identity, const std::string &path);
|
||||
void registerFiles(const char *const *identities, const char *const *paths, int count);
|
||||
void begin(const std::string &identity, bool live = false) noexcept;
|
||||
void observe(const PlayerState &state) noexcept { if (active) active->observe(state); }
|
||||
void closeMedia() noexcept { if (active) active->closeMedia(); }
|
||||
void observe(const PlayerState &state) noexcept;
|
||||
void closeMedia() noexcept;
|
||||
void finish() noexcept;
|
||||
std::string error() const;
|
||||
private:
|
||||
struct Entry { std::string path; std::unique_ptr<ProgressReporter> reporter; };
|
||||
std::unique_ptr<ProgressReporter> single;
|
||||
std::string singlePath;
|
||||
std::string singleIdentity;
|
||||
std::map<std::string, Entry> entries;
|
||||
std::set<std::string> paths;
|
||||
ProgressReporter *active = nullptr;
|
||||
|
||||
Reference in New Issue
Block a user