This commit is contained in:
parent
9876641ff6
commit
9e985d05f1
11 changed files with 1011 additions and 70 deletions
|
|
@ -22,6 +22,147 @@
|
||||||
using namespace drogon::orm;
|
using namespace drogon::orm;
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
|
// Generate waveform data using ffmpeg (safe version)
|
||||||
|
// Returns path to waveform JSON file, or empty string on failure
|
||||||
|
std::string generateWaveform(const std::string& audioPath, int64_t audioId) {
|
||||||
|
if (!isPathSafe(audioPath, "/app/uploads")) {
|
||||||
|
LOG_ERROR << "Unsafe audio path rejected for waveform: " << audioPath;
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure waveforms directory exists
|
||||||
|
const std::string waveformDir = "/app/uploads/audio/waveforms";
|
||||||
|
if (!ensureDirectoryExists(waveformDir)) {
|
||||||
|
LOG_ERROR << "Failed to create waveforms directory";
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string waveformPath = waveformDir + "/" + std::to_string(audioId) + ".json";
|
||||||
|
|
||||||
|
// Use ffmpeg to extract raw audio samples (mono, downsampled to 8000Hz)
|
||||||
|
// Then we'll compute peaks from the samples
|
||||||
|
std::vector<std::string> args = {
|
||||||
|
"/usr/bin/ffmpeg", "-i", audioPath,
|
||||||
|
"-ac", "1", // mono
|
||||||
|
"-ar", "8000", // 8kHz sample rate
|
||||||
|
"-f", "f32le", // 32-bit float, little endian
|
||||||
|
"-acodec", "pcm_f32le",
|
||||||
|
"-v", "error",
|
||||||
|
"-y", // overwrite
|
||||||
|
"pipe:1" // output to stdout
|
||||||
|
};
|
||||||
|
|
||||||
|
// Execute ffmpeg and capture raw samples
|
||||||
|
int pipefd[2];
|
||||||
|
if (pipe(pipefd) == -1) {
|
||||||
|
LOG_ERROR << "Failed to create pipe for waveform generation";
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
pid_t pid = fork();
|
||||||
|
if (pid == -1) {
|
||||||
|
LOG_ERROR << "Failed to fork for waveform generation";
|
||||||
|
close(pipefd[0]);
|
||||||
|
close(pipefd[1]);
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pid == 0) {
|
||||||
|
// Child process
|
||||||
|
close(pipefd[0]); // Close read end
|
||||||
|
dup2(pipefd[1], STDOUT_FILENO);
|
||||||
|
close(pipefd[1]);
|
||||||
|
|
||||||
|
// Redirect stderr to /dev/null
|
||||||
|
int devNull = open("/dev/null", O_WRONLY);
|
||||||
|
if (devNull >= 0) {
|
||||||
|
dup2(devNull, STDERR_FILENO);
|
||||||
|
close(devNull);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<char*> argv;
|
||||||
|
for (auto& arg : args) {
|
||||||
|
argv.push_back(const_cast<char*>(arg.c_str()));
|
||||||
|
}
|
||||||
|
argv.push_back(nullptr);
|
||||||
|
|
||||||
|
execv("/usr/bin/ffmpeg", argv.data());
|
||||||
|
_exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parent process
|
||||||
|
close(pipefd[1]); // Close write end
|
||||||
|
|
||||||
|
// Read raw samples (limit to reasonable amount - ~30 seconds of audio at 8kHz = 240KB)
|
||||||
|
const size_t maxSamples = 8000 * 30; // 30 seconds worth
|
||||||
|
std::vector<float> samples;
|
||||||
|
samples.reserve(maxSamples);
|
||||||
|
|
||||||
|
float sample;
|
||||||
|
ssize_t bytesRead;
|
||||||
|
while (samples.size() < maxSamples &&
|
||||||
|
(bytesRead = read(pipefd[0], &sample, sizeof(float))) == sizeof(float)) {
|
||||||
|
samples.push_back(std::abs(sample)); // Store absolute value
|
||||||
|
}
|
||||||
|
close(pipefd[0]);
|
||||||
|
|
||||||
|
// Wait for child process
|
||||||
|
int status;
|
||||||
|
waitpid(pid, &status, 0);
|
||||||
|
|
||||||
|
if (samples.empty()) {
|
||||||
|
LOG_WARN << "No samples extracted for waveform generation";
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute 200 peaks from samples
|
||||||
|
const int numPeaks = 200;
|
||||||
|
std::vector<float> peaks(numPeaks, 0.0f);
|
||||||
|
size_t samplesPerPeak = samples.size() / numPeaks;
|
||||||
|
if (samplesPerPeak == 0) samplesPerPeak = 1;
|
||||||
|
|
||||||
|
for (int i = 0; i < numPeaks; i++) {
|
||||||
|
size_t start = i * samplesPerPeak;
|
||||||
|
size_t end = std::min(start + samplesPerPeak, samples.size());
|
||||||
|
float maxVal = 0.0f;
|
||||||
|
for (size_t j = start; j < end; j++) {
|
||||||
|
if (samples[j] > maxVal) maxVal = samples[j];
|
||||||
|
}
|
||||||
|
peaks[i] = maxVal;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize peaks to 0-1 range
|
||||||
|
float maxPeak = *std::max_element(peaks.begin(), peaks.end());
|
||||||
|
if (maxPeak > 0.0f) {
|
||||||
|
for (auto& p : peaks) {
|
||||||
|
p /= maxPeak;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write JSON file
|
||||||
|
try {
|
||||||
|
std::ofstream ofs(waveformPath);
|
||||||
|
if (!ofs) {
|
||||||
|
LOG_ERROR << "Failed to create waveform file: " << waveformPath;
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
ofs << "{\"peaks\":[";
|
||||||
|
for (size_t i = 0; i < peaks.size(); i++) {
|
||||||
|
if (i > 0) ofs << ",";
|
||||||
|
ofs << std::fixed << std::setprecision(3) << peaks[i];
|
||||||
|
}
|
||||||
|
ofs << "]}";
|
||||||
|
ofs.close();
|
||||||
|
|
||||||
|
LOG_INFO << "Waveform generated for audio " << audioId << ": " << peaks.size() << " peaks";
|
||||||
|
return "/uploads/audio/waveforms/" + std::to_string(audioId) + ".json";
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
LOG_ERROR << "Exception writing waveform file: " << e.what();
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Get audio duration in seconds using ffprobe (safe version)
|
// Get audio duration in seconds using ffprobe (safe version)
|
||||||
int getAudioDuration(const std::string& audioPath) {
|
int getAudioDuration(const std::string& audioPath) {
|
||||||
if (!isPathSafe(audioPath, "/app/uploads")) {
|
if (!isPathSafe(audioPath, "/app/uploads")) {
|
||||||
|
|
@ -87,6 +228,7 @@ namespace {
|
||||||
audio["description"] = row["description"].isNull() ? "" : row["description"].as<std::string>();
|
audio["description"] = row["description"].isNull() ? "" : row["description"].as<std::string>();
|
||||||
audio["filePath"] = row["file_path"].as<std::string>();
|
audio["filePath"] = row["file_path"].as<std::string>();
|
||||||
audio["thumbnailPath"] = row["thumbnail_path"].isNull() ? "" : row["thumbnail_path"].as<std::string>();
|
audio["thumbnailPath"] = row["thumbnail_path"].isNull() ? "" : row["thumbnail_path"].as<std::string>();
|
||||||
|
audio["waveformPath"] = row["waveform_path"].isNull() ? "" : row["waveform_path"].as<std::string>();
|
||||||
audio["durationSeconds"] = row["duration_seconds"].as<int>();
|
audio["durationSeconds"] = row["duration_seconds"].as<int>();
|
||||||
audio["format"] = row["format"].isNull() ? "" : row["format"].as<std::string>();
|
audio["format"] = row["format"].isNull() ? "" : row["format"].as<std::string>();
|
||||||
audio["bitrate"] = row["bitrate"].isNull() ? 0 : row["bitrate"].as<int>();
|
audio["bitrate"] = row["bitrate"].isNull() ? 0 : row["bitrate"].as<int>();
|
||||||
|
|
@ -120,16 +262,32 @@ namespace {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Generate waveform data
|
||||||
|
std::string waveformPath = generateWaveform(audioFullPath, audioId);
|
||||||
|
|
||||||
auto dbClient = app().getDbClient();
|
auto dbClient = app().getDbClient();
|
||||||
*dbClient << "UPDATE audio_files SET duration_seconds = $1, bitrate = $2, format = $3, status = 'ready' WHERE id = $4"
|
if (!waveformPath.empty()) {
|
||||||
<< duration << bitrate << format << audioId
|
*dbClient << "UPDATE audio_files SET duration_seconds = $1, bitrate = $2, format = $3, waveform_path = $4, status = 'ready' WHERE id = $5"
|
||||||
>> [audioId](const Result&) {
|
<< duration << bitrate << format << waveformPath << audioId
|
||||||
LOG_INFO << "Audio " << audioId << " metadata processed successfully";
|
>> [audioId](const Result&) {
|
||||||
}
|
LOG_INFO << "Audio " << audioId << " metadata and waveform processed successfully";
|
||||||
>> [audioId](const DrogonDbException& e) {
|
}
|
||||||
LOG_ERROR << "Failed to update audio " << audioId << " metadata: " << e.base().what();
|
>> [audioId](const DrogonDbException& e) {
|
||||||
markAudioFailed(audioId);
|
LOG_ERROR << "Failed to update audio " << audioId << " metadata: " << e.base().what();
|
||||||
};
|
markAudioFailed(audioId);
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// Still mark as ready even without waveform
|
||||||
|
*dbClient << "UPDATE audio_files SET duration_seconds = $1, bitrate = $2, format = $3, status = 'ready' WHERE id = $4"
|
||||||
|
<< duration << bitrate << format << audioId
|
||||||
|
>> [audioId](const Result&) {
|
||||||
|
LOG_INFO << "Audio " << audioId << " metadata processed (no waveform)";
|
||||||
|
}
|
||||||
|
>> [audioId](const DrogonDbException& e) {
|
||||||
|
LOG_ERROR << "Failed to update audio " << audioId << " metadata: " << e.base().what();
|
||||||
|
markAudioFailed(audioId);
|
||||||
|
};
|
||||||
|
}
|
||||||
} catch (const std::exception& e) {
|
} catch (const std::exception& e) {
|
||||||
LOG_ERROR << "Exception processing metadata for audio " << audioId << ": " << e.what();
|
LOG_ERROR << "Exception processing metadata for audio " << audioId << ": " << e.what();
|
||||||
markAudioFailed(audioId);
|
markAudioFailed(audioId);
|
||||||
|
|
@ -143,7 +301,7 @@ void AudioController::getAllAudio(const HttpRequestPtr &req,
|
||||||
auto pagination = parsePagination(req, 20, 50);
|
auto pagination = parsePagination(req, 20, 50);
|
||||||
|
|
||||||
auto dbClient = app().getDbClient();
|
auto dbClient = app().getDbClient();
|
||||||
*dbClient << "SELECT a.id, a.title, a.description, a.file_path, a.thumbnail_path, "
|
*dbClient << "SELECT a.id, a.title, a.description, a.file_path, a.thumbnail_path, a.waveform_path, "
|
||||||
"a.duration_seconds, a.format, a.bitrate, a.play_count, a.created_at, a.realm_id, "
|
"a.duration_seconds, a.format, a.bitrate, a.play_count, a.created_at, a.realm_id, "
|
||||||
"u.id as user_id, u.username, u.avatar_url, "
|
"u.id as user_id, u.username, u.avatar_url, "
|
||||||
"r.name as realm_name "
|
"r.name as realm_name "
|
||||||
|
|
@ -170,7 +328,7 @@ void AudioController::getAllAudio(const HttpRequestPtr &req,
|
||||||
void AudioController::getLatestAudio(const HttpRequestPtr &,
|
void AudioController::getLatestAudio(const HttpRequestPtr &,
|
||||||
std::function<void(const HttpResponsePtr &)> &&callback) {
|
std::function<void(const HttpResponsePtr &)> &&callback) {
|
||||||
auto dbClient = app().getDbClient();
|
auto dbClient = app().getDbClient();
|
||||||
*dbClient << "SELECT a.id, a.title, a.description, a.file_path, a.thumbnail_path, "
|
*dbClient << "SELECT a.id, a.title, a.description, a.file_path, a.thumbnail_path, a.waveform_path, "
|
||||||
"a.duration_seconds, a.format, a.bitrate, a.play_count, a.created_at, a.realm_id, "
|
"a.duration_seconds, a.format, a.bitrate, a.play_count, a.created_at, a.realm_id, "
|
||||||
"u.id as user_id, u.username, u.avatar_url, "
|
"u.id as user_id, u.username, u.avatar_url, "
|
||||||
"r.name as realm_name "
|
"r.name as realm_name "
|
||||||
|
|
@ -280,7 +438,7 @@ void AudioController::getRealmAudio(const HttpRequestPtr &req,
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
*dbClient << "SELECT a.id, a.title, a.description, a.file_path, a.thumbnail_path, "
|
*dbClient << "SELECT a.id, a.title, a.description, a.file_path, a.thumbnail_path, a.waveform_path, "
|
||||||
"a.duration_seconds, a.format, a.bitrate, a.play_count, a.created_at "
|
"a.duration_seconds, a.format, a.bitrate, a.play_count, a.created_at "
|
||||||
"FROM audio_files a "
|
"FROM audio_files a "
|
||||||
"WHERE a.realm_id = $1 AND a.is_public = true AND a.status = 'ready' "
|
"WHERE a.realm_id = $1 AND a.is_public = true AND a.status = 'ready' "
|
||||||
|
|
@ -339,7 +497,7 @@ void AudioController::getRealmAudioByName(const HttpRequestPtr &req,
|
||||||
|
|
||||||
int64_t realmId = realmResult[0]["id"].as<int64_t>();
|
int64_t realmId = realmResult[0]["id"].as<int64_t>();
|
||||||
|
|
||||||
*dbClient << "SELECT a.id, a.title, a.description, a.file_path, a.thumbnail_path, "
|
*dbClient << "SELECT a.id, a.title, a.description, a.file_path, a.thumbnail_path, a.waveform_path, "
|
||||||
"a.duration_seconds, a.format, a.bitrate, a.play_count, a.created_at "
|
"a.duration_seconds, a.format, a.bitrate, a.play_count, a.created_at "
|
||||||
"FROM audio_files a "
|
"FROM audio_files a "
|
||||||
"WHERE a.realm_id = $1 AND a.is_public = true AND a.status = 'ready' "
|
"WHERE a.realm_id = $1 AND a.is_public = true AND a.status = 'ready' "
|
||||||
|
|
@ -422,6 +580,168 @@ void AudioController::incrementPlayCount(const HttpRequestPtr &req,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void AudioController::getWaveform(const HttpRequestPtr &,
|
||||||
|
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||||
|
const std::string &audioId) {
|
||||||
|
int64_t id;
|
||||||
|
try {
|
||||||
|
id = std::stoll(audioId);
|
||||||
|
} catch (...) {
|
||||||
|
callback(jsonError("Invalid audio ID", k400BadRequest));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto dbClient = app().getDbClient();
|
||||||
|
*dbClient << "SELECT waveform_path FROM audio_files WHERE id = $1 AND is_public = true AND status = 'ready'"
|
||||||
|
<< id
|
||||||
|
>> [callback, id](const Result& r) {
|
||||||
|
if (r.empty()) {
|
||||||
|
callback(jsonError("Audio not found", k404NotFound));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string waveformPath = r[0]["waveform_path"].isNull() ? "" : r[0]["waveform_path"].as<std::string>();
|
||||||
|
|
||||||
|
if (waveformPath.empty()) {
|
||||||
|
// Return empty peaks if no waveform available
|
||||||
|
Json::Value resp;
|
||||||
|
resp["peaks"] = Json::arrayValue;
|
||||||
|
callback(jsonResp(resp));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string fullPath = "/app" + waveformPath;
|
||||||
|
|
||||||
|
// Validate path is within allowed directory
|
||||||
|
if (!isPathSafe(fullPath, "/app/uploads/audio/waveforms")) {
|
||||||
|
LOG_WARN << "Blocked access to waveform file outside uploads: " << fullPath;
|
||||||
|
Json::Value resp;
|
||||||
|
resp["peaks"] = Json::arrayValue;
|
||||||
|
callback(jsonResp(resp));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read and return the JSON file
|
||||||
|
try {
|
||||||
|
std::ifstream ifs(fullPath);
|
||||||
|
if (!ifs) {
|
||||||
|
Json::Value resp;
|
||||||
|
resp["peaks"] = Json::arrayValue;
|
||||||
|
callback(jsonResp(resp));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::stringstream buffer;
|
||||||
|
buffer << ifs.rdbuf();
|
||||||
|
|
||||||
|
Json::Value resp;
|
||||||
|
Json::CharReaderBuilder builder;
|
||||||
|
std::string errors;
|
||||||
|
std::istringstream stream(buffer.str());
|
||||||
|
if (Json::parseFromStream(builder, stream, &resp, &errors)) {
|
||||||
|
// Add cache headers for waveforms (they don't change)
|
||||||
|
auto response = jsonResp(resp);
|
||||||
|
response->addHeader("Cache-Control", "public, max-age=31536000"); // 1 year
|
||||||
|
callback(response);
|
||||||
|
} else {
|
||||||
|
Json::Value errResp;
|
||||||
|
errResp["peaks"] = Json::arrayValue;
|
||||||
|
callback(jsonResp(errResp));
|
||||||
|
}
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
LOG_ERROR << "Error reading waveform file: " << e.what();
|
||||||
|
Json::Value resp;
|
||||||
|
resp["peaks"] = Json::arrayValue;
|
||||||
|
callback(jsonResp(resp));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>> DB_ERROR(callback, "get waveform");
|
||||||
|
}
|
||||||
|
|
||||||
|
void AudioController::downloadAudio(const HttpRequestPtr &req,
|
||||||
|
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||||
|
const std::string &audioId) {
|
||||||
|
// Require authentication for downloads
|
||||||
|
UserInfo user = getUserFromRequest(req);
|
||||||
|
if (user.id == 0) {
|
||||||
|
callback(jsonError("Please log in to download audio", k401Unauthorized));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int64_t id;
|
||||||
|
try {
|
||||||
|
id = std::stoll(audioId);
|
||||||
|
} catch (...) {
|
||||||
|
callback(jsonError("Invalid audio ID", k400BadRequest));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto dbClient = app().getDbClient();
|
||||||
|
|
||||||
|
// Get audio info - only allow download of public, ready audio
|
||||||
|
*dbClient << "SELECT title, file_path, format FROM audio_files WHERE id = $1 AND is_public = true AND status = 'ready'"
|
||||||
|
<< id
|
||||||
|
>> [callback, id](const Result& r) {
|
||||||
|
if (r.empty()) {
|
||||||
|
callback(jsonError("Audio not found", k404NotFound));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string title = r[0]["title"].as<std::string>();
|
||||||
|
std::string filePath = r[0]["file_path"].as<std::string>();
|
||||||
|
std::string format = r[0]["format"].isNull() ? "mp3" : r[0]["format"].as<std::string>();
|
||||||
|
std::string fullPath = "/app" + filePath;
|
||||||
|
|
||||||
|
// Validate path is within allowed directory
|
||||||
|
if (!isPathSafe(fullPath, "/app/uploads/audio")) {
|
||||||
|
LOG_WARN << "Blocked access to file outside uploads: " << fullPath;
|
||||||
|
callback(jsonError("Audio file not found", k404NotFound));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check file exists
|
||||||
|
if (!std::filesystem::exists(fullPath)) {
|
||||||
|
LOG_ERROR << "Audio file not found: " << fullPath;
|
||||||
|
callback(jsonError("Audio file not found", k404NotFound));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanitize title for filename (remove special chars)
|
||||||
|
std::string safeTitle;
|
||||||
|
for (char c : title) {
|
||||||
|
if (std::isalnum(c) || c == ' ' || c == '-' || c == '_') {
|
||||||
|
safeTitle += c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (safeTitle.empty()) safeTitle = "audio";
|
||||||
|
if (safeTitle.length() > 100) safeTitle = safeTitle.substr(0, 100);
|
||||||
|
|
||||||
|
// Determine content type based on format
|
||||||
|
std::string contentType = "audio/mpeg"; // default for mp3
|
||||||
|
std::string extension = "mp3";
|
||||||
|
if (format == "wav") {
|
||||||
|
contentType = "audio/wav";
|
||||||
|
extension = "wav";
|
||||||
|
} else if (format == "flac") {
|
||||||
|
contentType = "audio/flac";
|
||||||
|
extension = "flac";
|
||||||
|
} else if (format == "ogg") {
|
||||||
|
contentType = "audio/ogg";
|
||||||
|
extension = "ogg";
|
||||||
|
} else if (format == "aac" || format == "m4a") {
|
||||||
|
contentType = "audio/mp4";
|
||||||
|
extension = "m4a";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use Drogon's file response for efficient streaming
|
||||||
|
auto resp = HttpResponse::newFileResponse(fullPath, "", CT_CUSTOM);
|
||||||
|
resp->addHeader("Content-Type", contentType);
|
||||||
|
resp->addHeader("Content-Disposition", "attachment; filename=\"" + safeTitle + "." + extension + "\"");
|
||||||
|
callback(resp);
|
||||||
|
}
|
||||||
|
>> DB_ERROR(callback, "download audio");
|
||||||
|
}
|
||||||
|
|
||||||
void AudioController::getMyAudio(const HttpRequestPtr &req,
|
void AudioController::getMyAudio(const HttpRequestPtr &req,
|
||||||
std::function<void(const HttpResponsePtr &)> &&callback) {
|
std::function<void(const HttpResponsePtr &)> &&callback) {
|
||||||
UserInfo user = getUserFromRequest(req);
|
UserInfo user = getUserFromRequest(req);
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@ public:
|
||||||
ADD_METHOD_TO(AudioController::getRealmAudio, "/api/audio/realm/{1}", Get);
|
ADD_METHOD_TO(AudioController::getRealmAudio, "/api/audio/realm/{1}", Get);
|
||||||
ADD_METHOD_TO(AudioController::getRealmAudioByName, "/api/audio/realm/name/{1}", Get);
|
ADD_METHOD_TO(AudioController::getRealmAudioByName, "/api/audio/realm/name/{1}", Get);
|
||||||
ADD_METHOD_TO(AudioController::incrementPlayCount, "/api/audio/{1}/play", Post);
|
ADD_METHOD_TO(AudioController::incrementPlayCount, "/api/audio/{1}/play", Post);
|
||||||
|
ADD_METHOD_TO(AudioController::getWaveform, "/api/audio/{1}/waveform", Get);
|
||||||
|
ADD_METHOD_TO(AudioController::downloadAudio, "/api/audio/{1}/download", Get);
|
||||||
|
|
||||||
// Authenticated endpoints
|
// Authenticated endpoints
|
||||||
ADD_METHOD_TO(AudioController::getMyAudio, "/api/user/audio", Get);
|
ADD_METHOD_TO(AudioController::getMyAudio, "/api/user/audio", Get);
|
||||||
|
|
@ -47,6 +49,14 @@ public:
|
||||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||||
const std::string &audioId);
|
const std::string &audioId);
|
||||||
|
|
||||||
|
void getWaveform(const HttpRequestPtr &req,
|
||||||
|
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||||
|
const std::string &audioId);
|
||||||
|
|
||||||
|
void downloadAudio(const HttpRequestPtr &req,
|
||||||
|
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||||
|
const std::string &audioId);
|
||||||
|
|
||||||
// Authenticated audio management
|
// Authenticated audio management
|
||||||
void getMyAudio(const HttpRequestPtr &req,
|
void getMyAudio(const HttpRequestPtr &req,
|
||||||
std::function<void(const HttpResponsePtr &)> &&callback);
|
std::function<void(const HttpResponsePtr &)> &&callback);
|
||||||
|
|
|
||||||
|
|
@ -103,15 +103,15 @@ namespace {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to create a new viewer token
|
// Helper to create a new viewer token (uses realm-specific cookies to support multi-stream viewing)
|
||||||
void createNewViewerToken(std::function<void(const HttpResponsePtr &)> callback, const std::string& streamKey) {
|
void createNewViewerToken(std::function<void(const HttpResponsePtr &)> callback, const std::string& streamKey, const std::string& realmId) {
|
||||||
auto bytes = drogon::utils::genRandomString(32);
|
auto bytes = drogon::utils::genRandomString(32);
|
||||||
std::string token = drogon::utils::base64Encode(
|
std::string token = drogon::utils::base64Encode(
|
||||||
(const unsigned char*)bytes.data(), bytes.length()
|
(const unsigned char*)bytes.data(), bytes.length()
|
||||||
);
|
);
|
||||||
|
|
||||||
RedisHelper::storeKeyAsync("viewer_token:" + token, streamKey, 300,
|
RedisHelper::storeKeyAsync("viewer_token:" + token, streamKey, 300,
|
||||||
[callback, token](bool stored) {
|
[callback, token, realmId](bool stored) {
|
||||||
if (!stored) {
|
if (!stored) {
|
||||||
auto resp = HttpResponse::newHttpResponse();
|
auto resp = HttpResponse::newHttpResponse();
|
||||||
resp->setStatusCode(k500InternalServerError);
|
resp->setStatusCode(k500InternalServerError);
|
||||||
|
|
@ -121,7 +121,8 @@ namespace {
|
||||||
|
|
||||||
auto resp = HttpResponse::newHttpResponse();
|
auto resp = HttpResponse::newHttpResponse();
|
||||||
|
|
||||||
Cookie cookie("viewer_token", token);
|
// Use realm-specific cookie name to support multiple streams simultaneously
|
||||||
|
Cookie cookie("viewer_token_" + realmId, token);
|
||||||
cookie.setPath("/");
|
cookie.setPath("/");
|
||||||
cookie.setHttpOnly(true);
|
cookie.setHttpOnly(true);
|
||||||
cookie.setSecure(false);
|
cookie.setSecure(false);
|
||||||
|
|
@ -393,13 +394,13 @@ void RealmController::issueRealmViewerToken(const HttpRequestPtr &req,
|
||||||
const std::string &realmId) {
|
const std::string &realmId) {
|
||||||
int64_t id = std::stoll(realmId);
|
int64_t id = std::stoll(realmId);
|
||||||
|
|
||||||
// Check for existing viewer token to avoid creating duplicates on page refresh
|
// Check for existing viewer token for THIS specific realm (supports multi-stream viewing)
|
||||||
auto existingToken = req->getCookie("viewer_token");
|
auto existingToken = req->getCookie("viewer_token_" + realmId);
|
||||||
|
|
||||||
auto dbClient = app().getDbClient();
|
auto dbClient = app().getDbClient();
|
||||||
*dbClient << "SELECT stream_key FROM realms WHERE id = $1 AND is_active = true"
|
*dbClient << "SELECT stream_key FROM realms WHERE id = $1 AND is_active = true"
|
||||||
<< id
|
<< id
|
||||||
>> [callback, existingToken](const Result& r) {
|
>> [callback, existingToken, realmId](const Result& r) {
|
||||||
if (r.empty()) {
|
if (r.empty()) {
|
||||||
callback(jsonResp({}, k404NotFound));
|
callback(jsonResp({}, k404NotFound));
|
||||||
return;
|
return;
|
||||||
|
|
@ -407,18 +408,18 @@ void RealmController::issueRealmViewerToken(const HttpRequestPtr &req,
|
||||||
|
|
||||||
std::string streamKey = r[0]["stream_key"].as<std::string>();
|
std::string streamKey = r[0]["stream_key"].as<std::string>();
|
||||||
|
|
||||||
// If user has existing token, check if it's still valid for this stream
|
// If user has existing token for this realm, check if it's still valid
|
||||||
if (!existingToken.empty()) {
|
if (!existingToken.empty()) {
|
||||||
RedisHelper::getKeyAsync("viewer_token:" + existingToken,
|
RedisHelper::getKeyAsync("viewer_token:" + existingToken,
|
||||||
[callback, existingToken, streamKey](const std::string& storedKey) {
|
[callback, existingToken, streamKey, realmId](const std::string& storedKey) {
|
||||||
if (storedKey == streamKey) {
|
if (storedKey == streamKey) {
|
||||||
// Token is still valid for this stream - just refresh TTL and return it
|
// Token is still valid for this stream - just refresh TTL and return it
|
||||||
RedisHelper::storeKeyAsync("viewer_token:" + existingToken, streamKey, 300,
|
RedisHelper::storeKeyAsync("viewer_token:" + existingToken, streamKey, 300,
|
||||||
[callback, existingToken](bool stored) {
|
[callback, existingToken, realmId](bool stored) {
|
||||||
auto resp = HttpResponse::newHttpResponse();
|
auto resp = HttpResponse::newHttpResponse();
|
||||||
|
|
||||||
// Refresh cookie
|
// Refresh realm-specific cookie
|
||||||
Cookie cookie("viewer_token", existingToken);
|
Cookie cookie("viewer_token_" + realmId, existingToken);
|
||||||
cookie.setPath("/");
|
cookie.setPath("/");
|
||||||
cookie.setHttpOnly(true);
|
cookie.setHttpOnly(true);
|
||||||
cookie.setSecure(false);
|
cookie.setSecure(false);
|
||||||
|
|
@ -441,13 +442,13 @@ void RealmController::issueRealmViewerToken(const HttpRequestPtr &req,
|
||||||
if (!storedKey.empty()) {
|
if (!storedKey.empty()) {
|
||||||
RedisHelper::deleteKeyAsync("viewer_token:" + existingToken, [](bool){});
|
RedisHelper::deleteKeyAsync("viewer_token:" + existingToken, [](bool){});
|
||||||
}
|
}
|
||||||
createNewViewerToken(callback, streamKey);
|
createNewViewerToken(callback, streamKey, realmId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// No existing token, create new one
|
// No existing token for this realm, create new one
|
||||||
createNewViewerToken(callback, streamKey);
|
createNewViewerToken(callback, streamKey, realmId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
>> [callback](const DrogonDbException& e) {
|
>> [callback](const DrogonDbException& e) {
|
||||||
|
|
@ -459,15 +460,15 @@ void RealmController::issueRealmViewerToken(const HttpRequestPtr &req,
|
||||||
void RealmController::getRealmStreamKey(const HttpRequestPtr &req,
|
void RealmController::getRealmStreamKey(const HttpRequestPtr &req,
|
||||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||||
const std::string &realmId) {
|
const std::string &realmId) {
|
||||||
// Check for viewer token
|
// Check for realm-specific viewer token (supports multi-stream viewing)
|
||||||
auto token = req->getCookie("viewer_token");
|
auto token = req->getCookie("viewer_token_" + realmId);
|
||||||
if (token.empty()) {
|
if (token.empty()) {
|
||||||
callback(jsonError("No viewer token", k403Forbidden));
|
callback(jsonError("No viewer token for this realm", k403Forbidden));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
int64_t id = std::stoll(realmId);
|
int64_t id = std::stoll(realmId);
|
||||||
|
|
||||||
// First get the stream key for this realm
|
// First get the stream key for this realm
|
||||||
auto dbClient = app().getDbClient();
|
auto dbClient = app().getDbClient();
|
||||||
*dbClient << "SELECT stream_key FROM realms WHERE id = $1 AND is_active = true"
|
*dbClient << "SELECT stream_key FROM realms WHERE id = $1 AND is_active = true"
|
||||||
|
|
@ -477,9 +478,9 @@ void RealmController::getRealmStreamKey(const HttpRequestPtr &req,
|
||||||
callback(jsonError("Realm not found", k404NotFound));
|
callback(jsonError("Realm not found", k404NotFound));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
std::string streamKey = r[0]["stream_key"].as<std::string>();
|
std::string streamKey = r[0]["stream_key"].as<std::string>();
|
||||||
|
|
||||||
// Verify the token is valid for this stream
|
// Verify the token is valid for this stream
|
||||||
RedisHelper::getKeyAsync("viewer_token:" + token,
|
RedisHelper::getKeyAsync("viewer_token:" + token,
|
||||||
[callback, streamKey](const std::string& storedStreamKey) {
|
[callback, streamKey](const std::string& storedStreamKey) {
|
||||||
|
|
@ -487,7 +488,7 @@ void RealmController::getRealmStreamKey(const HttpRequestPtr &req,
|
||||||
callback(jsonError("Invalid token for this realm", k403Forbidden));
|
callback(jsonError("Invalid token for this realm", k403Forbidden));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Token is valid, return the stream key
|
// Token is valid, return the stream key
|
||||||
Json::Value resp;
|
Json::Value resp;
|
||||||
resp["success"] = true;
|
resp["success"] = true;
|
||||||
|
|
|
||||||
|
|
@ -2212,44 +2212,50 @@ void UserController::getTreasury(const HttpRequestPtr &,
|
||||||
double estimatedShare = totalUsers > 0 ? balance / static_cast<double>(totalUsers) : 0.0;
|
double estimatedShare = totalUsers > 0 ? balance / static_cast<double>(totalUsers) : 0.0;
|
||||||
estimatedShare = std::ceil(estimatedShare * 1000.0) / 1000.0;
|
estimatedShare = std::ceil(estimatedShare * 1000.0) / 1000.0;
|
||||||
|
|
||||||
// Calculate next Sunday (next distribution) in UTC
|
// Calculate next Sunday (next distribution) in EST (UTC-5)
|
||||||
|
// EST offset: -5 hours = -18000 seconds
|
||||||
|
const int EST_OFFSET_SECONDS = -5 * 3600;
|
||||||
|
|
||||||
std::time_t now = std::time(nullptr);
|
std::time_t now = std::time(nullptr);
|
||||||
std::tm nowUtc;
|
std::time_t nowEst = now + EST_OFFSET_SECONDS;
|
||||||
|
|
||||||
|
std::tm nowEstTm;
|
||||||
#ifdef _WIN32
|
#ifdef _WIN32
|
||||||
gmtime_s(&nowUtc, &now);
|
gmtime_s(&nowEstTm, &nowEst);
|
||||||
#else
|
#else
|
||||||
gmtime_r(&now, &nowUtc);
|
gmtime_r(&nowEst, &nowEstTm);
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
// Days until Sunday (0 = Sunday)
|
// Days until Sunday (0 = Sunday) based on EST
|
||||||
// If today is Sunday, next distribution is next Sunday (7 days)
|
int daysUntilSunday = (7 - nowEstTm.tm_wday) % 7;
|
||||||
int daysUntilSunday = (7 - nowUtc.tm_wday) % 7;
|
|
||||||
if (daysUntilSunday == 0) daysUntilSunday = 7;
|
if (daysUntilSunday == 0) daysUntilSunday = 7;
|
||||||
|
|
||||||
// Calculate next Sunday at midnight UTC
|
// Calculate next Sunday at midnight EST (which is 5 AM UTC)
|
||||||
std::tm nextSundayTm = nowUtc;
|
std::tm nextSundayTm = nowEstTm;
|
||||||
nextSundayTm.tm_mday += daysUntilSunday;
|
nextSundayTm.tm_mday += daysUntilSunday;
|
||||||
nextSundayTm.tm_hour = 0;
|
nextSundayTm.tm_hour = 0;
|
||||||
nextSundayTm.tm_min = 0;
|
nextSundayTm.tm_min = 0;
|
||||||
nextSundayTm.tm_sec = 0;
|
nextSundayTm.tm_sec = 0;
|
||||||
|
|
||||||
// Normalize the tm struct (handles month overflow etc)
|
// Convert to time_t (treating as UTC since we applied offset)
|
||||||
// Note: timegm is the UTC version of mktime
|
|
||||||
#ifdef _WIN32
|
#ifdef _WIN32
|
||||||
std::time_t nextSunday = _mkgmtime(&nextSundayTm);
|
std::time_t nextSundayEst = _mkgmtime(&nextSundayTm);
|
||||||
#else
|
#else
|
||||||
std::time_t nextSunday = timegm(&nextSundayTm);
|
std::time_t nextSundayEst = timegm(&nextSundayTm);
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
// Convert back to UTC for the response
|
||||||
|
std::time_t nextSundayUtc = nextSundayEst - EST_OFFSET_SECONDS;
|
||||||
|
|
||||||
// Format as ISO 8601 UTC
|
// Format as ISO 8601 UTC
|
||||||
char nextDistBuffer[32];
|
char nextDistBuffer[32];
|
||||||
std::tm nextSundayUtc;
|
std::tm nextSundayUtcTm;
|
||||||
#ifdef _WIN32
|
#ifdef _WIN32
|
||||||
gmtime_s(&nextSundayUtc, &nextSunday);
|
gmtime_s(&nextSundayUtcTm, &nextSundayUtc);
|
||||||
#else
|
#else
|
||||||
gmtime_r(&nextSunday, &nextSundayUtc);
|
gmtime_r(&nextSundayUtc, &nextSundayUtcTm);
|
||||||
#endif
|
#endif
|
||||||
std::strftime(nextDistBuffer, sizeof(nextDistBuffer), "%Y-%m-%dT%H:%M:%SZ", &nextSundayUtc);
|
std::strftime(nextDistBuffer, sizeof(nextDistBuffer), "%Y-%m-%dT%H:%M:%SZ", &nextSundayUtcTm);
|
||||||
|
|
||||||
Json::Value resp;
|
Json::Value resp;
|
||||||
resp["success"] = true;
|
resp["success"] = true;
|
||||||
|
|
|
||||||
140
frontend/src/lib/components/WaveformBackground.svelte
Normal file
140
frontend/src/lib/components/WaveformBackground.svelte
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
<script>
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
|
/** @type {string} Audio ID to fetch waveform for */
|
||||||
|
export let audioId = '';
|
||||||
|
|
||||||
|
/** @type {string} Direct waveform path (alternative to audioId) */
|
||||||
|
export let waveformPath = '';
|
||||||
|
|
||||||
|
/** @type {boolean} Whether audio is currently playing */
|
||||||
|
export let isPlaying = false;
|
||||||
|
|
||||||
|
/** @type {number} Current playback position in seconds */
|
||||||
|
export let currentTime = 0;
|
||||||
|
|
||||||
|
/** @type {number} Total duration in seconds */
|
||||||
|
export let duration = 0;
|
||||||
|
|
||||||
|
/** @type {string} Waveform color */
|
||||||
|
export let color = '#ec4899';
|
||||||
|
|
||||||
|
/** @type {number} Opacity of the waveform (0-1) */
|
||||||
|
export let opacity = 0.15;
|
||||||
|
|
||||||
|
/** @type {boolean} Whether this is the currently playing track */
|
||||||
|
export let isCurrentTrack = false;
|
||||||
|
|
||||||
|
// Waveform data cache (module-level for sharing across instances)
|
||||||
|
const waveformCache = new Map();
|
||||||
|
|
||||||
|
/** @type {number[]} */
|
||||||
|
let peaks = [];
|
||||||
|
let loading = true;
|
||||||
|
|
||||||
|
// Calculate scroll position based on playback
|
||||||
|
$: progress = duration > 0 ? currentTime / duration : 0;
|
||||||
|
$: translateX = isCurrentTrack && isPlaying ? -(progress * 50) : 0;
|
||||||
|
|
||||||
|
async function fetchWaveform() {
|
||||||
|
if (!audioId && !waveformPath) {
|
||||||
|
loading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheKey = audioId || waveformPath;
|
||||||
|
|
||||||
|
// Check cache first
|
||||||
|
if (waveformCache.has(cacheKey)) {
|
||||||
|
peaks = waveformCache.get(cacheKey);
|
||||||
|
loading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let url;
|
||||||
|
if (waveformPath) {
|
||||||
|
// Direct path to waveform JSON
|
||||||
|
url = waveformPath;
|
||||||
|
} else {
|
||||||
|
// Fetch via API endpoint
|
||||||
|
url = `/api/audio/${audioId}/waveform`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
peaks = data.peaks || [];
|
||||||
|
waveformCache.set(cacheKey, peaks);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load waveform:', e);
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
fetchWaveform();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Refetch if audioId or waveformPath changes
|
||||||
|
$: if (audioId || waveformPath) {
|
||||||
|
fetchWaveform();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if peaks.length > 0}
|
||||||
|
<div
|
||||||
|
class="waveform-container"
|
||||||
|
style="--waveform-color: {color}; --waveform-opacity: {opacity};"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="waveform-svg"
|
||||||
|
viewBox="0 0 400 100"
|
||||||
|
preserveAspectRatio="none"
|
||||||
|
style="transform: translateX({translateX}%);"
|
||||||
|
>
|
||||||
|
<!-- Mirror waveform: bars extend up and down from center -->
|
||||||
|
{#each peaks as peak, i}
|
||||||
|
{@const barWidth = 400 / peaks.length}
|
||||||
|
{@const barHeight = peak * 45}
|
||||||
|
{@const x = i * barWidth}
|
||||||
|
<!-- Top bar (from center going up) -->
|
||||||
|
<rect
|
||||||
|
x={x}
|
||||||
|
y={50 - barHeight}
|
||||||
|
width={barWidth * 0.8}
|
||||||
|
height={barHeight}
|
||||||
|
fill={color}
|
||||||
|
rx="1"
|
||||||
|
/>
|
||||||
|
<!-- Bottom bar (from center going down) -->
|
||||||
|
<rect
|
||||||
|
x={x}
|
||||||
|
y={50}
|
||||||
|
width={barWidth * 0.8}
|
||||||
|
height={barHeight}
|
||||||
|
fill={color}
|
||||||
|
rx="1"
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.waveform-container {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: var(--waveform-opacity, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.waveform-svg {
|
||||||
|
width: 200%;
|
||||||
|
height: 100%;
|
||||||
|
transition: transform 0.1s linear;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -451,6 +451,7 @@
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
border-bottom: 1px solid #333;
|
border-bottom: 1px solid #333;
|
||||||
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.terminal-container.undocked {
|
.terminal-container.undocked {
|
||||||
|
|
@ -487,6 +488,8 @@
|
||||||
gap: 0.375rem;
|
gap: 0.375rem;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.docked .terminal-header {
|
.docked .terminal-header {
|
||||||
|
|
@ -502,6 +505,7 @@
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.375rem;
|
gap: 0.375rem;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status {
|
.status {
|
||||||
|
|
@ -581,8 +585,10 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
overflow-x: hidden;
|
||||||
background: rgb(13, 17, 23);
|
background: rgb(13, 17, 23);
|
||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.datetime-container {
|
.datetime-container {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
<script>
|
<script>
|
||||||
import { audioPlaylist, currentTrack } from '$lib/stores/audioPlaylist';
|
import { audioPlaylist, currentTrack } from '$lib/stores/audioPlaylist';
|
||||||
|
import { isAuthenticated } from '$lib/stores/auth';
|
||||||
|
import WaveformBackground from '$lib/components/WaveformBackground.svelte';
|
||||||
|
|
||||||
/** @type {boolean} Whether the audio tab is currently active */
|
/** @type {boolean} Whether the audio tab is currently active */
|
||||||
export let isActive = false;
|
export let isActive = false;
|
||||||
|
|
@ -25,12 +27,57 @@
|
||||||
const volume = parseFloat(e.target.value);
|
const volume = parseFloat(e.target.value);
|
||||||
audioPlaylist.setVolume(volume);
|
audioPlaylist.setVolume(volume);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function downloadAudio(e, track) {
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
if (!$isAuthenticated) {
|
||||||
|
alert('Please log in to download audio');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/audio/${track.id}/download`, {
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const blob = await response.blob();
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
const safeTitle = track.title.replace(/[<>:"/\\|?*]/g, '_').substring(0, 100);
|
||||||
|
a.download = safeTitle || 'audio';
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
document.body.removeChild(a);
|
||||||
|
} else if (response.status === 401) {
|
||||||
|
alert('Please log in to download audio');
|
||||||
|
} else {
|
||||||
|
alert('Download failed');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Download error:', err);
|
||||||
|
alert('Download failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="audio-tab">
|
<div class="audio-tab">
|
||||||
<!-- Player Section (terminal/pixel style) -->
|
<!-- Player Section (terminal/pixel style) -->
|
||||||
<div class="player-section">
|
<div class="player-section">
|
||||||
{#if $currentTrack}
|
{#if $currentTrack}
|
||||||
|
{#if $currentTrack.waveformPath}
|
||||||
|
<WaveformBackground
|
||||||
|
waveformPath={$currentTrack.waveformPath}
|
||||||
|
isPlaying={$audioPlaylist.isPlaying}
|
||||||
|
currentTime={$audioPlaylist.currentTime}
|
||||||
|
duration={$audioPlaylist.duration}
|
||||||
|
isCurrentTrack={true}
|
||||||
|
opacity={0.1}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
<div class="player-header">
|
<div class="player-header">
|
||||||
<span class="player-label">NOW PLAYING</span>
|
<span class="player-label">NOW PLAYING</span>
|
||||||
<span class="player-status">{$audioPlaylist.isPlaying ? '▶' : '■'}</span>
|
<span class="player-status">{$audioPlaylist.isPlaying ? '▶' : '■'}</span>
|
||||||
|
|
@ -48,6 +95,9 @@
|
||||||
<span class="player-title">{$currentTrack.title}</span>
|
<span class="player-title">{$currentTrack.title}</span>
|
||||||
<span class="player-artist">{$currentTrack.username}</span>
|
<span class="player-artist">{$currentTrack.username}</span>
|
||||||
</div>
|
</div>
|
||||||
|
{#if $isAuthenticated}
|
||||||
|
<button class="dl-btn" on:click={(e) => downloadAudio(e, $currentTrack)} title="Download">↓</button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Progress bar (pixel style) -->
|
<!-- Progress bar (pixel style) -->
|
||||||
|
|
@ -126,6 +176,9 @@
|
||||||
<span class="queue-index">{index + 1}</span>
|
<span class="queue-index">{index + 1}</span>
|
||||||
<span class="queue-title">{track.title}</span>
|
<span class="queue-title">{track.title}</span>
|
||||||
<span class="queue-duration">{formatDuration(track.durationSeconds)}</span>
|
<span class="queue-duration">{formatDuration(track.durationSeconds)}</span>
|
||||||
|
{#if $isAuthenticated}
|
||||||
|
<button class="queue-dl-btn" on:click|stopPropagation={(e) => downloadAudio(e, track)} title="Download">↓</button>
|
||||||
|
{/if}
|
||||||
<button class="remove-btn" on:click|stopPropagation={() => audioPlaylist.removeTrack(track.id)}>×</button>
|
<button class="remove-btn" on:click|stopPropagation={() => audioPlaylist.removeTrack(track.id)}>×</button>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
@ -154,6 +207,13 @@
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
background: #0d1117;
|
background: #0d1117;
|
||||||
border-bottom: 1px solid #21262d;
|
border-bottom: 1px solid #21262d;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-section > :not(:global(.waveform-container)) {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.player-header {
|
.player-header {
|
||||||
|
|
@ -238,6 +298,27 @@
|
||||||
filter: invert(1);
|
filter: invert(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dl-btn {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border: 1px solid #30363d;
|
||||||
|
background: #161b22;
|
||||||
|
color: #8b949e;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dl-btn:hover {
|
||||||
|
background: rgba(59, 130, 246, 0.2);
|
||||||
|
border-color: #3b82f6;
|
||||||
|
color: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
/* Progress bar (pixel style) */
|
/* Progress bar (pixel style) */
|
||||||
.player-progress-wrap {
|
.player-progress-wrap {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -585,6 +666,29 @@
|
||||||
color: #f85149;
|
color: #f85149;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.queue-dl-btn {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: #484f58;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.1s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-item:hover .queue-dl-btn {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-dl-btn:hover {
|
||||||
|
color: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
/* Queue empty state */
|
/* Queue empty state */
|
||||||
.queue-empty {
|
.queue-empty {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
import { onDestroy } from 'svelte';
|
import { onDestroy } from 'svelte';
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import { ebookReader } from '$lib/stores/ebookReader';
|
import { ebookReader } from '$lib/stores/ebookReader';
|
||||||
|
import { isAuthenticated } from '$lib/stores/auth';
|
||||||
|
|
||||||
/** @type {boolean} Whether the ebooks tab is currently active */
|
/** @type {boolean} Whether the ebooks tab is currently active */
|
||||||
export let isActive = false;
|
export let isActive = false;
|
||||||
|
|
@ -99,6 +100,41 @@
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function downloadEbook(e, ebook) {
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
if (!$isAuthenticated) {
|
||||||
|
alert('Please log in to download ebooks');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/ebooks/${ebook.id}/download`, {
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const blob = await response.blob();
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
const safeTitle = ebook.title.replace(/[<>:"/\\|?*]/g, '_').substring(0, 100);
|
||||||
|
a.download = `${safeTitle || 'ebook'}.epub`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
document.body.removeChild(a);
|
||||||
|
} else if (response.status === 401) {
|
||||||
|
alert('Please log in to download ebooks');
|
||||||
|
} else {
|
||||||
|
alert('Download failed');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Download error:', err);
|
||||||
|
alert('Download failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function handleScroll(e) {
|
function handleScroll(e) {
|
||||||
const target = e.target;
|
const target = e.target;
|
||||||
const nearBottom = target.scrollHeight - target.scrollTop <= target.clientHeight + 100;
|
const nearBottom = target.scrollHeight - target.scrollTop <= target.clientHeight + 100;
|
||||||
|
|
@ -152,11 +188,20 @@
|
||||||
<span class="ebook-time">{timeAgo(ebook.createdAt)}</span>
|
<span class="ebook-time">{timeAgo(ebook.createdAt)}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div class="ebook-actions">
|
||||||
class="read-btn"
|
<button
|
||||||
on:click={() => openBook(ebook)}
|
class="read-btn"
|
||||||
title="Open in reader"
|
on:click={() => openBook(ebook)}
|
||||||
>Read</button>
|
title="Open in reader"
|
||||||
|
>Read</button>
|
||||||
|
{#if $isAuthenticated}
|
||||||
|
<button
|
||||||
|
class="dl-btn"
|
||||||
|
on:click={(e) => downloadEbook(e, ebook)}
|
||||||
|
title="Download"
|
||||||
|
>↓</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
{#if loadingMore}
|
{#if loadingMore}
|
||||||
|
|
@ -375,4 +420,28 @@
|
||||||
.read-btn:active {
|
.read-btn:active {
|
||||||
background: rgba(59, 130, 246, 0.35);
|
background: rgba(59, 130, 246, 0.35);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ebook-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.3rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dl-btn {
|
||||||
|
padding: 0.3rem 0.5rem;
|
||||||
|
background: rgba(107, 114, 128, 0.15);
|
||||||
|
border: 1px solid rgba(107, 114, 128, 0.3);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dl-btn:hover {
|
||||||
|
background: rgba(59, 130, 246, 0.25);
|
||||||
|
border-color: #3b82f6;
|
||||||
|
color: #3b82f6;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,59 @@
|
||||||
/**
|
/**
|
||||||
* Holiday data for terminal calendar
|
* Holiday data for terminal calendar
|
||||||
* Includes both Indian national holidays and international holidays
|
* Includes Indian, American, Canadian, Australian, UK, Russian, and international holidays
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fixed holidays - same date every year
|
* Fixed holidays - same date every year
|
||||||
* Format: { month: 0-11, day: 1-31, name: string, type: 'indian' | 'international' }
|
* Format: { month: 0-11, day: 1-31, name: string, type: string }
|
||||||
*/
|
*/
|
||||||
export const fixedHolidays = [
|
export const fixedHolidays = [
|
||||||
// Indian National Holidays (Fixed)
|
// Indian National Holidays (Fixed)
|
||||||
{ month: 0, day: 26, name: 'Republic Day', type: 'indian' },
|
{ month: 0, day: 26, name: 'Republic Day', type: 'indian' },
|
||||||
{ month: 7, day: 15, name: 'Independence Day', type: 'indian' },
|
{ month: 7, day: 15, name: 'Independence Day (India)', type: 'indian' },
|
||||||
{ month: 9, day: 2, name: 'Gandhi Jayanti', type: 'indian' },
|
{ month: 9, day: 2, name: 'Gandhi Jayanti', type: 'indian' },
|
||||||
|
|
||||||
|
// American Holidays (Fixed)
|
||||||
|
{ month: 6, day: 4, name: 'Independence Day (USA)', type: 'american' },
|
||||||
|
{ month: 10, day: 11, name: 'Veterans Day', type: 'american' },
|
||||||
|
|
||||||
|
// Canadian Holidays (Fixed)
|
||||||
|
{ month: 6, day: 1, name: 'Canada Day', type: 'canadian' },
|
||||||
|
{ month: 10, day: 11, name: 'Remembrance Day', type: 'canadian' },
|
||||||
|
|
||||||
|
// Australian Holidays (Fixed)
|
||||||
|
{ month: 0, day: 26, name: 'Australia Day', type: 'australian' },
|
||||||
|
{ month: 3, day: 25, name: 'ANZAC Day', type: 'australian' },
|
||||||
|
|
||||||
|
// UK Holidays (Fixed)
|
||||||
|
{ month: 10, day: 5, name: 'Guy Fawkes Night', type: 'uk' },
|
||||||
|
|
||||||
|
// Russian Holidays (Fixed)
|
||||||
|
{ month: 0, day: 7, name: 'Orthodox Christmas', type: 'russian' },
|
||||||
|
{ month: 1, day: 23, name: 'Defender of the Fatherland Day', type: 'russian' },
|
||||||
|
{ month: 2, day: 8, name: "International Women's Day", type: 'russian' },
|
||||||
|
{ month: 4, day: 1, name: 'Spring and Labour Day', type: 'russian' },
|
||||||
|
{ month: 4, day: 9, name: 'Victory Day', type: 'russian' },
|
||||||
|
{ month: 5, day: 12, name: 'Russia Day', type: 'russian' },
|
||||||
|
{ month: 10, day: 4, name: 'Unity Day', type: 'russian' },
|
||||||
|
|
||||||
// International Holidays (Fixed)
|
// International Holidays (Fixed)
|
||||||
{ month: 0, day: 1, name: "New Year's Day", type: 'international' },
|
{ month: 0, day: 1, name: "New Year's Day", type: 'international' },
|
||||||
{ month: 1, day: 14, name: "Valentine's Day", type: 'international' },
|
{ month: 1, day: 14, name: "Valentine's Day", type: 'international' },
|
||||||
|
{ month: 2, day: 17, name: "St. Patrick's Day", type: 'international' },
|
||||||
{ month: 9, day: 31, name: 'Halloween', type: 'international' },
|
{ month: 9, day: 31, name: 'Halloween', type: 'international' },
|
||||||
{ month: 11, day: 25, name: 'Christmas', type: 'international' },
|
{ month: 11, day: 25, name: 'Christmas', type: 'international' },
|
||||||
|
{ month: 11, day: 26, name: 'Boxing Day', type: 'international' },
|
||||||
{ month: 11, day: 31, name: "New Year's Eve", type: 'international' },
|
{ month: 11, day: 31, name: "New Year's Eve", type: 'international' },
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Variable holidays - different date each year (lunar calendar based)
|
* Variable holidays - different date each year (based on lunar calendar or day of week)
|
||||||
* Format: { [year]: Array<{ month: 0-11, day: 1-31, name: string, type: string }> }
|
* Format: { [year]: Array<{ month: 0-11, day: 1-31, name: string, type: string }> }
|
||||||
*/
|
*/
|
||||||
export const variableHolidays = {
|
export const variableHolidays = {
|
||||||
2024: [
|
2024: [
|
||||||
|
// Indian
|
||||||
{ month: 2, day: 25, name: 'Holi', type: 'indian' },
|
{ month: 2, day: 25, name: 'Holi', type: 'indian' },
|
||||||
{ month: 3, day: 10, name: 'Eid ul-Fitr', type: 'indian' },
|
{ month: 3, day: 10, name: 'Eid ul-Fitr', type: 'indian' },
|
||||||
{ month: 5, day: 17, name: 'Eid ul-Adha', type: 'indian' },
|
{ month: 5, day: 17, name: 'Eid ul-Adha', type: 'indian' },
|
||||||
|
|
@ -35,8 +62,27 @@ export const variableHolidays = {
|
||||||
{ month: 8, day: 7, name: 'Ganesh Chaturthi', type: 'indian' },
|
{ month: 8, day: 7, name: 'Ganesh Chaturthi', type: 'indian' },
|
||||||
{ month: 9, day: 12, name: 'Dussehra', type: 'indian' },
|
{ month: 9, day: 12, name: 'Dussehra', type: 'indian' },
|
||||||
{ month: 10, day: 1, name: 'Diwali', type: 'indian' },
|
{ month: 10, day: 1, name: 'Diwali', type: 'indian' },
|
||||||
|
// American
|
||||||
|
{ month: 0, day: 15, name: 'MLK Day', type: 'american' },
|
||||||
|
{ month: 1, day: 19, name: "Presidents' Day", type: 'american' },
|
||||||
|
{ month: 4, day: 27, name: 'Memorial Day', type: 'american' },
|
||||||
|
{ month: 8, day: 2, name: 'Labor Day', type: 'american' },
|
||||||
|
{ month: 9, day: 14, name: 'Columbus Day', type: 'american' },
|
||||||
|
{ month: 10, day: 28, name: 'Thanksgiving (USA)', type: 'american' },
|
||||||
|
// Canadian
|
||||||
|
{ month: 4, day: 20, name: 'Victoria Day', type: 'canadian' },
|
||||||
|
{ month: 9, day: 14, name: 'Thanksgiving (Canada)', type: 'canadian' },
|
||||||
|
// Australian
|
||||||
|
{ month: 5, day: 10, name: "Queen's Birthday", type: 'australian' },
|
||||||
|
// UK
|
||||||
|
{ month: 2, day: 29, name: 'Good Friday', type: 'uk' },
|
||||||
|
{ month: 3, day: 1, name: 'Easter Monday', type: 'uk' },
|
||||||
|
{ month: 4, day: 6, name: 'Early May Bank Holiday', type: 'uk' },
|
||||||
|
{ month: 4, day: 27, name: 'Spring Bank Holiday', type: 'uk' },
|
||||||
|
{ month: 7, day: 26, name: 'Summer Bank Holiday', type: 'uk' },
|
||||||
],
|
],
|
||||||
2025: [
|
2025: [
|
||||||
|
// Indian
|
||||||
{ month: 2, day: 14, name: 'Holi', type: 'indian' },
|
{ month: 2, day: 14, name: 'Holi', type: 'indian' },
|
||||||
{ month: 2, day: 31, name: 'Eid ul-Fitr', type: 'indian' },
|
{ month: 2, day: 31, name: 'Eid ul-Fitr', type: 'indian' },
|
||||||
{ month: 5, day: 7, name: 'Eid ul-Adha', type: 'indian' },
|
{ month: 5, day: 7, name: 'Eid ul-Adha', type: 'indian' },
|
||||||
|
|
@ -45,8 +91,27 @@ export const variableHolidays = {
|
||||||
{ month: 7, day: 27, name: 'Ganesh Chaturthi', type: 'indian' },
|
{ month: 7, day: 27, name: 'Ganesh Chaturthi', type: 'indian' },
|
||||||
{ month: 9, day: 2, name: 'Dussehra', type: 'indian' },
|
{ month: 9, day: 2, name: 'Dussehra', type: 'indian' },
|
||||||
{ month: 9, day: 20, name: 'Diwali', type: 'indian' },
|
{ month: 9, day: 20, name: 'Diwali', type: 'indian' },
|
||||||
|
// American
|
||||||
|
{ month: 0, day: 20, name: 'MLK Day', type: 'american' },
|
||||||
|
{ month: 1, day: 17, name: "Presidents' Day", type: 'american' },
|
||||||
|
{ month: 4, day: 26, name: 'Memorial Day', type: 'american' },
|
||||||
|
{ month: 8, day: 1, name: 'Labor Day', type: 'american' },
|
||||||
|
{ month: 9, day: 13, name: 'Columbus Day', type: 'american' },
|
||||||
|
{ month: 10, day: 27, name: 'Thanksgiving (USA)', type: 'american' },
|
||||||
|
// Canadian
|
||||||
|
{ month: 4, day: 19, name: 'Victoria Day', type: 'canadian' },
|
||||||
|
{ month: 9, day: 13, name: 'Thanksgiving (Canada)', type: 'canadian' },
|
||||||
|
// Australian
|
||||||
|
{ month: 5, day: 9, name: "Queen's Birthday", type: 'australian' },
|
||||||
|
// UK
|
||||||
|
{ month: 3, day: 18, name: 'Good Friday', type: 'uk' },
|
||||||
|
{ month: 3, day: 21, name: 'Easter Monday', type: 'uk' },
|
||||||
|
{ month: 4, day: 5, name: 'Early May Bank Holiday', type: 'uk' },
|
||||||
|
{ month: 4, day: 26, name: 'Spring Bank Holiday', type: 'uk' },
|
||||||
|
{ month: 7, day: 25, name: 'Summer Bank Holiday', type: 'uk' },
|
||||||
],
|
],
|
||||||
2026: [
|
2026: [
|
||||||
|
// Indian
|
||||||
{ month: 2, day: 4, name: 'Holi', type: 'indian' },
|
{ month: 2, day: 4, name: 'Holi', type: 'indian' },
|
||||||
{ month: 2, day: 20, name: 'Eid ul-Fitr', type: 'indian' },
|
{ month: 2, day: 20, name: 'Eid ul-Fitr', type: 'indian' },
|
||||||
{ month: 4, day: 27, name: 'Eid ul-Adha', type: 'indian' },
|
{ month: 4, day: 27, name: 'Eid ul-Adha', type: 'indian' },
|
||||||
|
|
@ -55,8 +120,27 @@ export const variableHolidays = {
|
||||||
{ month: 8, day: 17, name: 'Ganesh Chaturthi', type: 'indian' },
|
{ month: 8, day: 17, name: 'Ganesh Chaturthi', type: 'indian' },
|
||||||
{ month: 8, day: 20, name: 'Dussehra', type: 'indian' },
|
{ month: 8, day: 20, name: 'Dussehra', type: 'indian' },
|
||||||
{ month: 10, day: 8, name: 'Diwali', type: 'indian' },
|
{ month: 10, day: 8, name: 'Diwali', type: 'indian' },
|
||||||
|
// American
|
||||||
|
{ month: 0, day: 19, name: 'MLK Day', type: 'american' },
|
||||||
|
{ month: 1, day: 16, name: "Presidents' Day", type: 'american' },
|
||||||
|
{ month: 4, day: 25, name: 'Memorial Day', type: 'american' },
|
||||||
|
{ month: 8, day: 7, name: 'Labor Day', type: 'american' },
|
||||||
|
{ month: 9, day: 12, name: 'Columbus Day', type: 'american' },
|
||||||
|
{ month: 10, day: 26, name: 'Thanksgiving (USA)', type: 'american' },
|
||||||
|
// Canadian
|
||||||
|
{ month: 4, day: 18, name: 'Victoria Day', type: 'canadian' },
|
||||||
|
{ month: 9, day: 12, name: 'Thanksgiving (Canada)', type: 'canadian' },
|
||||||
|
// Australian
|
||||||
|
{ month: 5, day: 8, name: "Queen's Birthday", type: 'australian' },
|
||||||
|
// UK
|
||||||
|
{ month: 3, day: 3, name: 'Good Friday', type: 'uk' },
|
||||||
|
{ month: 3, day: 6, name: 'Easter Monday', type: 'uk' },
|
||||||
|
{ month: 4, day: 4, name: 'Early May Bank Holiday', type: 'uk' },
|
||||||
|
{ month: 4, day: 25, name: 'Spring Bank Holiday', type: 'uk' },
|
||||||
|
{ month: 7, day: 31, name: 'Summer Bank Holiday', type: 'uk' },
|
||||||
],
|
],
|
||||||
2027: [
|
2027: [
|
||||||
|
// Indian
|
||||||
{ month: 2, day: 22, name: 'Holi', type: 'indian' },
|
{ month: 2, day: 22, name: 'Holi', type: 'indian' },
|
||||||
{ month: 2, day: 10, name: 'Eid ul-Fitr', type: 'indian' },
|
{ month: 2, day: 10, name: 'Eid ul-Fitr', type: 'indian' },
|
||||||
{ month: 4, day: 17, name: 'Eid ul-Adha', type: 'indian' },
|
{ month: 4, day: 17, name: 'Eid ul-Adha', type: 'indian' },
|
||||||
|
|
@ -65,6 +149,24 @@ export const variableHolidays = {
|
||||||
{ month: 8, day: 6, name: 'Ganesh Chaturthi', type: 'indian' },
|
{ month: 8, day: 6, name: 'Ganesh Chaturthi', type: 'indian' },
|
||||||
{ month: 9, day: 9, name: 'Dussehra', type: 'indian' },
|
{ month: 9, day: 9, name: 'Dussehra', type: 'indian' },
|
||||||
{ month: 9, day: 28, name: 'Diwali', type: 'indian' },
|
{ month: 9, day: 28, name: 'Diwali', type: 'indian' },
|
||||||
|
// American
|
||||||
|
{ month: 0, day: 18, name: 'MLK Day', type: 'american' },
|
||||||
|
{ month: 1, day: 15, name: "Presidents' Day", type: 'american' },
|
||||||
|
{ month: 4, day: 31, name: 'Memorial Day', type: 'american' },
|
||||||
|
{ month: 8, day: 6, name: 'Labor Day', type: 'american' },
|
||||||
|
{ month: 9, day: 11, name: 'Columbus Day', type: 'american' },
|
||||||
|
{ month: 10, day: 25, name: 'Thanksgiving (USA)', type: 'american' },
|
||||||
|
// Canadian
|
||||||
|
{ month: 4, day: 24, name: 'Victoria Day', type: 'canadian' },
|
||||||
|
{ month: 9, day: 11, name: 'Thanksgiving (Canada)', type: 'canadian' },
|
||||||
|
// Australian
|
||||||
|
{ month: 5, day: 14, name: "Queen's Birthday", type: 'australian' },
|
||||||
|
// UK
|
||||||
|
{ month: 2, day: 26, name: 'Good Friday', type: 'uk' },
|
||||||
|
{ month: 2, day: 29, name: 'Easter Monday', type: 'uk' },
|
||||||
|
{ month: 4, day: 3, name: 'Early May Bank Holiday', type: 'uk' },
|
||||||
|
{ month: 4, day: 31, name: 'Spring Bank Holiday', type: 'uk' },
|
||||||
|
{ month: 7, day: 30, name: 'Summer Bank Holiday', type: 'uk' },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,9 @@
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import { siteSettings } from '$lib/stores/siteSettings';
|
import { siteSettings } from '$lib/stores/siteSettings';
|
||||||
import { audioPlaylist } from '$lib/stores/audioPlaylist';
|
import { audioPlaylist, currentTrack } from '$lib/stores/audioPlaylist';
|
||||||
|
import { auth, isAuthenticated } from '$lib/stores/auth';
|
||||||
|
import WaveformBackground from '$lib/components/WaveformBackground.svelte';
|
||||||
|
|
||||||
let audioFiles = [];
|
let audioFiles = [];
|
||||||
let loading = true;
|
let loading = true;
|
||||||
|
|
@ -55,6 +57,20 @@
|
||||||
return $audioPlaylist.queue.some(t => t.id === audioId);
|
return $audioPlaylist.queue.some(t => t.id === audioId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isCurrentlyPlaying(audioId) {
|
||||||
|
return $currentTrack?.id === audioId && $audioPlaylist.isPlaying;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePlayClick(audio, realmName) {
|
||||||
|
if ($currentTrack?.id === audio.id) {
|
||||||
|
// Toggle play/pause if this is the current track
|
||||||
|
audioPlaylist.togglePlay();
|
||||||
|
} else {
|
||||||
|
// Play this track
|
||||||
|
playNow(audio, realmName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function togglePlaylist(audio, realmName) {
|
function togglePlaylist(audio, realmName) {
|
||||||
if (isInPlaylist(audio.id)) {
|
if (isInPlaylist(audio.id)) {
|
||||||
audioPlaylist.removeTrack(audio.id);
|
audioPlaylist.removeTrack(audio.id);
|
||||||
|
|
@ -65,6 +81,7 @@
|
||||||
username: audio.username,
|
username: audio.username,
|
||||||
filePath: audio.filePath,
|
filePath: audio.filePath,
|
||||||
thumbnailPath: audio.thumbnailPath || '',
|
thumbnailPath: audio.thumbnailPath || '',
|
||||||
|
waveformPath: audio.waveformPath || '',
|
||||||
durationSeconds: audio.durationSeconds,
|
durationSeconds: audio.durationSeconds,
|
||||||
realmName: realmName
|
realmName: realmName
|
||||||
});
|
});
|
||||||
|
|
@ -78,11 +95,47 @@
|
||||||
username: audio.username,
|
username: audio.username,
|
||||||
filePath: audio.filePath,
|
filePath: audio.filePath,
|
||||||
thumbnailPath: audio.thumbnailPath || '',
|
thumbnailPath: audio.thumbnailPath || '',
|
||||||
|
waveformPath: audio.waveformPath || '',
|
||||||
durationSeconds: audio.durationSeconds,
|
durationSeconds: audio.durationSeconds,
|
||||||
realmName: realmName
|
realmName: realmName
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function downloadAudio(e, audio) {
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
if (!$isAuthenticated) {
|
||||||
|
alert('Please log in to download audio');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/audio/${audio.id}/download`, {
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const blob = await response.blob();
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
const safeTitle = audio.title.replace(/[<>:"/\\|?*]/g, '_').substring(0, 100);
|
||||||
|
a.download = safeTitle || 'audio';
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
document.body.removeChild(a);
|
||||||
|
} else if (response.status === 401) {
|
||||||
|
alert('Please log in to download audio');
|
||||||
|
} else {
|
||||||
|
alert('Download failed');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Download error:', err);
|
||||||
|
alert('Download failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadAudio(append = false) {
|
async function loadAudio(append = false) {
|
||||||
if (!browser) return;
|
if (!browser) return;
|
||||||
|
|
||||||
|
|
@ -111,6 +164,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
auth.init();
|
||||||
loadAudio();
|
loadAudio();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -204,6 +258,13 @@
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-item > :not(:global(.waveform-container)) {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.audio-item:hover {
|
.audio-item:hover {
|
||||||
|
|
@ -316,12 +377,23 @@
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.action-btn.play.playing {
|
||||||
|
background: #ec4899;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
.action-btn.added {
|
.action-btn.added {
|
||||||
background: rgba(126, 231, 135, 0.2);
|
background: rgba(126, 231, 135, 0.2);
|
||||||
border-color: #7ee787;
|
border-color: #7ee787;
|
||||||
color: #7ee787;
|
color: #7ee787;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.action-btn.download:hover {
|
||||||
|
background: rgba(59, 130, 246, 0.2);
|
||||||
|
border-color: #3b82f6;
|
||||||
|
color: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
.no-audio {
|
.no-audio {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 4rem 0;
|
padding: 4rem 0;
|
||||||
|
|
@ -387,6 +459,15 @@
|
||||||
<div class="audio-list">
|
<div class="audio-list">
|
||||||
{#each group.audio.slice(0, 5) as audio, index}
|
{#each group.audio.slice(0, 5) as audio, index}
|
||||||
<div class="audio-item">
|
<div class="audio-item">
|
||||||
|
{#if audio.waveformPath}
|
||||||
|
<WaveformBackground
|
||||||
|
waveformPath={audio.waveformPath}
|
||||||
|
isPlaying={$audioPlaylist.isPlaying}
|
||||||
|
currentTime={$audioPlaylist.currentTime}
|
||||||
|
duration={$audioPlaylist.duration}
|
||||||
|
isCurrentTrack={$currentTrack?.id === audio.id}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
<span class="audio-number">{index + 1}</span>
|
<span class="audio-number">{index + 1}</span>
|
||||||
<div class="audio-thumbnail">
|
<div class="audio-thumbnail">
|
||||||
{#if audio.thumbnailPath}
|
{#if audio.thumbnailPath}
|
||||||
|
|
@ -411,10 +492,11 @@
|
||||||
<div class="audio-actions">
|
<div class="audio-actions">
|
||||||
<button
|
<button
|
||||||
class="action-btn play"
|
class="action-btn play"
|
||||||
on:click={() => playNow(audio, group.realmName)}
|
class:playing={isCurrentlyPlaying(audio.id)}
|
||||||
title="Play now"
|
on:click={() => handlePlayClick(audio, group.realmName)}
|
||||||
|
title={isCurrentlyPlaying(audio.id) ? 'Pause' : 'Play now'}
|
||||||
>
|
>
|
||||||
▶
|
{isCurrentlyPlaying(audio.id) ? '▮▮' : '▶'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="action-btn"
|
class="action-btn"
|
||||||
|
|
@ -424,6 +506,15 @@
|
||||||
>
|
>
|
||||||
{isInPlaylist(audio.id) ? '✓' : '+'}
|
{isInPlaylist(audio.id) ? '✓' : '+'}
|
||||||
</button>
|
</button>
|
||||||
|
{#if $isAuthenticated}
|
||||||
|
<button
|
||||||
|
class="action-btn download"
|
||||||
|
on:click={(e) => downloadAudio(e, audio)}
|
||||||
|
title="Download"
|
||||||
|
>
|
||||||
|
↓
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,9 @@
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import { audioPlaylist } from '$lib/stores/audioPlaylist';
|
import { audioPlaylist, currentTrack } from '$lib/stores/audioPlaylist';
|
||||||
|
import { auth, isAuthenticated } from '$lib/stores/auth';
|
||||||
|
import WaveformBackground from '$lib/components/WaveformBackground.svelte';
|
||||||
|
|
||||||
let realm = null;
|
let realm = null;
|
||||||
let audioFiles = [];
|
let audioFiles = [];
|
||||||
|
|
@ -41,6 +43,20 @@
|
||||||
return $audioPlaylist.queue.some(t => t.id === audioId);
|
return $audioPlaylist.queue.some(t => t.id === audioId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isCurrentlyPlaying(audioId) {
|
||||||
|
return $currentTrack?.id === audioId && $audioPlaylist.isPlaying;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePlayClick(audio) {
|
||||||
|
if ($currentTrack?.id === audio.id) {
|
||||||
|
// Toggle play/pause if this is the current track
|
||||||
|
audioPlaylist.togglePlay();
|
||||||
|
} else {
|
||||||
|
// Play this track
|
||||||
|
playNow(audio);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function togglePlaylist(audio) {
|
function togglePlaylist(audio) {
|
||||||
if (isInPlaylist(audio.id)) {
|
if (isInPlaylist(audio.id)) {
|
||||||
audioPlaylist.removeTrack(audio.id);
|
audioPlaylist.removeTrack(audio.id);
|
||||||
|
|
@ -51,6 +67,7 @@
|
||||||
username: audio.username || realm?.username,
|
username: audio.username || realm?.username,
|
||||||
filePath: audio.filePath,
|
filePath: audio.filePath,
|
||||||
thumbnailPath: audio.thumbnailPath || '',
|
thumbnailPath: audio.thumbnailPath || '',
|
||||||
|
waveformPath: audio.waveformPath || '',
|
||||||
durationSeconds: audio.durationSeconds,
|
durationSeconds: audio.durationSeconds,
|
||||||
realmName: realm?.name
|
realmName: realm?.name
|
||||||
});
|
});
|
||||||
|
|
@ -64,6 +81,7 @@
|
||||||
username: audio.username || realm?.username,
|
username: audio.username || realm?.username,
|
||||||
filePath: audio.filePath,
|
filePath: audio.filePath,
|
||||||
thumbnailPath: audio.thumbnailPath || '',
|
thumbnailPath: audio.thumbnailPath || '',
|
||||||
|
waveformPath: audio.waveformPath || '',
|
||||||
durationSeconds: audio.durationSeconds,
|
durationSeconds: audio.durationSeconds,
|
||||||
realmName: realm?.name
|
realmName: realm?.name
|
||||||
});
|
});
|
||||||
|
|
@ -76,6 +94,7 @@
|
||||||
username: audio.username || realm?.username,
|
username: audio.username || realm?.username,
|
||||||
filePath: audio.filePath,
|
filePath: audio.filePath,
|
||||||
thumbnailPath: audio.thumbnailPath || '',
|
thumbnailPath: audio.thumbnailPath || '',
|
||||||
|
waveformPath: audio.waveformPath || '',
|
||||||
durationSeconds: audio.durationSeconds,
|
durationSeconds: audio.durationSeconds,
|
||||||
realmName: realm?.name
|
realmName: realm?.name
|
||||||
});
|
});
|
||||||
|
|
@ -89,6 +108,41 @@
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function downloadAudio(e, audio) {
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
if (!$isAuthenticated) {
|
||||||
|
alert('Please log in to download audio');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/audio/${audio.id}/download`, {
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const blob = await response.blob();
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
const safeTitle = audio.title.replace(/[<>:"/\\|?*]/g, '_').substring(0, 100);
|
||||||
|
a.download = safeTitle || 'audio';
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
document.body.removeChild(a);
|
||||||
|
} else if (response.status === 401) {
|
||||||
|
alert('Please log in to download audio');
|
||||||
|
} else {
|
||||||
|
alert('Download failed');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Download error:', err);
|
||||||
|
alert('Download failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadRealmAudio() {
|
async function loadRealmAudio() {
|
||||||
if (!browser || !realmName) return;
|
if (!browser || !realmName) return;
|
||||||
|
|
||||||
|
|
@ -114,6 +168,7 @@
|
||||||
let prevRealmName = null;
|
let prevRealmName = null;
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
auth.init();
|
||||||
prevRealmName = realmName;
|
prevRealmName = realmName;
|
||||||
loadRealmAudio();
|
loadRealmAudio();
|
||||||
});
|
});
|
||||||
|
|
@ -240,6 +295,13 @@
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-item > :not(:global(.waveform-container)) {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.audio-item:hover {
|
.audio-item:hover {
|
||||||
|
|
@ -350,12 +412,23 @@
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.action-btn.play.playing {
|
||||||
|
background: #ec4899;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
.action-btn.added {
|
.action-btn.added {
|
||||||
background: rgba(126, 231, 135, 0.2);
|
background: rgba(126, 231, 135, 0.2);
|
||||||
border-color: #7ee787;
|
border-color: #7ee787;
|
||||||
color: #7ee787;
|
color: #7ee787;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.action-btn.download:hover {
|
||||||
|
background: rgba(59, 130, 246, 0.2);
|
||||||
|
border-color: #3b82f6;
|
||||||
|
color: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
.no-audio {
|
.no-audio {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 4rem 0;
|
padding: 4rem 0;
|
||||||
|
|
@ -444,6 +517,15 @@
|
||||||
<div class="audio-list">
|
<div class="audio-list">
|
||||||
{#each audioFiles as audio, index}
|
{#each audioFiles as audio, index}
|
||||||
<div class="audio-item">
|
<div class="audio-item">
|
||||||
|
{#if audio.waveformPath}
|
||||||
|
<WaveformBackground
|
||||||
|
waveformPath={audio.waveformPath}
|
||||||
|
isPlaying={$audioPlaylist.isPlaying}
|
||||||
|
currentTime={$audioPlaylist.currentTime}
|
||||||
|
duration={$audioPlaylist.duration}
|
||||||
|
isCurrentTrack={$currentTrack?.id === audio.id}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
<span class="audio-number">{index + 1}</span>
|
<span class="audio-number">{index + 1}</span>
|
||||||
<div class="audio-thumbnail">
|
<div class="audio-thumbnail">
|
||||||
{#if audio.thumbnailPath}
|
{#if audio.thumbnailPath}
|
||||||
|
|
@ -466,10 +548,11 @@
|
||||||
<div class="audio-actions">
|
<div class="audio-actions">
|
||||||
<button
|
<button
|
||||||
class="action-btn play"
|
class="action-btn play"
|
||||||
on:click={() => playNow(audio)}
|
class:playing={isCurrentlyPlaying(audio.id)}
|
||||||
title="Play now"
|
on:click={() => handlePlayClick(audio)}
|
||||||
|
title={isCurrentlyPlaying(audio.id) ? 'Pause' : 'Play now'}
|
||||||
>
|
>
|
||||||
▶
|
{isCurrentlyPlaying(audio.id) ? '▮▮' : '▶'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="action-btn"
|
class="action-btn"
|
||||||
|
|
@ -479,6 +562,15 @@
|
||||||
>
|
>
|
||||||
{$audioPlaylist.queue.some(t => t.id === audio.id) ? '✓' : '+'}
|
{$audioPlaylist.queue.some(t => t.id === audio.id) ? '✓' : '+'}
|
||||||
</button>
|
</button>
|
||||||
|
{#if $isAuthenticated}
|
||||||
|
<button
|
||||||
|
class="action-btn download"
|
||||||
|
on:click={(e) => downloadAudio(e, audio)}
|
||||||
|
title="Download"
|
||||||
|
>
|
||||||
|
↓
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue