feat: complete `uninstall.sh` logic to make everything synced. feat: complete basic play logic to handle background, music, lyric play.
317 lines
10 KiB
C++
317 lines
10 KiB
C++
// lyricResolve.cpp
|
|
//
|
|
// LRC parser + timed playback. See lyricResolve.h.
|
|
|
|
#include "lyricResolve.h"
|
|
|
|
#include <algorithm>
|
|
#include <chrono>
|
|
#include <cmath>
|
|
#include <cstdio>
|
|
#include <thread>
|
|
|
|
#ifdef _WIN32
|
|
#include <windows.h>
|
|
#else
|
|
#include <sys/ioctl.h>
|
|
#include <unistd.h>
|
|
#endif
|
|
|
|
namespace lr {
|
|
|
|
namespace {
|
|
|
|
struct TermSize {
|
|
int cols = 0;
|
|
int rows = 0;
|
|
bool ok = false;
|
|
};
|
|
|
|
// Small local copy of the terminal-size query so lyricResolve stays
|
|
// independent of backgroundDisplay.
|
|
TermSize terminalSize() {
|
|
TermSize ts;
|
|
#ifdef _WIN32
|
|
HANDLE h = GetStdHandle(STD_OUTPUT_HANDLE);
|
|
CONSOLE_SCREEN_BUFFER_INFO csbi;
|
|
if (h != INVALID_HANDLE_VALUE && GetConsoleScreenBufferInfo(h, &csbi)) {
|
|
ts.cols = csbi.srWindow.Right - csbi.srWindow.Left + 1;
|
|
ts.rows = csbi.srWindow.Bottom - csbi.srWindow.Top + 1;
|
|
ts.ok = (ts.cols > 0 && ts.rows > 0);
|
|
}
|
|
#else
|
|
struct winsize ws;
|
|
if (ioctl(STDOUT_FILENO, TIOCGWINSZ, &ws) == 0 && ws.ws_col > 0 && ws.ws_row > 0) {
|
|
ts.cols = static_cast<int>(ws.ws_col);
|
|
ts.rows = static_cast<int>(ws.ws_row);
|
|
ts.ok = true;
|
|
}
|
|
#endif
|
|
return ts;
|
|
}
|
|
|
|
void writeRaw(const std::string& s) {
|
|
fwrite(s.data(), 1, s.size(), stdout);
|
|
fflush(stdout);
|
|
}
|
|
|
|
std::string ansiCursorTo(int row, int col) {
|
|
return "\x1b[" + std::to_string(row) + ";" + std::to_string(col) + "H";
|
|
}
|
|
|
|
// Parse a time tag (the text between brackets, e.g. "00:26.51" or "00:26")
|
|
// into milliseconds. Returns false if it isn't a time tag.
|
|
bool parseTimeTag(const std::string& tag, int64_t& outMs) {
|
|
const size_t colon = tag.find(':');
|
|
if (colon == std::string::npos || colon == 0) return false;
|
|
|
|
long mm = 0;
|
|
for (size_t i = 0; i < colon; ++i) {
|
|
if (tag[i] < '0' || tag[i] > '9') return false;
|
|
mm = mm * 10 + (tag[i] - '0');
|
|
}
|
|
|
|
size_t i = colon + 1;
|
|
long ss = 0;
|
|
while (i < tag.size() && tag[i] >= '0' && tag[i] <= '9') {
|
|
ss = ss * 10 + (tag[i] - '0');
|
|
++i;
|
|
}
|
|
if (i == colon + 1) return false; // no seconds digits
|
|
|
|
long frac = 0;
|
|
int fracDigits = 0;
|
|
if (i < tag.size() && tag[i] == '.') {
|
|
++i;
|
|
while (i < tag.size() && tag[i] >= '0' && tag[i] <= '9' && fracDigits < 3) {
|
|
frac = frac * 10 + (tag[i] - '0');
|
|
++fracDigits;
|
|
++i;
|
|
}
|
|
}
|
|
if (i != tag.size()) return false; // trailing junk
|
|
|
|
int64_t ms = mm * 60000 + ss * 1000;
|
|
if (fracDigits == 1) ms += frac * 100; // .1 -> 100 ms
|
|
else if (fracDigits == 2) ms += frac * 10; // .51 -> 510 ms
|
|
else if (fracDigits == 3) ms += frac; // .123 -> 123 ms
|
|
outMs = ms;
|
|
return true;
|
|
}
|
|
|
|
// Byte length of the UTF-8 sequence starting at byte c (1 for ASCII/invalid).
|
|
int utf8SeqLen(unsigned char c) {
|
|
if (c < 0x80) return 1;
|
|
if ((c & 0xE0) == 0xC0) return 2;
|
|
if ((c & 0xF0) == 0xE0) return 3;
|
|
if ((c & 0xF8) == 0xF0) return 4;
|
|
return 1;
|
|
}
|
|
|
|
// Does this codepoint occupy two terminal columns (CJK / wide)?
|
|
bool isWide(int cp) {
|
|
return (cp >= 0x1100 && cp <= 0x11FF) ||
|
|
(cp >= 0x2E80 && cp <= 0xA4CF) ||
|
|
(cp >= 0xAC00 && cp <= 0xD7A3) ||
|
|
(cp >= 0xF900 && cp <= 0xFAFF) ||
|
|
(cp >= 0xFE30 && cp <= 0xFE4F) ||
|
|
(cp >= 0xFF00 && cp <= 0xFF60) ||
|
|
(cp >= 0xFFE0 && cp <= 0xFFE6) ||
|
|
(cp >= 0x20000 && cp <= 0x3FFFD);
|
|
}
|
|
|
|
// Truncate `text` so it is at most `maxWidth` terminal columns wide, never
|
|
// 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;
|
|
while (i < text.size()) {
|
|
int len = utf8SeqLen(static_cast<unsigned char>(text[i]));
|
|
if (i + static_cast<size_t>(len) > text.size()) len = 1;
|
|
|
|
int cp = 0;
|
|
const unsigned char lead = static_cast<unsigned char>(text[i]);
|
|
if (len == 1) {
|
|
cp = lead;
|
|
} else {
|
|
if (len == 2) cp = (lead & 0x1F) << 6;
|
|
else if (len == 3) cp = (lead & 0x0F) << 12;
|
|
else cp = (lead & 0x07) << 18;
|
|
for (int k = 1; k < len; ++k) {
|
|
cp |= (static_cast<unsigned char>(text[i + k]) & 0x3F)
|
|
<< (6 * (len - 1 - k));
|
|
}
|
|
}
|
|
|
|
const int w = isWide(cp) ? 2 : 1;
|
|
if (width + w > maxWidth) break;
|
|
width += w;
|
|
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 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, 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 {
|
|
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);
|
|
}
|
|
|
|
} // namespace
|
|
|
|
LyricInfo parseLrc(const std::string& text) {
|
|
LyricInfo info;
|
|
size_t pos = 0;
|
|
while (pos <= text.size()) {
|
|
const size_t end = text.find('\n', pos);
|
|
std::string line = text.substr(pos, end == std::string::npos
|
|
? std::string::npos
|
|
: end - pos);
|
|
pos = (end == std::string::npos) ? text.size() + 1 : end + 1;
|
|
if (!line.empty() && line.back() == '\r') line.pop_back(); // CRLF tolerance
|
|
|
|
// Collect all leading [tag] groups.
|
|
std::vector<std::string> tags;
|
|
size_t idx = 0;
|
|
while (idx < line.size() && line[idx] == '[') {
|
|
const size_t close = line.find(']', idx);
|
|
if (close == std::string::npos) break;
|
|
tags.push_back(line.substr(idx + 1, close - idx - 1));
|
|
idx = close + 1;
|
|
}
|
|
if (tags.empty()) continue;
|
|
const std::string lyricText = line.substr(idx); // may be empty
|
|
|
|
for (const std::string& tag : tags) {
|
|
int64_t ms = 0;
|
|
if (parseTimeTag(tag, ms)) {
|
|
info.lines.push_back({ms, lyricText});
|
|
} else if (tag.rfind("ti:", 0) == 0) {
|
|
info.title = tag.substr(3);
|
|
} else if (tag.rfind("ar:", 0) == 0) {
|
|
info.artist = tag.substr(3);
|
|
} else if (tag.rfind("al:", 0) == 0) {
|
|
info.album = tag.substr(3);
|
|
} else if (tag.rfind("by:", 0) == 0) {
|
|
info.by = tag.substr(3);
|
|
} else if (tag.rfind("offset:", 0) == 0) {
|
|
try {
|
|
info.offsetMs = std::stoi(tag.substr(7));
|
|
} catch (...) {
|
|
info.offsetMs = 0;
|
|
}
|
|
}
|
|
// Other tags are ignored.
|
|
}
|
|
}
|
|
|
|
if (info.offsetMs != 0) {
|
|
for (LyricLine& line : info.lines) {
|
|
line.timeMs = std::max<int64_t>(0, line.timeMs + info.offsetMs);
|
|
}
|
|
}
|
|
std::stable_sort(info.lines.begin(), info.lines.end(),
|
|
[](const LyricLine& a, const LyricLine& b) {
|
|
return a.timeMs < b.timeMs;
|
|
});
|
|
info.valid = !info.lines.empty();
|
|
return info;
|
|
}
|
|
|
|
LyricInfo parseLrcFile(const std::string& path) {
|
|
FILE* f = fopen(path.c_str(), "rb");
|
|
if (!f) return LyricInfo{};
|
|
std::string text;
|
|
char buf[8192];
|
|
size_t n;
|
|
while ((n = fread(buf, 1, sizeof buf, f)) > 0) text.append(buf, n);
|
|
fclose(f);
|
|
return parseLrc(text);
|
|
}
|
|
|
|
int playLyrics(const LyricInfo& lyric, const LyricDisplayOptions& opts,
|
|
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 col = (opts.col > 0) ? opts.col : 1;
|
|
const int maxWidth = (opts.maxWidth > 0)
|
|
? opts.maxWidth
|
|
: (ts.ok ? std::max(1, ts.cols - 1) : 120);
|
|
|
|
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 (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,
|
|
Timeline* timeline, std::atomic<bool>* stop) {
|
|
const LyricInfo info = parseLrcFile(path);
|
|
if (!info.valid) return 1;
|
|
return playLyrics(info, opts, timeline, stop);
|
|
}
|
|
|
|
} // namespace lr
|