This commit is contained in:
parent
9e985d05f1
commit
33c20bf59d
11 changed files with 107 additions and 45 deletions
|
|
@ -211,8 +211,10 @@ void StreamController::issueViewerToken(const HttpRequestPtr &,
|
||||||
|
|
||||||
void StreamController::heartbeat(const HttpRequestPtr &req,
|
void StreamController::heartbeat(const HttpRequestPtr &req,
|
||||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||||
|
const std::string &realmId,
|
||||||
const std::string &streamKey) {
|
const std::string &streamKey) {
|
||||||
auto token = req->getCookie("viewer_token");
|
// Use realm-specific cookie to support multi-stream viewing
|
||||||
|
auto token = req->getCookie("viewer_token_" + realmId);
|
||||||
if (token.empty()) {
|
if (token.empty()) {
|
||||||
callback(jsonResp({}, k403Forbidden));
|
callback(jsonResp({}, k403Forbidden));
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ public:
|
||||||
ADD_METHOD_TO(StreamController::getStreamStats, "/api/stream/stats/{1}", Get);
|
ADD_METHOD_TO(StreamController::getStreamStats, "/api/stream/stats/{1}", Get);
|
||||||
ADD_METHOD_TO(StreamController::getActiveStreams, "/api/stream/active", Get);
|
ADD_METHOD_TO(StreamController::getActiveStreams, "/api/stream/active", Get);
|
||||||
ADD_METHOD_TO(StreamController::issueViewerToken, "/api/stream/token/{1}", Get);
|
ADD_METHOD_TO(StreamController::issueViewerToken, "/api/stream/token/{1}", Get);
|
||||||
ADD_METHOD_TO(StreamController::heartbeat, "/api/stream/heartbeat/{1}", Post);
|
ADD_METHOD_TO(StreamController::heartbeat, "/api/stream/heartbeat/{1}/{2}", Post);
|
||||||
// OvenMediaEngine webhook endpoints
|
// OvenMediaEngine webhook endpoints
|
||||||
ADD_METHOD_TO(StreamController::handleOmeWebhook, "/api/webhook/ome", Post);
|
ADD_METHOD_TO(StreamController::handleOmeWebhook, "/api/webhook/ome", Post);
|
||||||
ADD_METHOD_TO(StreamController::handleOmeAdmission, "/api/webhook/ome/admission", Post);
|
ADD_METHOD_TO(StreamController::handleOmeAdmission, "/api/webhook/ome/admission", Post);
|
||||||
|
|
@ -47,6 +47,7 @@ public:
|
||||||
|
|
||||||
void heartbeat(const HttpRequestPtr &req,
|
void heartbeat(const HttpRequestPtr &req,
|
||||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||||
|
const std::string &realmId,
|
||||||
const std::string &streamKey);
|
const std::string &streamKey);
|
||||||
|
|
||||||
// OvenMediaEngine webhook handlers
|
// OvenMediaEngine webhook handlers
|
||||||
|
|
|
||||||
|
|
@ -188,17 +188,24 @@ void StatsService::updateStreamStats(const std::string& streamKey) {
|
||||||
|
|
||||||
// Only broadcast if stream has meaningful data or is live
|
// Only broadcast if stream has meaningful data or is live
|
||||||
if (updatedStats.isLive || updatedStats.totalBytesIn > 0 || updatedStats.uniqueViewers > 0) {
|
if (updatedStats.isLive || updatedStats.totalBytesIn > 0 || updatedStats.uniqueViewers > 0) {
|
||||||
// Fetch live_started_at for duration display
|
// Fetch live_started_at and viewer_multiplier for duration display and consistent counts
|
||||||
auto dbClient = app().getDbClient();
|
auto dbClient = app().getDbClient();
|
||||||
*dbClient << "SELECT live_started_at FROM realms WHERE stream_key = $1"
|
*dbClient << "SELECT live_started_at, viewer_multiplier FROM realms WHERE stream_key = $1"
|
||||||
<< streamKey
|
<< streamKey
|
||||||
>> [streamKey, updatedStats](const orm::Result& r) {
|
>> [streamKey, updatedStats](const orm::Result& r) {
|
||||||
Json::Value msg;
|
Json::Value msg;
|
||||||
msg["type"] = "stats_update";
|
msg["type"] = "stats_update";
|
||||||
msg["stream_key"] = streamKey;
|
msg["stream_key"] = streamKey;
|
||||||
|
|
||||||
|
// Apply viewer multiplier for consistent display across all sources
|
||||||
|
int multiplier = 1;
|
||||||
|
if (!r.empty() && !r[0]["viewer_multiplier"].isNull()) {
|
||||||
|
multiplier = r[0]["viewer_multiplier"].as<int>();
|
||||||
|
if (multiplier < 1) multiplier = 1;
|
||||||
|
}
|
||||||
|
|
||||||
auto& s = msg["stats"];
|
auto& s = msg["stats"];
|
||||||
JSON_INT(s, "connections", updatedStats.uniqueViewers);
|
JSON_INT(s, "connections", updatedStats.uniqueViewers * multiplier);
|
||||||
JSON_INT(s, "raw_connections", updatedStats.currentConnections);
|
JSON_INT(s, "raw_connections", updatedStats.currentConnections);
|
||||||
s["bitrate"] = updatedStats.bitrate;
|
s["bitrate"] = updatedStats.bitrate;
|
||||||
s["resolution"] = updatedStats.resolution;
|
s["resolution"] = updatedStats.resolution;
|
||||||
|
|
@ -213,12 +220,12 @@ void StatsService::updateStreamStats(const std::string& streamKey) {
|
||||||
s["live_started_at"] = r[0]["live_started_at"].as<std::string>();
|
s["live_started_at"] = r[0]["live_started_at"].as<std::string>();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Protocol breakdown
|
// Protocol breakdown (also apply multiplier for consistency)
|
||||||
auto& pc = s["protocol_connections"];
|
auto& pc = s["protocol_connections"];
|
||||||
JSON_INT(pc, "webrtc", updatedStats.protocolConnections.webrtc);
|
JSON_INT(pc, "webrtc", updatedStats.protocolConnections.webrtc * multiplier);
|
||||||
JSON_INT(pc, "hls", updatedStats.protocolConnections.hls);
|
JSON_INT(pc, "hls", updatedStats.protocolConnections.hls * multiplier);
|
||||||
JSON_INT(pc, "llhls", updatedStats.protocolConnections.llhls);
|
JSON_INT(pc, "llhls", updatedStats.protocolConnections.llhls * multiplier);
|
||||||
JSON_INT(pc, "dash", updatedStats.protocolConnections.dash);
|
JSON_INT(pc, "dash", updatedStats.protocolConnections.dash * multiplier);
|
||||||
|
|
||||||
StreamWebSocketController::broadcastStatsUpdate(msg);
|
StreamWebSocketController::broadcastStatsUpdate(msg);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@
|
||||||
let playerId = `player-${stream.realmId}-${Math.random().toString(36).substr(2, 9)}`;
|
let playerId = `player-${stream.realmId}-${Math.random().toString(36).substr(2, 9)}`;
|
||||||
let showControls = false;
|
let showControls = false;
|
||||||
let showVolumeSlider = false;
|
let showVolumeSlider = false;
|
||||||
|
let statsInterval = null;
|
||||||
|
|
||||||
// Get muted/volume from stream object with defaults
|
// Get muted/volume from stream object with defaults
|
||||||
$: muted = stream.muted !== undefined ? stream.muted : true;
|
$: muted = stream.muted !== undefined ? stream.muted : true;
|
||||||
|
|
@ -68,6 +69,7 @@
|
||||||
|
|
||||||
if (viewerToken && actualStreamKey) {
|
if (viewerToken && actualStreamKey) {
|
||||||
initializePlayer();
|
initializePlayer();
|
||||||
|
startStatsPolling();
|
||||||
} else {
|
} else {
|
||||||
error = 'Could not get stream access';
|
error = 'Could not get stream access';
|
||||||
}
|
}
|
||||||
|
|
@ -75,7 +77,31 @@
|
||||||
loading = false;
|
loading = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function startStatsPolling() {
|
||||||
|
// Poll stats every 5 seconds to update viewer count for aggregation
|
||||||
|
statsInterval = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/realms/${stream.realmId}/stats`);
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.success && data.stats) {
|
||||||
|
// Update the store with this stream's viewer count
|
||||||
|
streamTiles.setViewerCount(stream.realmId, data.stats.connections || 0);
|
||||||
|
isLive = data.stats.is_live || false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to fetch stats for tile:', stream.name, e);
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
|
if (statsInterval) {
|
||||||
|
clearInterval(statsInterval);
|
||||||
|
}
|
||||||
|
// Clear this stream's viewer count from the store
|
||||||
|
streamTiles.setViewerCount(stream.realmId, 0);
|
||||||
if (player) {
|
if (player) {
|
||||||
try {
|
try {
|
||||||
player.remove();
|
player.remove();
|
||||||
|
|
|
||||||
|
|
@ -232,21 +232,21 @@
|
||||||
calendarDate.getFullYear() === today.getFullYear();
|
calendarDate.getFullYear() === today.getFullYear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$: calendarDays = getCalendarDays(calendarDate);
|
||||||
|
$: calendarMonthYear = calendarDate.toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
|
||||||
|
$: calendarHolidays = getHolidaysForMonth(calendarDate.getFullYear(), calendarDate.getMonth());
|
||||||
|
|
||||||
function isHoliday(day) {
|
function isHoliday(day) {
|
||||||
if (!day) return false;
|
if (!day) return false;
|
||||||
return calendarHolidays.has(day);
|
return calendarHolidays?.has(day) ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getHolidayName(day) {
|
function getHolidayName(day) {
|
||||||
if (!day) return null;
|
if (!day) return null;
|
||||||
const info = calendarHolidays.get(day);
|
const info = calendarHolidays?.get(day);
|
||||||
return info ? info.name : null;
|
return info ? info.name : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$: calendarDays = getCalendarDays(calendarDate);
|
|
||||||
$: calendarMonthYear = calendarDate.toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
|
|
||||||
$: calendarHolidays = getHolidaysForMonth(calendarDate.getFullYear(), calendarDate.getMonth());
|
|
||||||
|
|
||||||
// Timezone definitions
|
// Timezone definitions
|
||||||
const timezones = [
|
const timezones = [
|
||||||
{ label: 'UTC', zone: 'UTC' },
|
{ label: 'UTC', zone: 'UTC' },
|
||||||
|
|
|
||||||
|
|
@ -27,10 +27,10 @@
|
||||||
|
|
||||||
const SYNC_CHECK_INTERVAL = 1000; // Check sync every second (matches server sync interval)
|
const SYNC_CHECK_INTERVAL = 1000; // Check sync every second (matches server sync interval)
|
||||||
const RESTART_GRACE_PERIOD = 3000; // 3 seconds grace period during video restart
|
const RESTART_GRACE_PERIOD = 3000; // 3 seconds grace period during video restart
|
||||||
const DRIFT_THRESHOLD = 2; // Seek if drift > 2 seconds (CyTube-style tight sync)
|
const DRIFT_THRESHOLD = 5; // Seek if drift > 5 seconds (more lenient to avoid jitter)
|
||||||
const MIN_SYNC_INTERVAL = 1000; // Minimum 1 second between sync checks (server pushes every 1s)
|
const MIN_SYNC_INTERVAL = 1000; // Minimum 1 second between sync checks (server pushes every 1s)
|
||||||
const CONTROLLER_SEEK_DEBOUNCE = 2000; // Debounce controller seeks by 2 seconds
|
const CONTROLLER_SEEK_DEBOUNCE = 5000; // Debounce controller seeks by 5 seconds
|
||||||
const SEEK_RATE_LIMIT = 2000; // Minimum 2 seconds between seeks
|
const SEEK_RATE_LIMIT = 3000; // Minimum 3 seconds between seeks
|
||||||
const POST_LEAD_IN_GRACE = 500; // 500ms grace period after lead-in ends
|
const POST_LEAD_IN_GRACE = 500; // 500ms grace period after lead-in ends
|
||||||
|
|
||||||
// Load YouTube IFrame API
|
// Load YouTube IFrame API
|
||||||
|
|
|
||||||
|
|
@ -50,8 +50,8 @@ function loadState() {
|
||||||
return {
|
return {
|
||||||
...defaultState,
|
...defaultState,
|
||||||
...parsed,
|
...parsed,
|
||||||
// Don't persist currentBook or showToc
|
// Restore currentBook if it was persisted
|
||||||
currentBook: null,
|
currentBook: parsed.currentBook || null,
|
||||||
showToc: false
|
showToc: false
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -68,7 +68,8 @@ function saveState(state) {
|
||||||
try {
|
try {
|
||||||
const toSave = {
|
const toSave = {
|
||||||
enabled: state.enabled,
|
enabled: state.enabled,
|
||||||
minimized: state.minimized
|
minimized: state.minimized,
|
||||||
|
currentBook: state.currentBook
|
||||||
};
|
};
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(toSave));
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(toSave));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -96,14 +97,18 @@ function createEbookReaderStore() {
|
||||||
console.error('Invalid book object - missing id or filePath');
|
console.error('Invalid book object - missing id or filePath');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
update(state => ({
|
update(state => {
|
||||||
|
const newState = {
|
||||||
...state,
|
...state,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
currentBook: book,
|
currentBook: book,
|
||||||
progress: 0,
|
progress: 0,
|
||||||
showToc: false,
|
showToc: false,
|
||||||
minimized: false
|
minimized: false
|
||||||
}));
|
};
|
||||||
|
saveState(newState);
|
||||||
|
return newState;
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
// Close the reader (optionally save position first)
|
// Close the reader (optionally save position first)
|
||||||
|
|
@ -113,13 +118,15 @@ function createEbookReaderStore() {
|
||||||
if (cfi && state.currentBook?.id) {
|
if (cfi && state.currentBook?.id) {
|
||||||
persistPosition(state.currentBook.id, cfi);
|
persistPosition(state.currentBook.id, cfi);
|
||||||
}
|
}
|
||||||
return {
|
const newState = {
|
||||||
...state,
|
...state,
|
||||||
enabled: false,
|
enabled: false,
|
||||||
currentBook: null,
|
currentBook: null,
|
||||||
progress: 0,
|
progress: 0,
|
||||||
showToc: false
|
showToc: false
|
||||||
};
|
};
|
||||||
|
saveState(newState);
|
||||||
|
return newState;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ const STORAGE_KEY = 'streamTiles';
|
||||||
const defaultState = {
|
const defaultState = {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
streams: [], // Array of { streamKey, name, username, realmId, offlineImageUrl?, muted: true, volume: 0.5 }
|
streams: [], // Array of { streamKey, name, username, realmId, offlineImageUrl?, muted: true, volume: 0.5 }
|
||||||
|
viewerCounts: {}, // Map of realmId -> viewerCount for aggregating across tiles
|
||||||
// Grid sizing: percentage splits for resizable dividers
|
// Grid sizing: percentage splits for resizable dividers
|
||||||
horizontalSplit: 50, // Percentage for left/right split (2 streams side by side)
|
horizontalSplit: 50, // Percentage for left/right split (2 streams side by side)
|
||||||
verticalSplit: 50 // Percentage for top/bottom split (2x2 grid)
|
verticalSplit: 50 // Percentage for top/bottom split (2x2 grid)
|
||||||
|
|
@ -138,6 +139,18 @@ function createTileStore() {
|
||||||
return newState;
|
return newState;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// Update viewer count for a specific stream (used for aggregating across tiles)
|
||||||
|
setViewerCount: (realmId, count) => update(s => {
|
||||||
|
const newViewerCounts = { ...s.viewerCounts, [realmId]: count };
|
||||||
|
// Don't persist viewer counts to storage - they're ephemeral
|
||||||
|
return { ...s, viewerCounts: newViewerCounts };
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Get total viewer count across all tile streams
|
||||||
|
getTotalViewerCount: (state) => {
|
||||||
|
return Object.values(state.viewerCounts).reduce((sum, count) => sum + (count || 0), 0);
|
||||||
|
},
|
||||||
|
|
||||||
clear: () => {
|
clear: () => {
|
||||||
const newState = { ...defaultState };
|
const newState = { ...defaultState };
|
||||||
saveToStorage(newState);
|
saveToStorage(newState);
|
||||||
|
|
|
||||||
|
|
@ -256,9 +256,9 @@
|
||||||
|
|
||||||
function startHeartbeat() {
|
function startHeartbeat() {
|
||||||
heartbeatInterval = setInterval(async () => {
|
heartbeatInterval = setInterval(async () => {
|
||||||
if (streamKey && viewerToken) {
|
if (streamKey && viewerToken && realm) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/stream/heartbeat/${streamKey}`, {
|
const response = await fetch(`/api/stream/heartbeat/${realm.id}/${streamKey}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
credentials: 'include'
|
credentials: 'include'
|
||||||
});
|
});
|
||||||
|
|
@ -623,6 +623,11 @@
|
||||||
$: totalStreams = hasTiledStreams ? tiledStreams.length + 1 : 1; // +1 for main stream
|
$: totalStreams = hasTiledStreams ? tiledStreams.length + 1 : 1; // +1 for main stream
|
||||||
$: gridClass = totalStreams === 2 ? 'grid-2' : totalStreams >= 3 ? 'grid-4' : '';
|
$: gridClass = totalStreams === 2 ? 'grid-2' : totalStreams >= 3 ? 'grid-4' : '';
|
||||||
|
|
||||||
|
// Aggregated viewer count across all streams (main + tiles)
|
||||||
|
$: tileViewerCount = Object.values($streamTiles.viewerCounts).reduce((sum, count) => sum + (count || 0), 0);
|
||||||
|
$: mainViewerCount = stats.isLive ? stats.connections : (realm?.viewerCount || 0);
|
||||||
|
$: totalViewerCount = hasTiledStreams && $streamTiles.enabled ? mainViewerCount + tileViewerCount : mainViewerCount;
|
||||||
|
|
||||||
// Track previous layout to detect changes
|
// Track previous layout to detect changes
|
||||||
let prevTotalStreams = 1;
|
let prevTotalStreams = 1;
|
||||||
|
|
||||||
|
|
@ -1459,7 +1464,7 @@
|
||||||
<div class="header-top">
|
<div class="header-top">
|
||||||
<h1 style="color: {realm.titleColor || '#ffffff'};">{realm.displayName || realm.name}</h1>
|
<h1 style="color: {realm.titleColor || '#ffffff'};">{realm.displayName || realm.name}</h1>
|
||||||
<div class="live-status-badge" class:live={stats.isLive}>
|
<div class="live-status-badge" class:live={stats.isLive}>
|
||||||
<span class="badge-segment">{stats.isLive ? stats.connections : realm.viewerCount} viewers</span>
|
<span class="badge-segment">{totalViewerCount} viewers</span>
|
||||||
{#if stats.isLive && stats.bitrate > 0}
|
{#if stats.isLive && stats.bitrate > 0}
|
||||||
<span class="badge-divider"></span>
|
<span class="badge-divider"></span>
|
||||||
<span class="badge-segment">{formatBitrate(stats.bitrate)}</span>
|
<span class="badge-segment">{formatBitrate(stats.bitrate)}</span>
|
||||||
|
|
|
||||||
|
|
@ -130,21 +130,21 @@
|
||||||
calendarDate.getFullYear() === today.getFullYear();
|
calendarDate.getFullYear() === today.getFullYear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$: calendarDays = getCalendarDays(calendarDate);
|
||||||
|
$: calendarMonthYear = calendarDate.toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
|
||||||
|
$: calendarHolidays = getHolidaysForMonth(calendarDate.getFullYear(), calendarDate.getMonth());
|
||||||
|
|
||||||
function isHoliday(day) {
|
function isHoliday(day) {
|
||||||
if (!day) return false;
|
if (!day) return false;
|
||||||
return calendarHolidays.has(day);
|
return calendarHolidays?.has(day) ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getHolidayName(day) {
|
function getHolidayName(day) {
|
||||||
if (!day) return null;
|
if (!day) return null;
|
||||||
const info = calendarHolidays.get(day);
|
const info = calendarHolidays?.get(day);
|
||||||
return info ? info.name : null;
|
return info ? info.name : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$: calendarDays = getCalendarDays(calendarDate);
|
|
||||||
$: calendarMonthYear = calendarDate.toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
|
|
||||||
$: calendarHolidays = getHolidaysForMonth(calendarDate.getFullYear(), calendarDate.getMonth());
|
|
||||||
|
|
||||||
// Timezone definitions
|
// Timezone definitions
|
||||||
const timezones = [
|
const timezones = [
|
||||||
{ label: 'UTC', zone: 'UTC' },
|
{ label: 'UTC', zone: 'UTC' },
|
||||||
|
|
|
||||||
|
|
@ -224,6 +224,7 @@
|
||||||
controls
|
controls
|
||||||
autoplay
|
autoplay
|
||||||
playsinline
|
playsinline
|
||||||
|
on:loadedmetadata={() => { if (videoElement) videoElement.volume = 0.6; }}
|
||||||
>
|
>
|
||||||
<track kind="captions" />
|
<track kind="captions" />
|
||||||
</video>
|
</video>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue