feat: make everything threads-safe.
feat: complete `uninstall.sh` logic to make everything synced. feat: complete basic play logic to handle background, music, lyric play.
This commit is contained in:
@@ -410,9 +410,17 @@ void renderRgbFrame(const RgbFrame& frame, float intensity, int offsetX, int off
|
||||
|
||||
// --- playback --------------------------------------------------------------
|
||||
|
||||
int playVideo(const std::string& path, const PlayVideoOptions& opts) {
|
||||
int playVideo(const std::string& path, Timeline* timeline,
|
||||
const PlayVideoOptions& opts) {
|
||||
if (!isTerminal()) return 2;
|
||||
|
||||
// Fallback clock for callers that don't provide one.
|
||||
Timeline localTimeline;
|
||||
if (!timeline) {
|
||||
timeline = &localTimeline;
|
||||
timeline->start();
|
||||
}
|
||||
|
||||
DecodeContext dc;
|
||||
int videoW = 0, videoH = 0;
|
||||
double fps = 24.0;
|
||||
@@ -431,6 +439,7 @@ int playVideo(const std::string& path, const PlayVideoOptions& opts) {
|
||||
int gridW = 0, gridH = 0;
|
||||
int offsetX = 0, offsetY = 0;
|
||||
bool showLabel = false;
|
||||
const int topRows = opts.topRows > 0 ? opts.topRows : 0;
|
||||
const int bottomRows = opts.bottomRows > 0 ? opts.bottomRows : 0;
|
||||
|
||||
bool adjustable = false;
|
||||
@@ -438,7 +447,7 @@ int playVideo(const std::string& path, const PlayVideoOptions& opts) {
|
||||
int aspectRows = static_cast<int>(std::lround(
|
||||
ts.cols * static_cast<double>(videoH) / (2.0 * videoW)));
|
||||
aspectRows = std::clamp(aspectRows, 2, 200);
|
||||
adjustable = resizeTerminal(aspectRows + bottomRows, ts.cols);
|
||||
adjustable = resizeTerminal(aspectRows + topRows + bottomRows, ts.cols);
|
||||
if (adjustable) {
|
||||
// The terminal changed; use whatever it actually became.
|
||||
ts = terminalSize();
|
||||
@@ -447,22 +456,30 @@ int playVideo(const std::string& path, const PlayVideoOptions& opts) {
|
||||
|
||||
if (adjustable) {
|
||||
gridW = ts.cols;
|
||||
gridH = std::max(1, ts.rows - bottomRows);
|
||||
gridH = std::max(1, ts.rows - topRows - bottomRows);
|
||||
offsetY = topRows;
|
||||
} else {
|
||||
// Not adjustable (or resize disabled): fit proportionally into the
|
||||
// current size. The frame-size label gets its own line at the top, so
|
||||
// reserve it before fitting; bottom rows are kept free for other
|
||||
// content (e.g. the lyric line).
|
||||
const int labelRows = 1;
|
||||
const int maxRows = std::max(1, ts.rows - bottomRows - labelRows);
|
||||
// current size. When the top is reserved for other content (lyrics),
|
||||
// the frame-size label is suppressed; otherwise it gets its own line
|
||||
// at the top.
|
||||
showLabel = (topRows == 0);
|
||||
const int labelRows = showLabel ? 1 : 0;
|
||||
const int maxRows = std::max(1, ts.rows - topRows - bottomRows - labelRows);
|
||||
GridSize g = fitSizeForAspect(videoW, videoH, ts.cols, maxRows);
|
||||
gridW = g.cols;
|
||||
gridH = g.rows;
|
||||
showLabel = true; // print "WxH" on its own line at the top
|
||||
offsetX = (ts.cols - gridW) / 2;
|
||||
offsetY = 1 + (maxRows - gridH) / 2;
|
||||
offsetY = topRows + (maxRows - gridH) / 2;
|
||||
}
|
||||
|
||||
if (opts.outGrid) {
|
||||
opts.outGrid->cols = gridW;
|
||||
opts.outGrid->rows = gridH;
|
||||
}
|
||||
if (opts.outOffsetX) *opts.outOffsetX = offsetX;
|
||||
if (opts.gridReady) opts.gridReady->store(true);
|
||||
|
||||
if (gridW <= 0 || gridH <= 0 || setupScaler(dc, gridW, gridH) < 0) {
|
||||
freeDecode(dc);
|
||||
return 1;
|
||||
@@ -479,25 +496,38 @@ int playVideo(const std::string& path, const PlayVideoOptions& opts) {
|
||||
dc.loop = opts.loop;
|
||||
std::thread decoder(decoderThreadFn, std::ref(dc));
|
||||
|
||||
const auto interval =
|
||||
std::chrono::microseconds(static_cast<int64_t>(1'000'000.0 / fps));
|
||||
auto next = std::chrono::steady_clock::now();
|
||||
const double intervalSec = 1.0 / fps;
|
||||
std::string out;
|
||||
out.reserve(static_cast<size_t>(gridW) * gridH * 24);
|
||||
|
||||
// Render one (latest) frame per time slot, paced by the shared timeline.
|
||||
// `nextSlot` jumps past any missed slots, so a slow decode never causes a
|
||||
// catch-up burst of redraws.
|
||||
double nextSlot = 0.0;
|
||||
while (!(opts.stop && opts.stop->load())) {
|
||||
RgbFrame frame;
|
||||
if (queue.popLatestUntil(next, frame)) {
|
||||
const auto deadline =
|
||||
timeline->startTime() +
|
||||
std::chrono::duration_cast<std::chrono::steady_clock::duration>(
|
||||
std::chrono::duration<double>(nextSlot));
|
||||
const bool got = queue.popLatestUntil(deadline, frame);
|
||||
|
||||
// Wait until the timeline reaches this slot (unless stopped).
|
||||
while (timeline->seconds() < nextSlot) {
|
||||
if (opts.stop && opts.stop->load()) break;
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(5));
|
||||
}
|
||||
if (opts.stop && opts.stop->load()) break;
|
||||
|
||||
if (got) {
|
||||
renderRgbFrame(frame, opts.intensity, offsetX, offsetY, showLabel, videoW,
|
||||
videoH, out);
|
||||
writeRaw(out);
|
||||
}
|
||||
if (finished.load() && queue.empty()) break;
|
||||
|
||||
next += interval;
|
||||
const auto now = std::chrono::steady_clock::now();
|
||||
if (next <= now) next = now + interval; // fell behind: re-anchor
|
||||
std::this_thread::sleep_until(next);
|
||||
const double now = timeline->seconds();
|
||||
nextSlot = std::max(nextSlot + intervalSec, now + intervalSec);
|
||||
}
|
||||
|
||||
queue.setFinished();
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
#include <string>
|
||||
#include <atomic>
|
||||
|
||||
#include "timeline.h"
|
||||
|
||||
namespace bd {
|
||||
|
||||
struct TermSize {
|
||||
@@ -62,15 +64,21 @@ struct PlayVideoOptions {
|
||||
bool loop = false; // repeat the video until stop
|
||||
float intensity = 1.0f; // brightness multiplier for fg/bg
|
||||
bool tryResize = true; // false => force the "not adjustable" fit path
|
||||
int bottomRows = 0; // leave this many rows at the bottom for other content
|
||||
int topRows = 0; // reserve this many rows ABOVE the video (e.g. lyrics)
|
||||
int bottomRows = 0; // reserve this many rows BELOW the video
|
||||
GridSize* outGrid = nullptr; // receives the computed video grid (optional)
|
||||
int* outOffsetX = nullptr; // receives the video's horizontal offset in cols
|
||||
std::atomic<bool>* gridReady = nullptr; // set true once the layout is reported
|
||||
std::atomic<bool>* stop = nullptr; // set to true to end playback early
|
||||
};
|
||||
|
||||
// Play `path` as ASCII art. Blocks until the video ends (or `stop` is set).
|
||||
// Play `path` as ASCII art, paced by `timeline` (may be null for an
|
||||
// independent local clock). Blocks until the video ends (or `stop` is set).
|
||||
// Returns:
|
||||
// 0 playback finished (or stopped) cleanly
|
||||
// 1 failed to open/decode the video
|
||||
// 2 stdout is not a tty (nothing was written)
|
||||
int playVideo(const std::string& path, const PlayVideoOptions& opts = {});
|
||||
int playVideo(const std::string& path, Timeline* timeline,
|
||||
const PlayVideoOptions& opts = {});
|
||||
|
||||
} // namespace bd
|
||||
|
||||
+68
-26
@@ -121,9 +121,13 @@ bool isWide(int cp) {
|
||||
}
|
||||
|
||||
// Truncate `text` so it is at most `maxWidth` terminal columns wide, never
|
||||
// splitting a UTF-8 character in the middle.
|
||||
std::string truncateToWidth(const std::string& text, int maxWidth) {
|
||||
if (maxWidth <= 0) return std::string();
|
||||
// splitting a UTF-8 character in the middle. Optionally reports the display
|
||||
// width of the returned string.
|
||||
std::string truncateToWidth(const std::string& text, int maxWidth, int* outWidth) {
|
||||
if (maxWidth <= 0) {
|
||||
if (outWidth) *outWidth = 0;
|
||||
return std::string();
|
||||
}
|
||||
int width = 0;
|
||||
size_t i = 0;
|
||||
size_t lastBreak = 0;
|
||||
@@ -151,19 +155,39 @@ std::string truncateToWidth(const std::string& text, int maxWidth) {
|
||||
lastBreak = i + static_cast<size_t>(len);
|
||||
i += static_cast<size_t>(len);
|
||||
}
|
||||
if (outWidth) *outWidth = width;
|
||||
return text.substr(0, lastBreak);
|
||||
}
|
||||
|
||||
// Rewrite the current lyric on its row: clear the line, or write the text and
|
||||
// erase to the end of the line.
|
||||
void refreshLyricLine(int row, const std::string& text, int maxWidth) {
|
||||
// Rewrite the current lyric at (row, col). When `maxWidth` is given the area
|
||||
// is cleared within exactly those columns (ECH), so the lyric band never
|
||||
// extends past the caller's chosen width (e.g. the video frame). Otherwise the
|
||||
// whole line is cleared/written.
|
||||
void refreshLyricLine(int row, int col, const std::string& text, int maxWidth) {
|
||||
std::string out;
|
||||
out += ansiCursorTo(row, 1);
|
||||
if (text.empty()) {
|
||||
out += "\x1b[2K"; // erase entire line
|
||||
out += ansiCursorTo(row, col);
|
||||
if (maxWidth <= 0) {
|
||||
if (text.empty()) {
|
||||
out += "\x1b[2K"; // erase entire line
|
||||
} else {
|
||||
out += text;
|
||||
out += "\x1b[K"; // erase to end of line
|
||||
}
|
||||
} else {
|
||||
out += truncateToWidth(text, maxWidth);
|
||||
out += "\x1b[K"; // erase to end of line
|
||||
int width = 0;
|
||||
if (text.empty()) {
|
||||
out += "\x1b[";
|
||||
out += std::to_string(maxWidth);
|
||||
out += "X"; // erase the whole lyric band within the video width
|
||||
} else {
|
||||
out += truncateToWidth(text, maxWidth, &width);
|
||||
const int remain = maxWidth - width;
|
||||
if (remain > 0) {
|
||||
out += "\x1b[";
|
||||
out += std::to_string(remain);
|
||||
out += "X";
|
||||
}
|
||||
}
|
||||
}
|
||||
writeRaw(out);
|
||||
}
|
||||
@@ -241,34 +265,52 @@ LyricInfo parseLrcFile(const std::string& path) {
|
||||
}
|
||||
|
||||
int playLyrics(const LyricInfo& lyric, const LyricDisplayOptions& opts,
|
||||
std::atomic<bool>* stop) {
|
||||
Timeline* timeline, std::atomic<bool>* stop) {
|
||||
if (!lyric.valid) return 1;
|
||||
|
||||
Timeline localTimeline;
|
||||
if (!timeline) {
|
||||
timeline = &localTimeline;
|
||||
timeline->start();
|
||||
}
|
||||
|
||||
const TermSize ts = terminalSize();
|
||||
const int row = (opts.row != 0) ? opts.row : (ts.ok ? ts.rows : 1);
|
||||
const int maxWidth = ts.ok ? std::max(1, ts.cols - 1) : 120;
|
||||
const int col = (opts.col > 0) ? opts.col : 1;
|
||||
const int maxWidth = (opts.maxWidth > 0)
|
||||
? opts.maxWidth
|
||||
: (ts.ok ? std::max(1, ts.cols - 1) : 120);
|
||||
|
||||
const auto start = std::chrono::steady_clock::now();
|
||||
for (const LyricLine& line : lyric.lines) {
|
||||
if (stop && stop->load()) break;
|
||||
// Sleep in small steps so a stop request is honoured promptly even
|
||||
// when the next lyric line is far in the future.
|
||||
const auto target = start + std::chrono::milliseconds(line.timeMs);
|
||||
while (std::chrono::steady_clock::now() < target) {
|
||||
if (stop && stop->load()) break;
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(50));
|
||||
if (lyric.lines.empty()) return 0;
|
||||
// Hold the last line on screen a little while after it ends.
|
||||
const double endTime = lyric.lines.back().timeMs / 1000.0 + 5.0;
|
||||
|
||||
int shown = -1; // index of the line currently on screen
|
||||
while (!(stop && stop->load())) {
|
||||
const double t = timeline->seconds();
|
||||
|
||||
// Active line = last line whose timestamp the timeline has reached.
|
||||
int idx = -1;
|
||||
for (size_t i = 0; i < lyric.lines.size(); ++i) {
|
||||
if (lyric.lines[i].timeMs / 1000.0 <= t) idx = static_cast<int>(i);
|
||||
else break;
|
||||
}
|
||||
if (stop && stop->load()) break;
|
||||
if (opts.show) refreshLyricLine(row, line.text, maxWidth);
|
||||
if (idx != shown) {
|
||||
shown = idx;
|
||||
const std::string& text = (idx >= 0) ? lyric.lines[idx].text : std::string();
|
||||
if (opts.show) refreshLyricLine(row, col, text, maxWidth);
|
||||
}
|
||||
if (t >= endTime) break;
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(40));
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
int playLyricsFile(const std::string& path, const LyricDisplayOptions& opts,
|
||||
std::atomic<bool>* stop) {
|
||||
Timeline* timeline, std::atomic<bool>* stop) {
|
||||
const LyricInfo info = parseLrcFile(path);
|
||||
if (!info.valid) return 1;
|
||||
return playLyrics(info, opts, stop);
|
||||
return playLyrics(info, opts, timeline, stop);
|
||||
}
|
||||
|
||||
} // namespace lr
|
||||
|
||||
+10
-2
@@ -14,6 +14,8 @@
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "timeline.h"
|
||||
|
||||
namespace lr {
|
||||
|
||||
struct LyricLine {
|
||||
@@ -38,17 +40,23 @@ LyricInfo parseLrcFile(const std::string& path);
|
||||
|
||||
struct LyricDisplayOptions {
|
||||
int row = 0; // 1-based terminal row; 0 = auto (last terminal row)
|
||||
int col = 0; // 1-based start column; 0 = column 1
|
||||
int maxWidth = 0; // max display columns; 0 = terminal width - 1
|
||||
bool show = true; // false => run the timeline silently (no terminal writes)
|
||||
};
|
||||
|
||||
// Play the lyrics on a timeline starting at t = 0. Blocks until every line has
|
||||
// been shown (or `stop` is set). Returns 0.
|
||||
// Play the lyrics against `timeline` (may be null for a local clock): the
|
||||
// current lyric line is displayed in real time as the timeline advances.
|
||||
// Blocks until the timeline passes the last line (or `stop` is set).
|
||||
// Returns 0.
|
||||
int playLyrics(const LyricInfo& lyric,
|
||||
const LyricDisplayOptions& opts = {},
|
||||
Timeline* timeline = nullptr,
|
||||
std::atomic<bool>* stop = nullptr);
|
||||
|
||||
int playLyricsFile(const std::string& path,
|
||||
const LyricDisplayOptions& opts = {},
|
||||
Timeline* timeline = nullptr,
|
||||
std::atomic<bool>* stop = nullptr);
|
||||
|
||||
} // namespace lr
|
||||
|
||||
@@ -73,9 +73,13 @@ void dataCallback(ma_device* device, void* out, const void* /*in*/,
|
||||
|
||||
} // namespace
|
||||
|
||||
int play(const std::string& path, std::atomic<bool>* stop) {
|
||||
int play(const std::string& path, Timeline* timeline, std::atomic<bool>* stop) {
|
||||
if (path.empty()) return 1;
|
||||
|
||||
// Audio is the show's clock: anchor the timeline the moment playback is
|
||||
// about to begin (a no-op if the caller already anchored it).
|
||||
if (timeline && !timeline->started()) timeline->start();
|
||||
|
||||
ma_decoder_config decoderConfig = ma_decoder_config_init_default();
|
||||
ma_decoder decoder;
|
||||
if (ma_decoder_init_file(path.c_str(), &decoderConfig, &decoder) != MA_SUCCESS) {
|
||||
|
||||
@@ -10,13 +10,18 @@
|
||||
#include <atomic>
|
||||
#include <string>
|
||||
|
||||
#include "timeline.h"
|
||||
|
||||
namespace mp {
|
||||
|
||||
// Blocking playback of `path`. Returns 0 on success (played to the end or
|
||||
// stopped early via `stop`). Non-zero return values:
|
||||
// Blocking playback of `path` in real time. `timeline` is optional; if given
|
||||
// and not yet anchored, the music anchors it (audio is the show's clock).
|
||||
// Returns 0 on success (played to the end or stopped early via `stop`).
|
||||
// Non-zero return values:
|
||||
// 1 argument error
|
||||
// 2 could not open/decode the file
|
||||
// 3 no audio device available (caller should degrade gracefully)
|
||||
int play(const std::string& path, std::atomic<bool>* stop = nullptr);
|
||||
int play(const std::string& path, Timeline* timeline = nullptr,
|
||||
std::atomic<bool>* stop = nullptr);
|
||||
|
||||
} // namespace mp
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
// timeline.h
|
||||
//
|
||||
// Shared playback clock. The show's modules (video / music / lyrics) all
|
||||
// receive a Timeline* so they time their output against the same anchor:
|
||||
// the video paces its frames from Timeline::startTime(), the lyrics pick the
|
||||
// line for Timeline::seconds(), and the audio simply plays in real time.
|
||||
//
|
||||
// The timeline is anchored once (idempotently) before the show starts.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <chrono>
|
||||
#include <mutex>
|
||||
|
||||
class Timeline {
|
||||
public:
|
||||
// Anchor the timeline to now. Safe to call more than once; an already
|
||||
// anchored timeline is not re-anchored by the modules.
|
||||
void start() {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
if (started_) return;
|
||||
start_ = std::chrono::steady_clock::now();
|
||||
started_ = true;
|
||||
}
|
||||
|
||||
// Whether the timeline has been anchored yet.
|
||||
bool started() const {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
return started_;
|
||||
}
|
||||
|
||||
// Seconds elapsed since the anchor (0 until start()).
|
||||
double seconds() const {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
if (!started_) return 0.0;
|
||||
return std::chrono::duration<double>(std::chrono::steady_clock::now() - start_)
|
||||
.count();
|
||||
}
|
||||
|
||||
// Wall-clock instant the timeline was anchored, for deadline math.
|
||||
std::chrono::steady_clock::time_point startTime() const {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
return start_;
|
||||
}
|
||||
|
||||
private:
|
||||
mutable std::mutex mutex_;
|
||||
std::chrono::steady_clock::time_point start_{};
|
||||
bool started_ = false;
|
||||
};
|
||||
Reference in New Issue
Block a user