This commit is contained in:
parent
58392b7d6a
commit
381e8b79b0
17 changed files with 282 additions and 82 deletions
|
|
@ -115,8 +115,8 @@ namespace {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compute 200 peaks from samples
|
// Compute 500 peaks from samples for finer waveform detail
|
||||||
const int numPeaks = 200;
|
const int numPeaks = 500;
|
||||||
std::vector<float> peaks(numPeaks, 0.0f);
|
std::vector<float> peaks(numPeaks, 0.0f);
|
||||||
size_t samplesPerPeak = samples.size() / numPeaks;
|
size_t samplesPerPeak = samples.size() / numPeaks;
|
||||||
if (samplesPerPeak == 0) samplesPerPeak = 1;
|
if (samplesPerPeak == 0) samplesPerPeak = 1;
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@ std::mutex PyramidWebSocketController::connectionsMutex_;
|
||||||
// ==================== Validation Helpers ====================
|
// ==================== Validation Helpers ====================
|
||||||
|
|
||||||
bool PyramidController::isValidPixelPosition(int faceId, int x, int y) {
|
bool PyramidController::isValidPixelPosition(int faceId, int x, int y) {
|
||||||
|
constexpr int FACE_SIZE = 200;
|
||||||
|
|
||||||
// Face ID must be 0-4
|
// Face ID must be 0-4
|
||||||
if (faceId < 0 || faceId > 4) return false;
|
if (faceId < 0 || faceId > 4) return false;
|
||||||
|
|
||||||
|
|
@ -673,7 +675,25 @@ void PyramidWebSocketController::handlePlacePixel(const WebSocketConnectionPtr &
|
||||||
int y = msg["y"].asInt();
|
int y = msg["y"].asInt();
|
||||||
std::string color = msg["color"].asString();
|
std::string color = msg["color"].asString();
|
||||||
|
|
||||||
|
// Validate pixel position
|
||||||
|
if (!PyramidController::isValidPixelPosition(faceId, x, y)) {
|
||||||
|
Json::Value error;
|
||||||
|
error["type"] = "error";
|
||||||
|
error["message"] = "Invalid pixel position";
|
||||||
|
Json::StreamWriterBuilder builder;
|
||||||
|
wsConnPtr->send(Json::writeString(builder, error));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Validate and uppercase color
|
// Validate and uppercase color
|
||||||
|
if (!PyramidController::isValidColor(color)) {
|
||||||
|
Json::Value error;
|
||||||
|
error["type"] = "error";
|
||||||
|
error["message"] = "Invalid color format (use #RRGGBB)";
|
||||||
|
Json::StreamWriterBuilder builder;
|
||||||
|
wsConnPtr->send(Json::writeString(builder, error));
|
||||||
|
return;
|
||||||
|
}
|
||||||
std::transform(color.begin(), color.end(), color.begin(), ::toupper);
|
std::transform(color.begin(), color.end(), color.begin(), ::toupper);
|
||||||
|
|
||||||
// Use the REST endpoint logic for actual placement
|
// Use the REST endpoint logic for actual placement
|
||||||
|
|
|
||||||
|
|
@ -56,12 +56,13 @@ public:
|
||||||
const std::string &x,
|
const std::string &x,
|
||||||
const std::string &y);
|
const std::string &y);
|
||||||
|
|
||||||
|
// Static validation methods (also used by WebSocket controller)
|
||||||
|
static bool isValidPixelPosition(int faceId, int x, int y);
|
||||||
|
static bool isValidColor(const std::string &color);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
static constexpr int DAILY_PIXEL_LIMIT = 1000;
|
static constexpr int DAILY_PIXEL_LIMIT = 1000;
|
||||||
static constexpr int FACE_SIZE = 200;
|
static constexpr int FACE_SIZE = 200;
|
||||||
|
|
||||||
bool isValidPixelPosition(int faceId, int x, int y);
|
|
||||||
bool isValidColor(const std::string &color);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// WebSocket controller for real-time pixel updates
|
// WebSocket controller for real-time pixel updates
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,12 @@ std::string RestreamService::getBaseUrl() {
|
||||||
return "http://ovenmediaengine:8081";
|
return "http://ovenmediaengine:8081";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Use openresty proxy for push operations to avoid URL encoding issues
|
||||||
|
// Drogon encodes ':' as '%3A' but OME expects literal colon in path
|
||||||
|
std::string RestreamService::getPushProxyUrl() {
|
||||||
|
return "http://openresty:80";
|
||||||
|
}
|
||||||
|
|
||||||
std::string RestreamService::getApiToken() {
|
std::string RestreamService::getApiToken() {
|
||||||
const char* envToken = std::getenv("OME_API_TOKEN");
|
const char* envToken = std::getenv("OME_API_TOKEN");
|
||||||
if (!envToken || strlen(envToken) == 0) {
|
if (!envToken || strlen(envToken) == 0) {
|
||||||
|
|
@ -28,6 +34,10 @@ HttpClientPtr RestreamService::getClient() {
|
||||||
return HttpClient::newHttpClient(getBaseUrl());
|
return HttpClient::newHttpClient(getBaseUrl());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
HttpClientPtr RestreamService::getPushProxyClient() {
|
||||||
|
return HttpClient::newHttpClient(getPushProxyUrl());
|
||||||
|
}
|
||||||
|
|
||||||
HttpRequestPtr RestreamService::createRequest(HttpMethod method, const std::string& path) {
|
HttpRequestPtr RestreamService::createRequest(HttpMethod method, const std::string& path) {
|
||||||
auto request = HttpRequest::newHttpRequest();
|
auto request = HttpRequest::newHttpRequest();
|
||||||
request->setMethod(method);
|
request->setMethod(method);
|
||||||
|
|
@ -106,12 +116,13 @@ void RestreamService::startPush(const std::string& sourceStreamKey, const Restre
|
||||||
body["protocol"] = "rtmp";
|
body["protocol"] = "rtmp";
|
||||||
body["url"] = fullUrl;
|
body["url"] = fullUrl;
|
||||||
|
|
||||||
// Use Drogon HttpClient instead of curl for security
|
// Use openresty proxy to avoid URL encoding issues with colon in path
|
||||||
auto request = createJsonRequest(drogon::Post, "/v1/vhosts/default/apps/app:startPush", body);
|
// Drogon encodes ':' as '%3A' but OME expects literal colon
|
||||||
|
auto request = createJsonRequest(drogon::Post, "/ome-internal/push/start", body);
|
||||||
|
|
||||||
LOG_INFO << "Sending HTTP request for push start";
|
LOG_INFO << "Sending HTTP request for push start via proxy";
|
||||||
|
|
||||||
getClient()->sendRequest(request,
|
getPushProxyClient()->sendRequest(request,
|
||||||
[this, callback, pushId, sourceStreamKey, destId](ReqResult result, const HttpResponsePtr& response) {
|
[this, callback, pushId, sourceStreamKey, destId](ReqResult result, const HttpResponsePtr& response) {
|
||||||
if (result != ReqResult::Ok || !response) {
|
if (result != ReqResult::Ok || !response) {
|
||||||
std::string error = "Failed to connect to OME API";
|
std::string error = "Failed to connect to OME API";
|
||||||
|
|
@ -187,12 +198,12 @@ void RestreamService::stopPush(const std::string& sourceStreamKey, int64_t desti
|
||||||
Json::Value body;
|
Json::Value body;
|
||||||
body["id"] = pushId;
|
body["id"] = pushId;
|
||||||
|
|
||||||
// Use Drogon HttpClient instead of curl for security
|
// Use openresty proxy to avoid URL encoding issues with colon in path
|
||||||
auto request = createJsonRequest(drogon::Post, "/v1/vhosts/default/apps/app:stopPush", body);
|
auto request = createJsonRequest(drogon::Post, "/ome-internal/push/stop", body);
|
||||||
|
|
||||||
LOG_INFO << "Sending HTTP request for push stop";
|
LOG_INFO << "Sending HTTP request for push stop via proxy";
|
||||||
|
|
||||||
getClient()->sendRequest(request,
|
getPushProxyClient()->sendRequest(request,
|
||||||
[this, callback, pushId, sourceStreamKey, destinationId](ReqResult result, const HttpResponsePtr& response) {
|
[this, callback, pushId, sourceStreamKey, destinationId](ReqResult result, const HttpResponsePtr& response) {
|
||||||
// Remove from tracking regardless of result
|
// Remove from tracking regardless of result
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -57,8 +57,10 @@ private:
|
||||||
RestreamService& operator=(const RestreamService&) = delete;
|
RestreamService& operator=(const RestreamService&) = delete;
|
||||||
|
|
||||||
std::string getBaseUrl();
|
std::string getBaseUrl();
|
||||||
|
std::string getPushProxyUrl(); // Openresty proxy for push API (avoids URL encoding issues)
|
||||||
std::string getApiToken();
|
std::string getApiToken();
|
||||||
drogon::HttpClientPtr getClient();
|
drogon::HttpClientPtr getClient();
|
||||||
|
drogon::HttpClientPtr getPushProxyClient(); // Client for push proxy
|
||||||
drogon::HttpRequestPtr createRequest(drogon::HttpMethod method, const std::string& path);
|
drogon::HttpRequestPtr createRequest(drogon::HttpMethod method, const std::string& path);
|
||||||
drogon::HttpRequestPtr createJsonRequest(drogon::HttpMethod method, const std::string& path,
|
drogon::HttpRequestPtr createJsonRequest(drogon::HttpMethod method, const std::string& path,
|
||||||
const Json::Value& body);
|
const Json::Value& body);
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,7 @@ services:
|
||||||
environment:
|
environment:
|
||||||
OME_API_PORT: 8081
|
OME_API_PORT: 8081
|
||||||
OME_API_ACCESS_TOKEN: ${OME_API_TOKEN}
|
OME_API_ACCESS_TOKEN: ${OME_API_TOKEN}
|
||||||
|
OME_ICE_CANDIDATES: ${OME_ICE_CANDIDATES:-*}
|
||||||
networks:
|
networks:
|
||||||
- backend
|
- backend
|
||||||
- frontend
|
- frontend
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ services:
|
||||||
environment:
|
environment:
|
||||||
OME_API_PORT: 8081
|
OME_API_PORT: 8081
|
||||||
OME_API_ACCESS_TOKEN: ${OME_API_TOKEN}
|
OME_API_ACCESS_TOKEN: ${OME_API_TOKEN}
|
||||||
|
OME_ICE_CANDIDATES: ${OME_ICE_CANDIDATES:-*}
|
||||||
networks:
|
networks:
|
||||||
- backend
|
- backend
|
||||||
- frontend
|
- frontend
|
||||||
|
|
|
||||||
|
|
@ -172,14 +172,33 @@
|
||||||
debug: false,
|
debug: false,
|
||||||
enableWorker: true,
|
enableWorker: true,
|
||||||
lowLatencyMode: true,
|
lowLatencyMode: true,
|
||||||
backBufferLength: 90,
|
|
||||||
// Increased retry settings for LLHLS resilience
|
// Buffer Management - optimized for low latency
|
||||||
fragLoadingMaxRetry: 6,
|
maxBufferLength: 4,
|
||||||
fragLoadingRetryDelay: 1000,
|
maxMaxBufferLength: 6,
|
||||||
manifestLoadingMaxRetry: 4,
|
backBufferLength: 10,
|
||||||
levelLoadingMaxRetry: 4,
|
|
||||||
maxBufferLength: 30,
|
// Live Edge Sync - key for reducing latency
|
||||||
maxBufferHole: 0.5,
|
liveSyncDurationCount: 3,
|
||||||
|
liveMaxLatencyDurationCount: 5,
|
||||||
|
liveSyncDuration: 3,
|
||||||
|
liveMaxLatencyDuration: 6,
|
||||||
|
maxLiveSyncPlaybackRate: 1.5,
|
||||||
|
|
||||||
|
// Faster Recovery - reduced retry delays
|
||||||
|
fragLoadingMaxRetry: 4,
|
||||||
|
fragLoadingRetryDelay: 200,
|
||||||
|
fragLoadingMaxRetryTimeout: 4000,
|
||||||
|
manifestLoadingMaxRetry: 3,
|
||||||
|
manifestLoadingRetryDelay: 200,
|
||||||
|
levelLoadingMaxRetry: 3,
|
||||||
|
levelLoadingRetryDelay: 200,
|
||||||
|
|
||||||
|
// Startup optimization
|
||||||
|
startFragPrefetch: true,
|
||||||
|
testBandwidth: false,
|
||||||
|
maxBufferHole: 0.1,
|
||||||
|
|
||||||
xhrSetup: function(xhr, url) {
|
xhrSetup: function(xhr, url) {
|
||||||
let finalUrl = url;
|
let finalUrl = url;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -161,14 +161,33 @@
|
||||||
debug: false,
|
debug: false,
|
||||||
enableWorker: true,
|
enableWorker: true,
|
||||||
lowLatencyMode: true,
|
lowLatencyMode: true,
|
||||||
backBufferLength: 30,
|
|
||||||
// Increased retry settings for LLHLS resilience
|
// Buffer Management - optimized for low latency
|
||||||
fragLoadingMaxRetry: 6,
|
maxBufferLength: 4,
|
||||||
fragLoadingRetryDelay: 1000,
|
maxMaxBufferLength: 6,
|
||||||
manifestLoadingMaxRetry: 4,
|
backBufferLength: 10,
|
||||||
levelLoadingMaxRetry: 4,
|
|
||||||
maxBufferLength: 30,
|
// Live Edge Sync - key for reducing latency
|
||||||
maxBufferHole: 0.5,
|
liveSyncDurationCount: 3,
|
||||||
|
liveMaxLatencyDurationCount: 5,
|
||||||
|
liveSyncDuration: 3,
|
||||||
|
liveMaxLatencyDuration: 6,
|
||||||
|
maxLiveSyncPlaybackRate: 1.5,
|
||||||
|
|
||||||
|
// Faster Recovery - reduced retry delays
|
||||||
|
fragLoadingMaxRetry: 4,
|
||||||
|
fragLoadingRetryDelay: 200,
|
||||||
|
fragLoadingMaxRetryTimeout: 4000,
|
||||||
|
manifestLoadingMaxRetry: 3,
|
||||||
|
manifestLoadingRetryDelay: 200,
|
||||||
|
levelLoadingMaxRetry: 3,
|
||||||
|
levelLoadingRetryDelay: 200,
|
||||||
|
|
||||||
|
// Startup optimization
|
||||||
|
startFragPrefetch: true,
|
||||||
|
testBandwidth: false,
|
||||||
|
maxBufferHole: 0.1,
|
||||||
|
|
||||||
xhrSetup: function(xhr, url) {
|
xhrSetup: function(xhr, url) {
|
||||||
let finalUrl = url;
|
let finalUrl = url;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,9 @@
|
||||||
/** @type {string} Waveform color */
|
/** @type {string} Waveform color */
|
||||||
export let color = '#ec4899';
|
export let color = '#ec4899';
|
||||||
|
|
||||||
|
/** @type {string} Color for the played portion */
|
||||||
|
export let playedColor = '#f472b6';
|
||||||
|
|
||||||
/** @type {number} Opacity of the waveform (0-1) */
|
/** @type {number} Opacity of the waveform (0-1) */
|
||||||
export let opacity = 0.15;
|
export let opacity = 0.15;
|
||||||
|
|
||||||
|
|
@ -32,9 +35,8 @@
|
||||||
let peaks = [];
|
let peaks = [];
|
||||||
let loading = true;
|
let loading = true;
|
||||||
|
|
||||||
// Calculate scroll position based on playback
|
// Calculate progress percentage (0-100)
|
||||||
$: progress = duration > 0 ? currentTime / duration : 0;
|
$: progress = duration > 0 ? (currentTime / duration) * 100 : 0;
|
||||||
$: translateX = isCurrentTrack && isPlaying ? -(progress * 50) : 0;
|
|
||||||
|
|
||||||
async function fetchWaveform() {
|
async function fetchWaveform() {
|
||||||
if (!audioId && !waveformPath) {
|
if (!audioId && !waveformPath) {
|
||||||
|
|
@ -91,34 +93,52 @@
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
class="waveform-svg"
|
class="waveform-svg"
|
||||||
viewBox="0 0 400 100"
|
viewBox="0 0 1000 100"
|
||||||
preserveAspectRatio="none"
|
preserveAspectRatio="none"
|
||||||
style="transform: translateX({translateX}%);"
|
|
||||||
>
|
>
|
||||||
<!-- Mirror waveform: bars extend up and down from center -->
|
<!-- Mirror waveform: bars extend up and down from center -->
|
||||||
{#each peaks as peak, i}
|
{#each peaks as peak, i}
|
||||||
{@const barWidth = 400 / peaks.length}
|
{@const barWidth = 1000 / peaks.length}
|
||||||
{@const barHeight = peak * 45}
|
{@const barHeight = peak * 45}
|
||||||
{@const x = i * barWidth}
|
{@const x = i * barWidth}
|
||||||
|
{@const barProgress = (x / 1000) * 100}
|
||||||
|
{@const isPlayed = isCurrentTrack && barProgress < progress}
|
||||||
|
{@const barColor = isPlayed ? playedColor : color}
|
||||||
|
{@const barOpacity = isPlayed ? 0.6 : 1}
|
||||||
<!-- Top bar (from center going up) -->
|
<!-- Top bar (from center going up) -->
|
||||||
<rect
|
<rect
|
||||||
x={x}
|
x={x}
|
||||||
y={50 - barHeight}
|
y={50 - barHeight}
|
||||||
width={barWidth * 0.8}
|
width={barWidth * 0.7}
|
||||||
height={barHeight}
|
height={barHeight}
|
||||||
fill={color}
|
fill={barColor}
|
||||||
rx="1"
|
opacity={barOpacity}
|
||||||
|
rx="0.5"
|
||||||
/>
|
/>
|
||||||
<!-- Bottom bar (from center going down) -->
|
<!-- Bottom bar (from center going down) -->
|
||||||
<rect
|
<rect
|
||||||
x={x}
|
x={x}
|
||||||
y={50}
|
y={50}
|
||||||
width={barWidth * 0.8}
|
width={barWidth * 0.7}
|
||||||
height={barHeight}
|
height={barHeight}
|
||||||
fill={color}
|
fill={barColor}
|
||||||
rx="1"
|
opacity={barOpacity}
|
||||||
|
rx="0.5"
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
|
<!-- Playhead line -->
|
||||||
|
{#if isCurrentTrack && progress > 0}
|
||||||
|
<line
|
||||||
|
x1={progress * 10}
|
||||||
|
y1="5"
|
||||||
|
x2={progress * 10}
|
||||||
|
y2="95"
|
||||||
|
stroke={playedColor}
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -133,8 +153,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.waveform-svg {
|
.waveform-svg {
|
||||||
width: 200%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
transition: transform 0.1s linear;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@
|
||||||
let showCalendar = false;
|
let showCalendar = false;
|
||||||
let calendarDate = new Date();
|
let calendarDate = new Date();
|
||||||
let timeInterval;
|
let timeInterval;
|
||||||
|
let calendarContainerEl;
|
||||||
|
|
||||||
// Tab navigation - includes audio, ebooks, games, and treasury
|
// Tab navigation - includes audio, ebooks, games, and treasury
|
||||||
let activeTab = 'terminal';
|
let activeTab = 'terminal';
|
||||||
|
|
@ -308,7 +309,11 @@
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window on:click={() => showCalendar = false} />
|
<svelte:window on:click={(e) => {
|
||||||
|
if (calendarContainerEl && !calendarContainerEl.contains(e.target)) {
|
||||||
|
showCalendar = false;
|
||||||
|
}
|
||||||
|
}} />
|
||||||
|
|
||||||
{#if isOpen && $isAuthenticated}
|
{#if isOpen && $isAuthenticated}
|
||||||
<div
|
<div
|
||||||
|
|
@ -325,8 +330,8 @@
|
||||||
<div class="terminal-header" on:mousedown={!isDocked ? startDrag : null}>
|
<div class="terminal-header" on:mousedown={!isDocked ? startDrag : null}>
|
||||||
<TerminalTabBar {tabs} {activeTab} on:tabChange={handleTabChange} />
|
<TerminalTabBar {tabs} {activeTab} on:tabChange={handleTabChange} />
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
<div class="datetime-container">
|
<div class="datetime-container" bind:this={calendarContainerEl}>
|
||||||
<button class="datetime-button" on:click|stopPropagation={toggleCalendar} title="Show calendar">
|
<button class="datetime-button" on:click={toggleCalendar} title="Show calendar">
|
||||||
<span class="datetime-date">{formatDate(currentTime)}</span>
|
<span class="datetime-date">{formatDate(currentTime)}</span>
|
||||||
<span class="datetime-time">{formatTime(currentTime)}</span>
|
<span class="datetime-time">{formatTime(currentTime)}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,24 @@
|
||||||
let hoveredFace = null;
|
let hoveredFace = null;
|
||||||
let hoveredPixel = null;
|
let hoveredPixel = null;
|
||||||
|
|
||||||
|
// Validate pixel position (matches backend validation)
|
||||||
|
function isValidPixelPosition(faceId, x, y) {
|
||||||
|
// X and Y must be within face bounds
|
||||||
|
if (x < 0 || x >= FACE_SIZE || y < 0 || y >= FACE_SIZE) return false;
|
||||||
|
|
||||||
|
// For triangular faces (1-4), validate within triangle bounds
|
||||||
|
if (faceId > 0) {
|
||||||
|
// Triangle with base at bottom (y=199), apex at top (y=0)
|
||||||
|
const halfWidth = Math.floor((y + 1) / 2);
|
||||||
|
const center = Math.floor(FACE_SIZE / 2);
|
||||||
|
const minX = center - halfWidth;
|
||||||
|
const maxX = center + halfWidth;
|
||||||
|
if (x < minX || x > maxX) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// Unsubscribe function for store
|
// Unsubscribe function for store
|
||||||
let unsubscribePyramid;
|
let unsubscribePyramid;
|
||||||
|
|
||||||
|
|
@ -274,11 +292,18 @@
|
||||||
const x = Math.floor(uv.x * FACE_SIZE);
|
const x = Math.floor(uv.x * FACE_SIZE);
|
||||||
const y = Math.floor((1 - uv.y) * FACE_SIZE);
|
const y = Math.floor((1 - uv.y) * FACE_SIZE);
|
||||||
|
|
||||||
hoveredFace = faceId;
|
// Only set hover if position is valid (especially for triangular faces)
|
||||||
hoveredPixel = { faceId, x, y };
|
if (isValidPixelPosition(faceId, x, y)) {
|
||||||
|
hoveredFace = faceId;
|
||||||
|
hoveredPixel = { faceId, x, y };
|
||||||
|
|
||||||
dispatch('hover', { faceId, x, y });
|
dispatch('hover', { faceId, x, y });
|
||||||
pyramidStore.setHoveredPixel({ faceId, x, y });
|
pyramidStore.setHoveredPixel({ faceId, x, y });
|
||||||
|
} else {
|
||||||
|
hoveredFace = null;
|
||||||
|
hoveredPixel = null;
|
||||||
|
pyramidStore.setHoveredPixel(null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
hoveredFace = null;
|
hoveredFace = null;
|
||||||
|
|
|
||||||
|
|
@ -426,20 +426,42 @@
|
||||||
sources: sources,
|
sources: sources,
|
||||||
webrtcConfig: {
|
webrtcConfig: {
|
||||||
timeoutMaxRetry: 4,
|
timeoutMaxRetry: 4,
|
||||||
connectionTimeout: 10000
|
connectionTimeout: 10000,
|
||||||
|
iceServers: [
|
||||||
|
{ urls: 'stun:stun.l.google.com:19302' }
|
||||||
|
]
|
||||||
},
|
},
|
||||||
hlsConfig: {
|
hlsConfig: {
|
||||||
debug: false,
|
debug: false,
|
||||||
enableWorker: true,
|
enableWorker: true,
|
||||||
lowLatencyMode: true,
|
lowLatencyMode: true,
|
||||||
backBufferLength: 90,
|
|
||||||
// Increased retry settings for LLHLS resilience
|
// Buffer Management - optimized for low latency
|
||||||
fragLoadingMaxRetry: 6,
|
maxBufferLength: 4,
|
||||||
fragLoadingRetryDelay: 1000,
|
maxMaxBufferLength: 6,
|
||||||
manifestLoadingMaxRetry: 4,
|
backBufferLength: 10,
|
||||||
levelLoadingMaxRetry: 4,
|
|
||||||
maxBufferLength: 30,
|
// Live Edge Sync - key for reducing latency
|
||||||
maxBufferHole: 0.5,
|
liveSyncDurationCount: 3,
|
||||||
|
liveMaxLatencyDurationCount: 5,
|
||||||
|
liveSyncDuration: 3,
|
||||||
|
liveMaxLatencyDuration: 6,
|
||||||
|
maxLiveSyncPlaybackRate: 1.5,
|
||||||
|
|
||||||
|
// Faster Recovery - reduced retry delays
|
||||||
|
fragLoadingMaxRetry: 4,
|
||||||
|
fragLoadingRetryDelay: 200,
|
||||||
|
fragLoadingMaxRetryTimeout: 4000,
|
||||||
|
manifestLoadingMaxRetry: 3,
|
||||||
|
manifestLoadingRetryDelay: 200,
|
||||||
|
levelLoadingMaxRetry: 3,
|
||||||
|
levelLoadingRetryDelay: 200,
|
||||||
|
|
||||||
|
// Startup optimization
|
||||||
|
startFragPrefetch: true,
|
||||||
|
testBandwidth: false,
|
||||||
|
maxBufferHole: 0.1,
|
||||||
|
|
||||||
xhrSetup: function(xhr, url) {
|
xhrSetup: function(xhr, url) {
|
||||||
let finalUrl = url;
|
let finalUrl = url;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -39,13 +39,10 @@
|
||||||
return date.toLocaleDateString();
|
return date.toLocaleDateString();
|
||||||
}
|
}
|
||||||
|
|
||||||
function isInPlaylist(audioId) {
|
// Reactive sets for tracking playlist state - these update when stores change
|
||||||
return $audioPlaylist.queue.some(t => t.id === audioId);
|
$: playlistIds = new Set($audioPlaylist.queue.map(t => t.id));
|
||||||
}
|
$: currentPlayingId = $audioPlaylist.isPlaying ? $currentTrack?.id : null;
|
||||||
|
$: currentTrackId = $currentTrack?.id;
|
||||||
function isCurrentlyPlaying(audioId) {
|
|
||||||
return $currentTrack?.id === audioId && $audioPlaylist.isPlaying;
|
|
||||||
}
|
|
||||||
|
|
||||||
function handlePlayClick(audio) {
|
function handlePlayClick(audio) {
|
||||||
if ($currentTrack?.id === audio.id) {
|
if ($currentTrack?.id === audio.id) {
|
||||||
|
|
@ -91,7 +88,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function togglePlaylist(audio) {
|
function togglePlaylist(audio) {
|
||||||
if (isInPlaylist(audio.id)) {
|
if (playlistIds.has(audio.id)) {
|
||||||
audioPlaylist.removeTrack(audio.id);
|
audioPlaylist.removeTrack(audio.id);
|
||||||
} else {
|
} else {
|
||||||
audioPlaylist.addTrack({
|
audioPlaylist.addTrack({
|
||||||
|
|
@ -135,7 +132,7 @@
|
||||||
|
|
||||||
function addAllToPlaylist() {
|
function addAllToPlaylist() {
|
||||||
audioFiles.forEach(audio => {
|
audioFiles.forEach(audio => {
|
||||||
if (!isInPlaylist(audio.id)) {
|
if (!playlistIds.has(audio.id)) {
|
||||||
addToPlaylist(audio);
|
addToPlaylist(audio);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -521,7 +518,7 @@
|
||||||
isPlaying={$audioPlaylist.isPlaying}
|
isPlaying={$audioPlaylist.isPlaying}
|
||||||
currentTime={$audioPlaylist.currentTime}
|
currentTime={$audioPlaylist.currentTime}
|
||||||
duration={$audioPlaylist.duration}
|
duration={$audioPlaylist.duration}
|
||||||
isCurrentTrack={$currentTrack?.id === audio.id}
|
isCurrentTrack={currentTrackId === audio.id}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
<span class="audio-number">{index + 1}</span>
|
<span class="audio-number">{index + 1}</span>
|
||||||
|
|
@ -546,19 +543,19 @@
|
||||||
<div class="audio-actions">
|
<div class="audio-actions">
|
||||||
<button
|
<button
|
||||||
class="action-btn play"
|
class="action-btn play"
|
||||||
class:playing={isCurrentlyPlaying(audio.id)}
|
class:playing={currentPlayingId === audio.id}
|
||||||
on:click={() => handlePlayClick(audio)}
|
on:click={() => handlePlayClick(audio)}
|
||||||
title={isCurrentlyPlaying(audio.id) ? 'Pause' : 'Play now'}
|
title={currentPlayingId === audio.id ? 'Pause' : 'Play now'}
|
||||||
>
|
>
|
||||||
{isCurrentlyPlaying(audio.id) ? '▮▮' : '▶'}
|
{currentPlayingId === audio.id ? '▮▮' : '▶'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="action-btn"
|
class="action-btn"
|
||||||
class:added={isInPlaylist(audio.id)}
|
class:added={playlistIds.has(audio.id)}
|
||||||
on:click={() => togglePlaylist(audio)}
|
on:click={() => togglePlaylist(audio)}
|
||||||
title={isInPlaylist(audio.id) ? 'Remove from playlist' : 'Add to playlist'}
|
title={playlistIds.has(audio.id) ? 'Remove from playlist' : 'Add to playlist'}
|
||||||
>
|
>
|
||||||
{isInPlaylist(audio.id) ? '✓' : '+'}
|
{playlistIds.has(audio.id) ? '✓' : '+'}
|
||||||
</button>
|
</button>
|
||||||
{#if $isAuthenticated}
|
{#if $isAuthenticated}
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@
|
||||||
let showCalendar = false;
|
let showCalendar = false;
|
||||||
let calendarDate = new Date();
|
let calendarDate = new Date();
|
||||||
let timeInterval;
|
let timeInterval;
|
||||||
|
let calendarContainerEl;
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ id: 'terminal', label: 'Terminal' },
|
{ id: 'terminal', label: 'Terminal' },
|
||||||
|
|
@ -196,7 +197,11 @@
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window on:click={() => showCalendar = false} />
|
<svelte:window on:click={(e) => {
|
||||||
|
if (calendarContainerEl && !calendarContainerEl.contains(e.target)) {
|
||||||
|
showCalendar = false;
|
||||||
|
}
|
||||||
|
}} />
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>{$siteSettings.site_title} - Terminal</title>
|
<title>{$siteSettings.site_title} - Terminal</title>
|
||||||
|
|
@ -219,8 +224,8 @@
|
||||||
<div class="popout-header">
|
<div class="popout-header">
|
||||||
<TerminalTabBar {tabs} {activeTab} on:tabChange={handleTabChange} />
|
<TerminalTabBar {tabs} {activeTab} on:tabChange={handleTabChange} />
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
<div class="datetime-container">
|
<div class="datetime-container" bind:this={calendarContainerEl}>
|
||||||
<button class="datetime-button" on:click|stopPropagation={toggleCalendar} title="Show calendar">
|
<button class="datetime-button" on:click={toggleCalendar} title="Show calendar">
|
||||||
<span class="datetime-date">{formatDate(currentTime)}</span>
|
<span class="datetime-date">{formatDate(currentTime)}</span>
|
||||||
<span class="datetime-time">{formatTime(currentTime)}</span>
|
<span class="datetime-time">{formatTime(currentTime)}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,10 @@ http {
|
||||||
server ovenmediaengine:8080;
|
server ovenmediaengine:8080;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
upstream ome_api {
|
||||||
|
server ovenmediaengine:8081;
|
||||||
|
}
|
||||||
|
|
||||||
# Rate limiting zones
|
# Rate limiting zones
|
||||||
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;
|
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;
|
||||||
limit_req_zone $binary_remote_addr zone=auth_limit:10m rate=3r/m;
|
limit_req_zone $binary_remote_addr zone=auth_limit:10m rate=3r/m;
|
||||||
|
|
@ -735,6 +739,34 @@ http {
|
||||||
return 403;
|
return 403;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# OME Push API proxy (internal network only - fixes URL encoding of colon in path)
|
||||||
|
# Backend calls this instead of OME directly to avoid %3A encoding issue
|
||||||
|
location = /ome-internal/push/start {
|
||||||
|
# Only allow requests from Docker internal network
|
||||||
|
allow 172.16.0.0/12;
|
||||||
|
allow 10.0.0.0/8;
|
||||||
|
allow 192.168.0.0/16;
|
||||||
|
deny all;
|
||||||
|
|
||||||
|
proxy_pass http://ome_api/v1/vhosts/default/apps/app:startPush;
|
||||||
|
proxy_set_header Host ovenmediaengine:8081;
|
||||||
|
proxy_set_header Content-Type application/json;
|
||||||
|
proxy_pass_request_headers on;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /ome-internal/push/stop {
|
||||||
|
# Only allow requests from Docker internal network
|
||||||
|
allow 172.16.0.0/12;
|
||||||
|
allow 10.0.0.0/8;
|
||||||
|
allow 192.168.0.0/16;
|
||||||
|
deny all;
|
||||||
|
|
||||||
|
proxy_pass http://ome_api/v1/vhosts/default/apps/app:stopPush;
|
||||||
|
proxy_set_header Host ovenmediaengine:8081;
|
||||||
|
proxy_set_header Content-Type application/json;
|
||||||
|
proxy_pass_request_headers on;
|
||||||
|
}
|
||||||
|
|
||||||
# Public stickers endpoint - no authentication required (guests need stickers for chat)
|
# Public stickers endpoint - no authentication required (guests need stickers for chat)
|
||||||
location = /api/admin/stickers {
|
location = /api/admin/stickers {
|
||||||
# CORS headers
|
# CORS headers
|
||||||
|
|
@ -925,6 +957,23 @@ http {
|
||||||
# WebRTC Signaling proxy for OvenMediaEngine
|
# WebRTC Signaling proxy for OvenMediaEngine
|
||||||
# Handles wss:// → ws:// translation so OME doesn't need TLS certificates
|
# Handles wss:// → ws:// translation so OME doesn't need TLS certificates
|
||||||
location /webrtc/ {
|
location /webrtc/ {
|
||||||
|
# CORS headers for WebSocket upgrade
|
||||||
|
add_header Access-Control-Allow-Origin $cors_origin always;
|
||||||
|
add_header Access-Control-Allow-Methods "GET, POST, OPTIONS" always;
|
||||||
|
add_header Access-Control-Allow-Headers "Content-Type, Upgrade, Connection" always;
|
||||||
|
add_header Access-Control-Allow-Credentials "true" always;
|
||||||
|
|
||||||
|
# Handle preflight OPTIONS requests
|
||||||
|
if ($request_method = 'OPTIONS') {
|
||||||
|
add_header Access-Control-Allow-Origin $cors_origin always;
|
||||||
|
add_header Access-Control-Allow-Methods "GET, POST, OPTIONS" always;
|
||||||
|
add_header Access-Control-Allow-Headers "Content-Type, Upgrade, Connection" always;
|
||||||
|
add_header Access-Control-Allow-Credentials "true" always;
|
||||||
|
add_header Content-Length 0;
|
||||||
|
add_header Content-Type text/plain;
|
||||||
|
return 204;
|
||||||
|
}
|
||||||
|
|
||||||
# Proxy to OvenMediaEngine WebRTC signaling port
|
# Proxy to OvenMediaEngine WebRTC signaling port
|
||||||
proxy_pass http://ovenmediaengine:3333/;
|
proxy_pass http://ovenmediaengine:3333/;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
|
|
@ -937,6 +986,7 @@ http {
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header Origin $http_origin;
|
||||||
|
|
||||||
# WebRTC signaling needs long timeouts
|
# WebRTC signaling needs long timeouts
|
||||||
proxy_read_timeout 3600s;
|
proxy_read_timeout 3600s;
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,8 @@
|
||||||
<Port>3333</Port>
|
<Port>3333</Port>
|
||||||
</Signalling>
|
</Signalling>
|
||||||
<IceCandidates>
|
<IceCandidates>
|
||||||
<IceCandidate>*:10000-10009/udp</IceCandidate>
|
<!-- Use public IP/domain for NAT traversal -->
|
||||||
|
<IceCandidate>${env:OME_ICE_CANDIDATES:*}:10000-10009/udp</IceCandidate>
|
||||||
</IceCandidates>
|
</IceCandidates>
|
||||||
</WebRTC>
|
</WebRTC>
|
||||||
<Thumbnail>
|
<Thumbnail>
|
||||||
|
|
@ -100,9 +101,11 @@
|
||||||
|
|
||||||
<Publishers>
|
<Publishers>
|
||||||
<LLHLS>
|
<LLHLS>
|
||||||
<ChunkDuration>0.5</ChunkDuration>
|
<!-- Optimized for low latency: 200ms chunks, 1s segments -->
|
||||||
<SegmentDuration>3</SegmentDuration>
|
<ChunkDuration>0.2</ChunkDuration>
|
||||||
<SegmentCount>10</SegmentCount>
|
<SegmentDuration>1</SegmentDuration>
|
||||||
|
<SegmentCount>5</SegmentCount>
|
||||||
|
<PartHoldBack>0.6</PartHoldBack>
|
||||||
<CrossDomains>
|
<CrossDomains>
|
||||||
<Url>http://localhost</Url>
|
<Url>http://localhost</Url>
|
||||||
</CrossDomains>
|
</CrossDomains>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue